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/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/loan-request-default-end.spec.js b/tests/loan-request-default-end.spec.js new file mode 100644 index 000000000..74144d3c9 --- /dev/null +++ b/tests/loan-request-default-end.spec.js @@ -0,0 +1,123 @@ +// @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)', +); + +/** + * 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) { + 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(); +} + +/** + * 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`) || '', + 10, + ); + 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)); + 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/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(); } diff --git a/tests/role-boundaries-480.unit.php b/tests/role-boundaries-480.unit.php index d976a8bfc..3db79e790 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,6 +348,30 @@ 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 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. + // 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(); + } unset($_SESSION); R::reset(); }