From 137589a3b23ef197944f1239009c7f13e1b29125 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 19:33:13 +0100 Subject: [PATCH 01/17] Tests: patron type, closures, fines and debt block end to end across website, app and NCIP --- tests/stack-090-integration.spec.js | 316 ++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 tests/stack-090-integration.spec.js diff --git a/tests/stack-090-integration.spec.js b/tests/stack-090-integration.spec.js new file mode 100644 index 000000000..5f54f4061 --- /dev/null +++ b/tests/stack-090-integration.spec.js @@ -0,0 +1,316 @@ +// @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']; +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(); + +function readSetting(key) { + const exists = F.dbInt(`SELECT COUNT(*) FROM system_settings WHERE category='loans' AND setting_key=${F.sqlStr(key)}`) === 1; + return exists ? F.dbQuery(`SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key=${F.sqlStr(key)}`) : null; +} +function restoreSetting(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)`); +} +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 = []; + let gate = null; + let typeId = 0; + let reader; + let plain; + const books = {}; + const errors = []; + let today = ''; + let deskLoan = 0; + + test.beforeAll(async ({ browser }) => { + for (const k of LOAN_SETTINGS) saved[k] = readSetting(k); + savedClosures = F.dbQuery("SELECT CONCAT_WS(0x1f, id, data_inizio, data_fine, ricorrente_annuale, IFNULL(sede_id, ''), IFNULL(descrizione, '')) FROM calendario_chiusure").split('\n').filter(Boolean); + F.dbQuery('DELETE FROM calendario_chiusure'); + // 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']) restoreSetting(k, ''); + restoreSetting('fine_grace_days', '0'); + restoreSetting('fine_charged_email', '0'); + restoreSetting('closed_weekdays', null); + restoreSetting('auto_approve_requests', '0'); + clearConfigCache(); + today = F.appTodayISO(); + + 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}`); + + 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); + if (F.dbQuery(`SELECT is_active FROM plugins WHERE id=${ncipId}`) !== '1') { + await admin.goto(`${F.BASE}/admin/plugins`); + await admin.evaluate(async (pid) => { + const fd = new FormData(); + fd.append('csrf_token', document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''); + await fetch(`${window.location.origin}${window.BASE_PATH || ''}/admin/plugins/${pid}/activate`, { method: 'POST', body: fd }); + }, ncipId); + } + }); + + test.afterEach(() => { expect(errors.splice(0), 'no HTTP 5xx').toEqual([]); }); + + test.afterAll(async () => { + for (const k of LOAN_SETTINGS) restoreSetting(k, saved[k]); + clearConfigCache(); + F.dbQuery('DELETE FROM calendario_chiusure'); + for (const line of savedClosures) { + const [id, start, end, yearly, sede, description] = line.split('\x1f'); + F.dbQuery(`INSERT INTO calendario_chiusure (id, data_inizio, data_fine, ricorrente_annuale, sede_id, descrizione) VALUES (${Number(id)}, '${start}', '${end}', ${Number(yearly)}, ${sede === '' ? 'NULL' : Number(sede)}, ${F.sqlStr(description)})`); + } + 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); + 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}`)) + .toMatch(new RegExp(`^${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); + }); +}); From ac8588392ea27b86bd19ca88f57f94e09b78a616 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 19:36:29 +0100 Subject: [PATCH 02/17] Tests: an install with nothing configured keeps the 0.8.9 due dates, renewals, pickups and never fines or blocks --- tests/stack-090-zero-config.spec.js | 328 ++++++++++++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 tests/stack-090-zero-config.spec.js diff --git a/tests/stack-090-zero-config.spec.js b/tests/stack-090-zero-config.spec.js new file mode 100644 index 000000000..bde880b97 --- /dev/null +++ b/tests/stack-090-zero-config.spec.js @@ -0,0 +1,328 @@ +// @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'); + +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(); + +function readSetting(category, key) { + const exists = F.dbInt(`SELECT COUNT(*) FROM system_settings WHERE category=${F.sqlStr(category)} AND setting_key=${F.sqlStr(key)}`) === 1; + return exists ? F.dbQuery(`SELECT setting_value FROM system_settings WHERE category=${F.sqlStr(category)} AND setting_key=${F.sqlStr(key)}`) : null; +} +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)`); +} +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); +} +/** Rows of a table as tab-separated lines, to put back verbatim afterwards. */ +function stash(table) { + const cols = F.dbQuery(`SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY ORDINAL_POSITION) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='${table}'`); + const select = cols.split(',').map((c) => `QUOTE(\`${c}\`)`).join(", ',', "); + const rows = F.dbQuery(`SELECT CONCAT(${select}) FROM ${table}`).split('\n').filter(Boolean); + return { cols, rows }; +} +function unstash(table, s) { + F.dbQuery(`DELETE FROM ${table}`); + for (const values of s.rows) F.dbQuery(`INSERT INTO ${table} (${s.cols}) VALUES (${values})`); +} + +/** 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 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] = readSetting('loans', k); + closures = stash('calendario_chiusure'); + rules = stash('regole_prestito'); + emailSettings = F.dbQuery("SELECT CONCAT(QUOTE(setting_key), ',', QUOTE(setting_value)) FROM system_settings WHERE category='email'").split('\n').filter(Boolean); + 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}`); + + 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); + const ncipId = F.dbInt("SELECT id FROM plugins WHERE name='ncip-server' LIMIT 1"); + if (ncipId > 0 && F.dbQuery(`SELECT is_active FROM plugins WHERE id=${ncipId}`) !== '1') { + await admin.goto(`${F.BASE}/admin/plugins`); + await admin.evaluate(async (pid) => { + const fd = new FormData(); + fd.append('csrf_token', document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''); + await fetch(`${window.location.origin}${window.BASE_PATH || ''}/admin/plugins/${pid}/activate`, { method: 'POST', body: fd }); + }, ncipId); + } + }); + + test.afterEach(() => { expect(errors.splice(0), 'no HTTP 5xx').toEqual([]); }); + + test.afterAll(async () => { + for (const k of LOAN_SETTINGS) writeSetting('loans', k, saved[k]); + unstash('calendario_chiusure', closures); + unstash('regole_prestito', rules); + if (emailSettings.length) { + F.dbQuery("DELETE FROM system_settings WHERE category='email'"); + for (const pair of emailSettings) F.dbQuery(`INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('email', ${pair})`); + } + clearConfigCache(); + 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); + 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, the loan page shows no fine and the return email has no fine line', async () => { + let mailpit = false; + try { mailpit = (await fetch(`${MAILPIT_API}/messages`, { signal: AbortSignal.timeout(3000) })).ok; } catch { mailpit = false; } + 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); + + if (!mailpit) return; + 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}}'); + 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)); + }); +}); From c79b24a6d6ba0fd034a95bead5271539f278ca2d Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 19:46:25 +0100 Subject: [PATCH 03/17] Suppliers list: the search box follows the quoted-search rule (#501) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The supplier filter is the one search box of the 0.9.0 stack that runs in the browser, and it compared the typed text with includes(): a quoted word ("Müller") matched nothing because the quotes were searched as characters, and an unquoted "muller" did not find Müller. It now applies the ExactSearch rules: unquoted words ignore accents and case, a quoted group (straight or typographic quotes) keeps the exact spelling with case ignored, every term must match, and a box with only quotes filters nothing. The quote hint used under the other admin filters is shown under it too. --- app/Views/fornitori/index.php | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/app/Views/fornitori/index.php b/app/Views/fornitori/index.php index d39e6e461..777e1449c 100644 --- a/app/Views/fornitori/index.php +++ b/app/Views/fornitori/index.php @@ -54,8 +54,9 @@ - +

@@ -130,10 +131,34 @@ 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. + const fold = function (s) { return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLocaleLowerCase(); }; + const exact = function (s) { return s.normalize('NFC').toLocaleLowerCase(); }; + const terms = function (query) { + const out = []; + const re = /["“”„«»]([^"“”„«»]*)(?:["“”„«»]|$)|([^\s"“”„«»]+)/g; + let m; + while ((m = re.exec(query)) !== null) { + if (m[1] !== undefined) { + const phrase = m[1].trim().replace(/\s+/g, ' '); + if (phrase !== '') { out.push({ term: exact(phrase), exact: true }); } + } else { + out.push({ term: fold(m[2]), exact: false }); + } + } + 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); }); }); } From e70b89e6abf311a0b71594e6de940224c5cdff9f Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 19:46:32 +0100 Subject: [PATCH 04/17] Tests: late 0.9.0 review fixes (admin-only calendar and retention, ledger replays, turned-up copies, MARC X11, media type and oversized export, quoted search on the new boxes) --- tests/stack-090-review-fixes.spec.js | 538 +++++++++++++++++++++++++++ 1 file changed, 538 insertions(+) create mode 100644 tests/stack-090-review-fixes.spec.js diff --git a/tests/stack-090-review-fixes.spec.js b/tests/stack-090-review-fixes.spec.js new file mode 100644 index 000000000..c7b9d2a96 --- /dev/null +++ b/tests/stack-090-review-fixes.spec.js @@ -0,0 +1,538 @@ +// @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 }) => { + savedWeekdays = F.dbQuery("SELECT IFNULL(MAX(setting_value),'') FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'"); + savedRetention = F.dbQuery("SELECT IFNULL(MAX(setting_value),'') FROM system_settings WHERE category='attendance' AND setting_key='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.dbQuery("DELETE FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'"); + else F.dbQuery(`UPDATE system_settings SET setting_value=${S(savedWeekdays)} WHERE category='loans' AND setting_key='closed_weekdays'`); + if (savedRetention === '') F.dbQuery("DELETE FROM system_settings WHERE category='attendance' AND setting_key='retention_days'"); + else F.dbQuery(`UPDATE system_settings SET setting_value=${S(savedRetention)} WHERE category='attendance' AND setting_key='retention_days'`); + 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.dbQuery("SELECT IFNULL(MAX(setting_value),'') FROM system_settings WHERE category='attendance' AND setting_key='retention_days'")).toBe(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. + 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); + }); +}); + +// ── 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(',')})`); + 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: at most 20 contributors, the first one kept. + expect(read.records[0].autori.length).toBeGreaterThan(0); + expect(read.records[0].autori.length).toBeLessThanOrEqual(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()); + }); + + /** 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 }); + + 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(' '); + 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); + 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); + }); + + 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); + 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'); + 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'); + }); +}); From 05a2459c8b3b0d908ed493939c6a7635c9d68252 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 19:50:57 +0100 Subject: [PATCH 05/17] MARC 21 import: no double period after a name part that ends with an initial A corporate or meeting name whose $a keeps its final period because it ends with an initial ("Istituto X.") was joined to its subordinate unit as "Istituto X.. Ufficio studi". The parts are now joined with a single period. --- app/Support/Marc/Marc21LibriMapper.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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 From 3fdee1fc4fbb3c0c241cd5e3d966a0b6e7036c65 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 19:51:00 +0100 Subject: [PATCH 06/17] Tests: unit checks for the 0.9.0 review fixes, zero-config fines and due dates, and a source guard for quoted search --- tests/stack-090-review-fixes.unit.php | 179 ++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 tests/stack-090-review-fixes.unit.php diff --git a/tests/stack-090-review-fixes.unit.php b/tests/stack-090-review-fixes.unit.php new file mode 100644 index 000000000..99f388387 --- /dev/null +++ b/tests/stack-090-review-fixes.unit.php @@ -0,0 +1,179 @@ +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'); +$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 = 0; +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)) { + $publishers += count($field->subfieldValues('b')); + } +} +$check($level === 'reduced' && count($names) >= 1 && count($names) <= 20 && str_ends_with($names[0], ' 1') && $publishers >= 1 && $publishers <= 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 ?)'], +]; +$offenders = []; +foreach ($files as $rel) { + $src = (string) @file_get_contents($root . '/' . $rel); + if ($src === '') { + $offenders[] = "{$rel} (missing)"; + continue; + } + if (preg_match_all('/[^\n]*\bLIKE\s+\?[^\n]*/i', $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); + } + } + } +} +$check($offenders === [], '18 no bare "LIKE ?" on typed input 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, '[^"“”„«»]') && 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'); + +echo "\n{$pass} PASS, {$fail} FAIL\n"; +exit($fail === 0 ? 0 : 1); From e1b3f050f4d263ffff1b9f693bb6261c13ca3bea Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 20:03:20 +0100 Subject: [PATCH 07/17] CI: run every 0.9.0 Playwright suite in the E2E workflow The E2E workflow listed its specs explicitly and none of the 36 suites added by the 0.9.0 stack ran there. A new feature-090 job runs them in two parallel groups (circulation, catalogue) on the deep-regression environment: Apache, MySQL, Mailpit with a real sendmail transport, the CI MySQL wrapper, poppler for the PDF assertions, a fresh install and the shared seed fixtures. The result audit gains an optional PLAYWRIGHT_MAX_SKIPPED ceiling; the new job sets it to 0, so a test that skips for any reason fails the gate. --- .github/workflows/ci-e2e.yml | 283 +++++++++++++++++++++++++ scripts/ci-audit-playwright-results.js | 16 ++ 2 files changed, 299 insertions(+) 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/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)) { From 570e846c287330a71514e3742182f86ac4df7a7f Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 28 Oct 2026 20:09:40 +0100 Subject: [PATCH 08/17] Tests: the 0.9.0 stack specs put back exactly what they changed - Table snapshots travel hex-encoded and are restored in one transaction, so a closure description with an apostrophe or a backslash comes back intact and a failed restore leaves the original rows in place. - The email settings are always reset: an install that had none (a fresh CI database) no longer keeps the Mailpit SMTP rows written by the return test. - ncip-server and mobile-api are deactivated again when the suite activated them, and the activation is checked instead of assumed. - Every cleanup step runs even when an earlier one throws. - The stocktake cleanup removes scans and expected copies before the session. --- tests/helpers/circulation-fixtures.js | 59 +++++++++++++++++++ tests/stack-090-integration.spec.js | 64 +++++++++++---------- tests/stack-090-review-fixes.spec.js | 3 + tests/stack-090-zero-config.spec.js | 81 ++++++++++++--------------- 4 files changed, 131 insertions(+), 76 deletions(-) diff --git a/tests/helpers/circulation-fixtures.js b/tests/helpers/circulation-fixtures.js index 17dc8853f..cae6fe1c6 100644 --- a/tests/helpers/circulation-fixtures.js +++ b/tests/helpers/circulation-fixtures.js @@ -226,6 +226,64 @@ 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. + */ +function stashRows(table, where = '') { + const cols = dbQuery(`SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY ORDINAL_POSITION) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=${sqlStr(table)}`); + if (!cols) throw new Error(`table ${table} has no columns`); + const select = cols.split(',') + .map((c) => `IFNULL(CONCAT('CONVERT(X''', HEX(CAST(\`${c}\` AS CHAR)), ''' USING utf8mb4)'), 'NULL')`) + .join(", ',', "); + const filter = where ? ` WHERE ${where}` : ''; + const rows = dbQuery(`SELECT CONCAT(${select}) FROM \`${table}\`${filter}`).split('\n').filter(Boolean); + return { table, filter, cols: cols.split(',').map((c) => `\`${c}\``).join(','), rows }; +} + +/** Put back a stashRows() snapshot in one transaction: all of it or nothing. */ +function unstashRows(s) { + if (!s) return; + const sql = ['START TRANSACTION', `DELETE FROM \`${s.table}\`${s.filter}`]; + for (const values of s.rows) sql.push(`INSERT INTO \`${s.table}\` (${s.cols}) VALUES (${values})`); + sql.push('COMMIT'); + dbQuery(sql.join(';\n')); +} + +/** 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 +291,5 @@ module.exports = { loginAs, loginAsAdmin, openCirculationTab, createTypeViaUi, setMatrixCell, fillAutocomplete, staffLoan, requestFromBookPage, copyCodeOf, appTodayISO, appDateOffsetISO, + pluginActive, setPluginActive, stashRows, unstashRows, runAll, }; diff --git a/tests/stack-090-integration.spec.js b/tests/stack-090-integration.spec.js index 5f54f4061..4a55ff423 100644 --- a/tests/stack-090-integration.spec.js +++ b/tests/stack-090-integration.spec.js @@ -97,7 +97,9 @@ test.describe.serial('0.9.0 stack: patron type + closures + fines + debt block a /** @type {import('@playwright/test').Page} */ let admin; let adminCtx; const saved = {}; - let savedClosures = []; + let savedClosures = null; + let ncipWasActive = true; + let mobileWasActive = true; let gate = null; let typeId = 0; let reader; @@ -109,7 +111,7 @@ test.describe.serial('0.9.0 stack: patron type + closures + fines + debt block a test.beforeAll(async ({ browser }) => { for (const k of LOAN_SETTINGS) saved[k] = readSetting(k); - savedClosures = F.dbQuery("SELECT CONCAT_WS(0x1f, id, data_inizio, data_fine, ricorrente_annuale, IFNULL(sede_id, ''), IFNULL(descrizione, '')) FROM calendario_chiusure").split('\n').filter(Boolean); + savedClosures = F.stashRows('calendario_chiusure'); F.dbQuery('DELETE FROM calendario_chiusure'); // 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']) restoreSetting(k, ''); @@ -124,6 +126,7 @@ test.describe.serial('0.9.0 stack: patron type + closures + fines + debt block a 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(); @@ -134,40 +137,39 @@ test.describe.serial('0.9.0 stack: patron type + closures + fines + debt block a admin = await adminCtx.newPage(); admin.on('response', (r) => { if (r.status() >= 500) errors.push(`${r.status()} ${r.url()}`); }); await F.loginAsAdmin(admin); - if (F.dbQuery(`SELECT is_active FROM plugins WHERE id=${ncipId}`) !== '1') { - await admin.goto(`${F.BASE}/admin/plugins`); - await admin.evaluate(async (pid) => { - const fd = new FormData(); - fd.append('csrf_token', document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''); - await fetch(`${window.location.origin}${window.BASE_PATH || ''}/admin/plugins/${pid}/activate`, { method: 'POST', body: fd }); - }, ncipId); - } + ncipWasActive = F.pluginActive('ncip-server'); + await F.setPluginActive(admin, 'ncip-server', true); }); test.afterEach(() => { expect(errors.splice(0), 'no HTTP 5xx').toEqual([]); }); test.afterAll(async () => { - for (const k of LOAN_SETTINGS) restoreSetting(k, saved[k]); - clearConfigCache(); - F.dbQuery('DELETE FROM calendario_chiusure'); - for (const line of savedClosures) { - const [id, start, end, yearly, sede, description] = line.split('\x1f'); - F.dbQuery(`INSERT INTO calendario_chiusure (id, data_inizio, data_fine, ricorrente_annuale, sede_id, descrizione) VALUES (${Number(id)}, '${start}', '${end}', ${Number(yearly)}, ${sede === '' ? 'NULL' : Number(sede)}, ${F.sqlStr(description)})`); - } - 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); - await adminCtx?.close(); + // Independent restore steps: one failing must not leave the others undone. + await F.runAll([ + () => { for (const k of LOAN_SETTINGS) restoreSetting(k, saved[k]); }, + () => clearConfigCache(), + () => F.unstashRows(savedClosures), + () => { + 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), + async () => { if (admin && !ncipWasActive) await F.setPluginActive(admin, 'ncip-server', false); }, + async () => { if (admin && !mobileWasActive) await F.setPluginActive(admin, '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 () => { diff --git a/tests/stack-090-review-fixes.spec.js b/tests/stack-090-review-fixes.spec.js index c7b9d2a96..72dc760c5 100644 --- a/tests/stack-090-review-fixes.spec.js +++ b/tests/stack-090-review-fixes.spec.js @@ -264,6 +264,9 @@ test.describe.serial('C. stocktake and copies recorded out of circulation', () = 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) { diff --git a/tests/stack-090-zero-config.spec.js b/tests/stack-090-zero-config.spec.js index bde880b97..f89ae6bf6 100644 --- a/tests/stack-090-zero-config.spec.js +++ b/tests/stack-090-zero-config.spec.js @@ -62,18 +62,6 @@ function addDays(iso, days) { d.setUTCDate(d.getUTCDate() + days); return d.toISOString().slice(0, 10); } -/** Rows of a table as tab-separated lines, to put back verbatim afterwards. */ -function stash(table) { - const cols = F.dbQuery(`SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY ORDINAL_POSITION) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='${table}'`); - const select = cols.split(',').map((c) => `QUOTE(\`${c}\`)`).join(", ',', "); - const rows = F.dbQuery(`SELECT CONCAT(${select}) FROM ${table}`).split('\n').filter(Boolean); - return { cols, rows }; -} -function unstash(table, s) { - F.dbQuery(`DELETE FROM ${table}`); - for (const values of s.rows) F.dbQuery(`INSERT INTO ${table} (${s.cols}) VALUES (${values})`); -} - /** 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]; @@ -91,6 +79,8 @@ test.describe.serial('0.9.0 with nothing configured behaves as 0.8.9', () => { let closures; let rules; let emailSettings; + let ncipWasActive = true; + let mobileWasActive = true; let gate = null; let reader; let debtor; @@ -103,9 +93,9 @@ test.describe.serial('0.9.0 with nothing configured behaves as 0.8.9', () => { test.beforeAll(async ({ browser }) => { for (const k of LOAN_SETTINGS) saved[k] = readSetting('loans', k); - closures = stash('calendario_chiusure'); - rules = stash('regole_prestito'); - emailSettings = F.dbQuery("SELECT CONCAT(QUOTE(setting_key), ',', QUOTE(setting_value)) FROM system_settings WHERE category='email'").split('\n').filter(Boolean); + 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, ''); @@ -124,6 +114,7 @@ test.describe.serial('0.9.0 with nothing configured behaves as 0.8.9', () => { 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(); @@ -132,41 +123,41 @@ test.describe.serial('0.9.0 with nothing configured behaves as 0.8.9', () => { admin = await adminCtx.newPage(); admin.on('response', (r) => { if (r.status() >= 500) errors.push(`${r.status()} ${r.url()}`); }); await F.loginAsAdmin(admin); - const ncipId = F.dbInt("SELECT id FROM plugins WHERE name='ncip-server' LIMIT 1"); - if (ncipId > 0 && F.dbQuery(`SELECT is_active FROM plugins WHERE id=${ncipId}`) !== '1') { - await admin.goto(`${F.BASE}/admin/plugins`); - await admin.evaluate(async (pid) => { - const fd = new FormData(); - fd.append('csrf_token', document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''); - await fetch(`${window.location.origin}${window.BASE_PATH || ''}/admin/plugins/${pid}/activate`, { method: 'POST', body: fd }); - }, ncipId); - } + ncipWasActive = F.pluginActive('ncip-server'); + await F.setPluginActive(admin, 'ncip-server', true); }); test.afterEach(() => { expect(errors.splice(0), 'no HTTP 5xx').toEqual([]); }); test.afterAll(async () => { - for (const k of LOAN_SETTINGS) writeSetting('loans', k, saved[k]); - unstash('calendario_chiusure', closures); - unstash('regole_prestito', rules); - if (emailSettings.length) { - F.dbQuery("DELETE FROM system_settings WHERE category='email'"); - for (const pair of emailSettings) F.dbQuery(`INSERT INTO system_settings (category, setting_key, setting_value) VALUES ('email', ${pair})`); - } - clearConfigCache(); - 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); - await adminCtx?.close(); + // Independent restore steps: one failing must not leave the others undone. + await F.runAll([ + () => { for (const k of LOAN_SETTINGS) writeSetting('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(), + () => { + 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), + async () => { if (admin && !ncipWasActive) await F.setPluginActive(admin, 'ncip-server', false); }, + async () => { if (admin && !mobileWasActive) await F.setPluginActive(admin, 'mobile-api', false); }, + async () => { await adminCtx?.close(); }, + ]); }); test('1. the settings, calendar and circulation pages show nothing configured', async () => { From b2be8111f7b4c3b75672d0f08be3d595b35562db Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Fri, 30 Oct 2026 00:54:54 +0100 Subject: [PATCH 09/17] Zero-config spec: no silent pass without Mailpit in CI; cache helper explained When Mailpit was not reachable the e-mail assertions of the late-return test were skipped with a bare return, invisible to the zero-skip audit. In CI it now fails; local runs without Mailpit keep the database checks only. The clearConfigCache() comment says it is a no-op under APCu and why the specs do not depend on it. --- tests/stack-090-integration.spec.js | 4 ++++ tests/stack-090-zero-config.spec.js | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/tests/stack-090-integration.spec.js b/tests/stack-090-integration.spec.js index 4a55ff423..69c0f90d4 100644 --- a/tests/stack-090-integration.spec.js +++ b/tests/stack-090-integration.spec.js @@ -51,6 +51,10 @@ function restoreSetting(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; diff --git a/tests/stack-090-zero-config.spec.js b/tests/stack-090-zero-config.spec.js index f89ae6bf6..d6cb2bb64 100644 --- a/tests/stack-090-zero-config.spec.js +++ b/tests/stack-090-zero-config.spec.js @@ -48,6 +48,10 @@ 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; @@ -245,6 +249,11 @@ test.describe.serial('0.9.0 with nothing configured behaves as 0.8.9', () => { test('5. a late return charges nothing, the loan page shows no fine and the return email has no fine line', async () => { let mailpit = false; try { mailpit = (await fetch(`${MAILPIT_API}/messages`, { signal: AbortSignal.timeout(3000) })).ok; } catch { mailpit = false; } + // In CI Mailpit is a service of the job: without it the e-mail half of + // this test would pass silently, so it fails instead. Locally it is optional. + 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'), From 0d9f7400ddb1964e1412b7e38c56fa78a5a2c752 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Fri, 30 Oct 2026 18:37:44 +0100 Subject: [PATCH 10/17] Suppliers search box: inch marks, locale-free case and spaces as in ExactSearch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser filter on the suppliers list now tokenizes like ExactSearch::tokenize(): a straight or closing double quote right after a digit outside a group is a literal inch mark (12" no longer matches a plain 12), lower-casing uses toLowerCase() so a Turkish browser does not turn IKEA into ıkea, and runs of whitespace in the supplier name are collapsed like the quoted phrase, so "ACME Books" finds a name typed with two spaces. The unit check runs the view's own script under node and compares its terms with ExactSearch::tokenize() on a set of queries. --- app/Views/fornitori/index.php | 34 +++++++++++++++++++++++---- tests/stack-090-review-fixes.unit.php | 34 +++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/app/Views/fornitori/index.php b/app/Views/fornitori/index.php index 777e1449c..91ae1a866 100644 --- a/app/Views/fornitori/index.php +++ b/app/Views/fornitori/index.php @@ -134,19 +134,43 @@ class="form-input" /> // 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. - const fold = function (s) { return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLocaleLowerCase(); }; - const exact = function (s) { return s.normalize('NFC').toLocaleLowerCase(); }; + // term must match. An unclosed quote runs to the end, and a straight or + // closing double quote right after a digit outside a group is a literal + // inch mark (12" stays 12"). Lower-casing is locale-free (toLowerCase), + // so a Turkish browser does not turn "IKEA" into "ıkea". + const QUOTES = '"“”„«»'; + const INCH = '\uE000'; + const fold = function (s) { return s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); }; + const exact = function (s) { return s.normalize('NFC').replace(/\s+/g, ' ').toLowerCase(); }; + const protectInchMarks = function (query) { + let open = false; + let prev = ''; + let out = ''; + for (const ch of query) { + let c = ch; + if (QUOTES.includes(ch)) { + if (!open && (ch === '"' || ch === '”') && /^[0-9]$/.test(prev)) { + c = INCH; + } else { + open = !open; + } + } + out += c; + prev = c; + } + return out; + }; const terms = function (query) { const out = []; const re = /["“”„«»]([^"“”„«»]*)(?:["“”„«»]|$)|([^\s"“”„«»]+)/g; + const q = protectInchMarks(query); let m; - while ((m = re.exec(query)) !== null) { + while ((m = re.exec(q)) !== null) { if (m[1] !== undefined) { const phrase = m[1].trim().replace(/\s+/g, ' '); if (phrase !== '') { out.push({ term: exact(phrase), exact: true }); } } else { - out.push({ term: fold(m[2]), exact: false }); + out.push({ term: fold(m[2].split(INCH).join('"')), exact: false }); } } return out; diff --git a/tests/stack-090-review-fixes.unit.php b/tests/stack-090-review-fixes.unit.php index 99f388387..60fe95915 100644 --- a/tests/stack-090-review-fixes.unit.php +++ b/tests/stack-090-review-fixes.unit.php @@ -15,6 +15,9 @@ * 17 stocktake classification order (a turned-up copy stays out of scope) * 18-19 source guard: new searches go through ExactSearch, the suppliers * box applies the quote rule in the browser + * 20 the suppliers box tokenises like ExactSearch::tokenize() (inch marks, + * unclosed quotes, typographic quotes) and lower-cases locale-free; + * the parity half runs the view's own script under node when present * * Run: php tests/stack-090-review-fixes.unit.php (exit 0 iff every check passes) */ @@ -28,6 +31,7 @@ use App\Support\Marc\Marc21LibriMapper; use App\Support\Marc\MarcBookData; use App\Support\Marc\MarcBookImporter; +use App\Support\ExactSearch; use App\Support\Marc\MarcRecord; $root = dirname(__DIR__); @@ -175,5 +179,35 @@ $check(!str_contains($view, ".includes(q)") && str_contains($view, '[^"“”„«»]') && 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. +$guardOk = !str_contains($view, 'toLocaleLowerCase') && str_contains($view, 'protectInchMarks'); +$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']; + $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{Mn}/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 . ')'); + echo "\n{$pass} PASS, {$fail} FAIL\n"; exit($fail === 0 ? 0 : 1); From ed3593dc1b91525d5346c390f54478bb02faecdb Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Fri, 30 Oct 2026 18:37:47 +0100 Subject: [PATCH 11/17] 0.9.0 specs: restore only what was read, one step per key, and leave other visits alone The integration and zero-config teardowns restore each loan setting in its own runAll step and skip keys beforeAll never read, so a failed read cannot write the string 'undefined' and one failed write cannot leave the next keys changed. The zero-config spec reads the application day before every test, so a run crossing midnight compares each loan with the day it was made on, and it flushes the app cache after restoring the e-mail settings by SQL, so the web server does not keep the test SMTP values. The A4 purge check snapshots every other visit before the global purge and puts them back afterwards. --- tests/stack-090-integration.spec.js | 4 +++- tests/stack-090-review-fixes.spec.js | 19 +++++++++++++------ tests/stack-090-zero-config.spec.js | 14 +++++++++++++- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/tests/stack-090-integration.spec.js b/tests/stack-090-integration.spec.js index 69c0f90d4..f2c630546 100644 --- a/tests/stack-090-integration.spec.js +++ b/tests/stack-090-integration.spec.js @@ -150,7 +150,9 @@ test.describe.serial('0.9.0 stack: patron type + closures + fines + debt block a test.afterAll(async () => { // Independent restore steps: one failing must not leave the others undone. await F.runAll([ - () => { for (const k of LOAN_SETTINGS) restoreSetting(k, saved[k]); }, + // One step per key, and only for keys beforeAll actually read: + // readSetting() returns a string or null, so undefined means never read. + ...LOAN_SETTINGS.map((k) => () => { if (saved[k] !== undefined) restoreSetting(k, saved[k]); }), () => clearConfigCache(), () => F.unstashRows(savedClosures), () => { diff --git a/tests/stack-090-review-fixes.spec.js b/tests/stack-090-review-fixes.spec.js index 72dc760c5..a7b0c5674 100644 --- a/tests/stack-090-review-fixes.spec.js +++ b/tests/stack-090-review-fixes.spec.js @@ -151,12 +151,19 @@ test.describe.serial('A. closures calendar and visitor-log retention are reserve } expect(F.dbQuery("SELECT IFNULL(MAX(setting_value),'') FROM system_settings WHERE category='attendance' AND setting_key='retention_days'")).toBe(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. - 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); + // 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. + 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); + } }); }); diff --git a/tests/stack-090-zero-config.spec.js b/tests/stack-090-zero-config.spec.js index d6cb2bb64..52b4658a8 100644 --- a/tests/stack-090-zero-config.spec.js +++ b/tests/stack-090-zero-config.spec.js @@ -20,6 +20,7 @@ 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'); @@ -131,17 +132,28 @@ test.describe.serial('0.9.0 with nothing configured behaves as 0.8.9', () => { 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 () => { // Independent restore steps: one failing must not leave the others undone. await F.runAll([ - () => { for (const k of LOAN_SETTINGS) writeSetting('loans', k, saved[k]); }, + // One step per key, and only for keys beforeAll actually read: + // readSetting() returns a string or null, so undefined means never read. + ...LOAN_SETTINGS.map((k) => () => { if (saved[k] !== undefined) writeSetting('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; From 18213f2748674b8ae1c2f838c42c815e9a201752 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sat, 31 Oct 2026 21:52:18 +0100 Subject: [PATCH 12/17] Supplier search folds every combining mark and keeps a typed U+E000 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suppliers box now scans the query once instead of swapping inch marks for a private-use stand-in: a supplier whose name holds U+E000 is found again, a quote after a digit of any script (١٢") is an inch mark, and every Unicode combining mark is folded, so ACME finds A᪰CME. ExactSearch::tokenize() gets the same single pass, so the server no longer turns a typed U+E000 into a double quote and both tokenizers stay equal. --- app/Support/ExactSearch.php | 72 +++++++++++++++++---------- app/Views/fornitori/index.php | 64 ++++++++++++++---------- tests/stack-090-review-fixes.unit.php | 21 ++++++-- 3 files changed, 100 insertions(+), 57 deletions(-) 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/Views/fornitori/index.php b/app/Views/fornitori/index.php index 91ae1a866..bd82afef9 100644 --- a/app/Views/fornitori/index.php +++ b/app/Views/fornitori/index.php @@ -135,44 +135,54 @@ class="form-input" /> // 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 outside a group is a literal - // inch mark (12" stays 12"). Lower-casing is locale-free (toLowerCase), - // so a Turkish browser does not turn "IKEA" into "ıkea". + // 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 INCH = '\uE000'; - const fold = function (s) { return s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); }; + 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 protectInchMarks = function (query) { + const terms = function (query) { + const out = []; let open = false; - let prev = ''; - let out = ''; + 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) { - let c = ch; if (QUOTES.includes(ch)) { - if (!open && (ch === '"' || ch === '”') && /^[0-9]$/.test(prev)) { - c = INCH; + if (open) { + flushPhrase(); + open = false; + } else if ((ch === '"' || ch === '”') && afterDigit) { + word += '"'; } else { - open = !open; + flushWord(); + open = true; } + afterDigit = false; + continue; } - out += c; - prev = c; - } - return out; - }; - const terms = function (query) { - const out = []; - const re = /["“”„«»]([^"“”„«»]*)(?:["“”„«»]|$)|([^\s"“”„«»]+)/g; - const q = protectInchMarks(query); - let m; - while ((m = re.exec(q)) !== null) { - if (m[1] !== undefined) { - const phrase = m[1].trim().replace(/\s+/g, ' '); - if (phrase !== '') { out.push({ term: exact(phrase), exact: true }); } + if (open) { + phrase += ch; + } else if (/\s/.test(ch)) { + flushWord(); } else { - out.push({ term: fold(m[2].split(INCH).join('"')), exact: false }); + word += ch; } + afterDigit = /^\p{Nd}$/u.test(ch); } + if (open) { flushPhrase(); } else { flushWord(); } return out; }; search.addEventListener('input', function () { diff --git a/tests/stack-090-review-fixes.unit.php b/tests/stack-090-review-fixes.unit.php index 60fe95915..17ae7ea80 100644 --- a/tests/stack-090-review-fixes.unit.php +++ b/tests/stack-090-review-fixes.unit.php @@ -18,6 +18,8 @@ * 20 the suppliers box tokenises like ExactSearch::tokenize() (inch marks, * unclosed quotes, typographic quotes) and lower-cases locale-free; * the parity half runs the view's own script under node when present + * 21 ExactSearch::tokenize() keeps a typed U+E000 and reads an inch mark + * after a digit of any script * * Run: php tests/stack-090-review-fixes.unit.php (exit 0 iff every check passes) */ @@ -176,25 +178,29 @@ } $check($offenders === [], '18 no bare "LIKE ?" on typed input 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, '[^"“”„«»]') && str_contains($view, "normalize('NFD')") && str_contains($view, 'search_fornitore_hint'), +$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. -$guardOk = !str_contains($view, 'toLocaleLowerCase') && str_contains($view, 'protectInchMarks'); +// 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']; + '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{Mn}/u', '', (string) \Normalizer::normalize($term, \Normalizer::FORM_D))), 'exact' => $exact]; + : mb_strtolower((string) preg_replace('/\p{M}/u', '', (string) \Normalizer::normalize($term, \Normalizer::FORM_D))), 'exact' => $exact]; } $expected[] = $row; } @@ -209,5 +215,12 @@ } $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); From c6fb106a6a21df582c1fba4ceac757e6b7109207 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sat, 31 Oct 2026 21:56:04 +0100 Subject: [PATCH 13/17] 0.9.0 stack E2E teardown puts back exactly what was there - dbQuery() sends the SQL on stdin: a stashRows() restore of a big table no longer fails with E2BIG after the purge already removed the rows, and a large snapshot no longer overflows the 1 MB output buffer. - unstashRows(s, { onlySnapshotRows: true }) deletes and re-inserts only the snapshot rows by primary key; the visitor-log purge test uses it, so a visit recorded while it runs is kept. - readSettingState()/restoreSettingState() snapshot a system_settings row as absent, SQL NULL or text (hex-read, so "NULL", '' and whitespace stay as stored); the integration, zero-config and review-fixes suites restore with them instead of turning NULL into 'NULL' or an empty row into none. - Plugins switched on by a suite are switched off from a fresh admin context (setPluginActiveFresh), so a failed admin login in beforeAll no longer leaves mobile-api or ncip-server active. - The integration suite empties regole_prestito for its run, as the zero-config suite does, and reads the application day before every test. - The zero-config Mailpit check is its own test, reported as skipped when Mailpit is not reachable instead of passing without asserting. --- tests/helpers/circulation-fixtures.js | 94 +++++++++++++++++++++++---- tests/stack-090-integration.spec.js | 47 +++++++++----- tests/stack-090-review-fixes.spec.js | 17 ++--- tests/stack-090-zero-config.spec.js | 38 ++++++----- 4 files changed, 139 insertions(+), 57 deletions(-) diff --git a/tests/helpers/circulation-fixtures.js b/tests/helpers/circulation-fixtures.js index cae6fe1c6..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, "\\'") + "'"; @@ -253,28 +257,89 @@ async function setPluginActive(page, name, 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. + * 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 cols = dbQuery(`SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY ORDINAL_POSITION) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=${sqlStr(table)}`); + 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 select = cols.split(',') - .map((c) => `IFNULL(CONCAT('CONVERT(X''', HEX(CAST(\`${c}\` AS CHAR)), ''' USING utf8mb4)'), 'NULL')`) - .join(", ',', "); + 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(${select}) FROM \`${table}\`${filter}`).split('\n').filter(Boolean); - return { table, filter, cols: cols.split(',').map((c) => `\`${c}\``).join(','), rows }; + 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. */ -function unstashRows(s) { +/** + * 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', `DELETE FROM \`${s.table}\`${s.filter}`]; - for (const values of s.rows) sql.push(`INSERT INTO \`${s.table}\` (${s.cols}) VALUES (${values})`); + 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; @@ -291,5 +356,6 @@ module.exports = { loginAs, loginAsAdmin, openCirculationTab, createTypeViaUi, setMatrixCell, fillAutocomplete, staffLoan, requestFromBookPage, copyCodeOf, appTodayISO, appDateOffsetISO, - pluginActive, setPluginActive, stashRows, unstashRows, runAll, + pluginActive, setPluginActive, setPluginActiveFresh, stashRows, unstashRows, runAll, + readSettingState, restoreSettingState, }; diff --git a/tests/stack-090-integration.spec.js b/tests/stack-090-integration.spec.js index f2c630546..e5f2c00d0 100644 --- a/tests/stack-090-integration.spec.js +++ b/tests/stack-090-integration.spec.js @@ -43,11 +43,10 @@ const ncipXml = (service, userId, itemId) => ` (await request.post(`${F.BASE}/ncip`, { data: body, headers: { 'Content-Type': 'application/xml', Authorization: ncipAuth } })).text(); -function readSetting(key) { - const exists = F.dbInt(`SELECT COUNT(*) FROM system_settings WHERE category='loans' AND setting_key=${F.sqlStr(key)}`) === 1; - return exists ? F.dbQuery(`SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key=${F.sqlStr(key)}`) : null; -} -function restoreSetting(key, value) { +/** 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)`); } @@ -102,6 +101,7 @@ test.describe.serial('0.9.0 stack: patron type + closures + fines + debt block a let adminCtx; const saved = {}; let savedClosures = null; + let savedRules = null; let ncipWasActive = true; let mobileWasActive = true; let gate = null; @@ -114,17 +114,20 @@ test.describe.serial('0.9.0 stack: patron type + closures + fines + debt block a let deskLoan = 0; test.beforeAll(async ({ browser }) => { - for (const k of LOAN_SETTINGS) saved[k] = readSetting(k); + 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'); // 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']) restoreSetting(k, ''); - restoreSetting('fine_grace_days', '0'); - restoreSetting('fine_charged_email', '0'); - restoreSetting('closed_weekdays', null); - restoreSetting('auto_approve_requests', '0'); + 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(); - today = F.appTodayISO(); reader = F.createPatron(`INT${RUN}`, { password: PASS }); plain = F.createPatron(`INP${RUN}`, { password: PASS }); @@ -145,16 +148,22 @@ test.describe.serial('0.9.0 stack: patron type + closures + fines + debt block a 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 () => { + 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: - // readSetting() returns a string or null, so undefined means never read. - ...LOAN_SETTINGS.map((k) => () => { if (saved[k] !== undefined) restoreSetting(k, saved[k]); }), + // 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), () => { for (const p of [reader, plain]) { if (!p) continue; @@ -172,8 +181,10 @@ test.describe.serial('0.9.0 stack: patron type + closures + fines + debt block a () => F.deletePatrons([reader?.id, plain?.id]), () => F.deleteTypes(PREFIX), () => M.restoreGate(gate), - async () => { if (admin && !ncipWasActive) await F.setPluginActive(admin, 'ncip-server', false); }, - async () => { if (admin && !mobileWasActive) await F.setPluginActive(admin, 'mobile-api', false); }, + // 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(); }, ]); }); diff --git a/tests/stack-090-review-fixes.spec.js b/tests/stack-090-review-fixes.spec.js index a7b0c5674..1cb22faef 100644 --- a/tests/stack-090-review-fixes.spec.js +++ b/tests/stack-090-review-fixes.spec.js @@ -64,8 +64,9 @@ test.describe.serial('A. closures calendar and visitor-log retention are reserve + '#' + F.dbQuery("SELECT IFNULL(MAX(setting_value),'-') FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'"); test.beforeAll(async ({ browser }) => { - savedWeekdays = F.dbQuery("SELECT IFNULL(MAX(setting_value),'') FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'"); - savedRetention = F.dbQuery("SELECT IFNULL(MAX(setting_value),'') FROM system_settings WHERE category='attendance' AND setting_key='retention_days'"); + // 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. @@ -86,10 +87,8 @@ test.describe.serial('A. closures calendar and visitor-log retention are reserve 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.dbQuery("DELETE FROM system_settings WHERE category='loans' AND setting_key='closed_weekdays'"); - else F.dbQuery(`UPDATE system_settings SET setting_value=${S(savedWeekdays)} WHERE category='loans' AND setting_key='closed_weekdays'`); - if (savedRetention === '') F.dbQuery("DELETE FROM system_settings WHERE category='attendance' AND setting_key='retention_days'"); - else F.dbQuery(`UPDATE system_settings SET setting_value=${S(savedRetention)} WHERE category='attendance' AND setting_key='retention_days'`); + 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(); @@ -149,11 +148,13 @@ test.describe.serial('A. closures calendar and visitor-log retention are reserve const r = await staffPage.request.post(`${F.BASE}${route}`, { form: { csrf_token: csrf, ...fields }, maxRedirects: 0 }); expect(r.status(), route).toBe(302); } - expect(F.dbQuery("SELECT IFNULL(MAX(setting_value),'') FROM system_settings WHERE category='attendance' AND setting_key='retention_days'")).toBe(savedRetention); + 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`); @@ -162,7 +163,7 @@ test.describe.serial('A. closures calendar and visitor-log retention are reserve 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); + F.unstashRows(otherVisits, { onlySnapshotRows: true }); } }); }); diff --git a/tests/stack-090-zero-config.spec.js b/tests/stack-090-zero-config.spec.js index 52b4658a8..b50780351 100644 --- a/tests/stack-090-zero-config.spec.js +++ b/tests/stack-090-zero-config.spec.js @@ -41,10 +41,9 @@ const ncipXml = (service, userId, itemId) => ` (await request.post(`${F.BASE}/ncip`, { data: body, headers: { 'Content-Type': 'application/xml', Authorization: ncipAuth } })).text(); -function readSetting(category, key) { - const exists = F.dbInt(`SELECT COUNT(*) FROM system_settings WHERE category=${F.sqlStr(category)} AND setting_key=${F.sqlStr(key)}`) === 1; - return exists ? F.dbQuery(`SELECT setting_value FROM system_settings WHERE category=${F.sqlStr(category)} AND setting_key=${F.sqlStr(key)}`) : null; -} +/** 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)`); @@ -86,6 +85,7 @@ test.describe.serial('0.9.0 with nothing configured behaves as 0.8.9', () => { let emailSettings; let ncipWasActive = true; let mobileWasActive = true; + let mailpit = false; let gate = null; let reader; let debtor; @@ -97,7 +97,7 @@ test.describe.serial('0.9.0 with nothing configured behaves as 0.8.9', () => { let deskLoan = 0; test.beforeAll(async ({ browser }) => { - for (const k of LOAN_SETTINGS) saved[k] = readSetting('loans', k); + 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'"); @@ -130,6 +130,7 @@ test.describe.serial('0.9.0 with nothing configured behaves as 0.8.9', () => { 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 @@ -139,12 +140,12 @@ test.describe.serial('0.9.0 with nothing configured behaves as 0.8.9', () => { test.afterEach(() => { expect(errors.splice(0), 'no HTTP 5xx').toEqual([]); }); - test.afterAll(async () => { + 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: - // readSetting() returns a string or null, so undefined means never read. - ...LOAN_SETTINGS.map((k) => () => { if (saved[k] !== undefined) writeSetting('loans', k, saved[k]); }), + // 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. @@ -170,8 +171,10 @@ test.describe.serial('0.9.0 with nothing configured behaves as 0.8.9', () => { }, () => F.deletePatrons([reader?.id, debtor?.id]), () => M.restoreGate(gate), - async () => { if (admin && !ncipWasActive) await F.setPluginActive(admin, 'ncip-server', false); }, - async () => { if (admin && !mobileWasActive) await F.setPluginActive(admin, 'mobile-api', false); }, + // 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(); }, ]); }); @@ -258,11 +261,10 @@ test.describe.serial('0.9.0 with nothing configured behaves as 0.8.9', () => { .toBe(`${addDays(today, loanDays)}|${addDays(today, Math.min(pickupDays, loanDays))}`); }); - test('5. a late return charges nothing, the loan page shows no fine and the return email has no fine line', async () => { - let mailpit = false; - try { mailpit = (await fetch(`${MAILPIT_API}/messages`, { signal: AbortSignal.timeout(3000) })).ok; } catch { mailpit = false; } - // In CI Mailpit is a service of the job: without it the e-mail half of - // this test would pass silently, so it fails instead. Locally it is optional. + 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`); } @@ -289,8 +291,10 @@ test.describe.serial('0.9.0 with nothing configured behaves as 0.8.9', () => { 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); + }); - if (!mailpit) return; + 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; From 8596a88bcf67aeda785eac720bb84d9877496da0 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sat, 31 Oct 2026 22:09:41 +0100 Subject: [PATCH 14/17] 0.9.0 stack tests pin exact values instead of loose bounds The UNIMARC export check now reads 330 $a back and requires a prefix of the oversized description, so a cut that drops the text but still reports itself fails. The reduced-record check requires exactly 20 authors and 5 publishers, first and last in order. The LIKE source guard also catches named parameters and LIKE CONCAT(...). The integration spec requires the patron type's grace override to be stored as 0, not NULL. --- tests/stack-090-integration.spec.js | 2 +- tests/stack-090-review-fixes.unit.php | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/stack-090-integration.spec.js b/tests/stack-090-integration.spec.js index e5f2c00d0..76fba8eb4 100644 --- a/tests/stack-090-integration.spec.js +++ b/tests/stack-090-integration.spec.js @@ -195,7 +195,7 @@ test.describe.serial('0.9.0 stack: patron type + closures + fines + debt block a 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}`)) - .toMatch(new RegExp(`^${TYPE_DAYS}\\|1\\.00\\|(0|-)\\|2\\.00$`)); + .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(''); diff --git a/tests/stack-090-review-fixes.unit.php b/tests/stack-090-review-fixes.unit.php index 17ae7ea80..37bb785a9 100644 --- a/tests/stack-090-review-fixes.unit.php +++ b/tests/stack-090-review-fixes.unit.php @@ -82,22 +82,27 @@ $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 = 0; +$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)) { - $publishers += count($field->subfieldValues('b')); + array_push($publishers, ...$field->subfieldValues('b')); } } -$check($level === 'reduced' && count($names) >= 1 && count($names) <= 20 && str_ends_with($names[0], ' 1') && $publishers >= 1 && $publishers <= 5, +$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"; @@ -164,7 +169,7 @@ $offenders[] = "{$rel} (missing)"; continue; } - if (preg_match_all('/[^\n]*\bLIKE\s+\?[^\n]*/i', $src, $hits) > 0) { + if (preg_match_all('/[^\n]*\bLIKE\s+(?:\?|:\w+|CONCAT\s*\()[^\n]*/i', $src, $hits) > 0) { foreach ($hits[0] as $line) { $ok = false; foreach ($allowed[$rel] ?? [] as $fragment) { @@ -176,7 +181,7 @@ } } } -$check($offenders === [], '18 no bare "LIKE ?" on typed input in the 0.9.0 search code' . ($offenders !== [] ? ' — ' . implode(' | ', $offenders) : '')); +$check($offenders === [], '18 no bare LIKE on typed input (?, :name or CONCAT) 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'); From b6126a6d2b9c9f12b6e802a19de4f97892e08d3d Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 1 Nov 2026 09:27:05 +0100 Subject: [PATCH 15/17] Integration suite empties the default patron type fines for its run The plain reader of test 9 belongs to the default type, so a daily fine, grace, cap or debt threshold set on that type by the installation would charge or block it and fail the test for the database state. beforeAll now saves those four columns, sets them to NULL, and afterAll puts them back, SQL NULL included. --- tests/stack-090-integration.spec.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/stack-090-integration.spec.js b/tests/stack-090-integration.spec.js index 76fba8eb4..b579e67a9 100644 --- a/tests/stack-090-integration.spec.js +++ b/tests/stack-090-integration.spec.js @@ -31,6 +31,8 @@ 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) => ` @@ -112,6 +114,8 @@ test.describe.serial('0.9.0 stack: patron type + closures + fines + debt block a 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); @@ -121,6 +125,14 @@ test.describe.serial('0.9.0 stack: patron type + closures + fines + debt block a 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'); @@ -164,6 +176,11 @@ test.describe.serial('0.9.0 stack: patron type + closures + fines + debt block a () => 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; From 204394bdadb791bc4288bb60e9722b30225fa6fc Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 1 Nov 2026 09:29:22 +0100 Subject: [PATCH 16/17] Source guard sees a placeholder wrapped in any function after LIKE Check 18 recognised LIKE ?, LIKE :name and LIKE CONCAT(, but not LOWER(nome) LIKE LOWER(?), which bypasses the quote rule just as well. The pattern now flags any function call right after LIKE, and a new check 18a runs it against probe statements so the guard itself is tested. --- tests/stack-090-review-fixes.unit.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/stack-090-review-fixes.unit.php b/tests/stack-090-review-fixes.unit.php index 37bb785a9..999cf41a0 100644 --- a/tests/stack-090-review-fixes.unit.php +++ b/tests/stack-090-review-fixes.unit.php @@ -162,6 +162,10 @@ $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); @@ -169,7 +173,7 @@ $offenders[] = "{$rel} (missing)"; continue; } - if (preg_match_all('/[^\n]*\bLIKE\s+(?:\?|:\w+|CONCAT\s*\()[^\n]*/i', $src, $hits) > 0) { + if (preg_match_all($bareLike, $src, $hits) > 0) { foreach ($hits[0] as $line) { $ok = false; foreach ($allowed[$rel] ?? [] as $fragment) { @@ -181,7 +185,10 @@ } } } -$check($offenders === [], '18 no bare LIKE on typed input (?, :name or CONCAT) in the 0.9.0 search code' . ($offenders !== [] ? ' — ' . implode(' | ', $offenders) : '')); +$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'); From 0d6e0e7c935b69feee8f613526fac8f48ea09d62 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 1 Nov 2026 09:29:22 +0100 Subject: [PATCH 17/17] Review-fix specs assert what the page shows, not only the API answer D4 expects exactly the 20 contributors of a reduced MARC record instead of anything from 1 to 20. E4 and E5 check that the users and books tables draw exactly the rows of the filtered ids after each response, E3 checks that a box holding only quotes hides no supplier row at all, and the zero-config return email may not carry a fine amount even without its label. --- tests/stack-090-review-fixes.spec.js | 21 ++++++++++++++++++--- tests/stack-090-zero-config.spec.js | 3 +++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/tests/stack-090-review-fixes.spec.js b/tests/stack-090-review-fixes.spec.js index 1cb22faef..8bb2d9e77 100644 --- a/tests/stack-090-review-fixes.spec.js +++ b/tests/stack-090-review-fixes.spec.js @@ -416,9 +416,9 @@ test.describe.serial('D. MARC import and export review fixes', () => { expect(read.errors).toBe(0); expect(read.records).toHaveLength(1); expect(read.records[0].titolo).toBe(title); - // Reduced: at most 20 contributors, the first one kept. - expect(read.records[0].autori.length).toBeGreaterThan(0); - expect(read.records[0].autori.length).toBeLessThanOrEqual(20); + // 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`); }); }); @@ -494,6 +494,8 @@ test.describe.serial('E. quoted search (#501) on the new search boxes', () => { 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. */ @@ -503,18 +505,27 @@ test.describe.serial('E. quoted search (#501) on the new search boxes', () => { 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'); @@ -522,6 +533,7 @@ test.describe.serial('E. quoted search (#501) on the new search boxes', () => { // 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 () => { @@ -532,6 +544,7 @@ test.describe.serial('E. quoted search (#501) on the new search boxes', () => { 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'); @@ -539,11 +552,13 @@ test.describe.serial('E. quoted search (#501) on the new search boxes', () => { 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-zero-config.spec.js b/tests/stack-090-zero-config.spec.js index b50780351..b68a27b6e 100644 --- a/tests/stack-090-zero-config.spec.js +++ b/tests/stack-090-zero-config.spec.js @@ -308,6 +308,9 @@ test.describe.serial('0.9.0 with nothing configured behaves as 0.8.9', () => { 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([]); });