diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6d25b4449..ca1bf53a1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,35 @@
Full version-by-version history for Pinakes. The README shows only the latest release; everything older lives here.
+## [0.7.60]
+
+Maintenance release: UPC barcode support, a read-only availability field, and a
+PHP 8.5 scraping fix, bundled with CI hardening.
+
+### Features
+
+- The barcode field accepts a 12-digit **UPC-A** (board games and other
+ non-book items), canonicalised to its 13-digit GTIN so it validates, stores,
+ searches and de-duplicates exactly like an EAN-13. CSV and TSV import both go
+ through the same path. The field is relabelled EAN → EAN/UPC across all five
+ locales.
+
+### Fixes
+
+- ISBN scraping no longer fails with "Risposta non valida dal servizio ISBN." on
+ PHP 8.5: the deprecated `curl_close()` calls that leaked a notice into the JSON
+ response body were removed.
+- The book editor's availability field is now read-only, matching the fact that
+ it is a value derived from the physical copies; editing it was a silent no-op.
+- The floating scroll-to-top button no longer overlaps the Save button at the
+ bottom of the book form.
+
+### Internal
+
+- The GitHub Actions security audit no longer breaks on upstream tag drift, and
+ the NCIP CheckOut regression test provisions its own available copy so it is
+ deterministic under sharded runs.
+
## [0.7.59]
Consolidation release: the complete integration of PRs #335, #337, #339 and
diff --git a/README.md b/README.md
index 928416c29..0ba0d152b 100644
--- a/README.md
+++ b/README.md
@@ -39,31 +39,29 @@ Pinakes is a self-hosted, full-featured ILS for schools, municipalities, and pri
## What's New
-Highlights of the latest release are below. The full version-by-version history (v0.7.58 → v0.6.x) lives in **[CHANGELOG.md](CHANGELOG.md)**.
+Highlights of the latest release are below. The full version-by-version history (v0.7.59 → v0.6.x) lives in **[CHANGELOG.md](CHANGELOG.md)**.
-### v0.7.59 — latest
+### v0.7.60 — latest
-A consolidation release: four feature/fix branches integrated and hardened together, with a security pass on top.
+A maintenance release: UPC barcode support, a read-only availability field, and a PHP 8.5 scraping fix.
### New
-- **"Complete series" indicator** — admins can mark a series as complete; the flag shows on the series list and detail pages (#338).
+
+- **UPC-A barcodes are supported** — the barcode field now accepts a 12-digit UPC-A (board games and other non-book items) and stores it as its 13-digit GTIN, so it validates, searches and de-duplicates like an EAN-13. CSV and TSV import both handle it, and the field reads **EAN/UPC** in every locale (#348).
### Fixes
-- **Loan editing from the admin page works again** — the availability re-check no longer bounces every edit, and dates are validated strictly (#336).
-- **Loan status no longer shows "Unknown"** — cancelled and expired loans render their real state through canonical status/label helpers (#333).
-- **The notifications panel is no longer overlapped** in the admin during scroll (#334).
-- **Loan & reservation coherence** — clocks, availability and date handling share the same guarded paths across the web UI, mobile API and background jobs; auto-approval now honours the setting on the book-detail request path too (#301).
-- **The barcode scanner keeps focus** on the loan form and copies sort in natural order (#238).
-- **Related books are reachable on narrow and tablet screens** — the strip now shows a scroll affordance instead of silently clipping cards.
-### Security
-- Framework-generated error responses now receive the same nonce-based Content Security Policy as normal pages.
+- **ISBN scraping works on PHP 8.5 again** — a deprecated `curl_close()` notice was leaking into the JSON response and breaking the import with "Risposta non valida dal servizio ISBN.".
+- **The availability field in the book editor is now read-only** — it is derived from the physical copies, so editing it was a silent no-op (#351). Set a copy's status to make it unavailable.
+- **The scroll-to-top button no longer covers the Save button** at the bottom of the book form.
+
+### Internal
-### Database Changes
-- Adds a `collane.is_completa` flag; the in-app updater applies the migration automatically on upgrade.
+- The GitHub Actions security audit no longer breaks on upstream action tag drift, and the NCIP CheckOut regression test is now deterministic under sharded runs.
### Upgrade Notes
-- Back up your database before updating (the in-app updater does this automatically).
+
+- No database changes. Back up your database before updating anyway (the in-app updater does this automatically).
> Older releases → **[CHANGELOG.md](CHANGELOG.md)**.
diff --git a/app/Controllers/CsvImportController.php b/app/Controllers/CsvImportController.php
index 0a0905905..0bf9e43b9 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
{
@@ -1171,13 +1179,24 @@ 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: ASCII space and dash.
+ // 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 (empty($normalized)) {
+ if ($normalized === '' || !ctype_digit($normalized)) {
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/Views/libri/partials/book_form.php b/app/Views/libri/partials/book_form.php
index dcf7fb3ff..6e7f4f7b6 100644
--- a/app/Views/libri/partials/book_form.php
+++ b/app/Views/libri/partials/book_form.php
@@ -314,7 +314,7 @@
= __("Disponibilità") ?>
-
+
>= __("Disponibile") ?>
>= __("Non Disponibile") ?>
>= __("Prestato") ?>
@@ -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]);
?>
-
+
= __("Annulla") ?>
@@ -1016,6 +1016,37 @@ class="w-4 h-4 rounded border-gray-300 text-gray-900 focus:ring-gray-500"
+
+
+
diff --git a/locale/da_DK.json b/locale/da_DK.json
index ed7522568..bba957723 100644
--- a/locale/da_DK.json
+++ b/locale/da_DK.json
@@ -1264,8 +1264,8 @@
"Dry-run (analizza senza inserire)": "Tørkørsel (analyser uden indsættelse)",
"Dry-run:": "Tørkørsel:",
"Durata": "Varighed",
- "EAN": "EAN",
- "EAN:": "EAN:",
+ "EAN": "EAN/UPC",
+ "EAN:": "EAN/UPC:",
"ERRORE:": "FEJL:",
"Eccellente": "Fremragende",
"Eccezione creazione %s su %s:": "Undtagelse ved oprettelse af %s på %s:",
@@ -1712,7 +1712,7 @@
"Etichetta \"ombrello\" per spin-off (es. tutto il franchise di Fairy Tail).": "\"Paraply\"-mærkat for spin-offs (f.eks. hele Fairy Tail-franchiset).",
"Etichette": "Etiketter",
"Etichette interne grandi (Herma 4630, Avery 3490)": "Store interne etiketter (Herma 4630, Avery 3490)",
- "European Article Number (opzionale)": "European Article Number (valgfrit)",
+ "European Article Number (opzionale)": "European Article Number/Universal Product Code (valgfrit)",
"Eventi": "Begivenheder",
"Eventi Recenti": "Seneste begivenheder",
"Eventi e Incontri": "Begivenheder og møder",
diff --git a/locale/de_DE.json b/locale/de_DE.json
index 9319ea766..cd1845fe3 100644
--- a/locale/de_DE.json
+++ b/locale/de_DE.json
@@ -1264,8 +1264,8 @@
"Dry-run (analizza senza inserire)": "Testlauf (analysieren ohne einzufügen)",
"Dry-run:": "Testlauf:",
"Durata": "Dauer",
- "EAN": "EAN",
- "EAN:": "EAN:",
+ "EAN": "EAN/UPC",
+ "EAN:": "EAN/UPC:",
"ERRORE:": "FEHLER:",
"Eccellente": "Ausgezeichnet",
"Eccezione creazione %s su %s:": "Ausnahme beim Erstellen von %s auf %s:",
@@ -1712,7 +1712,7 @@
"Etichetta \"ombrello\" per spin-off (es. tutto il franchise di Fairy Tail).": "\"Dach\"-Bezeichnung für Spin-offs (z.B. das gesamte Fairy-Tail-Franchise).",
"Etichette": "Etiketten",
"Etichette interne grandi (Herma 4630, Avery 3490)": "Große interne Etiketten (Herma 4630, Avery 3490)",
- "European Article Number (opzionale)": "European Article Number (optional)",
+ "European Article Number (opzionale)": "European Article Number/Universal Product Code (optional)",
"Eventi": "Veranstaltungen",
"Eventi Recenti": "Aktuelle Veranstaltungen",
"Eventi e Incontri": "Veranstaltungen und Treffen",
diff --git a/locale/en_US.json b/locale/en_US.json
index f0c531b79..ad7eaf75c 100644
--- a/locale/en_US.json
+++ b/locale/en_US.json
@@ -1264,8 +1264,8 @@
"Dry-run (analizza senza inserire)": "Dry-run (parse without inserting)",
"Dry-run:": "Dry-run:",
"Durata": "Duration",
- "EAN": "EAN",
- "EAN:": "EAN:",
+ "EAN": "EAN/UPC",
+ "EAN:": "EAN/UPC:",
"ERRORE:": "ERROR:",
"Eccellente": "Excellent",
"Eccezione creazione %s su %s:": "Exception creating %s on %s:",
@@ -1712,7 +1712,7 @@
"Etichetta \"ombrello\" per spin-off (es. tutto il franchise di Fairy Tail).": "\"Umbrella\" label for spin-offs (e.g. the entire Fairy Tail franchise).",
"Etichette": "Labels",
"Etichette interne grandi (Herma 4630, Avery 3490)": "Large internal labels (Herma 4630, Avery 3490)",
- "European Article Number (opzionale)": "European Article Number (optional)",
+ "European Article Number (opzionale)": "European Article Number/Universal Product Code (optional)",
"Eventi": "Events",
"Eventi Recenti": "Recent Events",
"Eventi e Incontri": "Events and Meetings",
diff --git a/locale/fr_FR.json b/locale/fr_FR.json
index 90634065c..c7cdb0b8e 100644
--- a/locale/fr_FR.json
+++ b/locale/fr_FR.json
@@ -1264,8 +1264,8 @@
"Dry-run (analizza senza inserire)": "Dry-run (analyser sans insérer)",
"Dry-run:": "Dry-run :",
"Durata": "Durée",
- "EAN": "EAN",
- "EAN:": "EAN :",
+ "EAN": "EAN/UPC",
+ "EAN:": "EAN/UPC :",
"ERRORE:": "ERREUR :",
"Eccellente": "Excellent",
"Eccezione creazione %s su %s:": "Exception de création de %s sur %s :",
@@ -1712,7 +1712,7 @@
"Etichetta \"ombrello\" per spin-off (es. tutto il franchise di Fairy Tail).": "Étiquette \"parapluie\" pour les spin-offs (ex. toute la franchise Fairy Tail).",
"Etichette": "Étiquettes",
"Etichette interne grandi (Herma 4630, Avery 3490)": "Grandes étiquettes internes (Herma 4630, Avery 3490)",
- "European Article Number (opzionale)": "Numéro d'article européen (optionnel)",
+ "European Article Number (opzionale)": "Numéro d'article européen/Code universel des produits (optionnel)",
"Eventi": "Événements",
"Eventi Recenti": "Événements récents",
"Eventi e Incontri": "Événements et rencontres",
diff --git a/locale/it_IT.json b/locale/it_IT.json
index 7403b5f2a..b7b8c1676 100644
--- a/locale/it_IT.json
+++ b/locale/it_IT.json
@@ -1264,8 +1264,8 @@
"Dry-run (analizza senza inserire)": "Dry-run (analizza senza inserire)",
"Dry-run:": "Dry-run:",
"Durata": "Durata",
- "EAN": "EAN",
- "EAN:": "EAN:",
+ "EAN": "EAN/UPC",
+ "EAN:": "EAN/UPC:",
"ERRORE:": "ERRORE:",
"Eccellente": "Eccellente",
"Eccezione creazione %s su %s:": "Eccezione creazione %s su %s:",
@@ -1712,7 +1712,7 @@
"Etichetta \"ombrello\" per spin-off (es. tutto il franchise di Fairy Tail).": "Etichetta \"ombrello\" per spin-off (es. tutto il franchise di Fairy Tail).",
"Etichette": "Etichette",
"Etichette interne grandi (Herma 4630, Avery 3490)": "Etichette interne grandi (Herma 4630, Avery 3490)",
- "European Article Number (opzionale)": "European Article Number (opzionale)",
+ "European Article Number (opzionale)": "European Article Number/Universal Product Code (opzionale)",
"Eventi": "Eventi",
"Eventi Recenti": "Eventi Recenti",
"Eventi e Incontri": "Eventi e Incontri",
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);
+ });
+});
diff --git a/tests/full-test.spec.js b/tests/full-test.spec.js
index d98ca3193..73b9c34d1 100644
--- a/tests/full-test.spec.js
+++ b/tests/full-test.spec.js
@@ -2851,7 +2851,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 is disabled. Only edit it when it is actually editable —
+ // selectOption()/fill() on a disabled control waits for actionability until
+ // the test times out (which closes the page and cascades in this serial file).
+ if (await statoField.isVisible({ timeout: 2000 }).catch(() => false)
+ && await statoField.isEditable().catch(() => false)) {
// Select a value
if (await statoField.evaluate(el => el.tagName === 'SELECT')) {
const options = await statoField.locator('option').count();
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..3022eb4fc
--- /dev/null
+++ b/tests/import-upc-normalization-348.unit.php
@@ -0,0 +1,73 @@
+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');
+
+// 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');
+
+// 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);
diff --git a/tests/ncip-server.spec.js b/tests/ncip-server.spec.js
index 57337e59e..558333f2c 100644
--- a/tests/ncip-server.spec.js
+++ b/tests/ncip-server.spec.js
@@ -247,17 +247,54 @@ test.describe.serial('NCIP 2.0 Server plugin — v0.7.4 (20 tests)', () => {
let testUserId = 0;
/** @type {number} */
let createdLoanId = 0;
+ /** Dedicated book created in beforeAll (0 if we fell back to a shared book) */
+ let dedicatedBookId = 0;
+ /** Title suffix of the dedicated book, so cleanup can find it even if its id was never read back */
+ let dedicatedRunId = '';
/** Track specific prestiti IDs created during these tests for targeted cleanup */
let createdPrestitiIds = /** @type {number[]} */ ([]);
test.beforeAll(async ({ browser }) => {
await ensureNcipPlugin(browser);
- // Find a book with available copies.
- const bookRow = dbQuery(
- "SELECT id FROM libri WHERE deleted_at IS NULL AND copie_disponibili > 0 ORDER BY id LIMIT 1"
- );
- testBookId = parseInt(bookRow) || 0;
+ // Create a DEDICATED book with a real available copy row. A book with
+ // copie_disponibili > 0 at the aggregate level may have no individual
+ // `copie` row, and NCIP CheckOut needs a real available copy — otherwise
+ // it returns "No copies available" and test 9 fails nondeterministically
+ // depending on which shared book happens to sort first.
+ // Record the runId BEFORE inserting so afterAll can always remove the
+ // book by title — even if the SELECT that reads its id fails, leaving no
+ // dedicatedBookId. (LAST_INSERT_ID() is per-connection and each dbQuery
+ // opens a new mysql client, so we cannot read it back here.)
+ dedicatedRunId = `${Date.now().toString(36)}${Math.floor(process.hrtime()[1] % 1e6)}`;
+ try {
+ dbQuery(
+ "INSERT INTO libri (titolo, copie_totali, copie_disponibili, created_at, updated_at) " +
+ `VALUES ('NCIP Test Book ${dedicatedRunId}', 1, 1, NOW(), NOW())`
+ );
+ testBookId = parseInt(dbQuery(
+ `SELECT id FROM libri WHERE titolo = 'NCIP Test Book ${dedicatedRunId}' AND deleted_at IS NULL ORDER BY id DESC LIMIT 1`
+ )) || 0;
+ if (testBookId === 0) {
+ throw new Error('dedicated book insert did not yield an id');
+ }
+ dedicatedBookId = testBookId;
+ dbQuery(
+ "INSERT INTO copie (libro_id, numero_inventario, stato, created_at) " +
+ `VALUES (${testBookId}, 'NCIP-${dedicatedRunId}', 'disponibile', NOW())`
+ );
+ } catch {
+ // Dedicated creation failed. Fall back to an existing book that has a
+ // REAL available copy row — never the bare `copie_disponibili > 0`
+ // aggregate, which is exactly the nondeterminism this setup avoids. If
+ // none exists, leave testBookId = 0 so the CheckOut tests skip rather
+ // than flake. (dedicatedRunId cleanup still removes any partial insert.)
+ testBookId = parseInt(dbQuery(
+ "SELECT c.libro_id FROM copie c " +
+ "JOIN libri l ON l.id = c.libro_id AND l.deleted_at IS NULL " +
+ "WHERE c.stato = 'disponibile' ORDER BY c.libro_id LIMIT 1"
+ )) || 0;
+ }
// Find any non-admin user; fall back to any user if none found.
const userRow = dbQuery(
@@ -506,5 +543,24 @@ test.describe.serial('NCIP 2.0 Server plugin — v0.7.4 (20 tests)', () => {
dbQuery(`DELETE FROM prestiti WHERE id IN (${idList})`);
} catch { /* best-effort */ }
}
+ // Remove the dedicated book + its copy. Find it by title so a partial
+ // setup (book inserted but its id never read back) is still cleaned up.
+ // Delete EVERY prestito on its copies first (not only the tracked ids —
+ // RequestItem/CheckOut may leave an untracked loan). FK-safe order:
+ // ncip_transactions → prestiti → copie → libri, and each step is its own
+ // best-effort so a mid-sequence failure does not leave the rest undone.
+ if (dedicatedRunId) {
+ const bookSub = `SELECT id FROM libri WHERE titolo = 'NCIP Test Book ${dedicatedRunId}'`;
+ const copiaSub = `SELECT id FROM copie WHERE libro_id IN (${bookSub})`;
+ const steps = [
+ `DELETE FROM ncip_transactions WHERE prestito_id IN (SELECT id FROM prestiti WHERE copia_id IN (${copiaSub}))`,
+ `DELETE FROM prestiti WHERE copia_id IN (${copiaSub})`,
+ `DELETE FROM copie WHERE libro_id IN (${bookSub})`,
+ `DELETE FROM libri WHERE titolo = 'NCIP Test Book ${dedicatedRunId}'`,
+ ];
+ for (const sql of steps) {
+ try { dbQuery(sql); } catch { /* best-effort: keep going */ }
+ }
+ }
});
});
diff --git a/version.json b/version.json
index 94af8e7b6..46c8531da 100644
--- a/version.json
+++ b/version.json
@@ -1,5 +1,5 @@
{
"name": "Pinakes",
- "version": "0.7.59",
+ "version": "0.7.60",
"description": "Library Management System - Sistema di Gestione Bibliotecaria"
}