diff --git a/.github/workflows/ci-e2e.yml b/.github/workflows/ci-e2e.yml index 5ef21d5a3..2632a6f0f 100644 --- a/.github/workflows/ci-e2e.yml +++ b/.github/workflows/ci-e2e.yml @@ -389,3 +389,286 @@ jobs: name: apache-error-log-${{ github.run_id }} path: /var/log/apache2/pinakes-error.log retention-days: 7 + + # ── 0.9.0 feature suites ────────────────────────────────────────────────── + # Every Playwright suite added by the 0.9.0 stack (circulation rules, patron + # types, closures calendar, fines ledger, debt block, stocktake, MARC, cards, + # patron photo, user import, statistics, suppliers, RDA types, #501 quoted + # search). Same environment as the deep regression shards — Apache/mod_php, + # MySQL, Mailpit behind a real sendmail transport, the CI MySQL wrapper and + # poppler for the PDF assertions — on a fresh install bootstrapped by the + # full lifecycle suite. Two groups run in parallel; each list is explicit so + # a new spec has to be added here on purpose. + feature-090: + name: 0.9.0 feature suites (${{ matrix.group }}) + runs-on: ubuntu-24.04 + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + include: + - group: circulation + specs: >- + tests/circulation-card.spec.js + tests/circulation-consultation.spec.js + tests/circulation-interop.spec.js + tests/circulation-patron-types.spec.js + tests/circulation-reservations.spec.js + tests/circulation-rules-matrix.spec.js + tests/circulation-settings-access.spec.js + tests/closures-calendar-admin.spec.js + tests/closures-calendar-circulation.spec.js + tests/closures-calendar-loans.spec.js + tests/debt-block.spec.js + tests/fines-closed-days.spec.js + tests/fines-ledger.spec.js + tests/fines-patron-types.spec.js + tests/stack-090-integration.spec.js + tests/stack-090-review-fixes.spec.js + tests/stack-090-zero-config.spec.js + - group: catalogue + specs: >- + tests/attendance-090.spec.js + tests/catalogue-cards-090.spec.js + tests/inventory-stocktake.spec.js + tests/issue-501-admin-lists.spec.js + tests/issue-501-exact-quoted-search.spec.js + tests/issue-501-name-pages.spec.js + tests/issue-501-quoted-hint.spec.js + tests/issue-501-quoted-new-author.spec.js + tests/issue-501-search-fields.spec.js + tests/issue-501-search-hint.spec.js + tests/marc-bulk-export.spec.js + tests/marc-bulk-import.spec.js + tests/marc-copy-cataloging.spec.js + tests/patron-cards.spec.js + tests/patron-photo.spec.js + tests/rda-types-090.spec.js + tests/statistics-090.spec.js + tests/suppliers-090.spec.js + tests/user-import.spec.js + + services: + mysql: + image: mysql:8.0@sha256:7dcddc01f13bab2f15cde676d44d01f61fc9f99fe7785e86196dfc07d358ae2b + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: pinakes_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + mailpit: + image: axllent/mailpit:v1.21@sha256:81370195cd4a0eab9604d17c2617a7525b0486f9365555253b6c5376c6350f1a + ports: + - 1025:1025 + - 8025:8025 + + env: + E2E_BASE_URL: http://localhost:8081 + APP_URL: http://localhost:8081 + E2E_ADMIN_EMAIL: admin@pinakes.test + E2E_ADMIN_PASS: Test1234! + E2E_DB_HOST: 127.0.0.1 + E2E_DB_PORT: 3306 + E2E_DB_USER: root + E2E_DB_PASS: root + E2E_DB_NAME: pinakes_test + E2E_INSTALL_ROOT: ${{ github.workspace }} + MAILPIT_API: http://127.0.0.1:8025/api/v1 + E2E_PHP_UPLOAD_MAX_BYTES: 8388608 + E2E_PHP_POST_MAX_BYTES: 10485760 + + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - name: Install Apache, PHP module, MySQL client, mail transport and poppler + run: | + # This job never installs Google Chrome. Remove its hosted-runner + # apt source before the first refresh so mirror syncs cannot break CI. + sudo rm -f /etc/apt/sources.list.d/google-chrome.list + sudo apt-get update -q + # poppler-utils: patron-cards and patron-photo read the generated + # PDFs back with pdftotext/pdfinfo/pdfimages. + sudo apt-get install -y apache2 libapache2-mod-php php-apcu default-mysql-client msmtp-mta poppler-utils + sudo a2enmod rewrite headers env + php_module="$(find /etc/apache2/mods-available -maxdepth 1 -name 'php*.load' -printf '%f\n' | sed 's/\.load$//' | sort -V | tail -n1)" + [ -z "${php_module}" ] || sudo a2enmod "${php_module}" + sudo a2dissite 000-default || true + sudo tee /etc/msmtprc >/dev/null <<'EOF' + defaults + account default + host 127.0.0.1 + port 1025 + tls off + from pinakes-ci@localhost + EOF + sudo chmod 644 /etc/msmtprc + while IFS= read -r conf_dir; do + sudo tee "${conf_dir}/99-pinakes-ci.ini" >/dev/null <<'EOF' + sendmail_path = "/usr/bin/msmtp -t" + upload_max_filesize = 8M + post_max_size = 10M + apc.enable=1 + apc.enable_cli=1 + EOF + done < <(find /etc/php -type d -path '*/conf.d' | sort -u) + + - name: Setup PHP 8.2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: '8.2' + extensions: mysqli, mbstring, json, curl, openssl, zip, gd, intl, xml, apcu + ini-values: apc.enable=1, apc.enable_cli=1 + coverage: none + + - name: Setup Node 22 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: '22' + cache: npm + + - name: Install locked dependencies and Chromium + run: | + composer install --no-interaction --prefer-dist --no-dev --optimize-autoloader + npm ci --silent + npx playwright install chromium --with-deps + + - name: Route legacy MySQL test clients to the service container + run: echo "${GITHUB_WORKSPACE}/scripts/ci-bin" >> "${GITHUB_PATH}" + + - name: Verify test coverage policy + run: npm run test:ci-policy + + - name: Configure Apache on port 8081 + env: + WORKSPACE: ${{ github.workspace }} + run: | + sudo tee /etc/apache2/sites-available/pinakes.conf >/dev/null < + ServerName localhost + DocumentRoot ${WORKSPACE}/public + SetEnv PINAKES_E2E_BYPASS_RATE_LIMIT 1 + SetEnv PINAKES_E2E_SCRAPER_STUB 1 + # E2E only — arms GET /_e2e/flush-cache so specs that write to the + # DB directly can invalidate the page cache; 404s when unset. + SetEnv PINAKES_E2E_CACHE_FLUSH 1 + + Options -Indexes +FollowSymLinks + AllowOverride All + Require all granted + + ErrorLog \${APACHE_LOG_DIR}/pinakes-error.log + CustomLog \${APACHE_LOG_DIR}/pinakes-access.log combined + + EOF + sudo a2ensite pinakes + sudo apachectl configtest + + - name: Grant runtime permissions and start Apache + env: + WORKSPACE: ${{ github.workspace }} + run: | + path="${WORKSPACE}" + while [ "${path}" != "/" ]; do + sudo chmod o+x "${path}" + path="$(dirname "${path}")" + done + sudo chmod 777 "${WORKSPACE}" + sudo chmod -R 777 "${WORKSPACE}/storage" + sudo mkdir -p "${WORKSPACE}/public/uploads" + sudo chmod -R 777 "${WORKSPACE}/public/uploads" + sudo systemctl start apache2 + for _ in $(seq 1 30); do + curl -sf -o /dev/null http://localhost:8081/installer/ && exit 0 + sleep 1 + done + sudo cat /var/log/apache2/pinakes-error.log || true + exit 1 + + - name: Bootstrap a complete fresh installation + env: + PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/bootstrap-${{ matrix.group }}.json + PLAYWRIGHT_HTML_OUTPUT_DIR: playwright-report-bootstrap-${{ matrix.group }} + run: npx playwright test tests/full-test.spec.js --config=tests/playwright.ci.config.js --workers=1 + + - name: Audit bootstrap failures, flakes and suspicious skips + if: always() + env: + RESULT_FILE: test-results/bootstrap-${{ matrix.group }}.json + run: node scripts/ci-audit-playwright-results.js "$RESULT_FILE" + + - name: Seed deterministic cross-suite fixtures + env: + E2E_RUN_SEED: '1' + E2E_OFFLINE_SEED: '1' + PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/setup-${{ matrix.group }}.json + PLAYWRIGHT_HTML_OUTPUT_DIR: playwright-report-setup-${{ matrix.group }} + run: | + npx playwright test \ + tests/interop-00-activate-plugins.spec.js \ + tests/archives-seed-50.spec.js \ + tests/seed-catalog.spec.js \ + tests/persistent-seed-crud.spec.js \ + --config=tests/playwright.ci.config.js \ + --workers=1 + sudo chmod -R 777 "${GITHUB_WORKSPACE}/storage" + sudo chmod -R 777 "${GITHUB_WORKSPACE}/public/uploads" + # The CLI-level helpers (app clock, migrations) read the same + # throwaway CI credentials the installer protected from Apache users. + sudo chmod 640 "${GITHUB_WORKSPACE}/.env" + sudo chgrp "$(id -gn)" "${GITHUB_WORKSPACE}/.env" + + - name: Audit setup fixtures + if: always() + env: + RESULT_FILE: test-results/setup-${{ matrix.group }}.json + run: node scripts/ci-audit-playwright-results.js "$RESULT_FILE" + + - name: Run the 0.9.0 ${{ matrix.group }} suites + id: feature-suites + env: + SPECS: ${{ matrix.specs }} + GROUP: ${{ matrix.group }} + PLAYWRIGHT_JSON_OUTPUT_FILE: test-results/feature-090-${{ matrix.group }}.json + PLAYWRIGHT_HTML_OUTPUT_DIR: playwright-report-feature-090-${{ matrix.group }} + run: | + read -r -a spec_files <<< "${SPECS}" + for spec in "${spec_files[@]}"; do + [ -f "${spec}" ] || { echo "Listed spec does not exist: ${spec}"; exit 1; } + done + printf 'Running %s 0.9.0 specs (%s)\n' "${#spec_files[@]}" "${GROUP}" + npx playwright test "${spec_files[@]}" \ + --config=tests/playwright.ci.config.js \ + --workers=1 + + - name: Audit failures, flakes and skips (zero skips allowed) + if: always() && steps.feature-suites.outcome != 'skipped' + env: + RESULT_FILE: test-results/feature-090-${{ matrix.group }}.json + # The shared audit tolerates skips with an environmental reason. + # These suites were written for this environment: every test runs. + PLAYWRIGHT_MAX_SKIPPED: '0' + run: node scripts/ci-audit-playwright-results.js "$RESULT_FILE" + + - name: Upload browser evidence + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + if: always() + with: + name: feature-090-${{ matrix.group }}-${{ github.run_id }} + path: | + playwright-report-feature-090-${{ matrix.group }}/ + playwright-report-bootstrap-${{ matrix.group }}/ + playwright-report-setup-${{ matrix.group }}/ + test-results/ + storage/logs/app.log + /var/log/apache2/pinakes-error.log + /var/log/apache2/pinakes-access.log + retention-days: 14 + if-no-files-found: warn diff --git a/app/Support/ExactSearch.php b/app/Support/ExactSearch.php index d9d698bb4..d788c3d2b 100644 --- a/app/Support/ExactSearch.php +++ b/app/Support/ExactSearch.php @@ -25,7 +25,7 @@ final class ExactSearch /** Straight and typographic quotes (mobile keyboards type „…“ and “…”). */ private const QUOTES = '"“”„«»'; - /** Stand-in for a literal inch mark while the query is tokenized (U+E000). */ + /** Stand-in for a literal inch mark while hasQuote() looks for real quotes (U+E000). */ private const INCH_PLACEHOLDER = "\u{E000}"; /** Most words a single filter box turns into separate conditions. */ @@ -44,25 +44,56 @@ private function __construct() */ public static function tokenize(string $query): array { - $query = self::protectInchMarks(self::scrub($query)); - $q = '[' . preg_quote(self::QUOTES, '/') . ']'; - $parts = preg_split('/(' . $q . '[^' . preg_quote(self::QUOTES, '/') . ']*(?:' . $q . '|$))/u', $query, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); - if ($parts === false) { - $parts = [$query]; - } - + // One pass over the characters: an inch mark is appended to its word + // as it is met, so no stand-in character is substituted and later + // swapped back (a query that itself holds that character would come + // back altered). + $chars = preg_split('//u', self::scrub($query), -1, PREG_SPLIT_NO_EMPTY) ?: []; $terms = []; - foreach ($parts as $part) { - if (preg_match('/^' . $q . '/u', $part) === 1) { - $inner = self::collapse(self::stripQuotes($part)); - if ($inner !== '') { - $terms[] = ['term' => $inner, 'exact' => true]; + $open = false; + $word = ''; + $phrase = ''; + $afterDigit = false; + foreach ($chars as $char) { + if (str_contains(self::QUOTES, $char)) { + if ($open) { + $inner = self::collapse($phrase); + if ($inner !== '') { + $terms[] = ['term' => $inner, 'exact' => true]; + } + $phrase = ''; + $open = false; + } elseif (($char === '"' || $char === '”') && $afterDigit) { + $word .= '"'; + } else { + if ($word !== '') { + $terms[] = ['term' => $word, 'exact' => false]; + } + $word = ''; + $open = true; } + $afterDigit = false; continue; } - foreach (preg_split('/\s+/u', $part, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $word) { - $terms[] = ['term' => self::restoreInchMarks($word), 'exact' => false]; + if ($open) { + $phrase .= $char; + } elseif (preg_match('/^\s$/u', $char) === 1) { + if ($word !== '') { + $terms[] = ['term' => $word, 'exact' => false]; + } + $word = ''; + } else { + $word .= $char; + } + $afterDigit = preg_match('/^\d$/u', $char) === 1; + } + if ($open) { + $inner = self::collapse($phrase); + if ($inner !== '') { + $terms[] = ['term' => $inner, 'exact' => true]; } + } elseif ($word !== '') { + $terms[] = ['term' => $word, 'exact' => false]; } return $terms; } @@ -294,17 +325,6 @@ private static function protectInchMarks(string $value): string return implode('', $chars); } - private static function restoreInchMarks(string $value): string - { - return str_replace(self::INCH_PLACEHOLDER, '"', $value); - } - - private static function stripQuotes(string $value): string - { - $q = preg_quote(self::QUOTES, '/'); - return (string) preg_replace('/^[' . $q . ']+|[' . $q . ']+$/u', '', trim($value)); - } - private static function collapse(string $value): string { return trim((string) preg_replace('/\s+/u', ' ', $value)); diff --git a/app/Support/Marc/Marc21LibriMapper.php b/app/Support/Marc/Marc21LibriMapper.php index aaf7a1801..5dfeb4fde 100644 --- a/app/Support/Marc/Marc21LibriMapper.php +++ b/app/Support/Marc/Marc21LibriMapper.php @@ -65,13 +65,16 @@ private static function entryName(MarcField $field): string return $name; } $units = $field->subfieldValues(self::isMeeting($field) ? 'e' : 'b'); - $parts = []; + $joined = ''; foreach (array_merge([$name], array_map([MarcBookData::class, 'cleanName'], $units)) as $part) { - if ($part !== '') { - $parts[] = $part; + if ($part === '') { + continue; } + // A part that keeps its final period (it ends with an initial, + // "Istituto X.") is not followed by a second one. + $joined = $joined === '' ? $part : $joined . (str_ends_with($joined, '.') ? ' ' : '. ') . $part; } - return implode('. ', $parts); + return $joined; } public function map(MarcRecord $record): array diff --git a/app/Views/fornitori/index.php b/app/Views/fornitori/index.php index d39e6e461..bd82afef9 100644 --- a/app/Views/fornitori/index.php +++ b/app/Views/fornitori/index.php @@ -54,8 +54,9 @@ - +

@@ -130,10 +131,68 @@ class="form-input" /> document.addEventListener('DOMContentLoaded', function () { const search = document.getElementById('search_fornitore'); if (search) { + // Same rules as App\Support\ExactSearch (#501): a word matches whatever + // its accents ("muller" finds Müller), a quoted group matches the exact + // spelling ("Muller" does not find Müller), case is ignored, and every + // term must match. An unclosed quote runs to the end, and a straight or + // closing double quote right after a digit (any script: 12" or ١٢") + // outside a group is a literal inch mark that stays part of the word. + // The query is scanned once, so no stand-in character is substituted and + // a name holding any private-use character is matched as typed. + // Lower-casing is locale-free (toLowerCase), so a Turkish browser does + // not turn "IKEA" into "ıkea". + const QUOTES = '"“”„«»'; + const fold = function (s) { return s.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase(); }; + const exact = function (s) { return s.normalize('NFC').replace(/\s+/g, ' ').toLowerCase(); }; + const terms = function (query) { + const out = []; + let open = false; + let word = ''; + let phrase = ''; + let afterDigit = false; + const flushWord = function () { + if (word !== '') { out.push({ term: fold(word), exact: false }); } + word = ''; + }; + const flushPhrase = function () { + const p = phrase.trim().replace(/\s+/g, ' '); + if (p !== '') { out.push({ term: exact(p), exact: true }); } + phrase = ''; + }; + for (const ch of query) { + if (QUOTES.includes(ch)) { + if (open) { + flushPhrase(); + open = false; + } else if ((ch === '"' || ch === '”') && afterDigit) { + word += '"'; + } else { + flushWord(); + open = true; + } + afterDigit = false; + continue; + } + if (open) { + phrase += ch; + } else if (/\s/.test(ch)) { + flushWord(); + } else { + word += ch; + } + afterDigit = /^\p{Nd}$/u.test(ch); + } + if (open) { flushPhrase(); } else { flushWord(); } + return out; + }; search.addEventListener('input', function () { - const q = search.value.trim().toLowerCase(); + const wanted = terms(search.value); document.querySelectorAll('[data-supplier-row]').forEach(function (row) { - row.classList.toggle('hidden', q !== '' && !(row.dataset.name || '').includes(q)); + const name = row.dataset.name || ''; + const exactName = exact(name); + const foldedName = fold(name); + const match = wanted.every(function (t) { return (t.exact ? exactName : foldedName).includes(t.term); }); + row.classList.toggle('hidden', !match); }); }); } diff --git a/scripts/ci-audit-playwright-results.js b/scripts/ci-audit-playwright-results.js index 11512a786..08098935a 100755 --- a/scripts/ci-audit-playwright-results.js +++ b/scripts/ci-audit-playwright-results.js @@ -47,6 +47,22 @@ if (expected === 0) { console.error('✗ The suite executed zero passing tests'); failed = true; } +// Optional hard ceiling on skips, for suites written for the CI environment +// where any skip (whatever its reason) means a test silently did not run. +const maxSkippedRaw = process.env.PLAYWRIGHT_MAX_SKIPPED; +if (maxSkippedRaw !== undefined && maxSkippedRaw !== '') { + const maxSkipped = Number(maxSkippedRaw); + if (!Number.isInteger(maxSkipped) || maxSkipped < 0) { + console.error(`✗ PLAYWRIGHT_MAX_SKIPPED must be a non-negative integer, got "${maxSkippedRaw}"`); + failed = true; + } else if (skipped > maxSkipped) { + console.error(`✗ ${skipped} skipped test(s), at most ${maxSkipped} allowed:`); + for (const item of skipReasons.slice(0, 30)) { + console.error(` - ${item.file}: ${item.title} — ${item.reason || ''}`); + } + failed = true; + } +} if (suspicious.length > 0) { console.error(`✗ ${suspicious.length} suspicious skip(s) hide missing fixtures or infrastructure:`); for (const item of suspicious.slice(0, 30)) { diff --git a/tests/helpers/circulation-fixtures.js b/tests/helpers/circulation-fixtures.js index 17dc8853f..1ccb14325 100644 --- a/tests/helpers/circulation-fixtures.js +++ b/tests/helpers/circulation-fixtures.js @@ -20,13 +20,17 @@ const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; const DB_NAME = process.env.E2E_DB_NAME || ''; const CONFIGURED = Boolean(ADMIN_EMAIL && ADMIN_PASS && DB_USER && DB_NAME); +// The SQL goes in on stdin, not as `-e `: one command-line argument is +// capped (128 KiB on Linux, E2BIG beyond it), and a stashRows() restore of a +// large table is far bigger than that. The client stays in batch mode (-B) +// and stops at the first failing statement, as with -e. function dbQuery(sql) { - const args = ['--default-character-set=utf8mb4', '-N', '-B', '-e', sql]; + const args = ['--default-character-set=utf8mb4', '-N', '-B']; if (DB_HOST) args.push('-h', DB_HOST); if (DB_PORT) args.push('-P', DB_PORT); if (!DB_HOST && DB_SOCKET) args.push('-S', DB_SOCKET); args.push('-u', DB_USER, DB_NAME); - return execFileSync('mysql', args, { encoding: 'utf-8', timeout: 15000, env: { ...process.env, MYSQL_PWD: DB_PASS } }).trim(); + return execFileSync('mysql', args, { input: sql, encoding: 'utf-8', timeout: 15000, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, MYSQL_PWD: DB_PASS } }).trim(); } const dbInt = (sql) => parseInt(dbQuery(sql) || '0', 10); const sqlStr = (v) => "'" + String(v).replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'"; @@ -226,6 +230,125 @@ async function requestFromBookPage(page, bookId, startISO) { return { ok, text }; } +/** Whether the named plugin is active (false when it is not registered). */ +function pluginActive(name) { + return dbQuery(`SELECT is_active FROM plugins WHERE name=${sqlStr(name)} LIMIT 1`) === '1'; +} + +/** + * Activate or deactivate a plugin through the admin endpoint, as the admin + * plugin page does. `page` must be logged in as an administrator. A no-op + * when the plugin is already in the requested state. + */ +async function setPluginActive(page, name, active) { + const id = dbInt(`SELECT id FROM plugins WHERE name=${sqlStr(name)} LIMIT 1`); + if (id <= 0) throw new Error(`${name} plugin is not registered`); + if (pluginActive(name) === active) return; + await page.goto(`${BASE}/admin/plugins`); + const status = await page.evaluate(async ({ pid, action }) => { + const csrf = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''; + const r = await fetch(`${window.location.origin}${window.BASE_PATH || ''}/admin/plugins/${pid}/${action}`, { method: 'POST', headers: { 'X-CSRF-Token': csrf } }); + return r.status; + }, { pid: id, action: active ? 'activate' : 'deactivate' }); + if (status >= 400) throw new Error(`${name} ${active ? 'activate' : 'deactivate'} failed: HTTP ${status}`); + if (pluginActive(name) !== active) throw new Error(`${name} is still ${active ? 'inactive' : 'active'}`); +} + +/** + * Snapshot the rows of a table (optionally filtered by `where`) so they can be + * put back verbatim. Every value travels hex-encoded, so quotes, backslashes + * and non-ASCII text survive the mysql batch output unchanged. Each row also + * keeps its primary key, for a restore limited to the snapshot rows. + */ +function stashRows(table, where = '') { + const colsOf = (extra) => dbQuery(`SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY ORDINAL_POSITION) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=${sqlStr(table)}${extra}`); + const cols = colsOf(''); + if (!cols) throw new Error(`table ${table} has no columns`); + const pk = colsOf(" AND COLUMN_KEY='PRI'"); + const literal = (c) => `IFNULL(CONCAT('CONVERT(X''', HEX(CAST(\`${c}\` AS CHAR)), ''' USING utf8mb4)'), 'NULL')`; + const values = cols.split(',').map(literal).join(", ',', "); + const key = pk ? pk.split(',').map(literal).join(", ',', ") : "''"; + const filter = where ? ` WHERE ${where}` : ''; + const rows = dbQuery(`SELECT CONCAT(${values}), CONCAT(${key}) FROM \`${table}\`${filter}`) + .split('\n').filter(Boolean) + .map((line) => { const [v, k] = line.split('\t'); return { values: v, key: k || '' }; }); + const quote = (list) => list.split(',').map((c) => `\`${c}\``).join(','); + return { table, filter, cols: quote(cols), pk: pk ? quote(pk) : '', rows }; +} + +/** + * Put back a stashRows() snapshot in one transaction: all of it or nothing. + * By default every row matching the snapshot filter is replaced by the + * snapshot. With { onlySnapshotRows: true } only the snapshot's own rows + * (by primary key) are deleted and re-inserted, so a row added after the + * snapshot is left alone. + */ +function unstashRows(s, { onlySnapshotRows = false } = {}) { + if (!s) return; + const sql = ['START TRANSACTION']; + if (onlySnapshotRows) { + if (!s.pk) throw new Error(`table ${s.table} has no primary key: cannot restore only the snapshot rows`); + for (const row of s.rows) sql.push(`DELETE FROM \`${s.table}\` WHERE (${s.pk}) = (${row.key})`); + } else { + sql.push(`DELETE FROM \`${s.table}\`${s.filter}`); + } + for (const row of s.rows) sql.push(`INSERT INTO \`${s.table}\` (${s.cols}) VALUES (${row.values})`); + sql.push('COMMIT'); + dbQuery(sql.join(';\n')); +} + +/** + * State of one system_settings row: { exists: false } when there is no row, + * else { exists: true, value } with value null for SQL NULL. The value is + * read hex-encoded, so SQL NULL, the text "NULL", an empty string and + * surrounding whitespace all come back exactly as stored. + */ +function readSettingState(category, key) { + const raw = dbQuery(`SELECT IF(setting_value IS NULL, 'n', CONCAT('s', HEX(setting_value))) FROM system_settings WHERE category=${sqlStr(category)} AND setting_key=${sqlStr(key)} LIMIT 1`); + if (raw === '') return { exists: false, value: null }; + return { exists: true, value: raw === 'n' ? null : Buffer.from(raw.slice(1), 'hex').toString('utf8') }; +} + +/** Put a system_settings row back to a readSettingState() snapshot. */ +function restoreSettingState(category, key, state) { + if (!state.exists) { + dbQuery(`DELETE FROM system_settings WHERE category=${sqlStr(category)} AND setting_key=${sqlStr(key)}`); + return; + } + const value = state.value === null ? 'NULL' : sqlStr(state.value); + dbQuery(`INSERT INTO system_settings (category, setting_key, setting_value) VALUES (${sqlStr(category)}, ${sqlStr(key)}, ${value}) ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value)`); +} + +/** + * Switch a plugin on or off from a fresh browser context logged in as the + * administrator, for cleanup that must not depend on a page the suite set up + * (its context or login may be the very step that failed). Does nothing, + * and opens no context, when the plugin is already in that state. + * @param {import('@playwright/test').Browser} browser + * @param {string} name + * @param {boolean} active + */ +async function setPluginActiveFresh(browser, name, active) { + if (pluginActive(name) === active) return; + const ctx = await browser.newContext(); + try { + const page = await ctx.newPage(); + await loginAsAdmin(page); + await setPluginActive(page, name, active); + } finally { + await ctx.close(); + } +} + +/** Run every cleanup step even when an earlier one throws; rethrow the first error. */ +async function runAll(steps) { + let first = null; + for (const step of steps) { + try { await step(); } catch (err) { if (!first) first = err; } + } + if (first) throw first; +} + module.exports = { BASE, ADMIN_EMAIL, ADMIN_PASS, CONFIGURED, dbQuery, dbInt, sqlStr, clearRateLimits, phpHash, @@ -233,4 +356,6 @@ module.exports = { loginAs, loginAsAdmin, openCirculationTab, createTypeViaUi, setMatrixCell, fillAutocomplete, staffLoan, requestFromBookPage, copyCodeOf, appTodayISO, appDateOffsetISO, + pluginActive, setPluginActive, setPluginActiveFresh, stashRows, unstashRows, runAll, + readSettingState, restoreSettingState, }; diff --git a/tests/stack-090-integration.spec.js b/tests/stack-090-integration.spec.js new file mode 100644 index 000000000..b579e67a9 --- /dev/null +++ b/tests/stack-090-integration.spec.js @@ -0,0 +1,352 @@ +// @ts-check +/** + * 0.9.0 cross-feature flow, end to end through the admin UI: a patron type + * with its own loan length, daily fine and debt threshold (#508/#510), the + * closures calendar (#509) moving the due date and keeping closed days out of + * the late days, the late return charging the type's fine, the debt blocking + * the reader on the website, the Mobile API and NCIP, and the payment at the + * reader record lifting the block on all three channels. + * + * The global fine settings stay EMPTY for the whole file: every fine and + * block comes from the patron type, and a reader of the default type + * returning just as late pays nothing. + * + * The DB is used only to create the patron/books, to age the loan (dates in + * the application timezone) and to verify what the UI wrote. + * + * Run: /tmp/run-e2e.sh tests/stack-090-integration.spec.js --config=tests/playwright.config.js --workers=1 + */ +const { test, expect } = require('@playwright/test'); +const fs = require('fs'); +const path = require('path'); +const F = require('./helpers/circulation-fixtures'); +const M = require('./helpers/mobile-api-fixtures'); + +test.skip(!F.CONFIGURED, 'E2E credentials not configured'); + +const RUN = String(Date.now()).slice(-6); +const PREFIX = `e2eint${RUN}`; +const TAG = `E2E-INT-${RUN}`; +const PASS = 'Circ1234!'; +const TYPE_DAYS = 10; +const DEBT_MESSAGE = 'Saldo a debito oltre la soglia consentita'; +const LOAN_SETTINGS = ['daily_fine', 'fine_grace_days', 'fine_max_per_loan', 'debt_block_threshold', 'closed_weekdays', 'auto_approve_requests', 'fine_charged_email']; +// Columns of the default patron type that charge or block its readers. +const DEFAULT_TYPE_FINES = ['multa_giornaliera', 'giorni_tolleranza', 'multa_massima_prestito', 'soglia_blocco_debito']; +const NCIP_NS = 'http://www.niso.org/2008/ncip'; + +const ncipXml = (service, userId, itemId) => ` + + <${service}> + ${userId} + ${itemId} + +`; +const ncipAuth = 'Basic ' + Buffer.from(`${F.ADMIN_EMAIL}:${F.ADMIN_PASS}`).toString('base64'); +const ncip = async (request, body) => (await request.post(`${F.BASE}/ncip`, { data: body, headers: { 'Content-Type': 'application/xml', Authorization: ncipAuth } })).text(); + +/** Current value of a loans setting: a string, null when absent or SQL NULL. */ +const readSetting = (key) => F.readSettingState('loans', key).value; +/** Set a loans setting for the test (null removes the row). */ +function writeSetting(key, value) { + if (value === null) F.dbQuery(`DELETE FROM system_settings WHERE category='loans' AND setting_key=${F.sqlStr(key)}`); + else F.dbQuery(`INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('loans', ${F.sqlStr(key)}, ${F.sqlStr(value)}) ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value)`); +} +// Drops the file-backed settings cache used when APCu is off (local slots). +// With APCu (CI) this is a no-op: the settings these specs change are read +// through SettingsRepository, which is not cached, and email settings are +// refreshed by saving a settings tab through the app. +function clearConfigCache() { + const cacheDir = path.join(__dirname, '..', 'storage', 'cache'); + if (!fs.existsSync(cacheDir)) return; + for (const name of fs.readdirSync(cacheDir)) { + if (name.startsWith('pinakes_config_settings_raw_')) { + try { fs.unlinkSync(path.join(cacheDir, name)); } catch { /* best effort */ } + } + } +} +function addDays(iso, days) { + const d = new Date(`${iso}T12:00:00Z`); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} +const balanceCents = (userId) => Math.round(Number(F.dbQuery(`SELECT COALESCE(SUM(importo),0) FROM movimenti_lettore WHERE utente_id=${userId}`)) * 100); + +async function addClosure(page, { start, end = '', description }) { + await page.goto(`${F.BASE}/admin/calendar`); + await page.fill('#closure_start', start); + if (end) await page.fill('#closure_end', end); + await page.fill('#closure_description', description); + await Promise.all([page.waitForURL(/\/admin\/calendar/, { timeout: 15000 }), page.locator('#save-closure').click()]); + expect(F.dbInt(`SELECT COUNT(*) FROM calendario_chiusure WHERE descrizione=${F.sqlStr(description)}`), `closure ${description} saved`).toBe(1); +} + +/** Return screen: the fine it announces (cents, 0 when none), then confirm. */ +async function returnAtDesk(page, loanId) { + await page.goto(`${F.BASE}/admin/loans/returned/${loanId}`); + await expect(page.getByRole('button', { name: 'Conferma restituzione' })).toBeVisible(); + const box = page.locator('#fine-preview'); + const shown = (await box.isVisible().catch(() => false)) ? Number(await box.getAttribute('data-fine-cents')) : 0; + await page.getByRole('button', { name: 'Conferma restituzione' }).click(); + await page.waitForFunction(() => !window.location.pathname.includes('/admin/loans/returned/'), null, { timeout: 20000 }); + return shown; +} + +/** Age an active loan so that it was lent 20 days ago and due $late days ago. */ +function makeOverdue(loanId, late) { + F.dbQuery(`UPDATE prestiti SET data_prestito='${F.appDateOffsetISO(-20)}', data_scadenza='${F.appDateOffsetISO(-late)}', stato='in_ritardo' WHERE id=${loanId}`); +} + +test.describe.serial('0.9.0 stack: patron type + closures + fines + debt block across channels', () => { + test.describe.configure({ timeout: 240000 }); + /** @type {import('@playwright/test').Page} */ let admin; + let adminCtx; + const saved = {}; + let savedClosures = null; + let savedRules = null; + let ncipWasActive = true; + let mobileWasActive = true; + let gate = null; + let typeId = 0; + let reader; + let plain; + const books = {}; + const errors = []; + let today = ''; + let deskLoan = 0; + /** Fine columns of the default type before the suite: [column, value or null] pairs. */ + let savedDefaultFines = null; + + test.beforeAll(async ({ browser }) => { + for (const k of LOAN_SETTINGS) saved[k] = F.readSettingState('loans', k); + savedClosures = F.stashRows('calendario_chiusure'); + // A matrix rule for every type (tipologia_id=0) would sit above the + // patron type layer and change its loan length or fine: start empty. + savedRules = F.stashRows('regole_prestito'); + F.dbQuery('DELETE FROM calendario_chiusure'); + F.dbQuery('DELETE FROM regole_prestito'); + // The plain reader belongs to the default type: a fine or threshold the + // installation set on it would charge or block that reader in test 9, so + // they are emptied for the run and put back in afterAll (NULL included). + savedDefaultFines = DEFAULT_TYPE_FINES.map((c) => { + const raw = F.dbQuery(`SELECT IF(\`${c}\` IS NULL, 'n', CONCAT('v', \`${c}\`)) FROM tipologie_lettore WHERE predefinita=1 ORDER BY id LIMIT 1`); + return [c, raw.startsWith('v') ? raw.slice(1) : null]; + }); + F.dbQuery(`UPDATE tipologie_lettore SET ${DEFAULT_TYPE_FINES.map((c) => `\`${c}\`=NULL`).join(', ')} WHERE predefinita=1`); + // Global fines and threshold empty: only the patron type charges and blocks. + for (const k of ['daily_fine', 'fine_max_per_loan', 'debt_block_threshold']) writeSetting(k, ''); + writeSetting('fine_grace_days', '0'); + writeSetting('fine_charged_email', '0'); + writeSetting('closed_weekdays', null); + writeSetting('auto_approve_requests', '0'); + clearConfigCache(); + + reader = F.createPatron(`INT${RUN}`, { password: PASS }); + plain = F.createPatron(`INP${RUN}`, { password: PASS }); + for (const key of ['desk', 'web', 'app', 'ncip', 'plain']) books[key] = F.createTitle(`${PREFIX}-${key}`); + + mobileWasActive = F.pluginActive('mobile-api'); + const helper = await browser.newPage(); + gate = await M.ensureMobileApiReady(helper); + await helper.close(); + const ncipId = F.dbInt("SELECT id FROM plugins WHERE name='ncip-server' LIMIT 1"); + expect(ncipId, 'ncip-server plugin registered').toBeGreaterThan(0); + + adminCtx = await browser.newContext(); + admin = await adminCtx.newPage(); + admin.on('response', (r) => { if (r.status() >= 500) errors.push(`${r.status()} ${r.url()}`); }); + await F.loginAsAdmin(admin); + ncipWasActive = F.pluginActive('ncip-server'); + await F.setPluginActive(admin, 'ncip-server', true); + }); + + // The app dates a loan when the request arrives: read the application day + // again before every test, so a run that crosses midnight compares each + // loan with the day it was actually made on. + test.beforeEach(() => { today = F.appTodayISO(); }); + + test.afterEach(() => { expect(errors.splice(0), 'no HTTP 5xx').toEqual([]); }); + + test.afterAll(async ({ browser }) => { + // Independent restore steps: one failing must not leave the others undone. + await F.runAll([ + // One step per key, and only for keys beforeAll actually read (an + // absent row, SQL NULL and text all come back as they were). + ...LOAN_SETTINGS.map((k) => () => { if (saved[k] !== undefined) F.restoreSettingState('loans', k, saved[k]); }), + () => clearConfigCache(), + () => F.unstashRows(savedClosures), + () => F.unstashRows(savedRules), + () => { + if (!savedDefaultFines) return; + const sets = savedDefaultFines.map(([c, v]) => `\`${c}\`=${v === null ? 'NULL' : F.sqlStr(v)}`).join(', '); + F.dbQuery(`UPDATE tipologie_lettore SET ${sets} WHERE predefinita=1`); + }, + () => { + for (const p of [reader, plain]) { + if (!p) continue; + try { F.dbQuery(`DELETE FROM mobile_app_tokens WHERE user_id=${p.id}`); } catch { /* no token table */ } + } + }, + () => { + const ids = Object.values(books).map((b) => b.id); + if (ids.length) { + try { F.dbQuery(`DELETE FROM admin_notifications WHERE related_id IN (SELECT id FROM prestiti WHERE libro_id IN (${ids.join(',')}))`); } catch { /* */ } + try { F.dbQuery(`DELETE FROM log_modifiche WHERE tabella='libri' AND record_id IN (${ids.join(',')})`); } catch { /* */ } + } + F.deleteTitles(ids); + }, + () => F.deletePatrons([reader?.id, plain?.id]), + () => F.deleteTypes(PREFIX), + () => M.restoreGate(gate), + // A fresh admin login for the plugins: the suite's own admin page may + // be the very step that failed (context, page or login). + async () => { if (!ncipWasActive) await F.setPluginActiveFresh(browser, 'ncip-server', false); }, + async () => { if (!mobileWasActive) await F.setPluginActiveFresh(browser, 'mobile-api', false); }, + async () => { await adminCtx?.close(); }, + ]); + }); + + test('1. the librarian creates a patron type with its own loan length, daily fine and debt threshold', async () => { + typeId = await F.createTypeViaUi(admin, { + nome: `Integrazione ${RUN}`, codice: `${PREFIX}-t`, durata_prestito_giorni: TYPE_DAYS, + multa_giornaliera: '1,00', giorni_tolleranza: 0, soglia_blocco_debito: '2', + }); + expect(F.dbQuery(`SELECT CONCAT_WS('|', durata_prestito_giorni, multa_giornaliera, IFNULL(giorni_tolleranza,'-'), soglia_blocco_debito) FROM tipologie_lettore WHERE id=${typeId}`)) + .toBe(`${TYPE_DAYS}|1.00|0|2.00`); + // The globals stay empty: the type is the only source of fines. + expect(readSetting('daily_fine')).toBe(''); + expect(readSetting('debt_block_threshold')).toBe(''); + }); + + test('2. the reader gets the type from the user edit form and the record shows its rules', async () => { + await admin.goto(`${F.BASE}/admin/users/edit/${reader.id}`); + await admin.locator('#tipologia_id').selectOption(String(typeId)); + await admin.locator('#user-form button[type="submit"]').click(); + await admin.waitForURL((u) => !u.pathname.includes('/edit/'), { timeout: 20000 }); + expect(admin.url()).not.toContain('error='); + expect(F.dbInt(`SELECT IFNULL(tipologia_id,0) FROM utenti WHERE id=${reader.id}`)).toBe(typeId); + await admin.goto(`${F.BASE}/admin/users/details/${reader.id}`); + await expect(admin.locator('[data-testid="patron-type-name"]')).toContainText(`Integrazione ${RUN}`); + await expect(admin.locator('[data-ledger-blocked]')).toHaveCount(0); + }); + + test('3. a closure on the type due date: the desk loan is proposed and stored on the next open day', async () => { + const raw = addDays(today, TYPE_DAYS); + await addClosure(admin, { start: raw, description: `${TAG} scadenza` }); + await admin.goto(`${F.BASE}/admin/loans/create`); + await F.fillAutocomplete(admin, '#utente_search', '#utente_suggest', reader.surname, '/api/search/utenti'); + await expect(admin.locator('#utente_id')).toHaveValue(String(reader.id)); + const availability = admin.waitForResponse((r) => r.url().includes('/availability') && r.url().includes(`for_user=${reader.id}`), { timeout: 15000 }); + const resolved = admin.waitForResponse((r) => r.url().includes('/admin/copies/by-code')); + await admin.locator('#copy_code').fill(F.copyCodeOf(books.desk.copyIds[0])); + await admin.locator('#copy_code').dispatchEvent('change'); + await resolved; + const data = await (await availability).json(); + expect(Number(data.availability.staff_loan_duration_days)).toBe(TYPE_DAYS); + await expect(admin.locator('#data_scadenza')).toHaveValue(addDays(raw, 1)); + await admin.evaluate(() => document.querySelectorAll('.flatpickr-calendar.open').forEach((c) => c.classList.remove('open'))); + await admin.uncheck('#scarica_pdf').catch(() => {}); + await Promise.all([ + admin.waitForURL((u) => u.toString().includes('created=1') || u.toString().includes('error='), { timeout: 20000 }), + admin.locator('form[action$="/admin/loans/create"] button[type="submit"]:not([name="save_and_new"])').click(), + ]); + expect(admin.url()).toContain('created=1'); + deskLoan = F.dbInt(`SELECT id FROM prestiti WHERE libro_id=${books.desk.id} AND utente_id=${reader.id} ORDER BY id DESC LIMIT 1`); + expect(F.dbQuery(`SELECT data_scadenza FROM prestiti WHERE id=${deskLoan}`)).toBe(addDays(raw, 1)); + }); + + test('4. the late return is fined at the type rate on the open late days only', async () => { + // Due 6 days ago; the library was closed 4 and 3 days ago: 6 - 2 = 4 open late days. + makeOverdue(deskLoan, 6); + await addClosure(admin, { start: addDays(today, -4), end: addDays(today, -3), description: `${TAG} ponte` }); + await admin.goto(`${F.BASE}/admin/loans/details/${deskLoan}`); + await expect(admin.locator('[data-loan-fine-accrued]')).toHaveAttribute('data-loan-fine-accrued', '400'); + const shown = await returnAtDesk(admin, deskLoan); + expect(shown).toBe(400); + expect(F.dbQuery(`SELECT CONCAT(stato,'|',attivo) FROM prestiti WHERE id=${deskLoan}`)).toBe('restituito|0'); + expect(F.dbQuery(`SELECT GROUP_CONCAT(importo) FROM movimenti_lettore WHERE prestito_id=${deskLoan} AND tipo='multa_ritardo'`)).toBe('4.00'); + expect(balanceCents(reader.id)).toBe(400); + // 4,00 owed is over the type threshold of 2,00: the reader record says so. + await admin.goto(`${F.BASE}/admin/users/details/${reader.id}`); + await expect(admin.locator('[data-ledger-balance]')).toHaveAttribute('data-ledger-balance', '400'); + await expect(admin.locator('[data-ledger-blocked]')).toContainText('Prestiti bloccati'); + }); + + test('5. the website refuses the blocked reader with the debt message', async ({ browser }) => { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + try { + await F.loginAs(page, reader.email, PASS); + const { ok, text } = await F.requestFromBookPage(page, books.web.id, today); + expect(ok).toBe(false); + expect(text).toContain(DEBT_MESSAGE); + } finally { + await ctx.close(); + } + expect(F.dbInt(`SELECT COUNT(*) FROM prestiti WHERE utente_id=${reader.id} AND libro_id=${books.web.id}`)).toBe(0); + }); + + test('6. the Mobile API refuses the same reader with the same message', async ({ request }) => { + const token = await M.login(request, reader.email, PASS, `int-${RUN}`); + const r = await M.api(request, 'POST', '/reservations', { token, body: { book_id: books.app.id } }); + expect(r.status, r.text.slice(0, 300)).toBe(403); + expect(r.json.error.code).toBe('not_eligible'); + expect(r.json.error.message).toContain(DEBT_MESSAGE); + expect(F.dbInt(`SELECT COUNT(*) FROM prestiti WHERE utente_id=${reader.id} AND libro_id=${books.app.id}`)).toBe(0); + }); + + test('7. NCIP refuses the checkout to the same reader (User Ineligible)', async ({ request }) => { + const body = await ncip(request, ncipXml('CheckOutItem', reader.id, books.ncip.id)); + expect(body).toContain('User Ineligible To Check Out This Item'); + expect(F.dbInt(`SELECT COUNT(*) FROM prestiti WHERE utente_id=${reader.id} AND libro_id=${books.ncip.id}`)).toBe(0); + }); + + test('8. the payment at the reader record lifts the block on website, app and NCIP', async ({ browser, request }) => { + await admin.goto(`${F.BASE}/admin/users/details/${reader.id}#movimenti`); + await admin.fill('#ledger_pay_amount', '4,00'); + await admin.selectOption('#ledger_pay_method', 'contanti'); + await admin.locator('#ledger-payment-form button[type="submit"]').click(); + await expect(admin.locator('[data-ledger-flash="paid"]')).toBeVisible(); + expect(balanceCents(reader.id)).toBe(0); + await expect(admin.locator('[data-ledger-blocked]')).toHaveCount(0); + + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + try { + await F.loginAs(page, reader.email, PASS); + const { ok, text } = await F.requestFromBookPage(page, books.web.id, today); + expect(ok, text).toBe(true); + } finally { + await ctx.close(); + } + expect(F.dbQuery(`SELECT stato FROM prestiti WHERE utente_id=${reader.id} AND libro_id=${books.web.id} ORDER BY id DESC LIMIT 1`)).toBe('pendente'); + + const token = await M.login(request, reader.email, PASS, `int2-${RUN}`); + const r = await M.api(request, 'POST', '/reservations', { token, body: { book_id: books.app.id } }); + expect(r.status, r.text.slice(0, 300)).toBeLessThan(300); + expect(F.dbInt(`SELECT COUNT(*) FROM prestiti WHERE utente_id=${reader.id} AND libro_id=${books.app.id}`)).toBe(1); + + const body = await ncip(request, ncipXml('CheckOutItem', reader.id, books.ncip.id)); + expect(body).toContain('CheckOutItemResponse'); + expect(body).not.toContain('User Ineligible'); + // The NCIP loan follows the type length, moved over a closure if one falls on it. + const ncipDue = F.dbQuery(`SELECT data_scadenza FROM prestiti WHERE utente_id=${reader.id} AND libro_id=${books.ncip.id} AND attivo=1`); + expect(ncipDue).toBe(addDays(today, TYPE_DAYS + 1)); + }); + + test('9. a reader of the default type, just as late over the same closures, pays nothing (globals empty)', async () => { + const copyId = books.plain.copyIds[0]; + F.dbQuery(`UPDATE copie SET stato='prestato' WHERE id=${copyId}`); + F.dbQuery(`UPDATE libri SET copie_disponibili=0, stato='prestato' WHERE id=${books.plain.id}`); + F.dbQuery(`INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo) VALUES (${books.plain.id}, ${copyId}, ${plain.id}, '${F.appDateOffsetISO(-20)}', '${F.appDateOffsetISO(-6)}', 'in_ritardo', 'diretto', 1)`); + const loanId = F.dbInt(`SELECT id FROM prestiti WHERE libro_id=${books.plain.id} AND utente_id=${plain.id} ORDER BY id DESC LIMIT 1`); + await admin.goto(`${F.BASE}/admin/loans/returned/${loanId}`); + await expect(admin.locator('#fine-preview')).toHaveCount(0); + const shown = await returnAtDesk(admin, loanId); + expect(shown).toBe(0); + expect(F.dbQuery(`SELECT stato FROM prestiti WHERE id=${loanId}`)).toBe('restituito'); + expect(F.dbInt(`SELECT COUNT(*) FROM movimenti_lettore WHERE utente_id=${plain.id}`)).toBe(0); + await admin.goto(`${F.BASE}/admin/users/details/${plain.id}`); + await expect(admin.locator('[data-ledger-blocked]')).toHaveCount(0); + }); +}); diff --git a/tests/stack-090-review-fixes.spec.js b/tests/stack-090-review-fixes.spec.js new file mode 100644 index 000000000..8bb2d9e77 --- /dev/null +++ b/tests/stack-090-review-fixes.spec.js @@ -0,0 +1,564 @@ +// @ts-check +/** + * Review fixes made late in the 0.9.0 stack, driven like a librarian would: + * + * A. the closures calendar and the visitor-log retention are admin-only: + * staff gets neither the forms nor the actions (GET and every POST); + * B. the reader-ledger desk forms are recorded once (charge and waiver sent + * twice), and a replayed key with other values is refused; + * C. stocktake: a copy recorded as damaged that turns up is reported as + * found again, and a "mark missing as lost" close leaves it alone; + * D. MARC: 111/711 meeting entries ($e unit, $j relator), an overwrite that + * changes the media type clears stale RDA overrides (same type keeps + * them), an oversized record is exported reduced instead of skipped; + * E. #501 quoted search on the search boxes the stack added: the suppliers + * list (client-side filter), the users list with the patron type filter, + * the book list with the supplier filter. + * + * The DB is used only for fixtures and to verify the effects. + * + * Run: /tmp/run-e2e.sh tests/stack-090-review-fixes.spec.js --config=tests/playwright.config.js --workers=1 + */ +const { test, expect } = require('@playwright/test'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); +const F = require('./helpers/circulation-fixtures'); +const MARC = require('./helpers/marc-e2e'); + +test.skip(!F.CONFIGURED, 'E2E credentials not configured'); + +const RUN = String(Date.now()).slice(-7); +const ROOT = path.join(__dirname, '..'); +const PASS = 'Circ1234!'; +const S = F.sqlStr; + +function isbn13(twelve) { + const digits = String(twelve).padStart(12, '0').slice(-12); + const sum = digits.split('').reduce((acc, d, i) => acc + Number(d) * (i % 2 ? 3 : 1), 0); + return digits + String((10 - (sum % 10)) % 10); +} +const xmlEsc = (s) => String(s).replace(/&/g, '&').replace(//g, '>'); +/** A MARCXML collection file; records = [{leader, fields: [[tag, ind1, ind2, [[code, value]...]]...]}]. */ +function marcXmlFile(name, records) { + const body = records.map((r) => `${r.leader}` + + r.fields.map(([tag, i1, i2, subs]) => `${subs.map(([c, v]) => `${xmlEsc(v)}`).join('')}`).join('') + + '').join(''); + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'pinakes-rf-')), name); + fs.writeFileSync(file, `${body}`); + return file; +} + +// ── A. Admin-only settings for staff ───────────────────────────────────── +test.describe.serial('A. closures calendar and visitor-log retention are reserved to administrators', () => { + /** @type {import('@playwright/test').Page} */ let staffPage; + /** @type {import('@playwright/test').Page} */ let adminPage; + let staff; + let closureId = 0; + let presenzaId = 0; + const TAG = `E2E-RFA-${RUN}`; + let savedWeekdays = null; + let savedRetention = null; + const calendarSnapshot = () => F.dbQuery("SELECT CONCAT(COUNT(*),'|',COALESCE(SUM(CRC32(CONCAT_WS('|',id,data_inizio,data_fine,ricorrente_annuale,IFNULL(sede_id,''),IFNULL(descrizione,'')))),0)) FROM calendario_chiusure") + + '#' + F.dbQuery("SELECT IFNULL(MAX(setting_value),'-') FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'"); + + test.beforeAll(async ({ browser }) => { + // Row state, not just the text: an empty value is not an absent row. + savedWeekdays = F.readSettingState('loans', 'closed_weekdays'); + savedRetention = F.readSettingState('attendance', 'retention_days'); + adminPage = await (await browser.newContext()).newPage(); + await F.loginAsAdmin(adminPage); + // A closure made by the administrator from the calendar page. + await adminPage.goto(`${F.BASE}/admin/calendar`); + await adminPage.fill('#closure_start', F.appDateOffsetISO(40)); + await adminPage.fill('#closure_description', TAG); + await Promise.all([adminPage.waitForURL(/\/admin\/calendar/), adminPage.locator('#save-closure').click()]); + closureId = F.dbInt(`SELECT id FROM calendario_chiusure WHERE descrizione=${S(TAG)}`); + expect(closureId).toBeGreaterThan(0); + // A visit older than any retention, which a purge would remove. + F.dbQuery(`INSERT INTO presenze (tipo, nome_ospite, motivo, created_at) VALUES ('ospite', ${S(TAG)}, 'E2E', DATE_SUB(NOW(), INTERVAL 4000 DAY))`); + presenzaId = F.dbInt(`SELECT id FROM presenze WHERE nome_ospite=${S(TAG)}`); + staff = F.createPatron(`RFA${RUN}`, { role: 'staff', password: PASS }); + staffPage = await (await browser.newContext()).newPage(); + await F.loginAs(staffPage, staff.email, PASS); + }); + + test.afterAll(async () => { + F.dbQuery(`DELETE FROM calendario_chiusure WHERE descrizione=${S(TAG)}`); + F.dbQuery(`DELETE FROM presenze WHERE nome_ospite=${S(TAG)}`); + if (savedWeekdays) F.restoreSettingState('loans', 'closed_weekdays', savedWeekdays); + if (savedRetention) F.restoreSettingState('attendance', 'retention_days', savedRetention); + F.deletePatrons([staff?.id]); + await staffPage?.context().close(); + await adminPage?.context().close(); + }); + + test('A1. staff opening the calendar page lands on the dashboard with the admin-only message', async () => { + await staffPage.goto(`${F.BASE}/admin/calendar`); + await expect(staffPage).toHaveURL(/\/admin\/dashboard/); + await expect(staffPage.locator('body')).toContainText(/riservata agli amministratori/i); + await expect(staffPage.locator('#save-closure')).toHaveCount(0); + }); + + test('A2. every calendar action sent by staff is refused and changes nothing', async () => { + const before = calendarSnapshot(); + await staffPage.goto(`${F.BASE}/admin/dashboard`); + const csrf = String(await staffPage.locator('meta[name="csrf-token"]').getAttribute('content')); + const day = F.appDateOffsetISO(41); + const posts = [ + ['/admin/calendar/weekdays', { 'closed_weekdays[]': '0' }], + ['/admin/calendar/closures', { data_inizio: day, data_fine: day, descrizione: `${TAG} intruso` }], + [`/admin/calendar/closures/${closureId}/update`, { data_inizio: day, data_fine: day, descrizione: `${TAG} cambiato` }], + [`/admin/calendar/closures/${closureId}/delete`, {}], + ['/admin/calendar/preset', { year: day.slice(0, 4) }], + ]; + for (const [route, fields] of posts) { + const r = await staffPage.request.post(`${F.BASE}${route}`, { form: { csrf_token: csrf, ...fields }, maxRedirects: 0 }); + expect(r.status(), route).toBe(302); + expect(r.headers()['location'] || '', route).toMatch(/\/admin\/dashboard/); + } + const ics = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nDTSTART;VALUE=DATE:${day.replace(/-/g, '')}\r\nSUMMARY:${TAG} ics\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`; + const r = await staffPage.request.post(`${F.BASE}/admin/calendar/import-ics`, { + multipart: { csrf_token: csrf, ics_file: { name: 'chiusure.ics', mimeType: 'text/calendar', buffer: Buffer.from(ics) } }, + maxRedirects: 0, + }); + expect(r.status()).toBe(302); + expect(r.headers()['location'] || '').toMatch(/\/admin\/dashboard/); + expect(calendarSnapshot()).toBe(before); + expect(F.dbQuery(`SELECT descrizione FROM calendario_chiusure WHERE id=${closureId}`)).toBe(TAG); + }); + + test('A3. the administrator edits the same closure from the calendar page', async () => { + await adminPage.goto(`${F.BASE}/admin/calendar?edit=${closureId}`); + await expect(adminPage.locator('#closure_description')).toHaveValue(TAG); + await adminPage.fill('#closure_description', `${TAG}`); + await adminPage.fill('#closure_end', F.appDateOffsetISO(42)); + await Promise.all([adminPage.waitForURL(/\/admin\/calendar/), adminPage.locator('#save-closure').click()]); + expect(F.dbQuery(`SELECT data_fine FROM calendario_chiusure WHERE id=${closureId}`)).toBe(F.appDateOffsetISO(42)); + }); + + test('A4. staff sees no retention forms on the visitor log, and posting them changes nothing', async () => { + await staffPage.goto(`${F.BASE}/admin/attendance`); + await expect(staffPage).toHaveURL(/\/admin\/attendance/); + await expect(staffPage.locator('#attendance-retention-form')).toHaveCount(0); + await expect(staffPage.locator('#attendance-purge-form')).toHaveCount(0); + const csrf = String(await staffPage.locator('meta[name="csrf-token"]').getAttribute('content')); + for (const [route, fields] of [['/admin/attendance/settings', { retention_days: '1' }], ['/admin/attendance/purge', {}]]) { + const r = await staffPage.request.post(`${F.BASE}${route}`, { form: { csrf_token: csrf, ...fields }, maxRedirects: 0 }); + expect(r.status(), route).toBe(302); + } + expect(F.readSettingState('attendance', 'retention_days')).toEqual(savedRetention); + expect(F.dbInt(`SELECT COUNT(*) FROM presenze WHERE id=${presenzaId}`), 'the old visit is still there').toBe(1); + // The administrator's purge removes it. The purge deletes every visit + // older than the retention, not only this one: the other rows are + // snapshotted first and put back afterwards, so the shared DB keeps them. + // Only the snapshot's own rows are put back: a visit recorded meanwhile + // stays. + const otherVisits = F.stashRows('presenze', `id<>${presenzaId}`); + try { + await adminPage.goto(`${F.BASE}/admin/attendance`); + await adminPage.locator('#attendance-purge-form button[type="submit"]').click(); + const confirm = adminPage.locator('.swal2-confirm'); + if (await confirm.isVisible({ timeout: 3000 }).catch(() => false)) await confirm.click(); + await expect.poll(() => F.dbInt(`SELECT COUNT(*) FROM presenze WHERE id=${presenzaId}`), { timeout: 15000 }).toBe(0); + } finally { + F.unstashRows(otherVisits, { onlySnapshotRows: true }); + } + }); +}); + +// ── B. Ledger desk forms recorded once ─────────────────────────────────── +test.describe.serial('B. reader ledger desk forms', () => { + /** @type {import('@playwright/test').Page} */ let page; + let patron; + const movements = (tipo) => F.dbInt(`SELECT COUNT(*) FROM movimenti_lettore WHERE utente_id=${patron.id} AND tipo=${S(tipo)}`); + const balance = () => Math.round(Number(F.dbQuery(`SELECT COALESCE(SUM(importo),0) FROM movimenti_lettore WHERE utente_id=${patron.id}`)) * 100); + + /** Fill a form, read it as the browser would send it, post it $times times. */ + async function sendTwice(formSelector, fill) { + await page.goto(`${F.BASE}/admin/users/details/${patron.id}`); + await fill(); + const form = page.locator(formSelector); + const fields = await form.evaluate((f) => Object.fromEntries(new FormData(/** @type {HTMLFormElement} */ (f)).entries())); + expect(String(fields.request_key)).toMatch(/^[a-f0-9]{32}$/); + const action = new URL(String(await form.getAttribute('action')), F.BASE).toString(); + const locations = []; + for (let i = 0; i < 2; i++) { + const r = await page.request.post(action, { form: /** @type {Record} */ (fields), maxRedirects: 0 }); + expect(r.status()).toBe(302); + locations.push(r.headers()['location'] || ''); + } + return { fields, action, locations }; + } + + test.beforeAll(async ({ browser }) => { + patron = F.createPatron(`RFB${RUN}`, { password: PASS }); + page = await (await browser.newContext()).newPage(); + await F.loginAsAdmin(page); + }); + test.afterAll(async () => { + F.deletePatrons([patron?.id]); + await page?.context().close(); + }); + + test('B1. a manual charge sent twice (double click) is recorded once', async () => { + const { locations } = await sendTwice('#ledger-charge-form', async () => { + await page.fill('#ledger_charge_amount', '6,00'); + await page.fill('#ledger_charge_description', `Tessera smarrita ${RUN}`); + }); + expect(locations.every((l) => !l.includes('ledger_error'))).toBe(true); + expect(movements('addebito_manuale')).toBe(1); + expect(balance()).toBe(600); + }); + + test('B2. a waiver sent twice is recorded once', async () => { + const { locations } = await sendTwice('#ledger-waive-form', async () => { + await page.fill('#ledger_waive_amount', '2,00'); + await page.fill('#ledger_waive_reason', 'Primo anno di iscrizione'); + }); + expect(locations.every((l) => !l.includes('ledger_error'))).toBe(true); + expect(movements('condono')).toBe(1); + expect(balance()).toBe(400); + }); + + test('B3. the same form key replayed with another amount is refused, not answered with the old movement', async () => { + const { fields, action } = await sendTwice('#ledger-payment-form', async () => { + await page.fill('#ledger_pay_amount', '1,00'); + }); + expect(movements('pagamento')).toBe(1); + expect(balance()).toBe(300); + const r = await page.request.post(action, { form: { ...fields, importo: '3,00' }, maxRedirects: 0 }); + expect(r.status()).toBe(302); + expect(r.headers()['location'] || '').toContain('ledger_error=duplicate_request'); + expect(movements('pagamento')).toBe(1); + expect(balance()).toBe(300); + // The page tells the operator what happened. + await page.goto(new URL(String(r.headers()['location']), F.BASE).toString()); + await expect(page.locator('body')).toContainText('Richiesta già registrata'); + }); +}); + +// ── C. Stocktake: a damaged copy turns up ──────────────────────────────── +test.describe.serial('C. stocktake and copies recorded out of circulation', () => { + /** @type {import('@playwright/test').Page} */ let page; + const TAG = `RFC${RUN}`; + const fx = { shelf: 0, book: 0, copies: {}, session: 0 }; + const code = (c) => `${TAG}-${c}`; + const state = (c) => F.dbQuery(`SELECT stato FROM copie WHERE id=${fx.copies[c]}`); + + async function scan(value) { + const answered = page.waitForResponse((r) => r.url().includes('/scan') && r.request().method() === 'POST'); + await page.locator('#inventory-code').pressSequentially(value, { delay: 10 }); + await page.locator('#inventory-code').press('Enter'); + const body = await (await answered).json(); + await expect(page.locator('#inventory-feedback')).toHaveAttribute('data-esito', body.result.esito); + return body.result; + } + + test.beforeAll(async ({ browser }) => { + F.dbQuery(`INSERT INTO scaffali (codice, nome, lettera) VALUES (${S(TAG)}, ${S('Revisione ' + TAG)}, 'R')`); + fx.shelf = F.dbInt(`SELECT id FROM scaffali WHERE codice=${S(TAG)}`); + F.dbQuery(`INSERT INTO libri (titolo, scaffale_id, collocazione, copie_totali, copie_disponibili) VALUES (${S(TAG + ' libro')}, ${fx.shelf}, ${S(TAG + '.1')}, 3, 2)`); + fx.book = F.dbInt(`SELECT id FROM libri WHERE titolo=${S(TAG + ' libro')} AND deleted_at IS NULL`); + for (const [c, st] of [['OK', 'disponibile'], ['MISS', 'disponibile'], ['DMG', 'danneggiato']]) { + F.dbQuery(`INSERT INTO copie (libro_id, numero_inventario, stato) VALUES (${fx.book}, ${S(code(c))}, ${S(st)})`); + fx.copies[c] = F.dbInt(`SELECT id FROM copie WHERE numero_inventario=${S(code(c))}`); + } + page = await (await browser.newContext()).newPage(); + await F.loginAsAdmin(page); + }); + test.afterAll(async () => { + const sessions = F.dbQuery(`SELECT id FROM inventario_sessioni WHERE nome LIKE ${S(TAG + '%')}`).split('\n').filter(Boolean); + if (sessions.length) { + F.dbQuery(`DELETE FROM log_modifiche WHERE tabella='inventario_sessioni' AND record_id IN (${sessions.join(',')})`); + // Children first, in FK order, without relying on ON DELETE CASCADE. + F.dbQuery(`DELETE FROM inventario_scansioni WHERE sessione_id IN (${sessions.join(',')})`); + F.dbQuery(`DELETE FROM inventario_attese WHERE sessione_id IN (${sessions.join(',')})`); + F.dbQuery(`DELETE FROM inventario_sessioni WHERE id IN (${sessions.join(',')})`); + } + if (fx.book) { + F.dbQuery(`DELETE FROM log_modifiche WHERE tabella='libri' AND record_id=${fx.book}`); + F.dbQuery(`DELETE FROM copie WHERE libro_id=${fx.book}`); + F.dbQuery(`DELETE FROM libri WHERE id=${fx.book}`); + } + F.dbQuery(`DELETE FROM scaffali WHERE codice=${S(TAG)}`); + await page?.context().close(); + }); + + test('C1. a copy recorded as damaged that turns up is reported as found again, with its recorded state', async () => { + await page.goto(`${F.BASE}/admin/inventory`); + await page.fill('#inventory-name', `${TAG} danneggiata`); + await page.selectOption('#inventory-shelf', String(fx.shelf)); + await page.locator('#inventory-open-form button[type="submit"]').click(); + await page.waitForURL(/\/admin\/inventory\/\d+$/, { timeout: 15000 }); + fx.session = Number(new URL(page.url()).pathname.split('/').pop()); + // The damaged copy is not expected on the shelf: it is out of the snapshot. + expect(F.dbInt(`SELECT COUNT(*) FROM inventario_attese WHERE sessione_id=${fx.session}`)).toBe(2); + expect((await scan(code('OK'))).esito).toBe('trovata'); + const turnedUp = await scan(code('DMG')); + expect(turnedUp.esito).toBe('fuori_ambito'); + expect(turnedUp.fuori_circolazione).toBe(true); + await expect(page.locator('#inventory-feedback')).toContainText('risulta persa o fuori circolazione, ma è stata ritrovata'); + await expect(page.locator('#inventory-feedback')).toContainText('(danneggiato)'); + await expect(page.locator('#inventory-feedback')).not.toContainText('non rientra nel filtro della revisione'); + }); + + test('C2. closing with "mark missing as lost" marks only the missing copy and leaves the turned-up one as recorded', async () => { + await page.goto(`${F.BASE}/admin/inventory/${fx.session}`); + await page.locator('#inventory-close-form input[value="perso"]').check(); + await page.locator('#inventory-close-form button[type="submit"]').click(); + await expect(page.locator('.swal2-confirm')).toBeVisible({ timeout: 10000 }); + await page.locator('.swal2-confirm').click(); + await page.waitForURL(new RegExp(`/admin/inventory/${fx.session}$`)); + await expect(page.locator('#inventory-state')).toHaveText('Chiusa'); + await expect(page.locator('#inventory-report tr[data-categoria="mancante"]')).toContainText(code('MISS')); + expect(state('MISS')).toBe('perso'); + expect(state('OK')).toBe('disponibile'); + expect(state('DMG')).toBe('danneggiato'); + }); +}); + +// ── D. MARC review fixes ───────────────────────────────────────────────── +test.describe.serial('D. MARC import and export review fixes', () => { + test.describe.configure({ timeout: 180000 }); + /** @type {import('@playwright/test').Page} */ let page; + const MRUN = `RFD${RUN}`; + const isbnMeeting = isbn13(`978${RUN}11`); + const isbnDvd = isbn13(`978${RUN}22`); + const isbnSame = isbn13(`978${RUN}33`); + + test.beforeAll(async ({ browser }) => { + page = await (await browser.newContext()).newPage(); + await MARC.loginAsAdmin(page); + }); + test.afterAll(async () => { + MARC.cleanupRun(MRUN); + await page?.context().close(); + }); + + test('D1. a meeting entry (111/711) keeps its subordinate unit ($e) in the name and reads $j as the relator', async () => { + const file = marcXmlFile(`meeting-${MRUN}.xml`, [{ + leader: '00000cam a2200000 a 4500', + fields: [ + ['020', ' ', ' ', [['a', isbnMeeting]]], + ['111', '2', ' ', [['a', `Convegno ${MRUN}`], ['e', 'Comitato scientifico.'], ['d', '(2001 : Milano)']]], + ['245', '1', '0', [['a', `Atti ${MRUN}`]]], + ['711', '2', ' ', [['a', `Seminario ${MRUN}`], ['e', 'Sezione storica.'], ['j', 'editor.']]], + ], + }]); + const upload = await MARC.uploadForPreview(page, file); + expect(upload.success).toBe(true); + const result = await MARC.confirmImport(page); + expect(result.imported).toBe(1); + const roles = F.dbQuery(`SELECT GROUP_CONCAT(CONCAT(la.ruolo,'=',a.nome) ORDER BY a.nome SEPARATOR ';') FROM libri l JOIN libri_autori la ON la.libro_id=l.id JOIN autori a ON a.id=la.autore_id WHERE l.titolo=${S(`Atti ${MRUN}`)} AND l.deleted_at IS NULL`); + expect(roles).toBe(`principale=Convegno ${MRUN}. Comitato scientifico;curatore=Seminario ${MRUN}. Sezione storica`); + }); + + test('D2. an overwrite that turns a book into a DVD clears the RDA overrides of the old material', async () => { + const title = `Film ${MRUN}`; + F.dbQuery(`INSERT INTO libri (titolo, isbn13, tipo_media, rda_media_type, rda_carrier_type, copie_totali, copie_disponibili) VALUES (${S(title)}, ${S(isbnDvd)}, 'libro', 'h', 'nb', 1, 1)`); + const file = marcXmlFile(`dvd-${MRUN}.xml`, [{ + leader: '00000cgm a2200000 a 4500', + fields: [ + ['020', ' ', ' ', [['a', isbnDvd]]], + ['245', '1', '0', [['a', title]]], + ['336', ' ', ' ', [['a', 'two-dimensional moving image'], ['b', 'tdi'], ['2', 'rdacontent']]], + ], + }]); + const upload = await MARC.uploadForPreview(page, file, { duplicates: 'overwrite' }); + expect(upload.preview[0].existing_id).toBeGreaterThan(0); + const result = await MARC.confirmImport(page); + expect(result.updated).toBe(1); + expect(F.dbQuery(`SELECT CONCAT_WS('|', tipo_media, IFNULL(rda_content_type,'-'), IFNULL(rda_media_type,'-'), IFNULL(rda_carrier_type,'-')) FROM libri WHERE isbn13=${S(isbnDvd)} AND deleted_at IS NULL`)) + .toBe('dvd|-|-|-'); + // The book page now describes a video, not a sheet of microform. + const id = F.dbInt(`SELECT id FROM libri WHERE isbn13=${S(isbnDvd)} AND deleted_at IS NULL`); + await page.goto(`${F.BASE}/admin/books/${id}`); + await expect(page.locator('body')).not.toContainText('microform'); + }); + + test('D3. an overwrite with the same media type keeps the book\'s RDA overrides', async () => { + const title = `Mappa ${MRUN}`; + F.dbQuery(`INSERT INTO libri (titolo, isbn13, tipo_media, rda_media_type, rda_carrier_type, copie_totali, copie_disponibili) VALUES (${S(title)}, ${S(isbnSame)}, 'libro', 'h', 'nb', 1, 1)`); + const file = marcXmlFile(`same-${MRUN}.xml`, [{ + leader: '00000cam a2200000 a 4500', + fields: [['020', ' ', ' ', [['a', isbnSame]]], ['245', '1', '0', [['a', `${title} (nuova edizione)`]]]], + }]); + await MARC.uploadForPreview(page, file, { duplicates: 'overwrite' }); + const result = await MARC.confirmImport(page); + expect(result.updated).toBe(1); + expect(F.dbQuery(`SELECT CONCAT_WS('|', titolo, tipo_media, IFNULL(rda_media_type,'-'), IFNULL(rda_carrier_type,'-')) FROM libri WHERE isbn13=${S(isbnSame)} AND deleted_at IS NULL`)) + .toBe(`${title} (nuova edizione)|libro|h|nb`); + }); + + test('D4. a book too large for ISO 2709 even shortened is exported as a reduced record, never skipped', async () => { + const title = `Enciclopedia ${MRUN}`; + const token = `zzbig${MRUN}`.toLowerCase(); + F.dbQuery(`INSERT INTO libri (titolo, search_index, descrizione, copie_totali, copie_disponibili) VALUES (${S(title)}, ${S(`${title} ${token}`)}, REPEAT('lunga descrizione ', 800), 1, 1)`); + const bookId = F.dbInt(`SELECT id FROM libri WHERE titolo=${S(title)} AND deleted_at IS NULL`); + // 450 contributors with long names: more than 99,999 bytes of 700 fields. + F.dbQuery(`INSERT INTO autori (nome) WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 450) + SELECT CONCAT('Autore ${MRUN} ', LPAD(i, 3, '0'), ' ', REPEAT('n', 220)) FROM n`); + F.dbQuery(`INSERT INTO libri_autori (libro_id, autore_id, ruolo) SELECT ${bookId}, id, IF(nome LIKE 'Autore ${MRUN} 001 %', 'principale', 'co-autore') FROM autori WHERE nome LIKE 'Autore ${MRUN} %'`); + expect(F.dbInt(`SELECT COUNT(*) FROM libri_autori WHERE libro_id=${bookId}`)).toBe(450); + + await page.goto(`${F.BASE}/admin/books?keywords=${encodeURIComponent(token)}`); + await expect(page.locator('.row-select')).toHaveCount(1, { timeout: 15000 }); + await page.locator('#select-all').check(); + await expect(page.locator('#selected-count')).toHaveText('1'); + await page.click('#bulk-export-marc'); + await page.locator('.swal2-select').selectOption('marc21'); + const [download] = await Promise.all([page.waitForEvent('download'), page.click('.swal2-confirm')]); + const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'pinakes-rf-export-')), download.suggestedFilename()); + await download.saveAs(file); + const read = JSON.parse(execFileSync('php', [path.join(ROOT, 'tests', 'helpers', 'marc-inspect.php'), file], { encoding: 'utf-8' })); + expect(read.errors).toBe(0); + expect(read.records).toHaveLength(1); + expect(read.records[0].titolo).toBe(title); + // Reduced: exactly 20 contributors (MarcExportController::REDUCED_AUTHORS) + // of the 450, the first one kept. Fewer would mean the export lost some. + expect(read.records[0].autori).toHaveLength(20); + expect(read.records[0].autori[0]).toContain(`Autore ${MRUN} 001`); + }); +}); + +// ── E. #501 quoted search on the boxes the stack added ─────────────────── +test.describe.serial('E. quoted search (#501) on the new search boxes', () => { + /** @type {import('@playwright/test').Page} */ let page; + const ERUN = `RFE${RUN}`; + const suppliers = {}; + const books = []; + const patrons = []; + let typeId = 0; + const visibleSuppliers = async () => (await page.locator('[data-supplier-row]:not(.hidden) td:first-child a').allInnerTexts()).map((t) => t.trim()).sort(); + + async function filterSuppliers(value) { + await page.locator('#search_fornitore').fill(value); + await page.locator('#search_fornitore').dispatchEvent('input'); + } + + test.beforeAll(async ({ browser }) => { + for (const [key, name] of [['umlaut', `Bücherei Müller ${ERUN}`], ['plain', `Bucherei Muller ${ERUN}`], ['other', `Libreria Rossi ${ERUN}`]]) { + F.dbQuery(`INSERT INTO fornitori (nome) VALUES (${S(name)})`); + suppliers[key] = { id: F.dbInt(`SELECT id FROM fornitori WHERE nome=${S(name)} COLLATE utf8mb4_bin`), name }; + } + F.dbQuery(`INSERT INTO tipologie_lettore (codice, nome, predefinita, attiva, ordinamento) VALUES (${S(ERUN.toLowerCase())}, ${S('Tipo ' + ERUN)}, 0, 1, 99)`); + typeId = F.dbInt(`SELECT id FROM tipologie_lettore WHERE codice=${S(ERUN.toLowerCase())}`); + // Own inserts: the card codes must differ by more than an accent (unique index, unicode_ci). + for (const [i, surname, type] of [['a', `Häffner${ERUN}`, typeId], ['b', `Haffner${ERUN}`, typeId], ['c', `Häffner${ERUN}`, null]]) { + const email = `rfe-${i}-${RUN}@circ.test`; + F.dbQuery(`INSERT INTO utenti (nome, cognome, email, password, tipo_utente, stato, email_verificata, codice_tessera, privacy_accettata, tipologia_id, created_at) + VALUES ('Circ', ${S(surname)}, ${S(email)}, ${S(F.phpHash(PASS))}, 'standard', 'attivo', 1, ${S(`RFE${i}${RUN}`)}, 1, ${type || 'NULL'}, NOW())`); + patrons.push({ id: F.dbInt(`SELECT id FROM utenti WHERE email=${S(email)}`), email }); + } + for (const [title, supplier] of [[`Brücke ${ERUN}`, suppliers.umlaut.id], [`Brucke ${ERUN}`, suppliers.umlaut.id], [`Brücke ${ERUN} senza`, null]]) { + F.dbQuery(`INSERT INTO libri (titolo, search_index, fornitore_id, copie_totali, copie_disponibili) VALUES (${S(title)}, ${S(title)}, ${supplier || 'NULL'}, 1, 1)`); + books.push(F.dbInt(`SELECT id FROM libri WHERE titolo=${S(title)} COLLATE utf8mb4_bin AND deleted_at IS NULL`)); + } + page = await (await browser.newContext()).newPage(); + await F.loginAsAdmin(page); + }); + test.afterAll(async () => { + F.deleteTitles(books); + F.deletePatrons(patrons.map((p) => p.id)); + F.dbQuery(`DELETE FROM tipologie_lettore WHERE id=${typeId}`); + F.dbQuery(`DELETE FROM fornitori WHERE nome LIKE ${S(`%${ERUN}`)}`); + await page?.context().close(); + }); + + test('E1. suppliers list: an unquoted word finds every spelling, accents or not', async () => { + await page.goto(`${F.BASE}/admin/suppliers`); + await expect(page.locator('#search_fornitore_hint')).toContainText('virgolette'); + await filterSuppliers(`muller ${ERUN}`); + expect(await visibleSuppliers()).toEqual([suppliers.plain.name, suppliers.umlaut.name].sort()); + await filterSuppliers(`MÜLLER ${ERUN}`); + expect(await visibleSuppliers()).toEqual([suppliers.plain.name, suppliers.umlaut.name].sort()); + }); + + test('E2. suppliers list: a quoted word keeps only the exact spelling, case ignored', async () => { + await page.goto(`${F.BASE}/admin/suppliers`); + await filterSuppliers(`"Müller" ${ERUN}`); + expect(await visibleSuppliers()).toEqual([suppliers.umlaut.name]); + await filterSuppliers(`"muller" ${ERUN}`); + expect(await visibleSuppliers()).toEqual([suppliers.plain.name]); + await filterSuppliers(`“bücherei müller” ${ERUN}`); + expect(await visibleSuppliers()).toEqual([suppliers.umlaut.name]); + }); + + test('E3. suppliers list: each word keeps its own rule, and a box with only quotes filters nothing', async () => { + await page.goto(`${F.BASE}/admin/suppliers`); + await filterSuppliers(`bucherei "Muller" ${ERUN}`); + expect(await visibleSuppliers()).toEqual([suppliers.plain.name]); + await filterSuppliers(`"Rossi" muller ${ERUN}`); + expect(await visibleSuppliers()).toEqual([]); + await filterSuppliers('""'); + expect((await visibleSuppliers()).filter((n) => n.endsWith(ERUN))).toEqual(Object.values(suppliers).map((s) => s.name).sort()); + // Every supplier, not only this run's: no row of the list is hidden. + expect(await page.locator('[data-supplier-row]:not(.hidden)').count()).toBe(await page.locator('[data-supplier-row]').count()); + }); + + /** The next list response whose query string has every given value. */ + const listResponse = (api, params) => page.waitForResponse((r) => { + if (!r.url().includes(api)) return false; + const q = new URL(r.url()).searchParams; + return Object.entries(params).every(([k, v]) => (q.get(k) || '') === String(v)); + }, { timeout: 15000 }); + + /** The table ends up drawing exactly the rows of these ids (the API answer alone does not prove it). */ + const expectShownIds = async (table, ids) => { + const boxes = page.locator(`${table} tbody .row-select`); + await expect.poll(async () => (await boxes.evaluateAll((els) => els.map((e) => Number(e.getAttribute('data-id'))))).sort((a, b) => a - b), { timeout: 15000 }) + .toEqual([...ids].sort((a, b) => a - b)); + }; + + test('E4. users list: the patron type filter and a quoted surname combine', async () => { + await page.goto(`${F.BASE}/admin/users`); + let resp = listResponse('/api/utenti', { patron_type_filter: typeId }); + await page.locator('#patron_type_filter').selectOption(String(typeId)); + let json = await (await resp).json(); + const rowsOf = (j) => (j.data || []).map((r) => JSON.stringify(r)).join(' '); + await expectShownIds('#utenti-table', [patrons[0].id, patrons[1].id]); + resp = listResponse('/api/utenti', { patron_type_filter: typeId, search_text: `"Häffner${ERUN}"` }); + await page.locator('#search_text').fill(`"Häffner${ERUN}"`); + await page.locator('#search_text').dispatchEvent('keyup'); + json = await (await resp).json(); + expect(json.recordsFiltered).toBe(1); + expect(rowsOf(json)).toContain(patrons[0].email); + await expectShownIds('#utenti-table', [patrons[0].id]); + resp = listResponse('/api/utenti', { patron_type_filter: typeId, search_text: `haffner${ERUN}` }); + await page.locator('#search_text').fill(`haffner${ERUN}`); + await page.locator('#search_text').dispatchEvent('keyup'); + json = await (await resp).json(); + // Unquoted: both spellings of this type, never the reader of the default type. + expect(json.recordsFiltered).toBe(2); + expect(rowsOf(json)).not.toContain(patrons[2].email); + await expectShownIds('#utenti-table', [patrons[0].id, patrons[1].id]); + }); + + test('E5. book list: the supplier filter and a quoted title combine', async () => { + // The librarian follows "Libri di questo fornitore" from the suppliers list. + await page.goto(`${F.BASE}/admin/suppliers`); + let resp = listResponse('/api/libri', { fornitore_filter: suppliers.umlaut.id }); + await page.locator(`[data-supplier-row] a[href$="/admin/books?fornitore=${suppliers.umlaut.id}"]`).click(); + let json = await (await resp).json(); + const rowsOf = (j) => (j.data || []).map((r) => JSON.stringify(r)).join(' '); + expect(json.recordsFiltered).toBe(2); + await expectShownIds('#libri-table', [books[0], books[1]]); + resp = listResponse('/api/libri', { fornitore_filter: suppliers.umlaut.id, search_text: `"Brücke" ${ERUN}` }); + await page.locator('#search_text').fill(`"Brücke" ${ERUN}`); + await page.locator('#search_text').dispatchEvent('keyup'); + json = await (await resp).json(); + expect(json.recordsFiltered).toBe(1); + expect(rowsOf(json)).toContain(`Brücke ${ERUN}`); + expect(rowsOf(json)).not.toContain('senza'); + await expectShownIds('#libri-table', [books[0]]); + resp = listResponse('/api/libri', { fornitore_filter: suppliers.umlaut.id, search_text: `brucke ${ERUN}` }); + await page.locator('#search_text').fill(`brucke ${ERUN}`); + await page.locator('#search_text').dispatchEvent('keyup'); + json = await (await resp).json(); + expect(json.recordsFiltered).toBe(2); + expect(rowsOf(json)).not.toContain('senza'); + await expectShownIds('#libri-table', [books[0], books[1]]); + }); +}); diff --git a/tests/stack-090-review-fixes.unit.php b/tests/stack-090-review-fixes.unit.php new file mode 100644 index 000000000..999cf41a0 --- /dev/null +++ b/tests/stack-090-review-fixes.unit.php @@ -0,0 +1,238 @@ +addDataField('245', '1', '0', [['a', 'Titolo']]); + foreach ($fields as [$tag, $ind1, $subfields]) { + $record->addDataField($tag, $ind1, ' ', $subfields); + } + return (new Marc21LibriMapper())->map($record); +}; +$m = $map([['110', '1', [['a', 'United States.'], ['b', 'Congress.'], ['b', 'Senate.']]], ['710', '2', [['a', 'Società Dante Alighieri.'], ['b', 'Comitato di Roma.'], ['e', 'editor.']]]]); +$check($m['autori'] === ['United States. Congress. Senate'] && $m['curatore'] === ['Società Dante Alighieri. Comitato di Roma'], + '1 corporate entries (110/710): $b units joined to the name with the final periods dropped, $e is the relator'); +$m = $map([['711', '2', [['a', 'International Congress of Mathematicians.'], ['e', 'Organizing Committee.'], ['j', 'editor.']]], ['711', '2', [['a', 'Giornate di studio.'], ['e', 'Sezione storica.'], ['4', 'edt']]]]); +$check($m['curatore'] === ['International Congress of Mathematicians. Organizing Committee', 'Giornate di studio. Sezione storica'] && $m['autori'] === [], + '2 meeting entries (711): $e is part of the name, the editor role comes from $j or from $4'); +$m = $map([['111', '2', [['a', 'Congresso nazionale.'], ['n', '3.'], ['d', '(2001 :'], ['c', 'Milano)']]]]); +$check($m['autori'] === ['Congresso nazionale'], '3 a 111 with only number, date and place is an author named by its $a'); +$m = $map([['110', '2', [['a', 'Istituto X.'], ['b', 'Ufficio studi.'], ['e', 'editor.']]]]); +$check($m['curatore'] === ['Istituto X. Ufficio studi'], '4 a name part that keeps the period of an initial is not followed by a second period'); + +echo "MARC ISO 2709 export fallbacks\n"; +$cuts = ['stale']; +$bytes = MarcExportController::serializeIso('marc21', ['id' => 7, 'titolo' => 'Piccolo libro', 'descrizione' => 'Breve.'], [['nome' => 'Mario Rossi', 'ruolo' => 'principale']], [['nome' => 'Editrice']], null, $cuts, $level); +$check($bytes !== null && $level === 'full' && $cuts === [] && Iso2709Reader::parse(substr($bytes, 0, -1))->subfield('245', 'a') === 'Piccolo libro', + '5 a normal book is written as the full record, with no cut reported'); +$long = ['id' => 8, 'titolo' => 'T', 'descrizione' => str_repeat('lunga ', 3500)]; +$cuts = []; +$bytes = MarcExportController::serializeIso('unimarc', $long, [], [], null, $cuts, $level); +$check($bytes !== null && $level === 'full' && count($cuts) === 1 && str_contains($cuts[0], 'field 330') && strlen($bytes) < 99999, + '6 UNIMARC: one description over the field limit is cut inside the full record, and that cut is the one reported'); +// The cut must shorten the description, not drop it: 330 $a still holds its opening. +$summary = $bytes !== null ? (string) Iso2709Reader::parse(substr($bytes, 0, -1))->subfield('330', 'a') : ''; +$check(strlen($summary) > 1000 && str_starts_with($long['descrizione'], $summary), + '6b UNIMARC: field 330 $a keeps a prefix of the oversized description'); +$crowd = array_map(static fn (int $i): array => ['nome' => 'Autore ' . str_repeat('n', 200) . " {$i}", 'ruolo' => $i === 1 ? 'principale' : 'co-autore'], range(1, 700)); +$pubs = array_map(static fn (int $i): array => ['nome' => "Editore {$i}"], range(1, 30)); +$cuts = []; +$bytes = MarcExportController::serializeIso('marc21', $long, $crowd, $pubs, null, $cuts, $level); +$reduced = $bytes !== null ? Iso2709Reader::parse(substr($bytes, 0, -1)) : null; +$names = []; +$publishers = []; +foreach ($reduced !== null ? $reduced->fields() : [] as $field) { + if (in_array($field->tag, ['100', '700'], true)) { + $names[] = (string) $field->subfield('a'); + } + if (in_array($field->tag, ['260', '264'], true)) { + array_push($publishers, ...$field->subfieldValues('b')); + } +} +$check($level === 'reduced' && count($names) === 20 && str_ends_with($names[0], ' 1') && str_ends_with($names[19], ' 20') + && count($publishers) === 5 && $publishers[0] === 'Editore 1' && $publishers[4] === 'Editore 5', + '7 700 authors and 30 publishers: the reduced record keeps the first 20 authors and 5 publishers instead of dropping the book'); + +echo "MARC import: RDA overrides and the media type\n"; +$all = static fn (string $column): bool => true; +$dvd = ['tipo_media' => 'dvd', 'rda_media_type' => 'v', 'rda_carrier_type' => 'vf'] + MarcBookData::empty(); +$check(MarcBookImporter::extraColumnUpdates(MarcBookImporter::MODE_OVERWRITE, $dvd, null, $all) === ['rda_media_type' => 'v', 'rda_carrier_type' => 'vf'], + '8 a new book takes the record\'s overrides and nothing is cleared'); +$plainBook = ['tipo_media' => 'libro', 'rda_content_type' => null, 'rda_media_type' => '', 'rda_carrier_type' => null]; +$check(MarcBookImporter::extraColumnUpdates(MarcBookImporter::MODE_OVERWRITE, ['tipo_media' => 'dvd'] + MarcBookData::empty(), $plainBook, $all) === [], + '9 a media type change on a book with no override writes nothing (no pointless NULLs)'); +$printed = ['tipo_media' => 'libro', 'rda_content_type' => 'tct', 'rda_media_type' => 'h', 'rda_carrier_type' => 'nb']; +$check(MarcBookImporter::extraColumnUpdates(MarcBookImporter::MODE_OVERWRITE, $dvd, $printed, $all) === ['rda_content_type' => null, 'rda_media_type' => 'v', 'rda_carrier_type' => 'vf'], + '10 a media type change: the record\'s overrides replace the old ones and the one it lacks is cleared'); + +echo "Zero configuration (0.8.9 behaviour)\n"; +$zero = FineParameters::fromSettings([]); +$calc = new FineCalculator(); +$check(!$zero->finesEnabled() && !$zero->debtBlockEnabled() && $calc->lateFineCents($zero, '2026-01-01', '2026-04-10') === 0, + '11 no fine settings: fines and the debt block are off, 99 days late cost nothing'); +$zeroes = FineParameters::fromSettings(['daily_fine' => '0', 'fine_grace_days' => '0', 'fine_max_per_loan' => '0', 'debt_block_threshold' => '0']); +$check(!$zeroes->finesEnabled() && !$zeroes->debtBlockEnabled() && $zeroes->maxFinePerLoanCents === null, + '12 settings saved as 0 mean "off", exactly as empty ones'); +$check(CirculationPolicy::dueDateFor('2026-01-30', 30) === '2026-03-01' && CirculationPolicy::dueDateFor('2026-03-28', 14) === '2026-04-11', + '13 without a calendar the due date is start + days, across month ends and the DST change'); +$check(CirculationPolicy::dueDateFor('2026-05-10', 0) === '2026-05-10' && CirculationPolicy::dueDateFor('2026-05-10', -3) === '2026-05-10', + '14 a typed date (0 days) is kept as it is, a negative length never moves it back'); + +echo "Fines with closed days\n"; +$weekendsClosed = new FineCalculator(static fn (string $ymd, ?int $branch): bool => (int) (new DateTimeImmutable($ymd))->format('N') < 6); +$daily = FineParameters::fromSettings(['daily_fine' => '0,50', 'fine_grace_days' => '3']); +// Due Friday 2026-10-02, returned Monday 2026-10-12: 10 calendar days, 6 open (Mon-Fri + Mon). +$check($weekendsClosed->daysLate('2026-10-02', '2026-10-12') === 6 && $weekendsClosed->lateFineCents($daily, '2026-10-02', '2026-10-12') === 300, + '15 closed weekends are not late days: 6 open late days at 0,50 over a grace of 3 = 3,00 from day one'); +$check($weekendsClosed->lateFineCents($daily, '2026-10-02', '2026-10-07') === 0, + '16 3 open late days (weekend skipped) stay within the grace of 3'); + +echo "Stocktake classification\n"; +$check(StocktakeService::classify(true, false, false, true, null, null) === 'fuori_ambito' + && StocktakeService::classify(true, true, false, false, null, null) === 'duplicata' + && StocktakeService::classify(false, true, true, true, 1, 2) === 'sconosciuta' + && StocktakeService::classify(true, false, true, true, 1, 2) === 'in_prestito' + && StocktakeService::classify(true, false, true, false, 1, 2) === 'fuori_posto' + && StocktakeService::classify(true, false, true, false, 1, null) === 'trovata', + '17 order: unknown > duplicate > out of snapshot (a turned-up lost copy) > on loan > misplaced > found'); + +echo "Source guard: new searches go through ExactSearch\n"; +// Every 0.9.0 controller/service with a user-typed search: a bare LIKE on +// user input would ignore the quote rule. The collocation range of the +// stocktake filter is a prefix range on a shelf mark, not a text search. +$files = [ + 'app/Controllers/SuppliersController.php', 'app/Controllers/InventoryController.php', 'app/Services/StocktakeService.php', + 'app/Controllers/AttendanceController.php', 'app/Controllers/PatronLedgerController.php', 'app/Controllers/CirculationSettingsController.php', + 'app/Controllers/Admin/LibraryCalendarController.php', 'app/Support/UserImport.php', 'app/Support/PatronTypeImport.php', + 'app/Support/PatronTypeAssignment.php', 'app/Controllers/MarcExportController.php', 'app/Support/Marc/MarcBookImporter.php', + 'app/Services/PatronLedger.php', 'app/Models/AttendanceRepository.php', +]; +$allowed = [ + 'app/Services/StocktakeService.php' => ['(l.collocazione <= ? OR l.collocazione LIKE ?)'], +]; +// A placeholder, or any function call (CONCAT(, LOWER(?), UPPER(:q)...) +// right after LIKE: wrapping the typed value in a function bypasses the +// quote rule just as well. +$bareLike = '/[^\n]*\bLIKE\s+(?:\?|:\w+|\w+\s*\()[^\n]*/i'; +$offenders = []; +foreach ($files as $rel) { + $src = (string) @file_get_contents($root . '/' . $rel); + if ($src === '') { + $offenders[] = "{$rel} (missing)"; + continue; + } + if (preg_match_all($bareLike, $src, $hits) > 0) { + foreach ($hits[0] as $line) { + $ok = false; + foreach ($allowed[$rel] ?? [] as $fragment) { + $ok = $ok || str_contains($line, $fragment); + } + if (!$ok) { + $offenders[] = $rel . ': ' . trim($line); + } + } + } +} +$probes = ["WHERE nome LIKE ?", "WHERE nome LIKE :q", "LIKE CONCAT('%', ?, '%')", "WHERE LOWER(nome) LIKE LOWER(?)", "AND cognome LIKE UPPER( :term )"]; +$check(array_filter($probes, static fn (string $sql): bool => preg_match($bareLike, $sql) !== 1) === [] && preg_match($bareLike, "WHERE nome = ? AND note NOT LIKE 'x%'") !== 1, + '18a the guard sees a placeholder after LIKE bare or wrapped in any function (LOWER(?), UPPER(:term)), not a literal pattern'); +$check($offenders === [], '18 no bare LIKE on typed input (?, :name or a function around them) in the 0.9.0 search code' . ($offenders !== [] ? ' — ' . implode(' | ', $offenders) : '')); +$view = (string) file_get_contents($root . '/app/Views/fornitori/index.php'); +$check(!str_contains($view, ".includes(q)") && str_contains($view, 'QUOTES.includes(ch)') && str_contains($view, "normalize('NFD')") && str_contains($view, 'search_fornitore_hint'), + '19 the suppliers box tokenises quoted groups, folds accents for plain words and shows the quote hint'); + +// 20: the browser tokenizer of the suppliers box against ExactSearch. +// Any combining mark is folded (not only U+0300-U+036F), any script's +// digit makes a following quote an inch mark, and no private-use stand-in +// is substituted (a name holding U+E000 must still be found). +$guardOk = !str_contains($view, 'toLocaleLowerCase') && str_contains($view, '\p{M}') && str_contains($view, '\p{Nd}') && !str_contains($view, '\uE000'); +$parityNote = 'node not found, source guard only'; +$node = trim((string) shell_exec('command -v node 2>/dev/null')); +if ($guardOk && $node !== '') { + $start = strpos($view, ' const QUOTES'); + $end = strpos($view, " search.addEventListener('input'"); + $cases = ['12"', 'monitor 24" lcd', '"1984"', '"Thomas Mann" buddenbrooks', '„Häffner“ rolf', '"unclosed run to end', + '5” floppy', '«Il Mulino» 3"', 'abc "12" x', 'IKEA', '"ACME Books"', 'x 7""y z"', '"12" monitor', + "A\u{1AB0}CME", '١٢"', "foo\u{E000}bar", "\"x\u{E000}y\" 5\""]; + $expected = []; + foreach ($cases as $case) { + $row = []; + foreach (ExactSearch::tokenize($case) as ['term' => $term, 'exact' => $exact]) { + $row[] = ['term' => $exact + ? mb_strtolower((string) \Normalizer::normalize($term, \Normalizer::FORM_C)) + : mb_strtolower((string) preg_replace('/\p{M}/u', '', (string) \Normalizer::normalize($term, \Normalizer::FORM_D))), 'exact' => $exact]; + } + $expected[] = $row; + } + $tmp = tempnam(sys_get_temp_dir(), 'supplier-tok'); + $script = $start !== false && $end !== false && $end > $start ? substr($view, $start, $end - $start) : 'throw new Error("script not found")'; + file_put_contents($tmp, 'const terms = (function () {' . $script . '; return terms; })();' + . 'process.stdout.write(JSON.stringify(' . json_encode($cases, JSON_UNESCAPED_UNICODE) . '.map(terms)));'); + $actual = json_decode((string) shell_exec(escapeshellarg($node) . ' ' . escapeshellarg($tmp) . ' 2>&1'), true); + @unlink($tmp); + $guardOk = $actual === $expected; + $parityNote = $guardOk ? 'node parity on ' . count($cases) . ' queries' : 'node output differs from ExactSearch::tokenize()'; +} +$check($guardOk, '20 the suppliers box tokenises like ExactSearch (inch marks, unclosed quotes) and lower-cases locale-free (' . $parityNote . ')'); + +// 21: ExactSearch keeps a typed U+E000 as typed (it used to come back as a +// double quote, the stand-in it used for inch marks) and treats any script's +// digit before a quote as an inch mark. +$check(ExactSearch::tokenize("foo\u{E000}bar 12\"") === [['term' => "foo\u{E000}bar", 'exact' => false], ['term' => '12"', 'exact' => false]] + && ExactSearch::tokenize('١٢" "x"') === [['term' => '١٢"', 'exact' => false], ['term' => 'x', 'exact' => true]], + '21 ExactSearch::tokenize() keeps U+E000 verbatim and reads an inch mark after any digit'); + +echo "\n{$pass} PASS, {$fail} FAIL\n"; +exit($fail === 0 ? 0 : 1); diff --git a/tests/stack-090-zero-config.spec.js b/tests/stack-090-zero-config.spec.js new file mode 100644 index 000000000..b68a27b6e --- /dev/null +++ b/tests/stack-090-zero-config.spec.js @@ -0,0 +1,347 @@ +// @ts-check +/** + * Zero-config equivalence of the 0.9.0 stack: an install that never touches + * patron types, the rules matrix, the closures calendar, fines or the debt + * threshold must behave as 0.8.9 did. Due dates and renewals follow + * Settings → Loans (loan_duration_days), the pickup deadline follows + * pickup_expiry_days, a late return charges nothing and its email has no + * fine line, and a reader with money owed in the ledger is never blocked on + * the website, the Mobile API or NCIP. + * + * beforeAll puts the install in that state (empty calendar, no matrix rows, + * empty fine settings) and afterAll restores what was there. The DB is used + * only for that, for the fixtures (dates in the application timezone) and to + * verify what the UI wrote. + * + * Run: /tmp/run-e2e.sh tests/stack-090-zero-config.spec.js --config=tests/playwright.config.js --workers=1 + */ +const { test, expect } = require('@playwright/test'); +const fs = require('fs'); +const path = require('path'); +const F = require('./helpers/circulation-fixtures'); +const M = require('./helpers/mobile-api-fixtures'); +const { flushCache } = require('./helpers/flush-cache'); + +test.skip(!F.CONFIGURED, 'E2E credentials not configured'); + +const RUN = String(Date.now()).slice(-6); +const PREFIX = `e2ezc${RUN}`; +const PASS = 'Circ1234!'; +const MAILPIT_API = process.env.MAILPIT_API || 'http://localhost:8025/api/v1'; +const LOAN_SETTINGS = ['daily_fine', 'fine_grace_days', 'fine_max_per_loan', 'debt_block_threshold', 'closed_weekdays', 'auto_approve_requests', 'fine_charged_email']; +const NCIP_NS = 'http://www.niso.org/2008/ncip'; + +const ncipXml = (service, userId, itemId) => ` + + <${service}> + ${userId} + ${itemId} + +`; +const ncipAuth = 'Basic ' + Buffer.from(`${F.ADMIN_EMAIL}:${F.ADMIN_PASS}`).toString('base64'); +const ncip = async (request, body) => (await request.post(`${F.BASE}/ncip`, { data: body, headers: { 'Content-Type': 'application/xml', Authorization: ncipAuth } })).text(); + +/** Current value of a setting: a string, null when absent or SQL NULL. */ +const readSetting = (category, key) => F.readSettingState(category, key).value; +/** Set a setting for the test (null removes the row). */ +function writeSetting(category, key, value) { + if (value === null) F.dbQuery(`DELETE FROM system_settings WHERE category=${F.sqlStr(category)} AND setting_key=${F.sqlStr(key)}`); + else F.dbQuery(`INSERT INTO system_settings (category, setting_key, setting_value) VALUES (${F.sqlStr(category)}, ${F.sqlStr(key)}, ${F.sqlStr(value)}) ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value)`); +} +// Drops the file-backed settings cache used when APCu is off (local slots). +// With APCu (CI) this is a no-op: the settings these specs change are read +// through SettingsRepository, which is not cached, and email settings are +// refreshed by saving a settings tab through the app. +function clearConfigCache() { + const cacheDir = path.join(__dirname, '..', 'storage', 'cache'); + if (!fs.existsSync(cacheDir)) return; + for (const name of fs.readdirSync(cacheDir)) { + if (name.startsWith('pinakes_config_settings_raw_')) { + try { fs.unlinkSync(path.join(cacheDir, name)); } catch { /* best effort */ } + } + } +} +function addDays(iso, days) { + const d = new Date(`${iso}T12:00:00Z`); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} +/** A title with one copy lent to $patron, lent 20 days ago and due $late days ago. */ +function overdueLoan(patron, book, late) { + const copyId = book.copyIds[0]; + F.dbQuery(`UPDATE copie SET stato='prestato' WHERE id=${copyId}`); + F.dbQuery(`UPDATE libri SET copie_disponibili=0, stato='prestato' WHERE id=${book.id}`); + F.dbQuery(`INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo) VALUES (${book.id}, ${copyId}, ${patron.id}, '${F.appDateOffsetISO(-20)}', '${F.appDateOffsetISO(-late)}', 'in_ritardo', 'diretto', 1)`); + return F.dbInt(`SELECT id FROM prestiti WHERE libro_id=${book.id} AND utente_id=${patron.id} ORDER BY id DESC LIMIT 1`); +} + +test.describe.serial('0.9.0 with nothing configured behaves as 0.8.9', () => { + test.describe.configure({ timeout: 240000 }); + /** @type {import('@playwright/test').Page} */ let admin; + let adminCtx; + const saved = {}; + let closures; + let rules; + let emailSettings; + let ncipWasActive = true; + let mobileWasActive = true; + let mailpit = false; + let gate = null; + let reader; + let debtor; + const books = {}; + const errors = []; + let today = ''; + let loanDays = 30; + let pickupDays = 3; + let deskLoan = 0; + + test.beforeAll(async ({ browser }) => { + for (const k of LOAN_SETTINGS) saved[k] = F.readSettingState('loans', k); + closures = F.stashRows('calendario_chiusure'); + rules = F.stashRows('regole_prestito'); + emailSettings = F.stashRows('system_settings', "category='email'"); + F.dbQuery('DELETE FROM calendario_chiusure'); + F.dbQuery('DELETE FROM regole_prestito'); + for (const k of ['daily_fine', 'fine_max_per_loan', 'debt_block_threshold']) writeSetting('loans', k, ''); + writeSetting('loans', 'fine_grace_days', '0'); + writeSetting('loans', 'fine_charged_email', '0'); + writeSetting('loans', 'closed_weekdays', null); + writeSetting('loans', 'auto_approve_requests', '0'); + clearConfigCache(); + today = F.appTodayISO(); + loanDays = Number(readSetting('loans', 'loan_duration_days') || 30); + pickupDays = Number(readSetting('loans', 'pickup_expiry_days') || 3); + + reader = F.createPatron(`ZCR${RUN}`, { password: PASS, email: `zc-reader-${RUN}@circ.test` }); + debtor = F.createPatron(`ZCD${RUN}`, { password: PASS, email: `zc-debtor-${RUN}@circ.test` }); + // Money owed in the ledger, as an earlier manual charge would leave it. + F.dbQuery(`INSERT INTO movimenti_lettore (utente_id, tipo, importo, descrizione, created_at) VALUES (${debtor.id}, 'addebito_manuale', 50.00, 'Tessera smarrita', NOW())`); + for (const key of ['desk', 'renew', 'request', 'late', 'web', 'app', 'ncip']) books[key] = F.createTitle(`${PREFIX}-${key}`); + + mobileWasActive = F.pluginActive('mobile-api'); + const helper = await browser.newPage(); + gate = await M.ensureMobileApiReady(helper); + await helper.close(); + + adminCtx = await browser.newContext(); + admin = await adminCtx.newPage(); + admin.on('response', (r) => { if (r.status() >= 500) errors.push(`${r.status()} ${r.url()}`); }); + await F.loginAsAdmin(admin); + ncipWasActive = F.pluginActive('ncip-server'); + await F.setPluginActive(admin, 'ncip-server', true); + try { mailpit = (await fetch(`${MAILPIT_API}/messages`, { signal: AbortSignal.timeout(3000) })).ok; } catch { mailpit = false; } + }); + + // The app dates a loan when the request arrives: read the application day + // again before every test, so a run that crosses midnight compares each + // loan with the day it was actually made on. + test.beforeEach(() => { today = F.appTodayISO(); }); + + test.afterEach(() => { expect(errors.splice(0), 'no HTTP 5xx').toEqual([]); }); + + test.afterAll(async ({ browser }) => { + // Independent restore steps: one failing must not leave the others undone. + await F.runAll([ + // One step per key, and only for keys beforeAll actually read (an + // absent row, SQL NULL and text all come back as they were). + ...LOAN_SETTINGS.map((k) => () => { if (saved[k] !== undefined) F.restoreSettingState('loans', k, saved[k]); }), + () => F.unstashRows(closures), + () => F.unstashRows(rules), + // Always reset the category: an install with no email rows gets none back. + () => F.unstashRows(emailSettings), + () => clearConfigCache(), + // The SQL restore bypasses the app, so the web server's APCu copy of the + // settings would keep the test SMTP values until its TTL: flush it from + // inside the app process. + () => flushCache(), + () => { + for (const p of [reader, debtor]) { + if (!p) continue; + try { F.dbQuery(`DELETE FROM mobile_app_tokens WHERE user_id=${p.id}`); } catch { /* no token table */ } + } + }, + () => { + const ids = Object.values(books).map((b) => b.id); + if (ids.length) { + try { F.dbQuery(`DELETE FROM admin_notifications WHERE related_id IN (SELECT id FROM prestiti WHERE libro_id IN (${ids.join(',')}))`); } catch { /* */ } + try { F.dbQuery(`DELETE FROM log_modifiche WHERE tabella='libri' AND record_id IN (${ids.join(',')})`); } catch { /* */ } + } + F.deleteTitles(ids); + }, + () => F.deletePatrons([reader?.id, debtor?.id]), + () => M.restoreGate(gate), + // A fresh admin login for the plugins: the suite's own admin page may + // be the very step that failed (context, page or login). + async () => { if (!ncipWasActive) await F.setPluginActiveFresh(browser, 'ncip-server', false); }, + async () => { if (!mobileWasActive) await F.setPluginActiveFresh(browser, 'mobile-api', false); }, + async () => { await adminCtx?.close(); }, + ]); + }); + + test('1. the settings, calendar and circulation pages show nothing configured', async () => { + await admin.goto(`${F.BASE}/admin/settings?tab=loans`); + for (const id of ['#daily_fine', '#fine_max_per_loan', '#debt_block_threshold']) await expect(admin.locator(id)).toHaveValue(''); + // Saving the tab unchanged (also refreshes the server's settings cache) enables nothing. + await admin.locator('form[action$="/admin/settings/loans"] button[type="submit"]').click(); + await admin.waitForURL(/tab=loans/, { timeout: 15000 }); + expect(readSetting('loans', 'daily_fine')).toBe(''); + expect(readSetting('loans', 'debt_block_threshold')).toBe(''); + await F.openCirculationTab(admin); + // Only the seeded default type, inheriting every rule; an empty matrix. + await expect(admin.locator('#circulation-types-table tbody tr[data-type-code]')).toHaveCount(F.dbInt('SELECT COUNT(*) FROM tipologie_lettore')); + expect(F.dbInt('SELECT COUNT(*) FROM regole_prestito')).toBe(0); + expect(F.dbInt(`SELECT COUNT(*) FROM tipologie_lettore WHERE predefinita=1 AND durata_prestito_giorni IS NULL AND max_rinnovi IS NULL AND multa_giornaliera IS NULL AND soglia_blocco_debito IS NULL`)).toBe(1); + await admin.goto(`${F.BASE}/admin/calendar`); + await expect(admin.locator('#save-closure')).toBeVisible(); + expect(F.dbInt('SELECT COUNT(*) FROM calendario_chiusure')).toBe(0); + }); + + test('2. the desk loan is due after loan_duration_days, as in 0.8.9', async () => { + await admin.goto(`${F.BASE}/admin/loans/create`); + await expect(admin.locator('#data_scadenza')).toHaveValue(addDays(today, loanDays)); + await F.fillAutocomplete(admin, '#utente_search', '#utente_suggest', reader.surname, '/api/search/utenti'); + await expect(admin.locator('#utente_id')).toHaveValue(String(reader.id)); + const availability = admin.waitForResponse((r) => r.url().includes('/availability') && r.url().includes(`for_user=${reader.id}`), { timeout: 15000 }); + const resolved = admin.waitForResponse((r) => r.url().includes('/admin/copies/by-code')); + await admin.locator('#copy_code').fill(F.copyCodeOf(books.desk.copyIds[0])); + await admin.locator('#copy_code').dispatchEvent('change'); + await resolved; + const data = await (await availability).json(); + expect(Number(data.availability.staff_loan_duration_days)).toBe(loanDays); + await expect(admin.locator('#data_scadenza')).toHaveValue(addDays(today, loanDays)); + await admin.evaluate(() => document.querySelectorAll('.flatpickr-calendar.open').forEach((c) => c.classList.remove('open'))); + await admin.uncheck('#scarica_pdf').catch(() => {}); + await Promise.all([ + admin.waitForURL((u) => u.toString().includes('created=1') || u.toString().includes('error='), { timeout: 20000 }), + admin.locator('form[action$="/admin/loans/create"] button[type="submit"]:not([name="save_and_new"])').click(), + ]); + expect(admin.url()).toContain('created=1'); + deskLoan = F.dbInt(`SELECT id FROM prestiti WHERE libro_id=${books.desk.id} AND utente_id=${reader.id} ORDER BY id DESC LIMIT 1`); + expect(F.dbQuery(`SELECT CONCAT(data_prestito,'|',data_scadenza) FROM prestiti WHERE id=${deskLoan}`)).toBe(`${today}|${addDays(today, loanDays)}`); + }); + + test('3. a renewal adds loan_duration_days to the due date and counts one renewal', async () => { + const copyId = books.renew.copyIds[0]; + const due = addDays(today, 5); + F.dbQuery(`UPDATE copie SET stato='prestato' WHERE id=${copyId}`); + F.dbQuery(`UPDATE libri SET copie_disponibili=0, stato='prestato' WHERE id=${books.renew.id}`); + F.dbQuery(`INSERT INTO prestiti (libro_id, copia_id, utente_id, data_prestito, data_scadenza, stato, origine, attivo) VALUES (${books.renew.id}, ${copyId}, ${reader.id}, '${F.appDateOffsetISO(-3)}', '${due}', 'in_corso', 'diretto', 1)`); + const loanId = F.dbInt(`SELECT id FROM prestiti WHERE libro_id=${books.renew.id} AND utente_id=${reader.id} AND attivo=1`); + await admin.goto(`${F.BASE}/admin/books/${books.renew.id}`); + await admin.getByRole('button', { name: /Rinnova prestito/ }).click(); + await admin.locator('.swal2-confirm').click(); + await admin.waitForFunction((id) => window.location.search.includes('renewed') || window.location.search.includes('error=') || !document.querySelector(`[data-loan-id="${id}"]`), loanId, { timeout: 20000 }).catch(() => {}); + await expect.poll(() => F.dbQuery(`SELECT CONCAT(data_scadenza,'|',renewals) FROM prestiti WHERE id=${loanId}`), { timeout: 15000 }).toBe(`${addDays(due, loanDays)}|1`); + expect(admin.url()).not.toContain('error='); + }); + + test('4. a reader request and its approval: due after loan_duration_days, pickup after pickup_expiry_days', async ({ browser }) => { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + try { + await F.loginAs(page, reader.email, PASS); + const { ok, text } = await F.requestFromBookPage(page, books.request.id, today); + expect(ok, text).toBe(true); + } finally { + await ctx.close(); + } + const loanId = F.dbInt(`SELECT id FROM prestiti WHERE libro_id=${books.request.id} AND utente_id=${reader.id} AND stato='pendente'`); + expect(loanId).toBeGreaterThan(0); + expect(F.dbQuery(`SELECT CONCAT(data_prestito,'|',data_scadenza) FROM prestiti WHERE id=${loanId}`)).toBe(`${today}|${addDays(today, loanDays)}`); + + await admin.goto(`${F.BASE}/admin/loans/pending`); + const approve = admin.locator(`.approve-btn[data-loan-id="${loanId}"]`); + await expect(approve).toBeVisible({ timeout: 15000 }); + await approve.click(); + await admin.waitForSelector('.swal2-popup', { timeout: 10000 }); + await admin.locator('.swal2-confirm').click(); + await expect.poll(() => F.dbQuery(`SELECT stato FROM prestiti WHERE id=${loanId}`), { timeout: 15000 }).toBe('da_ritirare'); + expect(F.dbQuery(`SELECT CONCAT(data_scadenza,'|',IFNULL(pickup_deadline,'-')) FROM prestiti WHERE id=${loanId}`)) + .toBe(`${addDays(today, loanDays)}|${addDays(today, Math.min(pickupDays, loanDays))}`); + }); + + test('5. a late return charges nothing and the loan page shows no fine', async () => { + // In CI Mailpit is a service of the job: without it the e-mail check + // (5b) could not run, so this fails instead. Locally it is optional and + // 5b reports itself as skipped. + if (!mailpit && process.env.CI) { + throw new Error(`Mailpit is not reachable at ${MAILPIT_API} in CI`); + } + if (mailpit) { + F.dbQuery(`INSERT INTO system_settings (category, setting_key, setting_value) VALUES + ('email','type','smtp'),('email','driver_mode','smtp'),('email','smtp_host','localhost'),('email','smtp_port','1025'), + ('email','smtp_security',''),('email','smtp_username',''),('email','smtp_password',''), + ('email','from_email','test@biblioteca.local'),('email','from_name','Pinakes Test') + ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value)`); + clearConfigCache(); + // Saving through the app invalidates the web server's settings cache too. + await admin.goto(`${F.BASE}/admin/settings?tab=loans`); + await admin.locator('form[action$="/admin/settings/loans"] button[type="submit"]').click(); + await admin.waitForURL(/tab=loans/, { timeout: 15000 }); + } + const loanId = overdueLoan(reader, books.late, 12); + await admin.goto(`${F.BASE}/admin/loans/details/${loanId}`); + await expect(admin.locator('[data-loan-fine-accrued]')).toHaveCount(0); + await admin.goto(`${F.BASE}/admin/loans/returned/${loanId}`); + await expect(admin.getByRole('button', { name: 'Conferma restituzione' })).toBeVisible(); + await expect(admin.locator('#fine-preview')).toHaveCount(0); + await expect(admin.locator('#waive_fine')).toHaveCount(0); + await admin.getByRole('button', { name: 'Conferma restituzione' }).click(); + await admin.waitForFunction(() => !window.location.pathname.includes('/admin/loans/returned/'), null, { timeout: 20000 }); + expect(F.dbQuery(`SELECT CONCAT(stato,'|',attivo,'|',restituito_in_ritardo) FROM prestiti WHERE id=${loanId}`)).toBe('restituito|0|1'); + expect(F.dbInt(`SELECT COUNT(*) FROM movimenti_lettore WHERE utente_id=${reader.id}`)).toBe(0); + }); + + test('5b. the e-mail for that late return has no fine line', async () => { + test.skip(!mailpit, `Mailpit is not reachable at ${MAILPIT_API}`); + const search = async (query) => (await (await fetch(`${MAILPIT_API}/search?query=${encodeURIComponent(query)}`)).json()).messages || []; + let returned = null; + const deadline = Date.now() + 30000; + while (!returned && Date.now() < deadline) { + for (const m of await search(`to:${reader.email} subject:"Restituzione"`)) { + const full = await (await fetch(`${MAILPIT_API}/message/${m.ID}`)).json(); + if (full.HTML.includes(books.late.title)) { returned = full; break; } + } + if (!returned) await new Promise((r) => setTimeout(r, 500)); + } + expect(returned, 'loan_returned email in Mailpit').not.toBeNull(); + expect(returned.HTML).not.toContain('Multa'); + expect(returned.HTML).not.toContain('{{multa}}'); + // Not even an amount without its label (a "0,00 €" left in the {{multa}} + // paragraph): FineParameters::format() writes " €" or " ". + expect(returned.HTML).not.toMatch(/\d[.,]\d{2}\s*(?:€|[A-Z]{3}\b)|(?:€|\b[A-Z]{3})\s*-?\d+[.,]\d{2}/); + expect(await search(`to:${reader.email} subject:"Multa per ritardo"`)).toEqual([]); + }); + + test('6. money owed in the ledger does not block the website request', async ({ browser }) => { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + try { + await F.loginAs(page, debtor.email, PASS); + const { ok, text } = await F.requestFromBookPage(page, books.web.id, today); + expect(ok, text).toBe(true); + } finally { + await ctx.close(); + } + expect(F.dbQuery(`SELECT stato FROM prestiti WHERE utente_id=${debtor.id} AND libro_id=${books.web.id}`)).toBe('pendente'); + // The reader record shows the balance, without any block. + await admin.goto(`${F.BASE}/admin/users/details/${debtor.id}`); + await expect(admin.locator('[data-ledger-balance]')).toHaveAttribute('data-ledger-balance', '5000'); + await expect(admin.locator('[data-ledger-blocked]')).toHaveCount(0); + }); + + test('7. money owed does not block the Mobile API request', async ({ request }) => { + const token = await M.login(request, debtor.email, PASS, `zc-${RUN}`); + const r = await M.api(request, 'POST', '/reservations', { token, body: { book_id: books.app.id } }); + expect(r.status, r.text.slice(0, 300)).toBeLessThan(300); + expect(F.dbInt(`SELECT COUNT(*) FROM prestiti WHERE utente_id=${debtor.id} AND libro_id=${books.app.id}`)).toBe(1); + }); + + test('8. money owed does not block an NCIP checkout, due after loan_duration_days', async ({ request }) => { + const body = await ncip(request, ncipXml('CheckOutItem', debtor.id, books.ncip.id)); + expect(body).toContain('CheckOutItemResponse'); + expect(body).not.toContain('User Ineligible'); + expect(F.dbQuery(`SELECT data_scadenza FROM prestiti WHERE utente_id=${debtor.id} AND libro_id=${books.ncip.id} AND attivo=1`)).toBe(addDays(today, loanDays)); + }); +});