From c89decf50871be5269ab831279c218abb3c9e6bd Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 12:45:27 +0200 Subject: [PATCH 1/2] Give copies received through Desiderata the book's own inventory prefix The desiderata plugin created received copies with the base code 'LIB-' . $bookId in all three of its receipt paths (book form, direct receipt, donation offer), ignoring the book's "Prefisso inventario copie". Libraries with their own numbering scheme got LIB--C1 codes they cannot correct, since a copy's code is written once. The rule "the book's prefix, else LIB-{id}" now lives in one place, CopyRepository::inventoryBase(), and every path that allocates copy codes uses it: the three plugin paths (reading the prefix from the libri row they already lock FOR UPDATE), the book create form, the copies form, POST /api/libri/{id}/increase-copies and the Mobile API add-copies endpoint. The Mobile API receive endpoints delegate to the plugin and inherit the fix. Existing codes are never renamed. Fixes #489 --- app/Controllers/CopyController.php | 2 +- app/Controllers/LibriController.php | 4 +- app/Models/CopyRepository.php | 16 ++ app/Routes/web.php | 4 +- .../plugins/desiderata/DesiderataPlugin.php | 16 +- storage/plugins/desiderata/plugin.json | 2 +- .../src/Controllers/StaffBooksController.php | 4 +- .../desiderata-inventory-prefix-489.unit.php | 210 ++++++++++++++++++ 8 files changed, 241 insertions(+), 17 deletions(-) create mode 100644 tests/desiderata-inventory-prefix-489.unit.php diff --git a/app/Controllers/CopyController.php b/app/Controllers/CopyController.php index b9c53ffc3..7f5b655af 100644 --- a/app/Controllers/CopyController.php +++ b/app/Controllers/CopyController.php @@ -430,7 +430,7 @@ public function createCopy(Request $request, Response $response, mysqli $db, int // Re-evaluate after sanitisation: an explicit value made only of // control characters must fall back to automatic allocation. if ($numero === '') { - $base = !empty($book['numero_inventario']) ? (string) $book['numero_inventario'] : "LIB-{$bookId}"; + $base = CopyRepository::inventoryBase($bookId, $book['numero_inventario'] ?? null); $newCopyIds = $repo->createManyForBookWithIdsAndNote( $bookId, $base, diff --git a/app/Controllers/LibriController.php b/app/Controllers/LibriController.php index 1025d5cfc..dacbdf7c5 100644 --- a/app/Controllers/LibriController.php +++ b/app/Controllers/LibriController.php @@ -1335,9 +1335,7 @@ public function store(Request $request, Response $response, mysqli $db): Respons // Genera copie fisiche del libro $copyRepo = new \App\Models\CopyRepository($db); $copieTotali = (int) $fields['copie_totali']; - $baseInventario = !empty($fields['numero_inventario']) - ? $fields['numero_inventario'] - : "LIB-{$id}"; + $baseInventario = \App\Models\CopyRepository::inventoryBase($id, $fields['numero_inventario'] ?? null); // Create the requested holding set with one atomic multi-row INSERT: // a request for three copies must never leave a partial 1/3 or 2/3 diff --git a/app/Models/CopyRepository.php b/app/Models/CopyRepository.php index 171d10365..8da299766 100644 --- a/app/Models/CopyRepository.php +++ b/app/Models/CopyRepository.php @@ -152,6 +152,22 @@ public function getById(int $id): ?array return $copia ?: null; } + /** + * Base of the "{base}-C{N}" codes given to new copies of a book: the book's + * own "Prefisso inventario copie" (libri.numero_inventario) when it has + * one, otherwise "LIB-{id}". Every path that allocates copy codes reads the + * rule from here, so a library with its own inventory scheme gets it + * whichever screen, plugin or API created the copy (#489). + * + * $bookPrefix is the raw column/form value. A blank or whitespace-only + * value means "no prefix"; surrounding whitespace is never part of a code. + */ + public static function inventoryBase(int $bookId, mixed $bookPrefix): string + { + $prefix = is_scalar($bookPrefix) ? trim((string) $bookPrefix) : ''; + return $prefix !== '' ? $prefix : "LIB-{$bookId}"; + } + /** * Crea una nuova copia */ diff --git a/app/Routes/web.php b/app/Routes/web.php index 5b75cdbab..81df456cf 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -2658,9 +2658,7 @@ // manually-inflated copie_totali and a partial copy set behind. No manual // UPDATE, one transaction, rolled back on any failure. $copyRepo = new \App\Models\CopyRepository($db); - $baseInventario = !empty($book['numero_inventario']) - ? $book['numero_inventario'] - : "LIB-{$bookId}"; + $baseInventario = \App\Models\CopyRepository::inventoryBase($bookId, $book['numero_inventario'] ?? null); $reassignmentService = null; $reservationManager = null; diff --git a/storage/plugins/desiderata/DesiderataPlugin.php b/storage/plugins/desiderata/DesiderataPlugin.php index c7b33867e..a4b8f5fe0 100644 --- a/storage/plugins/desiderata/DesiderataPlugin.php +++ b/storage/plugins/desiderata/DesiderataPlugin.php @@ -556,7 +556,7 @@ private function registerCopies(int $bookId, int $howMany): void { $this->db->begin_transaction(); try { - $stmt = $this->db->prepare('SELECT titolo FROM libri l WHERE l.id = ? AND l.deleted_at IS NULL AND l.is_desiderata = 1 AND NOT EXISTS (SELECT 1 FROM copie c WHERE c.libro_id = l.id) FOR UPDATE'); + $stmt = $this->db->prepare('SELECT titolo, numero_inventario FROM libri l WHERE l.id = ? AND l.deleted_at IS NULL AND l.is_desiderata = 1 AND NOT EXISTS (SELECT 1 FROM copie c WHERE c.libro_id = l.id) FOR UPDATE'); if ($stmt === false) { throw new RuntimeException($this->db->error); } $stmt->bind_param('i', $bookId); $stmt->execute(); @@ -565,8 +565,10 @@ private function registerCopies(int $bookId, int $howMany): void if (!$book) { $this->db->rollback(); return; } $title = (string) ($book['titolo'] ?? ''); $this->assertNoOpenOffers($bookId); + // The book's own inventory prefix, as every core path uses it (#489). + $base = \App\Models\CopyRepository::inventoryBase($bookId, $book['numero_inventario'] ?? null); $ids = (new \App\Models\CopyRepository($this->db)) - ->createManyForBookWithIdsAndNote($bookId, 'LIB-' . $bookId, $howMany, 'disponibile', 'Donazione diretta'); + ->createManyForBookWithIdsAndNote($bookId, $base, $howMany, 'disponibile', 'Donazione diretta'); if (count($ids) !== $howMany) { throw new RuntimeException('Copy creation failed'); } $copyId = $ids[0]; $stmt = $this->db->prepare('UPDATE libri SET is_desiderata = 0' . \App\Support\BookVisibility::catalogueStamp($this->db) . ' WHERE id = ? AND deleted_at IS NULL'); @@ -1550,7 +1552,7 @@ public function receiveDirect(Request $q, Response $r, int $bookId): Response // inconsistency DataIntegrity exists to repair, and without this // clause a receipt recorded in that window would answer it by // creating a SECOND inventory copy rather than refusing. - $stmt = $this->db->prepare('SELECT titolo FROM libri l WHERE id = ? AND deleted_at IS NULL AND is_desiderata = 1 AND NOT EXISTS (SELECT 1 FROM copie c WHERE c.libro_id = l.id) FOR UPDATE'); + $stmt = $this->db->prepare('SELECT titolo, numero_inventario FROM libri l WHERE id = ? AND deleted_at IS NULL AND is_desiderata = 1 AND NOT EXISTS (SELECT 1 FROM copie c WHERE c.libro_id = l.id) FOR UPDATE'); if ($stmt === false) { throw new RuntimeException($this->db->error); } $stmt->bind_param('i', $bookId); $stmt->execute(); @@ -1559,7 +1561,8 @@ public function receiveDirect(Request $q, Response $r, int $bookId): Response if (!$book) { throw new InvalidArgumentException(__('Il libro non è più una richiesta aperta.')); } $title = (string) ($book['titolo'] ?? ''); $this->assertNoOpenOffers($bookId); - $copyId = (new \App\Models\CopyRepository($this->db))->createWithAllocatedInventoryCode($bookId, 'LIB-' . $bookId, 'disponibile', 'Donazione diretta'); + $base = \App\Models\CopyRepository::inventoryBase($bookId, $book['numero_inventario'] ?? null); + $copyId = (new \App\Models\CopyRepository($this->db))->createWithAllocatedInventoryCode($bookId, $base, 'disponibile', 'Donazione diretta'); if ($copyId <= 0) { throw new RuntimeException('Copy creation failed'); } // Self-guarding, per ABSOLUTE RULE 2, and kept even though // DataIntegrity clears the flag by itself once a copy exists: the @@ -1933,13 +1936,14 @@ public function manage(Request $q, Response $r, int $id): Response throw new InvalidArgumentException(__('Seleziona la scheda del libro ricevuto.')); } $bookId = (int)$rawBookId; - $stmt = $this->db->prepare('SELECT id FROM libri WHERE id=? AND deleted_at IS NULL FOR UPDATE'); + $stmt = $this->db->prepare('SELECT id, numero_inventario FROM libri WHERE id=? AND deleted_at IS NULL FOR UPDATE'); if ($stmt === false) { throw new RuntimeException('Book lock prepare failed: ' . $this->db->error); } $stmt->bind_param('i', $bookId); $stmt->execute(); $bookRow = $stmt->get_result()->fetch_assoc(); $stmt->close(); if (!$bookRow) { throw new InvalidArgumentException(__('Per la ricezione scegli una scheda libro esistente. Puoi prima crearla con zero copie.')); } - $copyId = (new \App\Models\CopyRepository($this->db))->createWithAllocatedInventoryCode($bookId, 'LIB-' . $bookId, 'disponibile', 'Donazione #' . $id); + $base = \App\Models\CopyRepository::inventoryBase($bookId, $bookRow['numero_inventario'] ?? null); + $copyId = (new \App\Models\CopyRepository($this->db))->createWithAllocatedInventoryCode($bookId, $base, 'disponibile', 'Donazione #' . $id); if ($copyId <= 0) { throw new RuntimeException('Copy creation failed'); } // Self-guarding, per ABSOLUTE RULE 2. Provably a no-op here — // the row was locked FOR UPDATE and checked for deleted_at two diff --git a/storage/plugins/desiderata/plugin.json b/storage/plugins/desiderata/plugin.json index db9740bde..1893e6195 100644 --- a/storage/plugins/desiderata/plugin.json +++ b/storage/plugins/desiderata/plugin.json @@ -2,7 +2,7 @@ "name": "desiderata", "display_name": "Desiderata e donazioni", "description": "Libri cercati dalla biblioteca, ricerca pubblica e proposte di donazione con ricezione controllata.", - "version": "1.2.0", + "version": "1.2.1", "author": "Pinakes", "main_file": "wrapper.php", "requires_php": "8.2", diff --git a/storage/plugins/mobile-api/src/Controllers/StaffBooksController.php b/storage/plugins/mobile-api/src/Controllers/StaffBooksController.php index 6d06d960b..6eae0d944 100644 --- a/storage/plugins/mobile-api/src/Controllers/StaffBooksController.php +++ b/storage/plugins/mobile-api/src/Controllers/StaffBooksController.php @@ -528,9 +528,7 @@ public function addCopies(ServerRequestInterface $request, ResponseInterface $re return ResponseEnvelope::error($response, 'not_found', __('Libro non trovato.'), 404); } $before = \App\Support\ActivityLog::loadBookSnapshot($this->db, $bookId); - $base = trim((string) ($book['numero_inventario'] ?? '')) !== '' - ? (string) $book['numero_inventario'] - : 'LIB-' . $bookId; + $base = \App\Models\CopyRepository::inventoryBase($bookId, $book['numero_inventario'] ?? null); // Same steps as POST /api/libri/{id}/increase-copies: one // transaction, book row locked first, every new copy first repairs diff --git a/tests/desiderata-inventory-prefix-489.unit.php b/tests/desiderata-inventory-prefix-489.unit.php new file mode 100644 index 000000000..55922ce73 --- /dev/null +++ b/tests/desiderata-inventory-prefix-489.unit.php @@ -0,0 +1,210 @@ +-C1 codes it + * cannot correct (a copy's code is written once and is editable nowhere, by + * design). This suite receives a book through each of those paths and reads + * back the code that was actually written: + * + * A. the book form — request closed by saving the record with copies + * (prepareBook() + bookSaved() → registerCopies()) + * B. direct receipt — "received at the desk", no proposal (receiveDirect()) + * C. donation offer — a proposal marked received (manage()) + * + * Each path runs twice: once on a book WITH a prefix (codes must be + * "-C") and once on a book WITHOUT one (codes must stay + * "LIB--C", the historical default). The Mobile API receive endpoints + * delegate to B and C, so they are covered by the same assertions. + * + * Real database, real plugin methods, no stubs except the mailer. FAILS HARD + * when the database is unreachable: a skip would be indistinguishable from a + * pass. Fixtures are removed by id at the end. + * + * Run: php tests/desiderata-inventory-prefix-489.unit.php + */ + +$root = dirname(__DIR__); +require $root . '/vendor/autoload.php'; +require_once $root . '/storage/plugins/desiderata/wrapper.php'; + +use App\Models\CopyRepository; + +mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); + +$pass = 0; +$fail = 0; +$check = static function (bool $ok, string $label) use (&$pass, &$fail): void { + echo ($ok ? 'PASS ' : 'FAIL ') . $label . "\n"; + $ok ? $pass++ : $fail++; +}; + +/** .env is parsed by hand: this file must run as a bare `php tests/...` process. */ +$env = []; +foreach (preg_split('/\r?\n/', (string) @file_get_contents($root . '/.env')) ?: [] as $line) { + if (!str_contains($line, '=') || str_starts_with(trim($line), '#')) { + continue; + } + [$key, $value] = explode('=', $line, 2); + $env[trim($key)] = trim(trim($value), "\"'"); +} + +$socket = getenv('E2E_DB_SOCKET') ?: ($env['DB_SOCKET'] ?? '/opt/homebrew/var/mysql/mysql.sock'); +$dbUser = getenv('E2E_DB_USER') ?: ($env['DB_USER'] ?? ''); +$dbPass = getenv('E2E_DB_PASS') ?: ($env['DB_PASS'] ?? ($env['DB_PASSWORD'] ?? '')); +$dbName = getenv('E2E_DB_NAME') ?: ($env['DB_NAME'] ?? ''); +$db = $socket !== '' && file_exists($socket) + ? new mysqli(null, $dbUser, $dbPass, $dbName, 0, $socket) + : new mysqli($env['DB_HOST'] ?? '127.0.0.1', $dbUser, $dbPass, $dbName, (int) ($env['DB_PORT'] ?? 3306)); +$db->set_charset('utf8mb4'); + +$plugin = new DesiderataPlugin($db, new App\Support\HookManager($db)); +// Receipts notify the operators by e-mail: a test must never mail anyone. +$plugin->setEmailService(new class($db) extends App\Support\EmailService { + public function sendEmail(string $to, string $subject, string $body, string $toName = '', ?string $locale = null, array $attachments = []): bool + { + return true; + } +}); + +// A per-run tag keeps the prefixes globally unique: numero_inventario has a +// GLOBAL unique index, so a fixed "BIB-" would collide with a previous run or +// with a real library's codes and the allocator would (correctly) skip to -C2. +$tag = 'INV489' . bin2hex(random_bytes(4)); +$bookIds = []; +$offerIds = []; +$fatalError = null; + +$repo = new App\Models\BookRepository($db); +$factory = new Slim\Psr7\Factory\ServerRequestFactory(); +$post = static fn (string $path, array $body) => $factory->createServerRequest('POST', $path)->withParsedBody($body); + +/** A wanted book (desiderata), created through the plugin's own form filter. */ +$createWanted = static function (string $suffix, ?string $prefix) use ($plugin, $repo, $tag, &$bookIds): int { + $fields = ['titolo' => $tag . ' ' . $suffix, 'copie_totali' => 1, 'numero_inventario' => $prefix]; + $fields = $plugin->prepareBook($fields, ['desiderata_form' => '1', 'is_desiderata' => '1'], null); + $id = $repo->createBasic($fields); + $bookIds[] = $id; + return $id; +}; + +/** @return list the codes of the book's copies, oldest first */ +$codesOf = static function (int $bookId) use ($db): array { + $stmt = $db->prepare('SELECT numero_inventario FROM copie WHERE libro_id = ? ORDER BY id'); + $stmt->bind_param('i', $bookId); + $stmt->execute(); + $codes = array_map(static fn (array $row): string => (string) $row[0], $stmt->get_result()->fetch_all(MYSQLI_NUM)); + $stmt->close(); + return $codes; +}; + +$show = static fn (array $codes): string => '[' . implode(', ', $codes) . ']'; + +try { + // Built, never skipped: the receipt paths need is_desiderata and the + // offers table, and a missing one would make every path below a no-op. + $plugin->ensureSchema(); + if (!App\Support\BookVisibility::hasDesiderata($db)) { + throw new RuntimeException('libri.is_desiderata is absent after ensureSchema()'); + } + + // One prefix per book: codes are globally unique, so books sharing a + // prefix continue one "-C" family instead of each starting at -C1. + foreach (['prefixed' => true, 'unprefixed' => false] as $case => $withPrefix) { + $prefixFor = static fn (string $path): ?string => $withPrefix ? "BIB-{$tag}{$path}" : null; + $expected = static fn (int $id, ?string $prefix, int $n): string => ($prefix ?? "LIB-{$id}") . "-C{$n}"; + + echo "\nA. Book form receipt ({$case})\n"; + $_SESSION = []; + $prefix = $prefixFor('A'); + $bookA = $createWanted("form {$case}", $prefix); + // The edit that closes the request: flag unticked, two copies asked for. + // prepareBook() is the book.form.save filter, bookSaved() the + // book.save.after action — the same pair core fires on a real save. + $plugin->prepareBook(['titolo' => $tag . " form {$case}"], ['desiderata_form' => '1', 'is_desiderata' => '0', 'desiderata_copies' => '2'], $bookA); + $plugin->bookSaved($bookA); + $codes = $codesOf($bookA); + $want = [$expected($bookA, $prefix, 1), $expected($bookA, $prefix, 2)]; + $check($codes === $want, "two copies coded {$show($want)} (got {$show($codes)})"); + + echo "\nB. Direct receipt at the desk ({$case})\n"; + $_SESSION = []; + $prefix = $prefixFor('B'); + $bookB = $createWanted("direct {$case}", $prefix); + $response = $plugin->receiveDirect($post('/admin/desiderata/books/' . $bookB . '/receive', []), new Slim\Psr7\Response(), $bookB); + $codes = $codesOf($bookB); + $check($response->getStatusCode() === 303, 'the receipt is accepted (got HTTP ' . $response->getStatusCode() . ')'); + $want = [$expected($bookB, $prefix, 1)]; + $check($codes === $want, "one copy coded {$show($want)} (got {$show($codes)})"); + + echo "\nC. Donation offer marked received ({$case})\n"; + $_SESSION = []; + $prefix = $prefixFor('C'); + $bookC = $createWanted("offer {$case}", $prefix); + $title = $tag . " offer {$case}"; + $stmt = $db->prepare("INSERT INTO desiderata_offers (book_id, donor_name, donor_email, title, notes, status) VALUES (?, 'Test donor', 'donor@example.invalid', ?, '', 'accepted')"); + $stmt->bind_param('is', $bookC, $title); + $stmt->execute(); + $offerId = (int) $db->insert_id; + $stmt->close(); + $offerIds[] = $offerId; + $response = $plugin->manage($post('/admin/desiderata/offers/' . $offerId, ['action' => 'received']), new Slim\Psr7\Response(), $offerId); + $codes = $codesOf($bookC); + $check($response->getStatusCode() === 303, 'the receipt is accepted (got HTTP ' . $response->getStatusCode() . ')'); + $want = [$expected($bookC, $prefix, 1)]; + $check($codes === $want, "one copy coded {$show($want)} (got {$show($codes)})"); + } + + echo "\nD. The rule itself\n"; + $check(CopyRepository::inventoryBase(7, 'BIB-') === 'BIB-', 'a prefix is used as the base'); + $check(CopyRepository::inventoryBase(7, ' BIB- ') === 'BIB-', 'surrounding whitespace is not part of the base'); + $check(CopyRepository::inventoryBase(7, '0') === '0', 'a prefix of "0" is a prefix, not an empty value'); + foreach ([null, '', ' '] as $blank) { + $check(CopyRepository::inventoryBase(7, $blank) === 'LIB-7', 'no prefix (' . var_export($blank, true) . ') falls back to LIB-{id}'); + } +} catch (Throwable $thrown) { + $fatalError = $thrown; +} finally { + $cleanupErrors = []; + $run = static function (string $sql) use ($db, &$cleanupErrors): void { + try { + $db->query($sql); + } catch (Throwable $e) { + $cleanupErrors[] = $sql . ': ' . $e->getMessage(); + } + }; + if ($offerIds !== []) { + $run('DELETE FROM desiderata_offers WHERE id IN (' . implode(',', $offerIds) . ')'); + } + if ($bookIds !== []) { + $list = implode(',', $bookIds); + // Paths A and B write their own audit row (status 'received') per book. + $run('DELETE FROM desiderata_offers WHERE book_id IN (' . $list . ') OR received_book_id IN (' . $list . ')'); + $run('DELETE FROM copie WHERE libro_id IN (' . $list . ')'); + $run("DELETE FROM log_modifiche WHERE tabella = 'libri' AND record_id IN (" . $list . ')'); + $run('DELETE FROM libri WHERE id IN (' . $list . ')'); + } + // notifyReceipt() rings the operators' bell with the book title. + $run("DELETE FROM admin_notifications WHERE title LIKE '%" . $db->real_escape_string($tag) . "%' OR message LIKE '%" . $db->real_escape_string($tag) . "%'"); + try { + App\Support\ContentCache::booksChanged(); + } catch (Throwable $e) { + $cleanupErrors[] = 'ContentCache::booksChanged(): ' . $e->getMessage(); + } +} + +foreach ($cleanupErrors as $message) { + fwrite(STDERR, "CLEANUP FAILED: {$message}\n"); +} +if ($fatalError !== null) { + fwrite(STDERR, "\nFAIL: " . get_class($fatalError) . ': ' . $fatalError->getMessage() . "\n"); + exit(1); +} + +echo "\n" . ($fail === 0 ? "ALL {$pass} PASS\n" : "{$pass} PASS, {$fail} FAIL\n"); +exit($fail === 0 && $cleanupErrors === [] ? 0 : 1); From c90951d6b2e59f1a914e4fbd4921f006c66543ce Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 12:58:31 +0200 Subject: [PATCH 2/2] Check the Desiderata inventory prefix through the real admin screens A Playwright spec creates wanted books from the admin book form with an inventory prefix and receives them through the three buttons an operator has (closing the request from the book form, the requests list, a donor proposal), then reads the copy code on the book page. A book without a prefix keeps the LIB--C1 default. --- tests/desiderata-inventory-prefix-489.spec.js | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 tests/desiderata-inventory-prefix-489.spec.js diff --git a/tests/desiderata-inventory-prefix-489.spec.js b/tests/desiderata-inventory-prefix-489.spec.js new file mode 100644 index 000000000..e2f40caeb --- /dev/null +++ b/tests/desiderata-inventory-prefix-489.spec.js @@ -0,0 +1,163 @@ +// @ts-check +/** + * #489 — a copy received through Desiderata carries the book's own + * "Prefisso inventario copie", through the real admin screens. + * + * Each wanted book is created from the admin book form with a prefix, then + * received through one of the three buttons an operator actually has: + * + * A. edit the record, untick "Desiderata", save with two copies + * B. "Libro donato: registra la copia" on /admin/desiderata + * C. a donor's proposal: "Accetta proposta", then + * "Libro arrivato: registra una copia" + * + * The code is read where the operator reads it: the Copie Fisiche table of the + * book page. A fourth book without a prefix keeps the LIB--C1 default. + * + * Run: /tmp/run-e2e.sh tests/desiderata-inventory-prefix-489.spec.js --config=tests/playwright.config.js --workers=1 + */ +const { test, expect } = require('@playwright/test'); +const { execFileSync } = require('node:child_process'); +const crypto = require('node:crypto'); +const { ensurePluginActive } = require('./helpers/plugin-activation'); + +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_NAME = process.env.E2E_DB_NAME || ''; +const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; + +test.skip(!ADMIN_EMAIL || !ADMIN_PASS || !DB_NAME, 'Requires an installed app, E2E_ADMIN_* and E2E_DB_* credentials'); +test.describe.configure({ mode: 'serial' }); + +// Unique per run: copy codes are globally unique, so a fixed prefix would +// continue a previous run's -C family instead of starting at -C1. +const TAG = 'E2E489' + crypto.randomBytes(4).toString('hex'); +const bookIds = []; + +function dbQuery(sql) { + const args = ['-u', DB_USER, DB_NAME, '-N', '-B', '-e', sql]; + if (DB_SOCKET) args.splice(2, 0, '-S', DB_SOCKET); + return execFileSync('mysql', args, { encoding: 'utf-8', timeout: 10000, env: { ...process.env, MYSQL_PWD: DB_PASS } }).trim(); +} + +async function login(page) { + await page.goto('/accedi'); + await page.getByRole('textbox', { name: 'Email', exact: true }).fill(ADMIN_EMAIL); + await page.getByRole('textbox', { name: 'Password', exact: true }).fill(ADMIN_PASS); + await page.getByRole('button', { name: 'Accedi', exact: true }).click(); + await page.waitForURL(/\/admin/); +} + +/** Submit the core book form, confirming its SweetAlert when it asks. */ +async function submitBookForm(page) { + await page.locator('#bookForm button[type="submit"]').click(); + const confirm = page.locator('.swal2-confirm'); + if (await confirm.isVisible({ timeout: 5000 }).catch(() => false)) { + await confirm.click(); + } + await page.waitForURL(/\/admin\/books\/(?!create|edit)\d+/, { timeout: 15000 }); +} + +/** A wanted book created from the admin form, as an operator does it. */ +async function createWanted(page, label, prefix) { + const title = `${TAG} ${label}`; + await page.goto('/admin/books/create'); + await page.fill('#titolo', title); + await page.getByRole('checkbox', { name: 'Desiderata: cerchiamo questo libro', exact: true }).check(); + if (prefix !== null) { + await page.fill('#numero_inventario', prefix); + } + await submitBookForm(page); + const id = Number(dbQuery(`SELECT id FROM libri WHERE titolo = '${title}' AND deleted_at IS NULL ORDER BY id DESC LIMIT 1`)); + expect(id).toBeGreaterThan(0); + bookIds.push(id); + expect(dbQuery(`SELECT is_desiderata FROM libri WHERE id = ${id}`)).toBe('1'); + return { id, title }; +} + +/** The codes the operator sees in the book page's Copie Fisiche table. */ +async function expectCopyCodes(page, bookId, codes) { + await page.goto(`/admin/books/${bookId}#physical-copies`); + const section = page.locator('#physical-copies'); + for (const code of codes) { + await expect(section).toContainText(code); + } + const stored = dbQuery(`SELECT GROUP_CONCAT(numero_inventario ORDER BY id SEPARATOR ',') FROM copie WHERE libro_id = ${bookId}`); + expect(stored).toBe(codes.join(',')); +} + +test.beforeAll(async ({ browser }) => { + const context = await browser.newContext(); + try { + const page = await context.newPage(); + await login(page); + await ensurePluginActive(page, 'desiderata', { smokePath: '/desiderata' }); + } finally { + await context.close(); + } +}); + +test.afterAll(() => { + if (bookIds.length === 0) return; + const list = bookIds.join(','); + dbQuery(`DELETE FROM desiderata_offers WHERE book_id IN (${list}) OR received_book_id IN (${list})`); + dbQuery(`DELETE FROM admin_notifications WHERE title LIKE '%${TAG}%' OR message LIKE '%${TAG}%'`); + dbQuery(`DELETE FROM copie WHERE libro_id IN (${list})`); + dbQuery(`DELETE FROM log_modifiche WHERE tabella = 'libri' AND record_id IN (${list})`); + dbQuery(`DELETE FROM libri WHERE id IN (${list})`); +}); + +test('A — closing the request from the book form uses the prefix', async ({ page }) => { + await login(page); + const prefix = `BIB-${TAG}A`; + const book = await createWanted(page, 'form', prefix); + await page.goto(`/admin/books/edit/${book.id}`); + await page.getByRole('checkbox', { name: 'Desiderata: cerchiamo questo libro', exact: true }).uncheck(); + await page.fill('#desiderata_copies', '2'); + await submitBookForm(page); + await expectCopyCodes(page, book.id, [`${prefix}-C1`, `${prefix}-C2`]); +}); + +test('B — "Libro donato" on the requests list uses the prefix', async ({ page }) => { + await login(page); + const prefix = `BIB-${TAG}B`; + const book = await createWanted(page, 'direct', prefix); + page.on('dialog', (dialog) => dialog.accept()); + await page.goto('/admin/desiderata'); + const row = page.locator('li').filter({ hasText: book.title }); + await row.getByRole('button', { name: 'Libro donato: registra la copia', exact: true }).click(); + await page.waitForURL(/\/admin\/desiderata/); + await expectCopyCodes(page, book.id, [`${prefix}-C1`]); +}); + +test('C — a donor proposal received through the admin buttons uses the prefix', async ({ page }) => { + await login(page); + const prefix = `BIB-${TAG}C`; + const book = await createWanted(page, 'offer', prefix); + // The donor's side is not under test here: the proposal row is what the + // public form writes, inserted directly so this spec does not depend on the + // anonymous form's rate limiter or captcha settings. + dbQuery(`INSERT INTO desiderata_offers (book_id, donor_name, donor_email, title, notes, status) VALUES (${book.id}, 'E2E donor', 'donor-${TAG}@example.invalid', '${book.title}', '', 'pending')`); + page.on('dialog', (dialog) => dialog.accept()); + await page.goto('/admin/desiderata'); + let offer = page.locator('article').filter({ hasText: book.title }); + await offer.getByRole('button', { name: 'Accetta proposta', exact: true }).click(); + offer = page.locator('article').filter({ hasText: book.title }); + await expect(offer).toContainText('In attesa della consegna'); + await offer.getByRole('button', { name: 'Libro arrivato: registra una copia', exact: true }).click(); + await expect(page.locator('article').filter({ hasText: book.title })).toContainText('Ricevuta'); + await expectCopyCodes(page, book.id, [`${prefix}-C1`]); +}); + +test('without a prefix the copy keeps the LIB- default', async ({ page }) => { + await login(page); + const book = await createWanted(page, 'no prefix', null); + page.on('dialog', (dialog) => dialog.accept()); + await page.goto('/admin/desiderata'); + const row = page.locator('li').filter({ hasText: book.title }); + await row.getByRole('button', { name: 'Libro donato: registra la copia', exact: true }).click(); + await page.waitForURL(/\/admin\/desiderata/); + await expectCopyCodes(page, book.id, [`LIB-${book.id}-C1`]); +});