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.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`]); +}); 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);