From eba51aacd489089a819b02d495a73f288b6567cf Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 20 Oct 2026 18:09:29 +0200 Subject: [PATCH 1/5] Default the return date of a loan request to the configured loan duration The return-date picker of the loan request spread the start picker's options, defaultDate included, so it opened on the start date. The start picker's onChange only fires on a click: a reader who sent the request without touching the calendars asked for end_date = start_date, a one-day loan, instead of the default duration set in the loan settings. The return picker now opens on the start date plus that duration, capped like the server default. The role-boundaries test now puts the custom JavaScript and the SMTP host back in its finally block. A failed run had left its probe script on every public page of the local install, which broke the reservations page check of the loan suite. --- app/Views/frontend/book-detail.php | 5 ++ tests/loan-request-default-end.spec.js | 104 +++++++++++++++++++++++++ tests/role-boundaries-480.unit.php | 19 +++++ 3 files changed, 128 insertions(+) create mode 100644 tests/loan-request-default-end.spec.js diff --git a/app/Views/frontend/book-detail.php b/app/Views/frontend/book-detail.php index c24134334..05f134143 100644 --- a/app/Views/frontend/book-detail.php +++ b/app/Views/frontend/book-detail.php @@ -1798,6 +1798,11 @@ function addDaysToIso(isoDate, days) { endPicker = window.flatpickr(endEl, { ...baseOpts, minDate: suggestedDate, + // baseOpts.defaultDate is the start date: inherited here, it made a + // request sent without touching the calendars a one-day loan. The + // start picker's onChange only fires on a click, so the default + // return date has to be set here too. + defaultDate: addDaysToIso(suggestedDate, defaultRequestLoanDays), maxDate: undefined // End date can extend beyond availability range }); diff --git a/tests/loan-request-default-end.spec.js b/tests/loan-request-default-end.spec.js new file mode 100644 index 000000000..50821fb1a --- /dev/null +++ b/tests/loan-request-default-end.spec.js @@ -0,0 +1,104 @@ +// @ts-check +// Regression: a loan request sent without touching the calendars must ask for the +// default loan duration. The return-date picker inherited the start picker's +// defaultDate, so it showed the start date and the request went out with +// end_date = start_date: a one-day loan. The expected duration comes from the +// settings in the database, with the same cap the reservation endpoint applies. +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_HOST = process.env.E2E_DB_HOST || ''; +const DB_PORT = process.env.E2E_DB_PORT || ''; +const DB_SOCKET = process.env.E2E_DB_SOCKET || ''; +const DB_NAME = process.env.E2E_DB_NAME || ''; + +test.skip( + !ADMIN_EMAIL || !ADMIN_PASS || !DB_USER || !DB_NAME, + 'E2E credentials not configured (set E2E_ADMIN_EMAIL, E2E_ADMIN_PASS, E2E_DB_USER, E2E_DB_NAME)', +); + +function dbQuery(sql) { + const args = []; + if (DB_HOST) { + args.push('-h', DB_HOST); + if (DB_PORT) args.push('-P', DB_PORT); + } else if (DB_SOCKET) { + args.push('-S', DB_SOCKET); + } + args.push('-u', DB_USER, DB_NAME, '-N', '-B', '-e', sql); + return execFileSync('mysql', args, { + encoding: 'utf-8', timeout: 10000, + env: { ...process.env, MYSQL_PWD: DB_PASS }, + }).trim(); +} + +function setting(key, fallback) { + const value = parseInt( + dbQuery(`SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='${key}' LIMIT 1`) || '', + 10, + ); + return Number.isFinite(value) && value >= 1 ? value : fallback; +} + +function addDays(iso, days) { + const [y, m, d] = iso.split('-').map(Number); + const date = new Date(Date.UTC(y, m - 1, d)); + date.setUTCDate(date.getUTCDate() + days); + return date.toISOString().slice(0, 10); +} + +test.describe('Book page: loan request return date', () => { + let bookId = 0; + let expectedDays = 0; + + test.beforeAll(() => { + bookId = parseInt( + dbQuery( + "SELECT c.libro_id FROM copie c JOIN libri l ON l.id=c.libro_id " + + "WHERE c.stato='disponibile' AND l.deleted_at IS NULL LIMIT 1", + ) || '0', + 10, + ); + expectedDays = Math.min(setting('loan_duration_days', 30), setting('max_loan_duration_days', 90)); + }); + + test('without touching the calendars, the return date is start + the default loan duration', async ({ page }) => { + test.skip(!bookId, 'No available book to open the loan request on'); + + await page.goto(`${BASE}/accedi`); + await page.fill('input[name="email"]', ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await page.locator('button[type="submit"]').click(); + await page.waitForURL(/(\/$|admin|profilo|account)/, { timeout: 15000 }).catch(() => {}); + + await page.goto(`${BASE}/libro/${bookId}`); + const btn = page.locator('#btn-request-loan'); + test.skip((await btn.count()) === 0, 'This book has no request-loan button'); + + await btn.click(); + await page.waitForSelector('.swal2-popup', { timeout: 8000 }); + await page.waitForFunction( + () => { + const start = document.querySelector('#swal-date-start'); + const end = document.querySelector('#swal-date-end'); + return start && end && /** @type {any} */ (start)._flatpickr && /** @type {any} */ (end)._flatpickr; + }, + { timeout: 8000 }, + ); + + const start = await page.locator('#swal-date-start').inputValue(); + const end = await page.locator('#swal-date-end').inputValue(); + expect(start).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(end, 'the return date must not repeat the start date').not.toBe(start); + expect(end).toBe(addDays(start, expectedDays)); + // What the visitor reads is the same date, not an empty field. + await expect(page.locator('#swal-date-end-display')).not.toHaveValue(''); + + }); +}); diff --git a/tests/role-boundaries-480.unit.php b/tests/role-boundaries-480.unit.php index d976a8bfc..3acf264fb 100644 --- a/tests/role-boundaries-480.unit.php +++ b/tests/role-boundaries-480.unit.php @@ -345,6 +345,25 @@ public function handle(ServerRequestInterface $request): ResponseInterface $db->query('DELETE FROM ruoli WHERE id = ' . (int) $id); } $db->query("DELETE FROM api_keys WHERE name = '" . $db->real_escape_string($tag) . "'"); + // A refusal that fails above stores its probe: a script on every public page + // and a foreign SMTP host. Put both back whatever happened, or the next suites + // run against that. + foreach ([['advanced', 'custom_js_essential', $savedJs], ['email', 'smtp_host', $savedHost]] as [$category, $key, $saved]) { + if ($value($category, $key) === $saved) { + continue; + } + $restore = $saved === null + ? $db->prepare('DELETE FROM system_settings WHERE category = ? AND setting_key = ?') + : $db->prepare('UPDATE system_settings SET setting_value = ? WHERE category = ? AND setting_key = ?'); + if ($saved === null) { + $restore->bind_param('ss', $category, $key); + } else { + $restore->bind_param('sss', $saved, $category, $key); + } + $restore->execute(); + $restore->close(); + } + \App\Support\ConfigStore::clearCache(); unset($_SESSION); R::reset(); } From 8ad6c6b1104c5775cab00206d668e015c22818e5 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 20 Oct 2026 17:14:36 +0000 Subject: [PATCH 2/5] Give the app the loan duration a loan request adds to its start POST /reservations of the Mobile API sets the return date to the chosen start plus the configured loan duration (UserActionsController::loan() and reserve()), but nothing told the app how long that is: it promised "about a month" whatever the loan settings said. GET /catalog/books/{id}/availability, which the app already calls when it opens the request calendar, now carries loan_duration_days, so the app can show the return date before the reader sends the request. The field is additive; the OpenAPI document describes it and the plugin moves to 1.8.1. tests/mobile-api-behaviors.spec.js checks that the calendar reports the configured duration. --- storage/plugins/mobile-api/plugin.json | 4 ++-- .../mobile-api/src/Controllers/CatalogController.php | 8 +++++++- .../mobile-api/src/Controllers/OpenApiController.php | 3 ++- tests/mobile-api-behaviors.spec.js | 4 ++++ 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/storage/plugins/mobile-api/plugin.json b/storage/plugins/mobile-api/plugin.json index a6db0e06e..d70fbe56e 100644 --- a/storage/plugins/mobile-api/plugin.json +++ b/storage/plugins/mobile-api/plugin.json @@ -2,7 +2,7 @@ "name": "mobile-api", "display_name": "Mobile API", "description": "API REST/JSON versionata (/api/v1) per l'app companion mobile di Pinakes: discovery, autenticazione a token per dispositivo, ricerca catalogo, prestiti/prenotazioni, wishlist, profilo, messaggi e notifiche push. Disattivata per default finché non viene abilitata.", - "version": "1.8.0", + "version": "1.8.1", "author": "Fabiodalez", "author_url": "", "plugin_url": "", @@ -22,5 +22,5 @@ "optional": true, "status": "stable" }, - "changelog": "v1.8.0: allegati digitali con visibilità (tutti, utenti registrati, solo staff, chi ha il prestito): il dettaglio libro indica per ogni file visibility e access, i file aperti hanno un link firmato valido 24 ore, quelli chiusi url null e il motivo. v1.7.0: circolazione completa nell'app (richieste, ritiri, prestiti attivi e scaduti, storico, prenotazioni con coda): creazione prestito con ricerca utente e scansione del codice copia, approvazione/rifiuto, conferma/annullamento ritiro, restituzione rapida o con esito della copia e sanzione, restituzione da codice a barre, rinnovo, proroga e solleciti multipli, modifica, ricevuta PDF e via email, calendario disponibilità per utente, contatori per la home staff; creazione di prestiti e prenotazioni ripetibile in sicurezza con chiave di idempotenza. Libri: gestione per singola copia (codice esplicito, stato, note, eliminazione), collane (albero, dettaglio, creazione, rinomina, unione, ordine, assegnazione multipla, opera madre), unione duplicati di autori/editori/generi, etichette PDF e relative impostazioni, importazione CSV e LibraryThing dal telefono con avanzamento ripristinabile ed esportazione LibraryThing, copertine mancanti e arricchimento massivo. Contenuti dei plugin: desiderata (proposte di donazione: accetta, rifiuta, ricevi, elimina, ricezione diretta) e archivi (schede, autorità, allegati, pubblicazione); se attivi, opere FRBR con le espressioni e il collegamento dei libri, allegati digitali (e-book e audiolibri), banco dell'emeroteca e gestione dei gruppi di lettura tramite i rispettivi plugin. In /health un flag per ogni area, vero solo quando l'area è disponibile. Le operazioni passano dagli stessi controller del sito: il registro attività riporta l'operatore e la sorgente del sito (manuale, approvazione, ritiro, restituzione). v1.6.0: area staff nell'app (riservata ad admin e staff): ricerca e scheda modificabile dei libri, importazione da ISBN/EAN con tutte le fonti di scraping, creazione e modifica tramite la stessa pipeline del modulo web, aggiunta copie, copertina da foto o da URL, eliminazione con controllo dei prestiti attivi; gestione di autori, editori, generi, scaffali e ripiani. Nuovo feed delta /catalog/sync per il catalogo offline e calendario personale di prestiti e prenotazioni (feed ICS con link segreto revocabile, eventi dall'inizio alla scadenza, senza dati personali). Sesso e codice fiscale seguono le impostazioni di registrazione: se nascosti non compaiono in /auth/registration-fields né in /me. v1.5.1: cifratura dei messaggi push e irrobustimento dell'API. v1.5.0: archivi e desiderata nell'app." + "changelog": "v1.8.1: il calendario di disponibilità (/catalog/books/{id}/availability) indica loan_duration_days, la durata del prestito che la richiesta aggiunge alla data scelta, così l'app mostra la data di restituzione prima dell'invio. v1.8.0: allegati digitali con visibilità (tutti, utenti registrati, solo staff, chi ha il prestito): il dettaglio libro indica per ogni file visibility e access, i file aperti hanno un link firmato valido 24 ore, quelli chiusi url null e il motivo. v1.7.0: circolazione completa nell'app (richieste, ritiri, prestiti attivi e scaduti, storico, prenotazioni con coda): creazione prestito con ricerca utente e scansione del codice copia, approvazione/rifiuto, conferma/annullamento ritiro, restituzione rapida o con esito della copia e sanzione, restituzione da codice a barre, rinnovo, proroga e solleciti multipli, modifica, ricevuta PDF e via email, calendario disponibilità per utente, contatori per la home staff; creazione di prestiti e prenotazioni ripetibile in sicurezza con chiave di idempotenza. Libri: gestione per singola copia (codice esplicito, stato, note, eliminazione), collane (albero, dettaglio, creazione, rinomina, unione, ordine, assegnazione multipla, opera madre), unione duplicati di autori/editori/generi, etichette PDF e relative impostazioni, importazione CSV e LibraryThing dal telefono con avanzamento ripristinabile ed esportazione LibraryThing, copertine mancanti e arricchimento massivo. Contenuti dei plugin: desiderata (proposte di donazione: accetta, rifiuta, ricevi, elimina, ricezione diretta) e archivi (schede, autorità, allegati, pubblicazione); se attivi, opere FRBR con le espressioni e il collegamento dei libri, allegati digitali (e-book e audiolibri), banco dell'emeroteca e gestione dei gruppi di lettura tramite i rispettivi plugin. In /health un flag per ogni area, vero solo quando l'area è disponibile. Le operazioni passano dagli stessi controller del sito: il registro attività riporta l'operatore e la sorgente del sito (manuale, approvazione, ritiro, restituzione). v1.6.0: area staff nell'app (riservata ad admin e staff): ricerca e scheda modificabile dei libri, importazione da ISBN/EAN con tutte le fonti di scraping, creazione e modifica tramite la stessa pipeline del modulo web, aggiunta copie, copertina da foto o da URL, eliminazione con controllo dei prestiti attivi; gestione di autori, editori, generi, scaffali e ripiani. Nuovo feed delta /catalog/sync per il catalogo offline e calendario personale di prestiti e prenotazioni (feed ICS con link segreto revocabile, eventi dall'inizio alla scadenza, senza dati personali). Sesso e codice fiscale seguono le impostazioni di registrazione: se nascosti non compaiono in /auth/registration-fields né in /me. v1.5.1: cifratura dei messaggi push e irrobustimento dell'API. v1.5.0: archivi e desiderata nell'app." } diff --git a/storage/plugins/mobile-api/src/Controllers/CatalogController.php b/storage/plugins/mobile-api/src/Controllers/CatalogController.php index eac8e668f..11e178952 100644 --- a/storage/plugins/mobile-api/src/Controllers/CatalogController.php +++ b/storage/plugins/mobile-api/src/Controllers/CatalogController.php @@ -829,7 +829,13 @@ public function bookAvailability( return ResponseEnvelope::error($response, 'not_found', __('Libro non trovato.'), 404); } - return ResponseEnvelope::success($response, self::mapAvailability($avail), []); + // The return date POST /reservations sets: the chosen start plus the + // configured loan duration (UserActionsController::loan()/reserve()), so + // the app can show it before the reader sends the request. + $data = self::mapAvailability($avail) + + ['loan_duration_days' => (new \App\Models\SettingsRepository($this->db))->loanDurationDays()]; + + return ResponseEnvelope::success($response, $data, []); } catch (\Throwable $e) { SecureLogger::error('[MobileApi] book availability failed: ' . $e->getMessage()); return ResponseEnvelope::error($response, 'internal_error', __('Disponibilità non disponibile.'), 500); diff --git a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php index ee35aca20..0707c63fc 100644 --- a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php +++ b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php @@ -583,7 +583,7 @@ private function paths(): array 'get' => [ 'tags' => ['catalog'], 'summary' => 'Book loan availability (calendar)', - 'description' => 'Per-day loan availability for the date-picker calendar: total_copies, earliest_available, unavailable_dates (fully-booked days), and days[] with per-day free/total counts. Same computation as the website; excludes the requesting user\'s own active reservations. Soft-deleted books return 404.', + 'description' => 'Per-day loan availability for the date-picker calendar: total_copies, earliest_available, unavailable_dates (fully-booked days), and days[] with per-day free/total counts, plus loan_duration_days, the configured loan duration that POST /reservations adds to the chosen start to set the return date (Mobile API 1.8.1). Same computation as the website; excludes the requesting user\'s own active reservations. Soft-deleted books return 404.', 'operationId' => 'getCatalogBookAvailability', 'security' => [['bearerAuth' => []]], 'parameters' => [ @@ -610,6 +610,7 @@ private function paths(): array 'state' => ['type' => 'string', 'enum' => ['free', 'partial', 'full']], ], ]], + 'loan_duration_days' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Days POST /reservations adds to the chosen start to set the return date.'], ], ]], ]]], diff --git a/tests/mobile-api-behaviors.spec.js b/tests/mobile-api-behaviors.spec.js index fa658729c..6c0271a9f 100644 --- a/tests/mobile-api-behaviors.spec.js +++ b/tests/mobile-api-behaviors.spec.js @@ -537,6 +537,10 @@ test.describe('Availability state', () => { // earliest_available must be strictly after today (the copy is busy now). expect(d.earliest_available, 'earliest_available present').toBeTruthy(); expect(d.earliest_available > start, 'earliest_available after today').toBe(true); + // The app shows the return date from it: POST /reservations adds the + // configured loan duration (30 days when unset or invalid) to the start. + const configured = dbInt("SELECT setting_value FROM system_settings WHERE category = 'loans' AND setting_key = 'loan_duration_days'"); + expect(d.loan_duration_days, 'loan_duration_days is the configured loan duration').toBe(configured >= 1 ? configured : 30); } finally { teardown(); } From 5dbd8fd9a0bd8e3e81f0008b6f1ed9dcf8391438 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 20 Oct 2026 17:31:29 +0000 Subject: [PATCH 3/5] Put the whole advanced and email settings back after the role-boundaries test The finally block restored only the two probed keys. A refusal that regresses saves the whole tab: updateAdvancedSettings also writes the other custom scripts, the header CSS and the session lifetime, updateEmailSettings the driver, port and sender. The test now keeps both categories (with their descriptions) before it starts and puts them back as they were, the way it already does for the cookie banner. --- tests/role-boundaries-480.unit.php | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/tests/role-boundaries-480.unit.php b/tests/role-boundaries-480.unit.php index 3acf264fb..9870512ae 100644 --- a/tests/role-boundaries-480.unit.php +++ b/tests/role-boundaries-480.unit.php @@ -99,6 +99,9 @@ public function handle(ServerRequestInterface $request): ResponseInterface $savedJs = $value('advanced', 'custom_js_essential'); $savedHost = $value('email', 'smtp_host'); +// A refusal that regresses saves the whole tab, not only the probed key: keep both +// categories to put them back as they were. +$guardedSettings = $db->query("SELECT category, setting_key, setting_value, description FROM system_settings WHERE category IN ('advanced', 'email')")->fetch_all(MYSQLI_ASSOC); try { $settingsRole = $addRole(['backend.access', 'settings.manage']); $usersRole = $addRole(['backend.access', 'users.manage']); @@ -345,24 +348,16 @@ public function handle(ServerRequestInterface $request): ResponseInterface $db->query('DELETE FROM ruoli WHERE id = ' . (int) $id); } $db->query("DELETE FROM api_keys WHERE name = '" . $db->real_escape_string($tag) . "'"); - // A refusal that fails above stores its probe: a script on every public page - // and a foreign SMTP host. Put both back whatever happened, or the next suites - // run against that. - foreach ([['advanced', 'custom_js_essential', $savedJs], ['email', 'smtp_host', $savedHost]] as [$category, $key, $saved]) { - if ($value($category, $key) === $saved) { - continue; - } - $restore = $saved === null - ? $db->prepare('DELETE FROM system_settings WHERE category = ? AND setting_key = ?') - : $db->prepare('UPDATE system_settings SET setting_value = ? WHERE category = ? AND setting_key = ?'); - if ($saved === null) { - $restore->bind_param('ss', $category, $key); - } else { - $restore->bind_param('sss', $saved, $category, $key); - } + // A refusal that fails above saves its whole tab: a script on every public page, + // a foreign SMTP host and the other keys of those forms. Put both categories back + // whatever happened, or the next suites run against that. + $db->query("DELETE FROM system_settings WHERE category IN ('advanced', 'email')"); + $restore = $db->prepare('INSERT INTO system_settings (category, setting_key, setting_value, description) VALUES (?, ?, ?, ?)'); + foreach ($guardedSettings as $row) { + $restore->bind_param('ssss', $row['category'], $row['setting_key'], $row['setting_value'], $row['description']); $restore->execute(); - $restore->close(); } + $restore->close(); \App\Support\ConfigStore::clearCache(); unset($_SESSION); R::reset(); From f94dc0f16500f75fda230983c3b2f4881d531e0b Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 20 Oct 2026 19:39:25 +0000 Subject: [PATCH 4/5] Document the helpers of the loan-request default-end spec CodeRabbit's docstring check counted the three helpers of the new spec (dbQuery, setting, addDays) as undocumented: 40% against the repository's 60% threshold. Each now says what it returns, and setting() notes it mirrors the server's fallback for a value below 1. --- tests/loan-request-default-end.spec.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/loan-request-default-end.spec.js b/tests/loan-request-default-end.spec.js index 50821fb1a..74144d3c9 100644 --- a/tests/loan-request-default-end.spec.js +++ b/tests/loan-request-default-end.spec.js @@ -23,6 +23,12 @@ test.skip( 'E2E credentials not configured (set E2E_ADMIN_EMAIL, E2E_ADMIN_PASS, E2E_DB_USER, E2E_DB_NAME)', ); +/** + * Runs one statement against the test database (TCP when E2E_DB_HOST is set, else the + * socket) and returns its tab-separated output, trimmed. + * @param {string} sql + * @returns {string} + */ function dbQuery(sql) { const args = []; if (DB_HOST) { @@ -38,6 +44,13 @@ function dbQuery(sql) { }).trim(); } +/** + * A loans setting as a positive integer, or the fallback the server uses when it is + * missing or below 1. + * @param {string} key + * @param {number} fallback + * @returns {number} + */ function setting(key, fallback) { const value = parseInt( dbQuery(`SELECT setting_value FROM system_settings WHERE category='loans' AND setting_key='${key}' LIMIT 1`) || '', @@ -46,6 +59,12 @@ function setting(key, fallback) { return Number.isFinite(value) && value >= 1 ? value : fallback; } +/** + * yyyy-mm-dd plus a number of days, computed in UTC so no time zone shifts the day. + * @param {string} iso + * @param {number} days + * @returns {string} + */ function addDays(iso, days) { const [y, m, d] = iso.split('-').map(Number); const date = new Date(Date.UTC(y, m - 1, d)); From 453e651eea1ab333975ae69f67e26542bc1aee1c Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 20 Oct 2026 22:28:28 +0000 Subject: [PATCH 5/5] Put the guarded settings back in one transaction after the role-boundaries test The DELETE and the reinserts of the advanced and email categories now run in one transaction. A failed insert rolls back the DELETE, the original error is kept even if the rollback fails, and the config cache is cleared either way. --- tests/role-boundaries-480.unit.php | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/tests/role-boundaries-480.unit.php b/tests/role-boundaries-480.unit.php index 9870512ae..3db79e790 100644 --- a/tests/role-boundaries-480.unit.php +++ b/tests/role-boundaries-480.unit.php @@ -351,14 +351,27 @@ public function handle(ServerRequestInterface $request): ResponseInterface // A refusal that fails above saves its whole tab: a script on every public page, // a foreign SMTP host and the other keys of those forms. Put both categories back // whatever happened, or the next suites run against that. - $db->query("DELETE FROM system_settings WHERE category IN ('advanced', 'email')"); - $restore = $db->prepare('INSERT INTO system_settings (category, setting_key, setting_value, description) VALUES (?, ?, ?, ?)'); - foreach ($guardedSettings as $row) { - $restore->bind_param('ssss', $row['category'], $row['setting_key'], $row['setting_value'], $row['description']); - $restore->execute(); + // In one transaction, so a failed insert never leaves both categories emptied. + $db->begin_transaction(); + try { + $db->query("DELETE FROM system_settings WHERE category IN ('advanced', 'email')"); + $restore = $db->prepare('INSERT INTO system_settings (category, setting_key, setting_value, description) VALUES (?, ?, ?, ?)'); + foreach ($guardedSettings as $row) { + $restore->bind_param('ssss', $row['category'], $row['setting_key'], $row['setting_value'], $row['description']); + $restore->execute(); + } + $restore->close(); + $db->commit(); + } catch (\Throwable $e) { + try { + $db->rollback(); + } catch (\Throwable) { + // Keep the error that stopped the restore. + } + throw $e; + } finally { + \App\Support\ConfigStore::clearCache(); } - $restore->close(); - \App\Support\ConfigStore::clearCache(); unset($_SESSION); R::reset(); }