From 2a4016ec7717f05cbff4ac5e4dc92b299da04874 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 13 Aug 2026 15:06:32 +0200 Subject: [PATCH 01/13] fix(books): make the book-editor availability field read-only (#351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Disponibilità" (Availability) select in the book form was editable but a no-op: LibriController::update() already unsets 'stato' because a book's availability is a derived summary, auto-managed from the physical copies. Editing it in the book editor did nothing, which was confusing (#351). Mark the select disabled (aria-readonly) — matching the existing disabled genere/sottogenere selects in the same partial — so it clearly reads as a derived indicator. To make a specific copy unavailable (damaged, lost, in maintenance), staff change that copy's status, which is the correct per-copy granularity. E2E (tests/book-create-edit-351.spec.js, 5 checks): create a book (DB-verified), the editor loads it, #stato is disabled, an edited subtitle persists, and the disabled field cannot alter libri.stato. --- app/Views/libri/partials/book_form.php | 2 +- tests/book-create-edit-351.spec.js | 171 +++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 tests/book-create-edit-351.spec.js diff --git a/app/Views/libri/partials/book_form.php b/app/Views/libri/partials/book_form.php index dcf7fb3ff..0228baa44 100644 --- a/app/Views/libri/partials/book_form.php +++ b/app/Views/libri/partials/book_form.php @@ -314,7 +314,7 @@
- diff --git a/tests/book-create-edit-351.spec.js b/tests/book-create-edit-351.spec.js new file mode 100644 index 000000000..704ed6a14 --- /dev/null +++ b/tests/book-create-edit-351.spec.js @@ -0,0 +1,171 @@ +// Behavioral E2E: create a book, edit it, and verify the fix for issue #351 +// (the "Availability" / #stato select in the book editor is read-only because +// availability is derived from physical copies, not user input). +// +// Form under test: app/Views/libri/partials/book_form.php +// - create: GET /admin/books/create → POST (LibriController::store) +// - edit: GET /admin/books/edit/ → POST (LibriController::update) +// - #stato is rendered with `disabled aria-readonly="true"` and update() +// unsets $fields['stato'] server-side. +// +// Requires an installed Pinakes instance and admin login. Skips when env is +// incomplete to avoid silent failures. + +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('child_process'); + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8082'; +const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || ''; +const ADMIN_PASS = process.env.E2E_ADMIN_PASS || ''; +const DB_HOST = process.env.E2E_DB_HOST || 'localhost'; +const DB_USER = process.env.E2E_DB_USER || ''; +const DB_PASS = process.env.E2E_DB_PASS || ''; +const DB_NAME = process.env.E2E_DB_NAME || ''; +const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; +const CREATE_BOOK_URL = `${BASE}/admin/books/create`; + +const TAG = Date.now(); +const CREATE_TITLE = `E2E CreateEdit351 ${TAG}`; +const EDITED_SUBTITLE = `E2E Edited Subtitle 351 ${TAG}`; + +test.skip( + !ADMIN_EMAIL || !ADMIN_PASS || !DB_USER || !DB_NAME, + 'book-create-edit-351 requires E2E_ADMIN_EMAIL/PASS + DB_USER/NAME', +); + +function dbQuery(sql) { + const args = ['-N', '-B', '-e', sql]; + if (DB_HOST) args.push('-h', DB_HOST); + if (DB_SOCKET) args.push('-S', DB_SOCKET); + args.push('-u', DB_USER); + if (DB_PASS !== '') args.push(`-p${DB_PASS}`); + args.push(DB_NAME); + return execFileSync('mysql', args, { encoding: 'utf-8', timeout: 10000 }).trim(); +} + +async function loginAsAdmin(page) { + await page.goto(`${BASE}/admin`); + if (page.url().includes('admin') && !page.url().match(/login|accedi|anmelden/)) return; + for (const slug of ['accedi', 'login', 'anmelden']) { + const resp = await page.goto(`${BASE}/${slug}`).catch(() => null); + if (resp && resp.status() === 200 && (await page.locator('input[name="email"]').count()) > 0) break; + } + await page.fill('input[name="email"]', ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await Promise.all([ + page.waitForURL(/admin/, { timeout: 15000 }), + page.locator('button[type="submit"]').click(), + ]); +} + +// Submits #bookForm and clicks through the SweetAlert confirm if one appears +// (duplicate-check / info dialogs use SweetAlert2, not native dialogs). +async function submitBookForm(page) { + await page.locator('#bookForm button[type="submit"]').click(); + const swalConfirm = page.locator('.swal2-confirm'); + if (await swalConfirm.isVisible({ timeout: 5000 }).catch(() => false)) { + await swalConfirm.click(); + } +} + +test.describe.serial('book create + edit — #351 read-only availability', () => { + let context; + let page; + let bookId = 0; + let statoBeforeEdit = ''; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + await loginAsAdmin(page); + }); + + test.afterAll(async () => { + if (bookId > 0) { + // FK-safe order: copies first, then the book. Hard delete — this is + // isolated test data tagged with a timestamp. + try { dbQuery(`DELETE FROM copie WHERE libro_id = ${bookId}`); } catch (_) { /* test cleanup */ } + try { dbQuery(`DELETE FROM libri WHERE id = ${bookId}`); } catch (_) { /* test cleanup */ } + } + await context?.close(); + }); + + test('1. Create a book with minimal required fields and verify it in the DB', async () => { + test.setTimeout(60000); + await page.goto(CREATE_BOOK_URL); + await expect(page.locator('#bookForm')).toBeVisible({ timeout: 10000 }); + + // titolo is the only field store() enforces; anno/lingua keep the record realistic. + await page.fill('#titolo', CREATE_TITLE); + await page.fill('#anno_pubblicazione', '2024'); + await page.fill('#lingua', 'Italiano'); + + await submitBookForm(page); + + await expect.poll( + () => dbQuery(`SELECT COUNT(*) FROM libri WHERE titolo = '${CREATE_TITLE}' AND deleted_at IS NULL`), + { timeout: 30000 }, + ).toBe('1'); + await page.waitForURL(/\/admin\/books(?:\/\d+)?(?:\?.*)?$/, { timeout: 30000 }); + + const row = dbQuery(`SELECT id, titolo FROM libri WHERE titolo = '${CREATE_TITLE}' AND deleted_at IS NULL`); + expect(row, 'created book must exist exactly once in DB').toContain(CREATE_TITLE); + bookId = parseInt(row.split('\t')[0], 10); + expect(bookId).toBeGreaterThan(0); + + // Baseline for test 5: the availability value the disabled field must not alter. + statoBeforeEdit = dbQuery(`SELECT stato FROM libri WHERE id = ${bookId}`); + expect(statoBeforeEdit.length).toBeGreaterThan(0); + }); + + test('2. Editor loads the created book with its fields populated', async () => { + test.skip(bookId === 0, 'requires test 1 to have created a book'); + await page.goto(`${BASE}/admin/books/edit/${bookId}`); + await expect(page.locator('#bookForm')).toBeVisible({ timeout: 10000 }); + + const dataMode = await page.locator('#bookForm').getAttribute('data-mode'); + expect(['edit', 'modifica']).toContain(String(dataMode || '').toLowerCase()); + await expect(page.locator('#titolo')).toHaveValue(CREATE_TITLE); + await expect(page.locator('#anno_pubblicazione')).toHaveValue('2024'); + }); + + test('3. #351 — Availability (#stato) select is disabled (read-only)', async () => { + test.skip(bookId === 0, 'requires test 1 to have created a book'); + await page.goto(`${BASE}/admin/books/edit/${bookId}`); + await expect(page.locator('#bookForm')).toBeVisible({ timeout: 10000 }); + + const stato = page.locator('#stato'); + await expect(stato).toHaveCount(1); + await expect(stato).toBeDisabled(); + await expect(stato).toHaveAttribute('aria-readonly', 'true'); + + // It must also be disabled on the create form — availability is derived + // from copies in both modes. + await page.goto(CREATE_BOOK_URL); + await expect(page.locator('#bookForm')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#stato')).toBeDisabled(); + }); + + test('4. Editing the subtitle persists to the DB', async () => { + test.skip(bookId === 0, 'requires test 1 to have created a book'); + test.setTimeout(60000); + await page.goto(`${BASE}/admin/books/edit/${bookId}`); + await expect(page.locator('#bookForm')).toBeVisible({ timeout: 10000 }); + + await page.fill('#sottotitolo', EDITED_SUBTITLE); + await submitBookForm(page); + + await expect.poll( + () => dbQuery(`SELECT sottotitolo FROM libri WHERE id = ${bookId}`), + { timeout: 30000 }, + ).toBe(EDITED_SUBTITLE); + // Title untouched by the edit. + expect(dbQuery(`SELECT titolo FROM libri WHERE id = ${bookId}`)).toBe(CREATE_TITLE); + }); + + test('5. #351 — the disabled availability field did not alter libri.stato', async () => { + test.skip(bookId === 0 || statoBeforeEdit === '', 'requires tests 1 and 4'); + const statoAfterEdit = dbQuery(`SELECT stato FROM libri WHERE id = ${bookId}`); + expect(statoAfterEdit, 'stato must be unchanged after an edit-save with the disabled select').toBe(statoBeforeEdit); + }); +}); From 9e989f466255731edea3499e3046e4717b27dce2 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 13 Aug 2026 15:31:20 +0200 Subject: [PATCH 02/13] fix(books): stop the floating scroll-to-top button covering the Save button The global scroll-to-top button is fixed in the bottom-right corner; the book form's Save/Cancel row is right-aligned at the end of the page. Scrolled all the way down to save, the floating button lands on top of Save. Give the action row an id and observe it with an IntersectionObserver: while it is in view (you are already at the bottom), add a body class whose CSS hides the scroll-to-top button with !important, overriding the inline opacity the scroll-to-top partial sets on scroll. Scoped to this page via the body class, so no other page changes; degrades to the old behaviour without IntersectionObserver. --- app/Views/libri/partials/book_form.php | 30 +++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/app/Views/libri/partials/book_form.php b/app/Views/libri/partials/book_form.php index 0228baa44..421701073 100644 --- a/app/Views/libri/partials/book_form.php +++ b/app/Views/libri/partials/book_form.php @@ -1003,7 +1003,7 @@ class="w-4 h-4 rounded border-gray-300 text-gray-900 focus:ring-gray-500" \App\Support\Hooks::do('book.form.fields', [$bookData, $bookId]); ?> -
+
+ + + From 9a9fb4194ed96f644d04f5b8656539f00bec5893 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 13 Aug 2026 16:26:40 +0200 Subject: [PATCH 03/13] feat(import): accept UPC-A barcodes (board games etc.) as EAN/UPC (#348) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some items — board games in particular — carry a 12-digit UPC-A instead of a 13-digit EAN. The barcode column already stores anything up to varchar(20) and search matches it via LIKE, but the CSV/TSV import silently dropped a 12-digit UPC: normalizeEan() required exactly 13 digits plus an EAN-13 checksum, so the value became NULL. A UPC-A (GTIN-12) is a GTIN-13/EAN-13 with a leading zero, and that zero-padding preserves the check digit (the EAN-13 weighting aligns). So canonicalise a valid 12-digit UPC-A to its 13-digit GTIN and let it flow through the existing EAN-13 validation, storage, dedup and search unchanged — the same barcode scanned as UPC-A or EAN-13 normalises to one value. - CsvImportController::normalizeEan() canonicalises 12→13 before the checksum. CSV and TSV share this path (the delimiter is auto-detected), so both are fixed. - LibriController store()/update() apply the same canonicalisation to a manually entered ean, so a UPC typed in the book form dedups against the same barcode imported from a file. Tests: - tests/import-upc-normalization-348.unit.php (9 checks): UPC-A → GTIN, with separators, bad-checksum → null, EAN-13 unchanged, UPC/EAN dedup identically, wrong lengths and empty/non-numeric → null. - tests/import-upc-348.spec.js (E2E, CSV + tab-delimited): import a book whose barcode is a 12-digit UPC-A through the real upload flow and assert the stored libri.ean is the zero-prepended GTIN-13, with zero row errors. Complements #348's EAN → EAN/UPC field relabelling by making the field actually accept a UPC end to end. --- app/Controllers/CsvImportController.php | 24 ++- app/Controllers/LibriController.php | 18 +++ tests/import-upc-348.spec.js | 153 ++++++++++++++++++++ tests/import-upc-normalization-348.unit.php | 64 ++++++++ 4 files changed, 254 insertions(+), 5 deletions(-) create mode 100644 tests/import-upc-348.spec.js create mode 100644 tests/import-upc-normalization-348.unit.php diff --git a/app/Controllers/CsvImportController.php b/app/Controllers/CsvImportController.php index 0a0905905..3f8526a84 100644 --- a/app/Controllers/CsvImportController.php +++ b/app/Controllers/CsvImportController.php @@ -1157,13 +1157,21 @@ private function normalizeIsbn(string $isbn): ?string } /** - * Validate and normalize EAN-13 barcode value + * Validate and normalize an EAN-13 or UPC-A barcode value * - * Unlike normalizeIsbn(), this only validates format and length (13 digits) - * without ISBN checksum checks, since valid EAN-13 barcodes may not be ISBNs. + * Unlike normalizeIsbn(), this only validates format and length without + * ISBN checksum checks, since valid EAN-13 barcodes may not be ISBNs. * - * @param string $ean Raw EAN value from CSV - * @return string|null Normalized EAN or null if invalid + * A UPC-A (GTIN-12, e.g. board games) is a GTIN-13/EAN-13 with a leading + * zero, and the zero-padding preserves its check digit (the EAN-13 weight + * pattern aligns once the leading zero occupies an odd position). So a + * 12-digit UPC-A is canonicalised to its 13-digit GTIN and then flows + * through the same EAN-13 validation, storage, dedup and search — the same + * barcode scanned as UPC-A or EAN-13 normalises to one value. (issue #348) + * Both CSV and TSV imports share this path (delimiter is auto-detected). + * + * @param string $ean Raw EAN/UPC value from CSV/TSV + * @return string|null Normalized 13-digit GTIN or null if invalid */ private function normalizeEan(string $ean): ?string { @@ -1178,6 +1186,12 @@ private function normalizeEan(string $ean): ?string return null; } + // UPC-A (12 digits) is EAN-13 with a leading zero — canonicalise it so + // the shared EAN-13 checksum below validates it unchanged. + if (strlen($normalized) === 12) { + $normalized = '0' . $normalized; + } + // EAN-13 must be exactly 13 digits if (strlen($normalized) !== 13) { return null; diff --git a/app/Controllers/LibriController.php b/app/Controllers/LibriController.php index 9494cde94..8ef9802be 100644 --- a/app/Controllers/LibriController.php +++ b/app/Controllers/LibriController.php @@ -818,6 +818,15 @@ public function store(Request $request, Response $response, mysqli $db): Respons } $fields[$codeKey] = preg_replace('/[\s-]+/', '', $rawValue); + + // UPC-A (12 digits) is EAN-13 with a leading zero. Canonicalise + // to the 13-digit GTIN so a UPC typed here dedups against the + // same barcode imported via CSV/TSV. (issue #348) + if ($codeKey === 'ean' + && strlen((string) $fields[$codeKey]) === 12 + && ctype_digit((string) $fields[$codeKey])) { + $fields[$codeKey] = '0' . $fields[$codeKey]; + } } } @@ -1385,6 +1394,15 @@ public function update(Request $request, Response $response, mysqli $db, int $id } $fields[$codeKey] = preg_replace('/[\s-]+/', '', $rawValue); + + // UPC-A (12 digits) is EAN-13 with a leading zero. Canonicalise + // to the 13-digit GTIN so a UPC typed here dedups against the + // same barcode imported via CSV/TSV. (issue #348) + if ($codeKey === 'ean' + && strlen((string) $fields[$codeKey]) === 12 + && ctype_digit((string) $fields[$codeKey])) { + $fields[$codeKey] = '0' . $fields[$codeKey]; + } } } diff --git a/tests/import-upc-348.spec.js b/tests/import-upc-348.spec.js new file mode 100644 index 000000000..297f65f16 --- /dev/null +++ b/tests/import-upc-348.spec.js @@ -0,0 +1,153 @@ +// E2E (#348): CSV and TSV import must accept a 12-digit UPC-A barcode and store +// it as the canonical 13-digit GTIN (a leading zero, which preserves the check +// digit). Both imports share one code path (delimiter auto-detected), so we +// exercise both a comma/semicolon CSV and a tab-delimited "TSV". +// +// Per project rule, CSV/TSV import is ALWAYS verified through the real browser +// upload flow (never API-only) and the stored value is read back from the DB. +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('child_process'); + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; + +const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || ''; +const ADMIN_PASS = process.env.E2E_ADMIN_PASS || ''; +const DB_USER = process.env.E2E_DB_USER || ''; +const DB_PASS = process.env.E2E_DB_PASS || ''; +const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; +const DB_HOST = process.env.E2E_DB_HOST || ''; +const DB_PORT = process.env.E2E_DB_PORT || ''; +const DB_NAME = process.env.E2E_DB_NAME || ''; + +test.skip( + !ADMIN_EMAIL || !ADMIN_PASS || !DB_USER || !DB_PASS || !DB_NAME, + 'E2E credentials not configured', +); + +const sqlEscape = (s) => String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + +function dbQuery(sql) { + const args = ['-u', DB_USER, `-p${DB_PASS}`, DB_NAME, '-N', '-B', '-e', sql]; + if (DB_HOST) { + args.splice(3, 0, '-h', DB_HOST); + if (DB_PORT) args.splice(5, 0, '-P', DB_PORT); + } else if (DB_SOCKET) { + args.splice(3, 0, '-S', DB_SOCKET); + } + return execFileSync('mysql', args, { encoding: 'utf-8', timeout: 10000 }).trim(); +} + +// Per-run token so the imported rows are unique and findable. +const RUN_ID = Date.now().toString(36); +const CSV_TITLE = `UPC CSV ${RUN_ID}`; +const TSV_TITLE = `UPC TSV ${RUN_ID}`; +const CSV_UPC = '036000291452'; // valid UPC-A → GTIN 0036000291452 +const TSV_UPC = '012345678905'; // valid UPC-A → GTIN 0012345678905 +const CSV_GTIN = '0036000291452'; +const TSV_GTIN = '0012345678905'; + +function cleanup() { + // libri.ean is UNIQUE: remove any leftover test rows so re-runs never hit a + // duplicate-ean. These are test-only rows we create — hard delete is fine. + dbQuery( + "DELETE FROM libri WHERE ean IN ('" + CSV_GTIN + "','" + TSV_GTIN + "')" + + " OR titolo LIKE 'UPC CSV %' OR titolo LIKE 'UPC TSV %'", + ); +} + +async function loginAsAdmin(page) { + await page.goto(`${BASE}/admin/dashboard`); + const email = page.locator('input[name="email"]'); + if (await email.isVisible({ timeout: 3000 }).catch(() => false)) { + await email.fill(ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await page.click('button[type="submit"]'); + await page.waitForURL(/.*(?:dashboard|admin).*/, { timeout: 15000 }); + } +} + +/** + * Upload one in-memory import file through the real browser flow and wait for + * the chunked import to report completion. Returns the collected HTTP statuses + * and the final /chunk payload. + */ +async function runImport(page, { name, mimeType, content }) { + await page.goto(`${BASE}/admin/books/import`); + + const statuses = []; + let lastChunk = null; + page.on('response', async (r) => { + const u = r.url(); + if (u.includes('/admin/books/import/upload') || u.includes('/admin/books/import/chunk')) { + statuses.push(r.status()); + if (u.includes('/chunk')) { + try { lastChunk = JSON.parse(await r.text()); } catch { /* non-JSON => assertions catch it */ } + } + } + }); + + await page.setInputFiles('#csv_file', { name, mimeType, buffer: Buffer.from(content, 'utf-8') }); + // The submit button is gated on the Uppy uploader; drive the plain input. + await page.evaluate(() => { const b = document.getElementById('submitBtn'); if (b) b.disabled = false; }); + await page.click('#submitBtn'); + + await expect + .poll(() => (lastChunk && lastChunk.complete === true) ? true : false, { timeout: 120000, intervals: [2000] }) + .toBe(true); + + return { statuses, lastChunk }; +} + +test.describe.serial('UPC barcode import (#348)', () => { + test.beforeAll(() => cleanup()); + test.afterAll(() => cleanup()); + + test('CSV: a 12-digit UPC-A is stored as its 13-digit GTIN', async ({ page }) => { + test.setTimeout(150000); + await loginAsAdmin(page); + + const content = `titolo;ean\n${CSV_TITLE};${CSV_UPC}\n`; + const { statuses, lastChunk } = await runImport(page, { + name: 'upc-test.csv', + mimeType: 'text/csv', + content, + }); + + expect(statuses.length).toBeGreaterThan(0); + expect(statuses.every((s) => s === 200)).toBeTruthy(); + expect(lastChunk).toBeTruthy(); + expect(lastChunk.complete).toBe(true); + expect(lastChunk.errors).toBe(0); + + const storedEan = dbQuery( + "SELECT ean FROM libri WHERE titolo='" + sqlEscape(CSV_TITLE) + "' AND deleted_at IS NULL ORDER BY id DESC LIMIT 1", + ); + expect(storedEan).toBe(CSV_GTIN); + }); + + test('TSV: a tab-delimited 12-digit UPC-A is stored as its 13-digit GTIN', async ({ page }) => { + test.setTimeout(150000); + await loginAsAdmin(page); + + // The app accepts only a .csv-named file (str_ends_with('.csv')) and + // auto-detects the delimiter (";", "," or TAB), so its "TSV" support is + // tab-delimited content inside a .csv file — not the .tsv extension. + const content = `titolo\tean\n${TSV_TITLE}\t${TSV_UPC}\n`; + const { statuses, lastChunk } = await runImport(page, { + name: 'upc-test-tab.csv', + mimeType: 'text/csv', + content, + }); + + expect(statuses.length).toBeGreaterThan(0); + expect(statuses.every((s) => s === 200)).toBeTruthy(); + expect(lastChunk).toBeTruthy(); + expect(lastChunk.complete).toBe(true); + expect(lastChunk.errors).toBe(0); + + const storedEan = dbQuery( + "SELECT ean FROM libri WHERE titolo='" + sqlEscape(TSV_TITLE) + "' AND deleted_at IS NULL ORDER BY id DESC LIMIT 1", + ); + expect(storedEan).toBe(TSV_GTIN); + }); +}); diff --git a/tests/import-upc-normalization-348.unit.php b/tests/import-upc-normalization-348.unit.php new file mode 100644 index 000000000..9e09beffa --- /dev/null +++ b/tests/import-upc-normalization-348.unit.php @@ -0,0 +1,64 @@ +newInstanceWithoutConstructor(); +$method = new ReflectionMethod(CsvImportController::class, 'normalizeEan'); +$method->setAccessible(true); + +/** @return string|null */ +$normalize = static fn (string $ean) => $method->invoke($controller, $ean); + +$passed = 0; +$failed = 0; +$check = static function (bool $ok, string $label) use (&$passed, &$failed): void { + echo ($ok ? ' OK ' : ' FAIL ') . $label . PHP_EOL; + $ok ? $passed++ : $failed++; +}; + +// A valid UPC-A (Coca-Cola sample checksum) → canonical GTIN-13 "0" + upc. +$check($normalize('036000291452') === '0036000291452', 'valid UPC-A is canonicalised to its 13-digit GTIN'); + +// The same UPC with separators the scanner/label may include. +$check($normalize('0 36000 29145 2') === '0036000291452', 'UPC-A with separators normalises to the same GTIN'); + +// A UPC-A whose 13-digit GTIN form fails the checksum is rejected. +$check($normalize('036000291453') === null, 'UPC-A with a bad check digit is rejected'); + +// EAN-13 keeps working unchanged. +$check($normalize('9788804763178') === '9788804763178', 'valid EAN-13 is returned unchanged'); + +// A UPC-A and its EAN-13 GTIN form collapse to one value (dedup coherence). +$check( + $normalize('036000291452') === $normalize('0036000291452'), + 'UPC-A and its zero-padded EAN-13 form normalise identically' +); + +// Wrong lengths are rejected (11 and 14 digits). +$check($normalize('01234567890') === null, '11-digit input is rejected'); +$check($normalize('01234567890123') === null, '14-digit input is rejected'); + +// Empty / non-numeric input is rejected. +$check($normalize('') === null, 'empty input is rejected'); +$check($normalize('not-a-barcode') === null, 'non-numeric input is rejected'); + +echo PHP_EOL . "Passed: {$passed}, Failed: {$failed}" . PHP_EOL; +exit($failed === 0 ? 0 : 1); From 9ce61ed1d88d967a05f3612d6fbff4091b851938 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 13 Aug 2026 19:26:34 +0200 Subject: [PATCH 04/13] fix(import): validate UPC-A before canonicalising; reject non-digit barcode cells Address review feedback on the UPC support: - LibriController now canonicalises a manually entered ean to GTIN-13 only when it is a VALID UPC-A (new isValidUpcA() mod-10 check), in both store() and update(). An invalid 12-digit code is left untouched instead of being turned into a bogus GTIN. - normalizeEan() strips only real separators (spaces, dashes) and rejects any remaining non-digit, so "ABC036000291452" no longer becomes a valid UPC-A. This matches LibriController's ean sanitisation. - Unit test adds: letters around a valid UPC-A stay invalid; a dash-separated EAN-13 is accepted. (11 checks total, all green; E2E CSV+TSV still green.) --- app/Controllers/CsvImportController.php | 9 +++-- app/Controllers/LibriController.php | 45 +++++++++++++++------ tests/import-upc-normalization-348.unit.php | 5 +++ 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/app/Controllers/CsvImportController.php b/app/Controllers/CsvImportController.php index 3f8526a84..201331034 100644 --- a/app/Controllers/CsvImportController.php +++ b/app/Controllers/CsvImportController.php @@ -1179,10 +1179,13 @@ private function normalizeEan(string $ean): ?string return null; } - // Remove all non-digit characters - $normalized = preg_replace('/[^0-9]/', '', trim($ean)); + // Strip only the separators a barcode may carry (spaces, dashes). Any + // other character means the cell is not a bare barcode — reject it + // rather than silently extracting digits (e.g. "ABC036000291452" must + // not become a valid UPC-A). Mirrors LibriController's ean sanitisation. + $normalized = preg_replace('/[\s-]+/', '', trim($ean)); - if (empty($normalized)) { + if ($normalized === '' || !ctype_digit($normalized)) { return null; } diff --git a/app/Controllers/LibriController.php b/app/Controllers/LibriController.php index 8ef9802be..afacbcd12 100644 --- a/app/Controllers/LibriController.php +++ b/app/Controllers/LibriController.php @@ -288,6 +288,27 @@ private function normalizeIssn(?string $raw): ?string return substr($issnCompact, 0, 4) . '-' . substr($issnCompact, 4, 4); } + /** + * Validate a UPC-A (GTIN-12) barcode by its mod-10 check digit. + * + * UPC-A weights the first 11 digits 3,1,3,1,… (odd positions ×3), and the + * 12th is the check digit. Used to decide whether a 12-digit ean may be + * canonicalised to its GTIN-13 form ('0' + upc) — we only rewrite a code + * that is actually a valid UPC-A, never fabricate a GTIN from a bad value. + */ + private static function isValidUpcA(string $code): bool + { + if (strlen($code) !== 12 || !ctype_digit($code)) { + return false; + } + $sum = 0; + for ($i = 0; $i < 11; $i++) { + $sum += (int) $code[$i] * ($i % 2 === 0 ? 3 : 1); + } + $check = (10 - ($sum % 10)) % 10; + return $check === (int) $code[11]; + } + /** * Rotate log files to prevent unlimited growth * Keeps only last 7 days of logs, max 10MB per file @@ -819,12 +840,12 @@ public function store(Request $request, Response $response, mysqli $db): Respons $fields[$codeKey] = preg_replace('/[\s-]+/', '', $rawValue); - // UPC-A (12 digits) is EAN-13 with a leading zero. Canonicalise - // to the 13-digit GTIN so a UPC typed here dedups against the - // same barcode imported via CSV/TSV. (issue #348) - if ($codeKey === 'ean' - && strlen((string) $fields[$codeKey]) === 12 - && ctype_digit((string) $fields[$codeKey])) { + // A VALID UPC-A (12 digits) is EAN-13 with a leading zero, and + // the zero-padding preserves its check digit. Canonicalise it to + // the 13-digit GTIN so a UPC typed here dedups against the same + // barcode imported via CSV/TSV; an invalid or non-UPC value is + // left untouched (never fabricate a GTIN from a bad code). (#348) + if ($codeKey === 'ean' && self::isValidUpcA((string) $fields[$codeKey])) { $fields[$codeKey] = '0' . $fields[$codeKey]; } } @@ -1395,12 +1416,12 @@ public function update(Request $request, Response $response, mysqli $db, int $id $fields[$codeKey] = preg_replace('/[\s-]+/', '', $rawValue); - // UPC-A (12 digits) is EAN-13 with a leading zero. Canonicalise - // to the 13-digit GTIN so a UPC typed here dedups against the - // same barcode imported via CSV/TSV. (issue #348) - if ($codeKey === 'ean' - && strlen((string) $fields[$codeKey]) === 12 - && ctype_digit((string) $fields[$codeKey])) { + // A VALID UPC-A (12 digits) is EAN-13 with a leading zero, and + // the zero-padding preserves its check digit. Canonicalise it to + // the 13-digit GTIN so a UPC typed here dedups against the same + // barcode imported via CSV/TSV; an invalid or non-UPC value is + // left untouched (never fabricate a GTIN from a bad code). (#348) + if ($codeKey === 'ean' && self::isValidUpcA((string) $fields[$codeKey])) { $fields[$codeKey] = '0' . $fields[$codeKey]; } } diff --git a/tests/import-upc-normalization-348.unit.php b/tests/import-upc-normalization-348.unit.php index 9e09beffa..80e798515 100644 --- a/tests/import-upc-normalization-348.unit.php +++ b/tests/import-upc-normalization-348.unit.php @@ -60,5 +60,10 @@ $check($normalize('') === null, 'empty input is rejected'); $check($normalize('not-a-barcode') === null, 'non-numeric input is rejected'); +// A stray letter must not be silently stripped into a valid barcode: only +// spaces and dashes are separators, anything else invalidates the cell. +$check($normalize('ABC036000291452') === null, 'letters around a valid UPC-A do not make it valid'); +$check($normalize("978-88-04-76317-8") === '9788804763178', 'a dash-separated EAN-13 is accepted'); + echo PHP_EOL . "Passed: {$passed}, Failed: {$failed}" . PHP_EOL; exit($failed === 0 ? 0 : 1); From 9cffc18695e24e84766e119e70e384fa17c5f27f Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 13 Aug 2026 21:32:26 +0200 Subject: [PATCH 05/13] fix(import): strip only ASCII space/dash; don't truncate a formatted UPC-A MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review round on the UPC support: - normalizeEan() now strips only ASCII space and dash (str_replace) instead of the \s class, so a field with an embedded TAB/CR/LF can no longer collapse into a "valid" barcode — it is rejected by the ctype_digit guard. - LibriController raises the ean length bound from 13 to 20 (fits varchar(20)), so a separator-formatted UPC-A like "0 36000 29145 2" is no longer truncated before isValidUpcA() runs, which would otherwise skip a legitimate canonicalisation. Applied in store() and update(). - Unit test locks LibriController::isValidUpcA() via reflection (valid UPC-A, bad check digit, wrong length, non-numeric). 18 checks total, all green; E2E CSV+TSV still green. --- app/Controllers/CsvImportController.php | 11 ++++++----- app/Controllers/LibriController.php | 20 ++++++++++++++------ tests/import-upc-normalization-348.unit.php | 16 ++++++++++++++++ 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/app/Controllers/CsvImportController.php b/app/Controllers/CsvImportController.php index 201331034..527344082 100644 --- a/app/Controllers/CsvImportController.php +++ b/app/Controllers/CsvImportController.php @@ -1179,11 +1179,12 @@ private function normalizeEan(string $ean): ?string return null; } - // Strip only the separators a barcode may carry (spaces, dashes). Any - // other character means the cell is not a bare barcode — reject it - // rather than silently extracting digits (e.g. "ABC036000291452" must - // not become a valid UPC-A). Mirrors LibriController's ean sanitisation. - $normalized = preg_replace('/[\s-]+/', '', trim($ean)); + // Strip only the separators a barcode may carry: ASCII space and dash. + // Not \s — that also removes TAB/CR/LF, which would let a field with an + // embedded newline collapse into a "valid" barcode. Any character left + // after this means the cell is not a bare barcode (e.g. "ABC036000291452" + // or "036000\n291452"), so reject it rather than extracting digits. + $normalized = str_replace([' ', '-'], '', trim($ean)); if ($normalized === '' || !ctype_digit($normalized)) { return null; diff --git a/app/Controllers/LibriController.php b/app/Controllers/LibriController.php index afacbcd12..fff5a974e 100644 --- a/app/Controllers/LibriController.php +++ b/app/Controllers/LibriController.php @@ -831,9 +831,13 @@ public function store(Request $request, Response $response, mysqli $db): Respons if (isset($fields[$codeKey])) { $rawValue = (string) $fields[$codeKey]; - // Input validation: prevent ReDoS by checking length before regex - // ISBN-10: max 13 chars (10 digits + 3 separators), ISBN-13: max 17 chars (13 digits + 4 separators), EAN: max 13 - $maxLength = ($codeKey === 'isbn10') ? 13 : (($codeKey === 'isbn13') ? 17 : 13); + // Input validation: bound length before the strip regex. + // ISBN-10: 13 (10 digits + 3 separators); ISBN-13: 17 (13 + 4). + // EAN/UPC: 20 (fits varchar(20)) — a separator-formatted UPC-A + // (e.g. "0 36000 29145 2") must NOT be truncated before the + // isValidUpcA() check below, or the check would see a corrupted + // value and skip a legitimate canonicalisation. + $maxLength = ($codeKey === 'isbn10') ? 13 : (($codeKey === 'isbn13') ? 17 : 20); if (strlen($rawValue) > $maxLength) { $rawValue = substr($rawValue, 0, $maxLength); } @@ -1407,9 +1411,13 @@ public function update(Request $request, Response $response, mysqli $db, int $id if (isset($fields[$codeKey])) { $rawValue = (string) $fields[$codeKey]; - // Input validation: prevent ReDoS by checking length before regex - // ISBN-10: max 13 chars (10 digits + 3 separators), ISBN-13: max 17 chars (13 digits + 4 separators), EAN: max 13 - $maxLength = ($codeKey === 'isbn10') ? 13 : (($codeKey === 'isbn13') ? 17 : 13); + // Input validation: bound length before the strip regex. + // ISBN-10: 13 (10 digits + 3 separators); ISBN-13: 17 (13 + 4). + // EAN/UPC: 20 (fits varchar(20)) — a separator-formatted UPC-A + // (e.g. "0 36000 29145 2") must NOT be truncated before the + // isValidUpcA() check below, or the check would see a corrupted + // value and skip a legitimate canonicalisation. + $maxLength = ($codeKey === 'isbn10') ? 13 : (($codeKey === 'isbn13') ? 17 : 20); if (strlen($rawValue) > $maxLength) { $rawValue = substr($rawValue, 0, $maxLength); } diff --git a/tests/import-upc-normalization-348.unit.php b/tests/import-upc-normalization-348.unit.php index 80e798515..0b159bd21 100644 --- a/tests/import-upc-normalization-348.unit.php +++ b/tests/import-upc-normalization-348.unit.php @@ -65,5 +65,21 @@ $check($normalize('ABC036000291452') === null, 'letters around a valid UPC-A do not make it valid'); $check($normalize("978-88-04-76317-8") === '9788804763178', 'a dash-separated EAN-13 is accepted'); +// ── LibriController::isValidUpcA() — the manual-save canonicalisation guard ── +// store()/update() only zero-pad an ean when it is a valid UPC-A, so the +// checksum logic is locked here (private static, exercised via reflection). +$upcMethod = new ReflectionMethod(\App\Controllers\LibriController::class, 'isValidUpcA'); +$upcMethod->setAccessible(true); +/** @return bool */ +$isValidUpcA = static fn (string $code) => $upcMethod->invoke(null, $code); + +$check($isValidUpcA('036000291452') === true, 'isValidUpcA accepts a valid UPC-A'); +$check($isValidUpcA('012345678905') === true, 'isValidUpcA accepts a second valid UPC-A'); +$check($isValidUpcA('036000291453') === false, 'isValidUpcA rejects a bad check digit'); +$check($isValidUpcA('999999999999') === false, 'isValidUpcA rejects a 12-digit number with a wrong check digit'); +$check($isValidUpcA('03600029145') === false, 'isValidUpcA rejects an 11-digit value'); +$check($isValidUpcA('0036000291452') === false, 'isValidUpcA rejects a 13-digit value'); +$check($isValidUpcA('ABC000291452') === false, 'isValidUpcA rejects a non-numeric value'); + echo PHP_EOL . "Passed: {$passed}, Failed: {$failed}" . PHP_EOL; exit($failed === 0 ? 0 : 1); From 8c9e2d4afa58e2af974ab232151605ec0e6a9a9f Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 13 Aug 2026 22:12:52 +0200 Subject: [PATCH 06/13] refactor(import): keep UPC-A support to the single import path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate the UPC-A handling to one place. The reported bug (#348) is that the CSV/TSV import silently dropped a 12-digit UPC-A; that is fixed in normalizeEan(). A UPC typed into the book form already stored and searched fine as a raw 12-digit value, so the manual-form canonicalisation I had added was an optional dedup nicety — I have removed it (LibriController is back to its main-branch state). That leaves exactly one UPC-A code path, so there is no longer a set of sanitisers to keep in sync, and it avoids changing the manual save behaviour other book tests depend on. normalizeEan() now strips only ASCII space and dash (no trim(), no \s), so a field carrying a stray TAB/CR/LF is rejected by the ctype_digit guard instead of being collapsed into a valid GTIN. The 12→13 UPC-A canonicalisation and EAN-13 checksum are unchanged. Unit test updated accordingly (12 checks incl. a leading-TAB rejection); the CSV+TSV E2E is still green. --- app/Controllers/CsvImportController.php | 11 ++-- app/Controllers/LibriController.php | 59 +++------------------ tests/import-upc-normalization-348.unit.php | 18 ++----- 3 files changed, 15 insertions(+), 73 deletions(-) diff --git a/app/Controllers/CsvImportController.php b/app/Controllers/CsvImportController.php index 527344082..0bf9e43b9 100644 --- a/app/Controllers/CsvImportController.php +++ b/app/Controllers/CsvImportController.php @@ -1180,11 +1180,12 @@ private function normalizeEan(string $ean): ?string } // Strip only the separators a barcode may carry: ASCII space and dash. - // Not \s — that also removes TAB/CR/LF, which would let a field with an - // embedded newline collapse into a "valid" barcode. Any character left - // after this means the cell is not a bare barcode (e.g. "ABC036000291452" - // or "036000\n291452"), so reject it rather than extracting digits. - $normalized = str_replace([' ', '-'], '', trim($ean)); + // Not \s and not trim() — those also remove TAB/CR/LF, which would let a + // field with a stray control character collapse into a "valid" barcode. + // Any character left after this means the cell is not a bare barcode + // (e.g. "ABC036000291452", "036000\n291452", "\t036000291452"), so + // reject it via the ctype_digit guard rather than extracting digits. + $normalized = str_replace([' ', '-'], '', $ean); if ($normalized === '' || !ctype_digit($normalized)) { return null; diff --git a/app/Controllers/LibriController.php b/app/Controllers/LibriController.php index fff5a974e..9494cde94 100644 --- a/app/Controllers/LibriController.php +++ b/app/Controllers/LibriController.php @@ -288,27 +288,6 @@ private function normalizeIssn(?string $raw): ?string return substr($issnCompact, 0, 4) . '-' . substr($issnCompact, 4, 4); } - /** - * Validate a UPC-A (GTIN-12) barcode by its mod-10 check digit. - * - * UPC-A weights the first 11 digits 3,1,3,1,… (odd positions ×3), and the - * 12th is the check digit. Used to decide whether a 12-digit ean may be - * canonicalised to its GTIN-13 form ('0' + upc) — we only rewrite a code - * that is actually a valid UPC-A, never fabricate a GTIN from a bad value. - */ - private static function isValidUpcA(string $code): bool - { - if (strlen($code) !== 12 || !ctype_digit($code)) { - return false; - } - $sum = 0; - for ($i = 0; $i < 11; $i++) { - $sum += (int) $code[$i] * ($i % 2 === 0 ? 3 : 1); - } - $check = (10 - ($sum % 10)) % 10; - return $check === (int) $code[11]; - } - /** * Rotate log files to prevent unlimited growth * Keeps only last 7 days of logs, max 10MB per file @@ -831,27 +810,14 @@ public function store(Request $request, Response $response, mysqli $db): Respons if (isset($fields[$codeKey])) { $rawValue = (string) $fields[$codeKey]; - // Input validation: bound length before the strip regex. - // ISBN-10: 13 (10 digits + 3 separators); ISBN-13: 17 (13 + 4). - // EAN/UPC: 20 (fits varchar(20)) — a separator-formatted UPC-A - // (e.g. "0 36000 29145 2") must NOT be truncated before the - // isValidUpcA() check below, or the check would see a corrupted - // value and skip a legitimate canonicalisation. - $maxLength = ($codeKey === 'isbn10') ? 13 : (($codeKey === 'isbn13') ? 17 : 20); + // Input validation: prevent ReDoS by checking length before regex + // ISBN-10: max 13 chars (10 digits + 3 separators), ISBN-13: max 17 chars (13 digits + 4 separators), EAN: max 13 + $maxLength = ($codeKey === 'isbn10') ? 13 : (($codeKey === 'isbn13') ? 17 : 13); if (strlen($rawValue) > $maxLength) { $rawValue = substr($rawValue, 0, $maxLength); } $fields[$codeKey] = preg_replace('/[\s-]+/', '', $rawValue); - - // A VALID UPC-A (12 digits) is EAN-13 with a leading zero, and - // the zero-padding preserves its check digit. Canonicalise it to - // the 13-digit GTIN so a UPC typed here dedups against the same - // barcode imported via CSV/TSV; an invalid or non-UPC value is - // left untouched (never fabricate a GTIN from a bad code). (#348) - if ($codeKey === 'ean' && self::isValidUpcA((string) $fields[$codeKey])) { - $fields[$codeKey] = '0' . $fields[$codeKey]; - } } } @@ -1411,27 +1377,14 @@ public function update(Request $request, Response $response, mysqli $db, int $id if (isset($fields[$codeKey])) { $rawValue = (string) $fields[$codeKey]; - // Input validation: bound length before the strip regex. - // ISBN-10: 13 (10 digits + 3 separators); ISBN-13: 17 (13 + 4). - // EAN/UPC: 20 (fits varchar(20)) — a separator-formatted UPC-A - // (e.g. "0 36000 29145 2") must NOT be truncated before the - // isValidUpcA() check below, or the check would see a corrupted - // value and skip a legitimate canonicalisation. - $maxLength = ($codeKey === 'isbn10') ? 13 : (($codeKey === 'isbn13') ? 17 : 20); + // Input validation: prevent ReDoS by checking length before regex + // ISBN-10: max 13 chars (10 digits + 3 separators), ISBN-13: max 17 chars (13 digits + 4 separators), EAN: max 13 + $maxLength = ($codeKey === 'isbn10') ? 13 : (($codeKey === 'isbn13') ? 17 : 13); if (strlen($rawValue) > $maxLength) { $rawValue = substr($rawValue, 0, $maxLength); } $fields[$codeKey] = preg_replace('/[\s-]+/', '', $rawValue); - - // A VALID UPC-A (12 digits) is EAN-13 with a leading zero, and - // the zero-padding preserves its check digit. Canonicalise it to - // the 13-digit GTIN so a UPC typed here dedups against the same - // barcode imported via CSV/TSV; an invalid or non-UPC value is - // left untouched (never fabricate a GTIN from a bad code). (#348) - if ($codeKey === 'ean' && self::isValidUpcA((string) $fields[$codeKey])) { - $fields[$codeKey] = '0' . $fields[$codeKey]; - } } } diff --git a/tests/import-upc-normalization-348.unit.php b/tests/import-upc-normalization-348.unit.php index 0b159bd21..3022eb4fc 100644 --- a/tests/import-upc-normalization-348.unit.php +++ b/tests/import-upc-normalization-348.unit.php @@ -65,21 +65,9 @@ $check($normalize('ABC036000291452') === null, 'letters around a valid UPC-A do not make it valid'); $check($normalize("978-88-04-76317-8") === '9788804763178', 'a dash-separated EAN-13 is accepted'); -// ── LibriController::isValidUpcA() — the manual-save canonicalisation guard ── -// store()/update() only zero-pad an ean when it is a valid UPC-A, so the -// checksum logic is locked here (private static, exercised via reflection). -$upcMethod = new ReflectionMethod(\App\Controllers\LibriController::class, 'isValidUpcA'); -$upcMethod->setAccessible(true); -/** @return bool */ -$isValidUpcA = static fn (string $code) => $upcMethod->invoke(null, $code); - -$check($isValidUpcA('036000291452') === true, 'isValidUpcA accepts a valid UPC-A'); -$check($isValidUpcA('012345678905') === true, 'isValidUpcA accepts a second valid UPC-A'); -$check($isValidUpcA('036000291453') === false, 'isValidUpcA rejects a bad check digit'); -$check($isValidUpcA('999999999999') === false, 'isValidUpcA rejects a 12-digit number with a wrong check digit'); -$check($isValidUpcA('03600029145') === false, 'isValidUpcA rejects an 11-digit value'); -$check($isValidUpcA('0036000291452') === false, 'isValidUpcA rejects a 13-digit value'); -$check($isValidUpcA('ABC000291452') === false, 'isValidUpcA rejects a non-numeric value'); +// A leading control character (TAB) is not a separator, so it must not be +// stripped into a valid barcode — trim() was deliberately removed. +$check($normalize("\t036000291452") === null, 'a leading TAB is not stripped into a valid UPC-A'); echo PHP_EOL . "Passed: {$passed}, Failed: {$failed}" . PHP_EOL; exit($failed === 0 ? 0 : 1); From 414d67449fd4512d0a731dd251f988a2fd2bfff2 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 13 Aug 2026 22:36:16 +0200 Subject: [PATCH 07/13] test(e2e): don't edit the read-only availability field in full-test 18.9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since the availability #stato select is disabled by design (#351), calling selectOption()/fill() on it waits for actionability until the 120s test timeout, which closes the shared page and cascades every later test in this serial file — that is why all four browser-regression shards and the Full E2E suite failed. Guard the interaction with isEditable(): a disabled control is skipped, the test still asserts the edit form loads. No product change. --- tests/full-test.spec.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/full-test.spec.js b/tests/full-test.spec.js index 0430b3ff7..02b7c3cad 100644 --- a/tests/full-test.spec.js +++ b/tests/full-test.spec.js @@ -2842,7 +2842,12 @@ test.describe.serial('Phase 18: Issue Regressions', () => { await page.waitForLoadState('domcontentloaded'); const statoField = page.locator('#stato, select[name="stato"]'); - if (await statoField.isVisible({ timeout: 2000 }).catch(() => false)) { + // #stato (availability) is a derived value and read-only by design (#351): + // the