From a0b75f43dd06c011bbff9d1439a0cf4e302ea6f4 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Fri, 14 Aug 2026 13:05:19 +0200 Subject: [PATCH 01/42] feat(copies): manage physical copies from the book summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marking a book lost/damaged is done per physical copy (a book's availability is derived from its copies, #351). That was only reachable when the book already had copie rows — created on loan or import, never for a manually-created book that was never loaned — so such books had no way to set the status, and there was no way to add a copy from the UI. Add copy management to the book summary (/admin/books/{id}): - The "Copie Fisiche" section is always shown; when the book has no copies it shows an empty state plus an "Aggiungi copia" button. - A new "Aggiungi copia" modal (same style as the existing edit-copy modal) creates a physical copy: optional inventory number (auto-allocated as the next collision-free "{base}-C{N}" when left blank), initial status and a note. - Backend: CopyController::createCopy + POST /admin/books/{id}/copies/create, reusing CopyRepository and recalculating availability. The availability model already excludes lost/damaged/maintenance copies from copie_totali, so marking a copy lost lowers the total on its own. E2E: tests/copy-management-scheda.spec.js (10 real browser tests, all green): section + button, empty state, add (auto + explicit inventory), duplicate rejected, a born-damaged copy not counted, edit to lost/damaged lowering the total and round-tripping, delete guard + delete of an out-of-circulation copy, and "every copy out of circulation → non_disponibile". New UI strings added to all five locales. --- app/Controllers/CopyController.php | 67 ++++++++ app/Routes/web.php | 6 + app/Views/libri/scheda_libro.php | 103 ++++++++++++- locale/da_DK.json | 13 +- locale/de_DE.json | 13 +- locale/en_US.json | 13 +- locale/fr_FR.json | 13 +- locale/it_IT.json | 13 +- tests/copy-management-scheda.spec.js | 218 +++++++++++++++++++++++++++ 9 files changed, 446 insertions(+), 13 deletions(-) create mode 100644 tests/copy-management-scheda.spec.js diff --git a/app/Controllers/CopyController.php b/app/Controllers/CopyController.php index 90bccb658..1b394c846 100644 --- a/app/Controllers/CopyController.php +++ b/app/Controllers/CopyController.php @@ -290,6 +290,73 @@ public function updateCopy(Request $request, Response $response, mysqli $db, int return $response->withHeader('Location', url("/admin/books/{$libroId}"))->withStatus(302); } + /** + * Crea una nuova copia fisica per un libro, direttamente dalla scheda. + * + * Loan states ('prestato'/'prenotato') are never created here — those belong + * to the Prestiti system. A lost/damaged/maintenance copy is allowed: the + * availability recalculation then excludes it from copie_totali, so marking a + * copy lost reduces the book's total on its own. + */ + public function createCopy(Request $request, Response $response, mysqli $db, int $bookId): Response + { + $data = (array) $request->getParsedBody(); + // CSRF validated by CsrfMiddleware + + $stmt = $db->prepare("SELECT id, numero_inventario FROM libri WHERE id = ? AND deleted_at IS NULL"); + $stmt->bind_param('i', $bookId); + $stmt->execute(); + $book = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + if (!$book) { + $_SESSION['error_message'] = __('Libro non trovato.'); + return $response->withHeader('Location', $this->safeReferer('/admin/books'))->withStatus(302); + } + + // Only physical statuses a copy can be created in — loan states are + // managed by the Prestiti system, never set here. + $stato = (string) ($data['stato'] ?? 'disponibile'); + $statiValidi = ['disponibile', 'manutenzione', 'in_restauro', 'perso', 'danneggiato', 'in_trasferimento']; + if (!in_array($stato, $statiValidi, true)) { + $_SESSION['error_message'] = __('Stato non valido.'); + return $response->withHeader('Location', url("/admin/books/{$bookId}"))->withStatus(302); + } + $note = trim((string) ($data['note'] ?? '')); + + $repo = new \App\Models\CopyRepository($db); + + // Inventory code: honour an explicit value (must be unique), otherwise + // auto-allocate the next collision-free "{base}-C{N}" like book creation. + $numero = trim((string) ($data['numero_inventario'] ?? '')); + if ($numero !== '') { + $numero = preg_replace('/[\x00-\x1F]/', '', $numero); + if (mb_strlen($numero) > 100) { + $numero = mb_substr($numero, 0, 100); + } + if ($repo->inventoryCodeExists($numero)) { + $_SESSION['error_message'] = __('Esiste già una copia con questo numero di inventario.'); + return $response->withHeader('Location', url("/admin/books/{$bookId}"))->withStatus(302); + } + } else { + $base = !empty($book['numero_inventario']) ? (string) $book['numero_inventario'] : "LIB-{$bookId}"; + $codes = $repo->allocateInventoryCodes($base, 1); + $numero = $codes[0] ?? ($base . '-C1'); + } + + try { + $repo->create($bookId, $numero, $stato, $note !== '' ? $note : null); + } catch (\Throwable $e) { + SecureLogger::error('[CopyController] createCopy failed', ['book' => $bookId, 'error' => $e->getMessage()]); + $_SESSION['error_message'] = __('Impossibile aggiungere la copia.'); + return $response->withHeader('Location', url("/admin/books/{$bookId}"))->withStatus(302); + } + + (new \App\Support\DataIntegrity($db))->recalculateBookAvailability($bookId); + + $_SESSION['success_message'] = __('Copia aggiunta con successo.'); + return $response->withHeader('Location', url("/admin/books/{$bookId}"))->withStatus(302); + } + /** * Elimina una singola copia */ diff --git a/app/Routes/web.php b/app/Routes/web.php index 6c04f3a55..92c67cfe0 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -1494,6 +1494,12 @@ return $controller->deleteCopy($request, $response, $db, (int) $args['id']); })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + $app->post('/admin/books/{id:\d+}/copies/create', function ($request, $response, $args) use ($app) { + $controller = new \App\Controllers\CopyController(); + $db = $app->getContainer()->get('db'); + return $controller->createCopy($request, $response, $db, (int) $args['id']); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + // Series (Series) management $app->get('/admin/series', function ($request, $response) use ($app) { $controller = new \App\Controllers\CollaneController(); diff --git a/app/Views/libri/scheda_libro.php b/app/Views/libri/scheda_libro.php index 421014f15..549cc08d6 100644 --- a/app/Views/libri/scheda_libro.php +++ b/app/Views/libri/scheda_libro.php @@ -908,8 +908,8 @@ class="inline-flex items-center gap-1 px-3 py-2 text-xs font-medium rounded-lg b - - 0): ?> + +
@@ -930,11 +930,19 @@ class="inline-flex items-center gap-1 px-3 py-2 text-xs font-medium rounded-lg b - - - +
+ + + + + + +
@@ -951,6 +959,15 @@ class="inline-flex items-center gap-2 px-3 py-1.5 bg-gray-800 text-white text-sm + + + + + + + + + @@ -1120,7 +1137,6 @@ class="text-red-600 hover:text-red-900 transition-colors"
- @@ -1866,6 +1882,77 @@ function confirmRenewal(e){
+ + + + + diff --git a/app/Views/utenti/modifica_utente.php b/app/Views/utenti/modifica_utente.php index 06b6685f2..e7c2d4697 100644 --- a/app/Views/utenti/modifica_utente.php +++ b/app/Views/utenti/modifica_utente.php @@ -18,6 +18,12 @@ $stato = (string)($utente['stato'] ?? 'attivo'); $ruolo = (string)($utente['tipo_utente'] ?? 'standard'); $note = HtmlHelper::e($utente['note_utente'] ?? ''); +$installationLocale = isset($installationLocale) && is_string($installationLocale) + ? $installationLocale + : \App\Support\I18n::getInstallationLocale(); +$installationLocaleName = isset($installationLocaleName) && is_string($installationLocaleName) + ? $installationLocaleName + : (\App\Support\I18n::getAvailableLocales()[$installationLocale] ?? $installationLocale); ?>
@@ -140,6 +146,18 @@ ">
+
+ + +

+
diff --git a/locale/da_DK.json b/locale/da_DK.json index 7281975e0..0a2c49292 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -2719,6 +2719,8 @@ "Limite pagina: 500.": "Sidelimite: 500.", "Limiti: massimo 50 libri con scraping attivo, timeout 5 minuti": "Grænser: maks. 50 bøger med aktiv scraping, timeout 5 minutter", "Lingua": "Sprog", + "Lingua dell'applicazione": "Applikationssprog", + "È una configurazione globale: viene scelta durante l'installazione e può essere modificata dall'amministratore.": "Dette er en global indstilling: den vælges under installationen og kan ændres af en administrator.", "Lingua App": "App-sprog", "Lingua Attiva": "Aktivt sprog", "Lingua Predefinita": "Standardsprog", @@ -6806,5 +6808,16 @@ "Impossibile eliminare la copia.": "Kunne ikke slette eksemplaret.", "Per prenotare una copia, utilizza il sistema Prestiti. Non è possibile impostare manualmente lo stato \"Prenotato\".": "Brug udlånssystemet for at reservere et eksemplar. Status \"Reserveret\" kan ikke angives manuelt.", "Per annullare o spostare una prenotazione, utilizza il sistema Prestiti.": "Brug udlånssystemet for at annullere eller flytte en reservation.", - "Prenotato (gestisci dal sistema Prestiti)": "Reserveret (administrér fra udlånssystemet)" + "Prenotato (gestisci dal sistema Prestiti)": "Reserveret (administrér fra udlånssystemet)", + "Aggiungi copie": "Tilføj eksemplarer", + "Aggiungi copie fisiche": "Tilføj fysiske eksemplarer", + "Quantità": "Antal", + "Puoi aggiungere fino a 100 copie in un'unica operazione atomica.": "Du kan tilføje op til 100 eksemplarer i én atomisk handling.", + "Disponibile solo per una copia. Se lo lasci vuoto, il numero viene assegnato automaticamente.": "Kun tilgængeligt, når ét eksemplar tilføjes. Lad feltet stå tomt for automatisk nummerering.", + "I numeri di inventario saranno generati automaticamente dal prefisso del libro, uno diverso per ogni copia.": "Inventarnumrene genereres automatisk ud fra bogens præfiks med et forskelligt nummer til hvert eksemplar.", + "La nota verrà applicata a tutte le copie del gruppo.": "Noten anvendes på alle eksemplarer i gruppen.", + "Aggiungi %s copie": "Tilføj %s eksemplarer", + "Per aggiungere più copie, lascia vuoto il numero di inventario: verrà assegnato automaticamente a ogni copia.": "For at tilføje flere eksemplarer skal inventarnummeret stå tomt: det tildeles automatisk til hvert eksemplar.", + "%d copie aggiunte con successo.": "%d eksemplarer blev tilføjet.", + "Usa \"Aggiungi copie\" per crearne una o più e impostarne lo stato.": "Brug \"Tilføj eksemplarer\" til at oprette et eller flere eksemplarer og angive deres status." } diff --git a/locale/de_DE.json b/locale/de_DE.json index 62a6f81bd..c67149c48 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -2719,6 +2719,8 @@ "Limite pagina: 500.": "Seitenlimit: 500.", "Limiti: massimo 50 libri con scraping attivo, timeout 5 minuti": "Grenzen: maximal 50 Bücher mit aktivem Scraping, Timeout 5 Minuten", "Lingua": "Sprache", + "Lingua dell'applicazione": "Anwendungssprache", + "È una configurazione globale: viene scelta durante l'installazione e può essere modificata dall'amministratore.": "Dies ist eine globale Einstellung: Sie wird bei der Installation ausgewählt und kann von einem Administrator geändert werden.", "Lingua App": "App-Sprache", "Lingua Attiva": "Aktive Sprache", "Lingua Predefinita": "Standardsprache", @@ -6806,5 +6808,16 @@ "Impossibile eliminare la copia.": "Exemplar konnte nicht gelöscht werden.", "Per prenotare una copia, utilizza il sistema Prestiti. Non è possibile impostare manualmente lo stato \"Prenotato\".": "Um ein Exemplar vorzumerken, verwende das Ausleihsystem. Der Status „Vorgemerkt\" kann nicht manuell gesetzt werden.", "Per annullare o spostare una prenotazione, utilizza il sistema Prestiti.": "Um eine Vormerkung zu stornieren oder zu verschieben, verwende das Ausleihsystem.", - "Prenotato (gestisci dal sistema Prestiti)": "Vorgemerkt (über das Ausleihsystem verwalten)" + "Prenotato (gestisci dal sistema Prestiti)": "Vorgemerkt (über das Ausleihsystem verwalten)", + "Aggiungi copie": "Exemplare hinzufügen", + "Aggiungi copie fisiche": "Physische Exemplare hinzufügen", + "Quantità": "Anzahl", + "Puoi aggiungere fino a 100 copie in un'unica operazione atomica.": "Sie können bis zu 100 Exemplare in einem einzigen atomaren Vorgang hinzufügen.", + "Disponibile solo per una copia. Se lo lasci vuoto, il numero viene assegnato automaticamente.": "Nur beim Hinzufügen eines Exemplars verfügbar. Leer lassen, um die Nummer automatisch zu vergeben.", + "I numeri di inventario saranno generati automaticamente dal prefisso del libro, uno diverso per ogni copia.": "Die Inventarnummern werden automatisch aus dem Buchpräfix erzeugt, mit einer eigenen Nummer für jedes Exemplar.", + "La nota verrà applicata a tutte le copie del gruppo.": "Die Notiz wird auf alle Exemplare der Gruppe angewendet.", + "Aggiungi %s copie": "%s Exemplare hinzufügen", + "Per aggiungere più copie, lascia vuoto il numero di inventario: verrà assegnato automaticamente a ogni copia.": "Um mehrere Exemplare hinzuzufügen, lassen Sie die Inventarnummer leer: Sie wird jedem Exemplar automatisch zugewiesen.", + "%d copie aggiunte con successo.": "%d Exemplare erfolgreich hinzugefügt.", + "Usa \"Aggiungi copie\" per crearne una o più e impostarne lo stato.": "Verwenden Sie \"Exemplare hinzufügen\", um ein oder mehrere Exemplare anzulegen und ihren Status festzulegen." } diff --git a/locale/en_US.json b/locale/en_US.json index 18c96ff94..f3ea3b765 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -2719,6 +2719,8 @@ "Limite pagina: 500.": "Page limit: 500.", "Limiti: massimo 50 libri con scraping attivo, timeout 5 minuti": "Limits: maximum 50 books with scraping enabled, 5 minute timeout", "Lingua": "Language", + "Lingua dell'applicazione": "Application language", + "È una configurazione globale: viene scelta durante l'installazione e può essere modificata dall'amministratore.": "This is a global setting: it is selected during installation and can be changed by an administrator.", "Lingua App": "App Language", "Lingua Attiva": "Active Language", "Lingua Predefinita": "Default Language", @@ -6806,5 +6808,16 @@ "Impossibile eliminare la copia.": "Unable to delete the copy.", "Per prenotare una copia, utilizza il sistema Prestiti. Non è possibile impostare manualmente lo stato \"Prenotato\".": "To reserve a copy, use the Loans system. The \"Reserved\" status cannot be set manually.", "Per annullare o spostare una prenotazione, utilizza il sistema Prestiti.": "To cancel or move a reservation, use the Loans system.", - "Prenotato (gestisci dal sistema Prestiti)": "Reserved (manage from the Loans system)" + "Prenotato (gestisci dal sistema Prestiti)": "Reserved (manage from the Loans system)", + "Aggiungi copie": "Add copies", + "Aggiungi copie fisiche": "Add physical copies", + "Quantità": "Quantity", + "Puoi aggiungere fino a 100 copie in un'unica operazione atomica.": "You can add up to 100 copies in a single atomic operation.", + "Disponibile solo per una copia. Se lo lasci vuoto, il numero viene assegnato automaticamente.": "Available only when adding one copy. Leave it blank to assign the number automatically.", + "I numeri di inventario saranno generati automaticamente dal prefisso del libro, uno diverso per ogni copia.": "Inventory numbers will be generated automatically from the book prefix, with a different number for each copy.", + "La nota verrà applicata a tutte le copie del gruppo.": "The note will be applied to every copy in the batch.", + "Aggiungi %s copie": "Add %s copies", + "Per aggiungere più copie, lascia vuoto il numero di inventario: verrà assegnato automaticamente a ogni copia.": "To add multiple copies, leave the inventory number blank: it will be assigned automatically to each copy.", + "%d copie aggiunte con successo.": "%d copies added successfully.", + "Usa \"Aggiungi copie\" per crearne una o più e impostarne lo stato.": "Use \"Add copies\" to create one or more copies and set their status." } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 91173acf0..70ee82a09 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -2719,6 +2719,8 @@ "Limite pagina: 500.": "Limite de page : 500.", "Limiti: massimo 50 libri con scraping attivo, timeout 5 minuti": "Limites : maximum 50 livres avec scraping actif, délai 5 minutes", "Lingua": "Langue", + "Lingua dell'applicazione": "Langue de l'application", + "È una configurazione globale: viene scelta durante l'installazione e può essere modificata dall'amministratore.": "Il s'agit d'un paramètre global : il est choisi lors de l'installation et peut être modifié par un administrateur.", "Lingua App": "Langue de l'application", "Lingua Attiva": "Langue active", "Lingua Predefinita": "Langue par défaut", @@ -6806,5 +6808,16 @@ "Impossibile eliminare la copia.": "Impossible de supprimer la copie.", "Per prenotare una copia, utilizza il sistema Prestiti. Non è possibile impostare manualmente lo stato \"Prenotato\".": "Pour réserver une copie, utilisez le système de prêts. L'état « Réservé » ne peut pas être défini manuellement.", "Per annullare o spostare una prenotazione, utilizza il sistema Prestiti.": "Pour annuler ou déplacer une réservation, utilisez le système de prêts.", - "Prenotato (gestisci dal sistema Prestiti)": "Réservé (gérer depuis le système de prêts)" + "Prenotato (gestisci dal sistema Prestiti)": "Réservé (gérer depuis le système de prêts)", + "Aggiungi copie": "Ajouter des copies", + "Aggiungi copie fisiche": "Ajouter des copies physiques", + "Quantità": "Quantité", + "Puoi aggiungere fino a 100 copie in un'unica operazione atomica.": "Vous pouvez ajouter jusqu’à 100 copies en une seule opération atomique.", + "Disponibile solo per una copia. Se lo lasci vuoto, il numero viene assegnato automaticamente.": "Disponible uniquement pour l’ajout d’une seule copie. Laissez vide pour attribuer le numéro automatiquement.", + "I numeri di inventario saranno generati automaticamente dal prefisso del libro, uno diverso per ogni copia.": "Les numéros d’inventaire seront générés automatiquement à partir du préfixe du livre, avec un numéro différent pour chaque copie.", + "La nota verrà applicata a tutte le copie del gruppo.": "La note sera appliquée à toutes les copies du groupe.", + "Aggiungi %s copie": "Ajouter %s copies", + "Per aggiungere più copie, lascia vuoto il numero di inventario: verrà assegnato automaticamente a ogni copia.": "Pour ajouter plusieurs copies, laissez le numéro d’inventaire vide : il sera attribué automatiquement à chaque copie.", + "%d copie aggiunte con successo.": "%d copies ajoutées avec succès.", + "Usa \"Aggiungi copie\" per crearne una o più e impostarne lo stato.": "Utilisez \"Ajouter des copies\" pour en créer une ou plusieurs et définir leur statut." } diff --git a/locale/it_IT.json b/locale/it_IT.json index eaf46310b..9d56f6564 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -2719,6 +2719,8 @@ "Limite pagina: 500.": "Limite pagina: 500.", "Limiti: massimo 50 libri con scraping attivo, timeout 5 minuti": "Limiti: massimo 50 libri con scraping attivo, timeout 5 minuti", "Lingua": "Lingua", + "Lingua dell'applicazione": "Lingua dell'applicazione", + "È una configurazione globale: viene scelta durante l'installazione e può essere modificata dall'amministratore.": "È una configurazione globale: viene scelta durante l'installazione e può essere modificata dall'amministratore.", "Lingua App": "Lingua App", "Lingua Attiva": "Lingua Attiva", "Lingua Predefinita": "Lingua Predefinita", @@ -6806,5 +6808,16 @@ "Impossibile eliminare la copia.": "Impossibile eliminare la copia.", "Per prenotare una copia, utilizza il sistema Prestiti. Non è possibile impostare manualmente lo stato \"Prenotato\".": "Per prenotare una copia, utilizza il sistema Prestiti. Non è possibile impostare manualmente lo stato \"Prenotato\".", "Per annullare o spostare una prenotazione, utilizza il sistema Prestiti.": "Per annullare o spostare una prenotazione, utilizza il sistema Prestiti.", - "Prenotato (gestisci dal sistema Prestiti)": "Prenotato (gestisci dal sistema Prestiti)" + "Prenotato (gestisci dal sistema Prestiti)": "Prenotato (gestisci dal sistema Prestiti)", + "Aggiungi copie": "Aggiungi copie", + "Aggiungi copie fisiche": "Aggiungi copie fisiche", + "Quantità": "Quantità", + "Puoi aggiungere fino a 100 copie in un'unica operazione atomica.": "Puoi aggiungere fino a 100 copie in un'unica operazione atomica.", + "Disponibile solo per una copia. Se lo lasci vuoto, il numero viene assegnato automaticamente.": "Disponibile solo per una copia. Se lo lasci vuoto, il numero viene assegnato automaticamente.", + "I numeri di inventario saranno generati automaticamente dal prefisso del libro, uno diverso per ogni copia.": "I numeri di inventario saranno generati automaticamente dal prefisso del libro, uno diverso per ogni copia.", + "La nota verrà applicata a tutte le copie del gruppo.": "La nota verrà applicata a tutte le copie del gruppo.", + "Aggiungi %s copie": "Aggiungi %s copie", + "Per aggiungere più copie, lascia vuoto il numero di inventario: verrà assegnato automaticamente a ogni copia.": "Per aggiungere più copie, lascia vuoto il numero di inventario: verrà assegnato automaticamente a ogni copia.", + "%d copie aggiunte con successo.": "%d copie aggiunte con successo.", + "Usa \"Aggiungi copie\" per crearne una o più e impostarne lo stato.": "Usa \"Aggiungi copie\" per crearne una o più e impostarne lo stato." } diff --git a/public/assets/main.css b/public/assets/main.css index 3f9fcfa7b..03b7c97d3 100644 --- a/public/assets/main.css +++ b/public/assets/main.css @@ -4338,6 +4338,9 @@ input:where([type='file']):focus { .max-h-\[90vh\] { max-height: 90vh; } +.min-h-11 { + min-height: 2.75rem; +} .min-h-\[400px\] { min-height: 400px; } @@ -4443,6 +4446,9 @@ input:where([type='file']):focus { .min-w-0 { min-width: 0px; } +.min-w-11 { + min-width: 2.75rem; +} .min-w-\[10rem\] { min-width: 10rem; } diff --git a/storage/plugins/mobile-api/src/Controllers/AuthController.php b/storage/plugins/mobile-api/src/Controllers/AuthController.php index 3cae01c12..5a32ffd0f 100644 --- a/storage/plugins/mobile-api/src/Controllers/AuthController.php +++ b/storage/plugins/mobile-api/src/Controllers/AuthController.php @@ -236,13 +236,19 @@ public function register(Request $request, ResponseInterface $response): Respons $privacyVersion = '1.0'; $stato = 'sospeso'; // requires admin approval (same as web) $ruolo = 'standard'; + $locale = \App\Support\I18n::normalizeLocaleCode( + \App\Support\I18n::getInstallationLocale() + ); + if (!\App\Support\I18n::isValidLocaleCode($locale)) { + $locale = 'it_IT'; + } - $columns = 'nome, cognome, email, password, codice_tessera, stato, tipo_utente, email_verificata, token_verifica_email, data_token_verifica, data_scadenza_tessera, privacy_accettata, data_accettazione_privacy, privacy_policy_version'; - $placeholders = '?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, 1, ?, ?'; - $types = 'ssssssssssss'; + $columns = 'nome, cognome, email, password, codice_tessera, stato, tipo_utente, locale, email_verificata, token_verifica_email, data_token_verifica, data_scadenza_tessera, privacy_accettata, data_accettazione_privacy, privacy_policy_version'; + $placeholders = '?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, 1, ?, ?'; + $types = 'sssssssssssss'; $values = [ $nome, $cognome, $email, $hash, $codiceTessera, $stato, $ruolo, - $token, $scadenzaToken, $scadenzaTessera, $accettazione, $privacyVersion, + $locale, $token, $scadenzaToken, $scadenzaTessera, $accettazione, $privacyVersion, ]; if ($telefono !== '') { $columns .= ', telefono'; diff --git a/tests/admin-features.spec.js b/tests/admin-features.spec.js index 7513d2b57..c30cd05eb 100644 --- a/tests/admin-features.spec.js +++ b/tests/admin-features.spec.js @@ -543,9 +543,11 @@ test.describe.serial('User Self-Registration', () => { await userPage.locator('button[type="submit"]').click(); await userPage.waitForURL(/successo|registrat/, { timeout: 15000 }); - // DB verify: user created with stato=sospeso - const stato = dbQuery(`SELECT stato FROM utenti WHERE email='${testEmail}'`); + // DB verify: user created suspended and inherits the installation-wide locale. + const [stato, locale] = dbQuery(`SELECT stato, locale FROM utenti WHERE email='${testEmail}'`).split('\t'); expect(stato).toBe('sospeso'); + const installationLocale = dbQuery('SELECT code FROM languages WHERE is_default = 1 LIMIT 1'); + expect(locale).toBe(installationLocale); const emailVerified = dbQuery(`SELECT email_verificata FROM utenti WHERE email='${testEmail}'`); expect(emailVerified).toBe('0'); diff --git a/tests/copy-count-inventory.unit.php b/tests/copy-count-inventory.unit.php index 5f0260c86..b0f59fb27 100644 --- a/tests/copy-count-inventory.unit.php +++ b/tests/copy-count-inventory.unit.php @@ -158,6 +158,17 @@ function check(bool $cond, string $desc): void check(count(array_intersect($next, $existing)) === 0, "allocate never collides with existing codes"); check($next === ["{$base}-C3", "{$base}-C4", "{$base}-C5"], "allocate continues past the highest used index"); + // Step 7: the admin batch helper inserts every row in one call, returns all + // ids, and treats an operator-entered '%' as literal note text. + $sharedNote = 'Batch note 100% literal'; + $db->begin_transaction(); + $batchIds = $repo->createManyForBookWithIdsAndNote($bookId, $base, 3, 'disponibile', $sharedNote); + $db->commit(); + check(count($batchIds) === 3 && count(array_unique($batchIds)) === 3, 'batch helper returns three distinct inserted ids'); + $escapedNote = $db->real_escape_string($sharedNote); + $noteCount = (int) ($db->query("SELECT COUNT(*) FROM copie WHERE libro_id = {$bookId} AND note = '{$escapedNote}'")->fetch_row()[0] ?? 0); + check($noteCount === 3, 'batch helper stores the shared note literally on every copy'); + } catch (\Throwable $e) { $cleanup(); fwrite(STDERR, 'FAIL: ' . $e->getMessage() . "\n"); diff --git a/tests/copy-management-scheda.spec.js b/tests/copy-management-scheda.spec.js index fd950bc69..21f56c5f6 100644 --- a/tests/copy-management-scheda.spec.js +++ b/tests/copy-management-scheda.spec.js @@ -1,8 +1,9 @@ // E2E: manage physical copies from the book summary page (admin). // // New capability: /admin/books/{id} always shows the "Copie Fisiche" section -// with an "Aggiungi copia" button (even when the book has no copies), a create -// modal, and per-copy status editing. A lost/damaged/maintenance copy is +// with an "Aggiungi copie" button (even when the book has no copies), a create +// modal supporting atomic batches, and per-copy status editing. A +// lost/damaged/maintenance copy is // excluded from libri.copie_totali (DataIntegrity::recalculateBookAvailability), // so marking a copy lost lowers the total. // @@ -66,11 +67,16 @@ async function loginAsAdmin(page) { } } -// Open the "Aggiungi copia" modal, fill it, submit, wait for the reload. -async function addCopy(page, { inventario = '', stato = 'disponibile', note = '' } = {}) { +// Open the add-copy modal, fill one copy or a batch, submit, wait for reload. +async function addCopy(page, { quantita = 1, inventario = '', stato = 'disponibile', note = '' } = {}) { await page.evaluate(() => window.openAddCopyModal()); await expect(page.locator('#add-copy-modal')).toBeVisible(); - await page.fill('#add-copy-inventario', inventario); + await page.fill('#add-copy-quantita', String(quantita)); + if (quantita === 1) { + await page.fill('#add-copy-inventario', inventario); + } else { + await expect(page.locator('#add-copy-inventario')).toBeDisabled(); + } await page.selectOption('#add-copy-stato', stato); if (note) await page.fill('#add-copy-note', note); await Promise.all([ @@ -112,18 +118,18 @@ test.describe.serial('Copy management from the book summary (#238/#351)', () => }); test.afterAll(() => cleanupByTitle()); - test('1. book summary shows the Copie Fisiche section and Aggiungi copia button', async ({ page }) => { + test('1. book summary shows the Copie Fisiche section and Aggiungi copie button', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${BASE}/admin/books/${bookId}`); - await expect(page.locator('text=Copie Fisiche')).toBeVisible(); - await expect(page.locator('button:has-text("Aggiungi copia")').first()).toBeVisible(); + await expect(page.locator('#physical-copies h2').filter({ hasText: 'Copie Fisiche' })).toBeVisible(); + await expect(page.locator('button:has-text("Aggiungi copie")').first()).toBeVisible(); }); test('2. a book with no copies shows the empty-state and the add button', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${BASE}/admin/books/${emptyBookId}`); await expect(page.locator('text=Nessuna copia fisica')).toBeVisible(); - await expect(page.locator('button:has-text("Aggiungi copia")').first()).toBeVisible(); + await expect(page.locator('button:has-text("Aggiungi copie")').first()).toBeVisible(); expect(copieCount(emptyBookId)).toBe(0); }); @@ -146,6 +152,20 @@ test.describe.serial('Copy management from the book summary (#238/#351)', () => expect(dbQuery(`SELECT COUNT(*) FROM copie WHERE libro_id=${bookId} AND numero_inventario='${code}'`)).toBe('1'); }); + test('4b. add three copies atomically with unique automatic inventories and a shared literal note', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${BASE}/admin/books/${bookId}`); + const beforeRows = copieCount(bookId); + const beforeTotal = copieTotali(bookId); + const note = `batch-100%-${RUN}`; + await addCopy(page, { quantita: 3, stato: 'disponibile', note }); + + expect(copieCount(bookId)).toBe(beforeRows + 3); + expect(copieTotali(bookId)).toBe(beforeTotal + 3); + expect(Number(dbQuery(`SELECT COUNT(*) FROM copie WHERE libro_id=${bookId} AND note='${sqlEscape(note)}'`))).toBe(3); + expect(Number(dbQuery(`SELECT COUNT(DISTINCT numero_inventario) FROM copie WHERE libro_id=${bookId} AND note='${sqlEscape(note)}'`))).toBe(3); + }); + test('5. a duplicate inventory number is rejected (no new copy)', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${BASE}/admin/books/${bookId}`); @@ -274,6 +294,36 @@ test.describe.serial('Copy management from the book summary (#238/#351)', () => expect(Number(dbQuery(`SELECT copie_disponibili FROM libri WHERE id=${queueBookId}`))).toBe(0); }); + test('13b. a batch of three available copies promotes three queued reservations onto distinct copies', async ({ page }) => { + await loginAsAdmin(page); + const title = `CopyMgmtBatchQueue ${RUN}`; + const emailPrefix = `copy-mgmt-batch-${RUN}`; + dbQuery(`INSERT INTO libri (titolo, numero_inventario, stato, copie_totali, copie_disponibili) VALUES ('${sqlEscape(title)}', 'BQ-${RUN}', 'non_disponibile', 0, 0)`); + const id = Number(dbQuery(`SELECT id FROM libri WHERE titolo='${sqlEscape(title)}' ORDER BY id DESC LIMIT 1`)); + try { + for (let position = 1; position <= 3; position++) { + const email = `${emailPrefix}-${position}@example.test`; + dbQuery(`INSERT INTO utenti (codice_tessera, nome, cognome, email, password, stato, tipo_utente, email_verificata, privacy_accettata) VALUES ('BQ-${RUN}-${position}', 'Batch', 'Queue ${position}', '${sqlEscape(email)}', 'not-used', 'attivo', 'standard', 1, 1)`); + const userId = Number(dbQuery(`SELECT id FROM utenti WHERE email='${sqlEscape(email)}'`)); + dbQuery(`INSERT INTO prenotazioni (libro_id, utente_id, data_inizio_richiesta, data_fine_richiesta, data_scadenza_prenotazione, queue_position, stato) VALUES (${id}, ${userId}, CURDATE(), DATE_ADD(CURDATE(), INTERVAL 14 DAY), DATE_ADD(NOW(), INTERVAL 14 DAY), ${position}, 'attiva')`); + } + + await page.goto(`${BASE}/admin/books/${id}`); + await addCopy(page, { quantita: 3, stato: 'disponibile', note: 'batch-queue-capacity' }); + + expect(Number(dbQuery(`SELECT COUNT(*) FROM prenotazioni WHERE libro_id=${id} AND stato='completata'`))).toBe(3); + expect(Number(dbQuery(`SELECT COUNT(*) FROM prestiti WHERE libro_id=${id} AND origine='prenotazione' AND stato='pendente' AND copia_id IS NOT NULL`))).toBe(3); + expect(Number(dbQuery(`SELECT COUNT(DISTINCT copia_id) FROM prestiti WHERE libro_id=${id}`))).toBe(3); + expect(copieTotali(id)).toBe(3); + expect(Number(dbQuery(`SELECT copie_disponibili FROM libri WHERE id=${id}`))).toBe(0); + } finally { + dbQuery(`DELETE FROM prestiti WHERE libro_id=${id}`); + dbQuery(`DELETE FROM prenotazioni WHERE libro_id=${id}`); + dbQuery(`DELETE FROM libri WHERE id=${id}`); + dbQuery(`DELETE FROM utenti WHERE email LIKE '${sqlEscape(emailPrefix)}-%@example.test'`); + } + }); + // ---- PR #356 review fixes --------------------------------------------- // #2: in_restauro / in_trasferimento are out-of-circulation states and must be @@ -332,15 +382,27 @@ test.describe.serial('Copy management from the book summary (#238/#351)', () => expect(await page.locator('#copie_totali').getAttribute('readonly')).toBeNull(); }); - // #6: the Add-copy modal title matches the button label ("Aggiungi copia"). - test('17. the Add-copy modal title matches the button label (#356)', async ({ page }) => { + // The modal exposes the batch size directly and makes the exact inventory + // field unavailable when more than one code must be allocated. + test('17. the add-copy modal exposes an accessible 1-100 batch control', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${BASE}/admin/books/${bookId}`); await page.evaluate(() => window.openAddCopyModal()); await expect(page.locator('#add-copy-modal')).toBeVisible(); - expect((await page.locator('#add-copy-modal h3').first().innerText()).trim()).toBe('Aggiungi copia'); - // The old mixed-case "Aggiungi Copia" string is gone from the modal. - expect(await page.locator('#add-copy-modal').innerText()).not.toContain('Aggiungi Copia'); + expect((await page.locator('#add-copy-modal h3').first().innerText()).trim()).toBe('Aggiungi copie fisiche'); + await expect(page.locator('#add-copy-quantita')).toHaveValue('1'); + await expect(page.locator('#add-copy-quantita')).toHaveAttribute('max', '100'); + await page.fill('#add-copy-quantita', '3'); + await expect(page.locator('#add-copy-inventario')).toBeDisabled(); + await expect(page.locator('#add-copy-inventario-group')).toBeHidden(); + await expect(page.locator('#add-copy-batch-inventario-help')).toBeVisible(); + await expect(page.locator('#add-copy-submit-label')).toHaveText('Aggiungi 3 copie'); + await expect(page.locator('#add-copy-note-help')).toBeVisible(); + await page.fill('#add-copy-quantita', '1'); + await expect(page.locator('#add-copy-inventario')).toBeEnabled(); + await expect(page.locator('#add-copy-inventario-group')).toBeVisible(); + await expect(page.locator('#add-copy-batch-inventario-help')).toBeHidden(); + await expect(page.locator('#add-copy-submit-label')).toHaveText('Aggiungi copia'); }); // #1: copie_totali is derived server-side on edit — a crafted POST that bypasses @@ -408,9 +470,9 @@ test.describe.serial('Copy management from the book summary (#238/#351)', () => } }); - test('21. add-copy rejects array-shaped status, note and inventory inputs (#356)', async ({ page }) => { + test('21. add-copy rejects array-shaped status, note, inventory and quantity inputs (#356)', async ({ page }) => { await loginAsAdmin(page); - for (const field of ['stato', 'note', 'numero_inventario']) { + for (const field of ['stato', 'note', 'numero_inventario', 'quantita']) { await page.goto(`${BASE}/admin/books/${emptyBookId}`); const before = copieCount(emptyBookId); await page.evaluate(() => window.openAddCopyModal()); @@ -424,11 +486,47 @@ test.describe.serial('Copy management from the book summary (#238/#351)', () => ]); const errorAlert = page.getByRole('alert'); await expect(errorAlert).toBeVisible(); - await expect(errorAlert).toContainText('Impossibile aggiungere la copia'); + await expect(errorAlert).toContainText(field === 'quantita' ? 'Numero di copie non valido' : 'Impossibile aggiungere la copia'); expect(copieCount(emptyBookId)).toBe(before); } }); + test('21b. batch creation rejects a manually supplied exact inventory code server-side', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${BASE}/admin/books/${emptyBookId}`); + const before = copieCount(emptyBookId); + await page.evaluate(() => window.openAddCopyModal()); + await page.fill('#add-copy-quantita', '3'); + await page.locator('#add-copy-inventario').evaluate((element) => { + element.disabled = false; + element.value = 'CRAFTED-BATCH-CODE'; + }); + await Promise.all([ + page.waitForNavigation({ waitUntil: 'domcontentloaded' }).catch(() => {}), + page.click('#add-copy-form button[type="submit"]'), + ]); + await expect(page.getByRole('alert')).toContainText('lascia vuoto il numero di inventario'); + expect(copieCount(emptyBookId)).toBe(before); + }); + + test('21c. a failed batch insert rolls back every requested copy', async ({ page }) => { + await loginAsAdmin(page); + const trigger = `trg_cm_batch_${RUN.replace(/[^a-z0-9]/gi, '').slice(0, 20)}`; + const note = `batch-fail-${RUN}`; + const before = copieCount(emptyBookId); + dbQuery(`DROP TRIGGER IF EXISTS ${trigger}`); + dbQuery(`CREATE TRIGGER ${trigger} BEFORE INSERT ON copie FOR EACH ROW SET NEW.numero_inventario=IF(NEW.note='${sqlEscape(note)}', NULL, NEW.numero_inventario)`); + try { + await page.goto(`${BASE}/admin/books/${emptyBookId}`); + await addCopy(page, { quantita: 3, stato: 'disponibile', note }); + await expect(page.getByRole('alert')).toContainText('Impossibile aggiungere le copie'); + expect(copieCount(emptyBookId)).toBe(before); + expect(Number(dbQuery(`SELECT COUNT(*) FROM copie WHERE libro_id=${emptyBookId} AND note='${sqlEscape(note)}'`))).toBe(0); + } finally { + dbQuery(`DROP TRIGGER IF EXISTS ${trigger}`); + } + }); + test('22. edit-copy normalizes control characters and caps notes at 500 characters', async ({ page }) => { await loginAsAdmin(page); await page.goto(`${BASE}/admin/books/${bookId}`); diff --git a/tests/email-notifications.spec.js b/tests/email-notifications.spec.js index 924bde923..2181b74d7 100644 --- a/tests/email-notifications.spec.js +++ b/tests/email-notifications.spec.js @@ -290,6 +290,27 @@ test.describe.serial('Email Notifications E2E', () => { await cleanupAndRestore(); }); + /** + * Persisting one settings group through the application invalidates the + * web server's APCu-backed ConfigStore cache. File deletion alone cannot do + * that because Playwright's Node process and Apache do not share memory. + */ + async function persistContactNotificationThroughApp(notificationEmail) { + await withAdminPage(browserRef, async (page) => { + await page.goto(`${BASE}/admin/settings?tab=contacts`, { waitUntil: 'domcontentloaded' }); + const form = page.locator('form[action$="/admin/settings/contacts"]'); + await expect(form).toHaveCount(1); + const formData = await form.evaluate((element) => + Object.fromEntries(new FormData(/** @type {HTMLFormElement} */ (element)).entries()), + ); + formData.notification_email = notificationEmail; + + const result = await page.request.post(`${BASE}/admin/settings/contacts`, { form: formData }); + expect(result.status()).toBeLessThan(400); + expect(result.headers().location || '').not.toContain('error='); + }); + } + // ── A.1: Configure SMTP driver → Mailpit ──────────────────────── test('A.1 — Configure SMTP driver to Mailpit', async () => { // Set email settings via DB to point at Mailpit @@ -309,12 +330,10 @@ test.describe.serial('Email Notifications E2E', () => { `); clearConfigCache(); - // Set contact notification email so contact form tests work - dbQuery(` - INSERT INTO system_settings (category, setting_key, setting_value) - VALUES ('contacts', 'notification_email', '${sqlEscape(ADMIN_EMAIL)}') - ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value) - `); + // Use the real settings endpoint for the final write. Besides storing the + // contact recipient, ConfigStore::set() invalidates APCu in Apache so the + // directly seeded SMTP values are visible on the very next request. + await persistContactNotificationThroughApp(ADMIN_EMAIL); // Verify settings are stored const type = dbQuery("SELECT setting_value FROM system_settings WHERE category='email' AND setting_key='type'"); @@ -1023,6 +1042,14 @@ test.describe.serial('Email Notifications E2E', () => { } clearConfigCache(); + // Invalidate the web process' APCu copy after the direct SQL restore. + // This also prevents the following E2E shard from inheriting Mailpit SMTP. + try { + await persistContactNotificationThroughApp(originalSettings.contact_notification || ''); + } catch (err) { + console.error('[Cleanup] Could not invalidate web settings cache:', err.message); + } + // Clear Mailpit await clearMailpit(); } diff --git a/tests/installation-locale-users-238.unit.php b/tests/installation-locale-users-238.unit.php new file mode 100644 index 000000000..d33891193 --- /dev/null +++ b/tests/installation-locale-users-238.unit.php @@ -0,0 +1,80 @@ +installationLocale();') === 2, + 'admin create and update both use the installation locale' +); +$check( + !str_contains($users, "\$data['locale']"), + 'admin endpoints ignore client-controlled locale values' +); +$check( + str_contains($mobileRegistration, 'I18n::getInstallationLocale()') + && str_contains($mobileRegistration, 'tipo_utente, locale, email_verificata'), + 'Android/API registration explicitly persists the installation locale' +); +$check( + str_contains($editView, 'id="installation_locale"') + && str_contains($editView, 'readonly') + && !str_contains($editView, 'name="locale"'), + 'admin edit exposes installation language as read-only information' +); + +$keys = [ + "Lingua dell'applicazione", + "È una configurazione globale: viene scelta durante l'installazione e può essere modificata dall'amministratore.", +]; +foreach (['it_IT', 'en_US', 'de_DE', 'fr_FR', 'da_DK'] as $locale) { + $catalogue = json_decode( + (string) file_get_contents($root . '/locale/' . $locale . '.json'), + true, + 512, + JSON_THROW_ON_ERROR + ); + $check( + isset($catalogue[$keys[0]], $catalogue[$keys[1]]) + && trim((string) $catalogue[$keys[0]]) !== '' + && trim((string) $catalogue[$keys[1]]) !== '', + "{$locale} contains the installation-language labels" + ); +} + +echo "\n{$passed} passed, {$failed} failed\n"; +exit($failed === 0 ? 0 : 1); diff --git a/tests/mobile-api.spec.js b/tests/mobile-api.spec.js index c8945cf55..fa328f834 100644 --- a/tests/mobile-api.spec.js +++ b/tests/mobile-api.spec.js @@ -607,10 +607,11 @@ test.describe.serial('Mobile API plugin — E2E suite', () => { const body = await envelope(res, 201); expect(body.error).toBeNull(); const stored = dbQuery( - `SELECT CONCAT(cognome, '|', COALESCE(telefono, 'NULL'), '|', COALESCE(indirizzo, 'NULL')) + `SELECT CONCAT(cognome, '|', COALESCE(telefono, 'NULL'), '|', COALESCE(indirizzo, 'NULL'), '|', locale) FROM utenti WHERE email='${REGISTRATION_EMAIL}' LIMIT 1` ); - expect(stored).toBe('|NULL|NULL'); + const installationLocale = dbQuery('SELECT code FROM languages WHERE is_default = 1 LIMIT 1'); + expect(stored).toBe(`|NULL|NULL|${installationLocale}`); const storedCustom = dbQuery( `SELECT v.valore FROM utenti_campi_valori v JOIN utenti u ON u.id=v.utente_id diff --git a/tests/multilang-install-i18n.spec.js b/tests/multilang-install-i18n.spec.js index 5f687cfbf..6bd7413c4 100644 --- a/tests/multilang-install-i18n.spec.js +++ b/tests/multilang-install-i18n.spec.js @@ -4,7 +4,8 @@ // 1. Installer wizard accepts the requested locale // 2. Post-install DB seeds match the chosen locale (`generi` table) // 3. utenti.locale row matches the chosen locale -// 4. First login renders the correct locale (URL + UI strings) +// 4. Public registrations inherit the installation locale +// 5. First login renders the correct locale (URL + UI strings) const { test, expect } = require('@playwright/test'); const { execFileSync } = require('child_process'); @@ -52,6 +53,13 @@ const LOGIN_SLUGS = { fr_FR: 'connexion', da_DK: 'log-ind', }; +const REGISTER_SLUGS = { + it_IT: 'registrati', + en_US: 'register', + de_DE: 'registrieren', + fr_FR: 'inscription', + da_DK: 'registrer', +}; const LOGIN_URL_PATTERNS = { it_IT: /\/accedi/, en_US: /\/login/, @@ -206,6 +214,34 @@ test.describe.serial(`multilang install — ${LOCALE}`, () => { expect(locale).toBe(LOCALE); }); + test(`Public registration inherits installation locale ${LOCALE}`, async () => { + test.skip(!appReady, 'Install did not complete'); + const fresh = await page.context().browser().newContext(); + const registrationPage = await fresh.newPage(); + const email = `locale-${LOCALE.toLowerCase()}@example.test`; + try { + await registrationPage.goto(`${BASE}/${REGISTER_SLUGS[LOCALE]}`); + await registrationPage.fill('input[name="nome"]', 'Locale'); + await registrationPage.fill('input[name="cognome"]', 'Installation'); + await registrationPage.fill('input[name="email"]', email); + await registrationPage.fill('input[name="telefono"]', '3331234567'); + await registrationPage.fill('textarea[name="indirizzo"]', 'Test address 1'); + await registrationPage.fill('input[name="password"]', 'LocaleTest123!'); + await registrationPage.fill('input[name="password_confirm"]', 'LocaleTest123!'); + await registrationPage.locator('input[name="privacy_acceptance"]').check(); + await registrationPage.locator('button[type="submit"]').click(); + await registrationPage.waitForURL(url => !url.pathname.endsWith(`/${REGISTER_SLUGS[LOCALE]}`), { timeout: 15000 }); + + const safeEmail = email.replace(/[^A-Za-z0-9._@+\-]/g, ''); + expect(safeEmail).toBe(email); + const locale = dbQuery(`SELECT locale FROM utenti WHERE email = '${safeEmail}' LIMIT 1`); + expect(locale).toBe(LOCALE); + } finally { + dbQuery(`DELETE FROM utenti WHERE email = '${email}'`); + await fresh.close(); + } + }); + test(`Locale-routed login URL responds 200 (${LOCALE})`, async () => { test.skip(!appReady, 'Install did not complete'); const loginSlug = LOGIN_SLUGS[LOCALE]; From 0364b80c378f3a498ecfbc7430e4df1e0b3284fc Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Sun, 16 Aug 2026 09:35:28 +0200 Subject: [PATCH 33/42] fix: restore per-user locale preferences --- app/Controllers/AuthController.php | 14 ++--- app/Controllers/LanguageController.php | 1 + app/Controllers/ProfileController.php | 23 ++++---- app/Controllers/UsersController.php | 37 +++++++++--- app/Middleware/RememberMeMiddleware.php | 16 +++-- app/Support/I18n.php | 22 +++++++ app/Views/utenti/crea_utente.php | 17 ++++++ app/Views/utenti/modifica_utente.php | 29 +++++----- locale/da_DK.json | 4 +- locale/de_DE.json | 4 +- locale/en_US.json | 4 +- locale/fr_FR.json | 4 +- locale/it_IT.json | 4 +- tests/admin-features.spec.js | 55 ++++++++++++++++++ tests/book-field-types-static.spec.js | 9 +-- tests/email-notifications.spec.js | 21 +++++-- tests/installation-locale-users-238.unit.php | 61 +++++++++++++++----- tests/multilang-install-i18n.spec.js | 11 ++-- 18 files changed, 251 insertions(+), 85 deletions(-) diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php index 8c4377781..033bdb779 100644 --- a/app/Controllers/AuthController.php +++ b/app/Controllers/AuthController.php @@ -146,13 +146,13 @@ public function login(Request $request, Response $response, mysqli $db): Respons 'name' => trim(\App\Support\HtmlHelper::decode((string) ($row['nome'] ?? '')) . ' ' . \App\Support\HtmlHelper::decode((string) ($row['cognome'] ?? ''))), ]; - // Load and apply user's preferred locale (only persist if setLocale succeeds) - if (!empty($row['locale'])) { - $requestedLocale = (string) $row['locale']; - if (\App\Support\I18n::setLocale($requestedLocale)) { - $_SESSION['locale'] = $requestedLocale; - } - } + // Apply the per-user preference on every login. NULL, empty or + // obsolete values inherit the current installation default, + // preventing a previous anonymous/user session locale leaking in. + $requestedLocale = \App\Support\I18n::resolveUserLocale($row['locale'] ?? null); + \App\Support\I18n::setLocale($requestedLocale); + $_SESSION['locale'] = $requestedLocale; + $_SESSION['user']['locale'] = $requestedLocale; // Handle "Remember Me" functionality with database-backed tokens if ($remember) { diff --git a/app/Controllers/LanguageController.php b/app/Controllers/LanguageController.php index 79f142298..8cc80178e 100644 --- a/app/Controllers/LanguageController.php +++ b/app/Controllers/LanguageController.php @@ -40,6 +40,7 @@ public function switchLanguage(Request $request, Response $response, mysqli $db, $stmt->bind_param('si', $locale, $userId); $stmt->execute(); $stmt->close(); + $_SESSION['user']['locale'] = $locale; } } diff --git a/app/Controllers/ProfileController.php b/app/Controllers/ProfileController.php index 177853c9c..4ae91fde3 100644 --- a/app/Controllers/ProfileController.php +++ b/app/Controllers/ProfileController.php @@ -156,10 +156,17 @@ public function update(Request $request, Response $response, mysqli $db): Respon $sesso = null; // Invalid value, set to null } - // Validate locale - only allow known locales + // Validate locale. An empty value explicitly means "site default"; + // an unknown non-empty value is ignored instead of resetting the user. if ($localeProvided) { $availableLocales = \App\Support\I18n::getAvailableLocales(); - $locale = ($locale !== '' && isset($availableLocales[$locale])) ? $locale : null; + $locale = \App\Support\I18n::normalizeLocaleCode((string) $locale); + if ($locale === '') { + $locale = null; + } elseif (!isset($availableLocales[$locale])) { + $localeProvided = false; + $locale = null; + } } // Validate required fields. Surname/phone/address follow the admin @@ -245,14 +252,10 @@ public function update(Request $request, Response $response, mysqli $db): Respon $_SESSION['user']['name'] = trim($nome . ' ' . $cognome); // Apply locale change immediately (only when locale was in the form) if ($localeProvided) { - if ($locale !== null) { - \App\Support\I18n::setLocale($locale); - $_SESSION['locale'] = $locale; - } else { - // Reset runtime locale to site default so flash renders correctly - \App\Support\I18n::setLocale(\App\Support\I18n::getInstallationLocale()); - unset($_SESSION['locale']); - } + $runtimeLocale = \App\Support\I18n::resolveUserLocale($locale); + \App\Support\I18n::setLocale($runtimeLocale); + $_SESSION['locale'] = $runtimeLocale; + $_SESSION['user']['locale'] = $locale; } // Flash message AFTER locale switch so it renders in the new language $_SESSION['success_message'] = __('Profilo aggiornato con successo.'); diff --git a/app/Controllers/UsersController.php b/app/Controllers/UsersController.php index 9316c20de..a701d510c 100644 --- a/app/Controllers/UsersController.php +++ b/app/Controllers/UsersController.php @@ -51,6 +51,8 @@ public function createForm(Request $request, Response $response): Response if ($guard = $this->guardAdminStaff($response)) { return $guard; } + $availableLocales = \App\Support\I18n::getAvailableLocales(); + $selectedLocale = $this->installationLocale(); ob_start(); require __DIR__ . '/../Views/utenti/crea_utente.php'; $content = ob_get_clean(); @@ -148,8 +150,9 @@ public function store(Request $request, Response $response, mysqli $db): Respons $note = trim(strip_tags((string) ($data['note_utente'] ?? ''))); $note = $note !== '' ? $note : null; - // Language is installation-wide: never trust a per-user form value. - $locale = $this->installationLocale(); + // New accounts start from the installation language unless the admin + // explicitly selects another active interface language. + $locale = $this->localeFromInput($data['locale'] ?? null, $this->installationLocale()); $codiceTesseraInput = trim((string) ($data['codice_tessera'] ?? '')); $dataScadenzaInput = trim((string) ($data['data_scadenza_tessera'] ?? '')); @@ -278,9 +281,11 @@ public function editForm(Request $request, Response $response, mysqli $db, int $ $utente = $result->fetch_assoc(); $stmt->close(); - $installationLocale = $this->installationLocale(); - $installationLocaleName = \App\Support\I18n::getAvailableLocales()[$installationLocale] - ?? $installationLocale; + $availableLocales = \App\Support\I18n::getAvailableLocales(); + $selectedLocale = $this->localeFromInput( + $utente['locale'] ?? null, + $this->installationLocale() + ); // Custom registration field values (issue #255) — shown read-only so the // admin can see community handles (e.g. Telegram) alongside the account. @@ -420,9 +425,9 @@ public function update(Request $request, Response $response, mysqli $db, int $id $note = trim(strip_tags((string) ($data['note_utente'] ?? ''))); $note = $note !== '' ? $note : null; - // Saving an existing account also repairs legacy rows that inherited - // the old it_IT column default on a non-Italian installation. - $locale = $this->installationLocale(); + // Locale is a per-user preference. Invalid or omitted input preserves + // the user's current value instead of resetting it to the site default. + $locale = $this->localeFromInput($data['locale'] ?? null, (string) ($original['locale'] ?? '')); $telefono = $telefono !== '' ? $telefono : null; @@ -725,6 +730,22 @@ private function installationLocale(): string return \App\Support\I18n::isValidLocaleCode($locale) ? $locale : 'it_IT'; } + private function localeFromInput(mixed $input, string $fallback): string + { + $availableLocales = \App\Support\I18n::getAvailableLocales(); + $requested = \App\Support\I18n::normalizeLocaleCode(is_string($input) ? trim($input) : ''); + if ($requested !== '' && isset($availableLocales[$requested])) { + return $requested; + } + + $current = \App\Support\I18n::normalizeLocaleCode($fallback); + if ($current !== '' && isset($availableLocales[$current])) { + return $current; + } + + return $this->installationLocale(); + } + private function generateTessera(mysqli $db): string { do { diff --git a/app/Middleware/RememberMeMiddleware.php b/app/Middleware/RememberMeMiddleware.php index af22edd25..91917b206 100644 --- a/app/Middleware/RememberMeMiddleware.php +++ b/app/Middleware/RememberMeMiddleware.php @@ -104,15 +104,13 @@ private function attemptAutoLogin(): void // the misleading "session expired" screen even though they are logged in. \App\Support\Csrf::ensureToken(); - // Load and apply user's preferred locale (only persist if setLocale succeeds) - if (!empty($row['locale'])) { - $locale = (string) $row['locale']; - // Ensure language cache is loaded (middleware may run before bootstrap i18n) - \App\Support\I18n::loadFromDatabase($this->db); - if (\App\Support\I18n::setLocale($locale)) { - $_SESSION['locale'] = $locale; - } - } + // Ensure language cache is loaded (middleware may run before bootstrap i18n), + // then apply the per-user preference or the installation default. + \App\Support\I18n::loadFromDatabase($this->db); + $locale = \App\Support\I18n::resolveUserLocale($row['locale'] ?? null); + \App\Support\I18n::setLocale($locale); + $_SESSION['locale'] = $locale; + $_SESSION['user']['locale'] = $locale; // Log auto-login for security auditing (no PII logged per GDPR) \App\Support\Log::security('login.remember_me', [ diff --git a/app/Support/I18n.php b/app/Support/I18n.php index a52108cd1..a73915926 100644 --- a/app/Support/I18n.php +++ b/app/Support/I18n.php @@ -344,6 +344,28 @@ public static function getAvailableLocales(): array return self::$availableLocales; } + /** + * Resolve a stored user preference to an active locale. + * + * NULL, empty and obsolete locale codes inherit the installation default. + */ + public static function resolveUserLocale(mixed $locale): string + { + $availableLocales = self::getAvailableLocales(); + $requested = self::normalizeLocaleCode(is_string($locale) ? trim($locale) : ''); + if ($requested !== '' && isset($availableLocales[$requested])) { + return $requested; + } + + $installationLocale = self::normalizeLocaleCode(self::getInstallationLocale()); + if ($installationLocale !== '' && isset($availableLocales[$installationLocale])) { + return $installationLocale; + } + + $firstAvailable = array_key_first($availableLocales); + return is_string($firstAvailable) ? $firstAvailable : 'it_IT'; + } + /** * Normalize locale codes to canonical format (xx_YY) */ diff --git a/app/Views/utenti/crea_utente.php b/app/Views/utenti/crea_utente.php index 1e2061b57..625f55f59 100644 --- a/app/Views/utenti/crea_utente.php +++ b/app/Views/utenti/crea_utente.php @@ -4,6 +4,12 @@ $csrfToken = Csrf::ensureToken(); $errorKey = (string)($_GET['error'] ?? ''); +$availableLocales = isset($availableLocales) && is_array($availableLocales) + ? $availableLocales + : \App\Support\I18n::getAvailableLocales(); +$selectedLocale = isset($selectedLocale) && is_string($selectedLocale) + ? $selectedLocale + : \App\Support\I18n::getInstallationLocale(); ?>
@@ -119,6 +125,17 @@ ">
+
+ + +

+
diff --git a/app/Views/utenti/modifica_utente.php b/app/Views/utenti/modifica_utente.php index e7c2d4697..d6f40a5e2 100644 --- a/app/Views/utenti/modifica_utente.php +++ b/app/Views/utenti/modifica_utente.php @@ -18,12 +18,12 @@ $stato = (string)($utente['stato'] ?? 'attivo'); $ruolo = (string)($utente['tipo_utente'] ?? 'standard'); $note = HtmlHelper::e($utente['note_utente'] ?? ''); -$installationLocale = isset($installationLocale) && is_string($installationLocale) - ? $installationLocale +$availableLocales = isset($availableLocales) && is_array($availableLocales) + ? $availableLocales + : \App\Support\I18n::getAvailableLocales(); +$selectedLocale = isset($selectedLocale) && is_string($selectedLocale) + ? $selectedLocale : \App\Support\I18n::getInstallationLocale(); -$installationLocaleName = isset($installationLocaleName) && is_string($installationLocaleName) - ? $installationLocaleName - : (\App\Support\I18n::getAvailableLocales()[$installationLocale] ?? $installationLocale); ?>
@@ -147,16 +147,15 @@ ">
- - -

+ + +

diff --git a/locale/da_DK.json b/locale/da_DK.json index 0a2c49292..2b726549c 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -2719,8 +2719,8 @@ "Limite pagina: 500.": "Sidelimite: 500.", "Limiti: massimo 50 libri con scraping attivo, timeout 5 minuti": "Grænser: maks. 50 bøger med aktiv scraping, timeout 5 minutter", "Lingua": "Sprog", - "Lingua dell'applicazione": "Applikationssprog", - "È una configurazione globale: viene scelta durante l'installazione e può essere modificata dall'amministratore.": "Dette er en global indstilling: den vælges under installationen og kan ændres af en administrator.", + "Lingua dell'interfaccia": "Grænsefladesprog", + "L'utente potrà cambiarla in qualsiasi momento.": "Brugeren kan ændre det når som helst.", "Lingua App": "App-sprog", "Lingua Attiva": "Aktivt sprog", "Lingua Predefinita": "Standardsprog", diff --git a/locale/de_DE.json b/locale/de_DE.json index c67149c48..87c36f960 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -2719,8 +2719,8 @@ "Limite pagina: 500.": "Seitenlimit: 500.", "Limiti: massimo 50 libri con scraping attivo, timeout 5 minuti": "Grenzen: maximal 50 Bücher mit aktivem Scraping, Timeout 5 Minuten", "Lingua": "Sprache", - "Lingua dell'applicazione": "Anwendungssprache", - "È una configurazione globale: viene scelta durante l'installazione e può essere modificata dall'amministratore.": "Dies ist eine globale Einstellung: Sie wird bei der Installation ausgewählt und kann von einem Administrator geändert werden.", + "Lingua dell'interfaccia": "Oberflächensprache", + "L'utente potrà cambiarla in qualsiasi momento.": "Die Person kann sie jederzeit ändern.", "Lingua App": "App-Sprache", "Lingua Attiva": "Aktive Sprache", "Lingua Predefinita": "Standardsprache", diff --git a/locale/en_US.json b/locale/en_US.json index f3ea3b765..dddb0e6ae 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -2719,8 +2719,8 @@ "Limite pagina: 500.": "Page limit: 500.", "Limiti: massimo 50 libri con scraping attivo, timeout 5 minuti": "Limits: maximum 50 books with scraping enabled, 5 minute timeout", "Lingua": "Language", - "Lingua dell'applicazione": "Application language", - "È una configurazione globale: viene scelta durante l'installazione e può essere modificata dall'amministratore.": "This is a global setting: it is selected during installation and can be changed by an administrator.", + "Lingua dell'interfaccia": "Interface language", + "L'utente potrà cambiarla in qualsiasi momento.": "The user can change it at any time.", "Lingua App": "App Language", "Lingua Attiva": "Active Language", "Lingua Predefinita": "Default Language", diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 70ee82a09..122f62642 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -2719,8 +2719,8 @@ "Limite pagina: 500.": "Limite de page : 500.", "Limiti: massimo 50 libri con scraping attivo, timeout 5 minuti": "Limites : maximum 50 livres avec scraping actif, délai 5 minutes", "Lingua": "Langue", - "Lingua dell'applicazione": "Langue de l'application", - "È una configurazione globale: viene scelta durante l'installazione e può essere modificata dall'amministratore.": "Il s'agit d'un paramètre global : il est choisi lors de l'installation et peut être modifié par un administrateur.", + "Lingua dell'interfaccia": "Langue de l'interface", + "L'utente potrà cambiarla in qualsiasi momento.": "La personne pourra la modifier à tout moment.", "Lingua App": "Langue de l'application", "Lingua Attiva": "Langue active", "Lingua Predefinita": "Langue par défaut", diff --git a/locale/it_IT.json b/locale/it_IT.json index 9d56f6564..3753faae4 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -2719,8 +2719,8 @@ "Limite pagina: 500.": "Limite pagina: 500.", "Limiti: massimo 50 libri con scraping attivo, timeout 5 minuti": "Limiti: massimo 50 libri con scraping attivo, timeout 5 minuti", "Lingua": "Lingua", - "Lingua dell'applicazione": "Lingua dell'applicazione", - "È una configurazione globale: viene scelta durante l'installazione e può essere modificata dall'amministratore.": "È una configurazione globale: viene scelta durante l'installazione e può essere modificata dall'amministratore.", + "Lingua dell'interfaccia": "Lingua dell'interfaccia", + "L'utente potrà cambiarla in qualsiasi momento.": "L'utente potrà cambiarla in qualsiasi momento.", "Lingua App": "Lingua App", "Lingua Attiva": "Lingua Attiva", "Lingua Predefinita": "Lingua Predefinita", diff --git a/tests/admin-features.spec.js b/tests/admin-features.spec.js index c30cd05eb..d1d8d0a6f 100644 --- a/tests/admin-features.spec.js +++ b/tests/admin-features.spec.js @@ -454,6 +454,7 @@ test.describe.serial('User Self-Registration', () => { const testEmail = `reg-${RUN_ID}@example.com`; const testPass = 'TestPass1234!'; + let preferredLocale = ''; test.beforeAll(async ({ browser }) => { userContext = await browser.newContext(); @@ -561,6 +562,34 @@ test.describe.serial('User Self-Registration', () => { expect(stato).toBe('attivo'); }); + test('Admin can set a per-user locale and invalid input preserves it', async () => { + const userId = Number(dbQuery(`SELECT id FROM utenti WHERE email='${testEmail}' LIMIT 1`)); + const installationLocale = dbQuery('SELECT code FROM languages WHERE is_default = 1 LIMIT 1'); + preferredLocale = dbQuery(`SELECT code FROM languages WHERE is_active = 1 AND code != '${installationLocale}' ORDER BY code LIMIT 1`); + expect(userId).toBeGreaterThan(0); + expect(preferredLocale).not.toBe(''); + + await adminPage.goto(`${BASE}/admin/users/edit/${userId}`); + await expect(adminPage.locator('select[name="locale"]')).toHaveValue(installationLocale); + await adminPage.selectOption('select[name="locale"]', preferredLocale); + await adminPage.locator('#user-form button[type="submit"]').click(); + await adminPage.waitForURL(/\/admin\/users(?:\?|$)/, { timeout: 15000 }); + expect(dbQuery(`SELECT locale FROM utenti WHERE id=${userId}`)).toBe(preferredLocale); + + await adminPage.goto(`${BASE}/admin/users/edit/${userId}`); + const form = adminPage.locator('#user-form'); + const action = await form.getAttribute('action'); + expect(action).toBeTruthy(); + const formData = await form.evaluate((element) => + Object.fromEntries(new FormData(/** @type {HTMLFormElement} */ (element)).entries()), + ); + formData.locale = 'xx_INVALID'; + const response = await adminPage.request.post(action || '', { form: formData, maxRedirects: 0 }); + expect(response.status()).toBeGreaterThanOrEqual(300); + expect(response.status()).toBeLessThan(400); + expect(dbQuery(`SELECT locale FROM utenti WHERE id=${userId}`)).toBe(preferredLocale); + }); + test('Activated user can login', async () => { await userPage.goto(`${BASE}/accedi`); await userPage.waitForLoadState('networkidle'); @@ -574,6 +603,32 @@ test.describe.serial('User Self-Registration', () => { // Should not be on login page anymore const finalUrl = userPage.url(); expect(finalUrl).not.toMatch(/accedi.*error/); + await expect(userPage.locator('html')).toHaveAttribute('lang', preferredLocale.split('_')[0]); + }); + + test('Language switch persists across logout and login', async () => { + const activeLocales = dbQuery('SELECT code FROM languages WHERE is_active = 1 ORDER BY code').split('\n').filter(Boolean); + const switchedLocale = activeLocales.find(locale => locale !== preferredLocale) || ''; + expect(switchedLocale).not.toBe(''); + + await userPage.goto(`${BASE}/language/${switchedLocale}?redirect=/`); + await userPage.waitForLoadState('domcontentloaded'); + expect(dbQuery(`SELECT locale FROM utenti WHERE email='${testEmail}'`)).toBe(switchedLocale); + await expect(userPage.locator('html')).toHaveAttribute('lang', switchedLocale.split('_')[0]); + + const logoutPaths = /** @type {Record} */ ({ it_IT: '/esci', en_US: '/logout', de_DE: '/abmelden', fr_FR: '/deconnexion', da_DK: '/log-ud' }); + await userPage.getByRole('button', { name: /UserE2E/ }).click(); + const logoutForm = userPage.locator(`form[action$="${logoutPaths[switchedLocale]}"]`).first(); + await expect(logoutForm).toBeVisible(); + await logoutForm.locator('a, button').first().click(); + + const loginPaths = /** @type {Record} */ ({ it_IT: '/accedi', en_US: '/login', de_DE: '/anmelden', fr_FR: '/connexion', da_DK: '/log-ind' }); + await userPage.goto(`${BASE}${loginPaths[switchedLocale]}`); + await userPage.fill('input[name="email"]', testEmail); + await userPage.fill('input[name="password"]', testPass); + await userPage.locator('button[type="submit"]').click(); + await userPage.waitForURL(url => !url.pathname.endsWith(loginPaths[switchedLocale]), { timeout: 15000 }); + await expect(userPage.locator('html')).toHaveAttribute('lang', switchedLocale.split('_')[0]); }); }); diff --git a/tests/book-field-types-static.spec.js b/tests/book-field-types-static.spec.js index 0d8869356..00465a040 100644 --- a/tests/book-field-types-static.spec.js +++ b/tests/book-field-types-static.spec.js @@ -26,10 +26,11 @@ test.describe('book field-type consistency', () => { test('2) BookRepository derives availability on create and never writes it on update', () => { const repository = read('app/Models/BookRepository.php'); - const updateBasic = repository.slice( - repository.indexOf('public function updateBasic'), - repository.indexOf('public function updateOptionals') - ); + const updateBasicStart = repository.indexOf('public function updateBasic'); + const updateOptionalsStart = repository.indexOf('public function updateOptionals'); + expect(updateBasicStart).toBeGreaterThanOrEqual(0); + expect(updateOptionalsStart).toBeGreaterThan(updateBasicStart); + const updateBasic = repository.slice(updateBasicStart, updateOptionalsStart); expect(repository).toContain('private function sanitizeAcquisitionType(mixed $value): string'); expect(repository).toContain('private function normalizeEnumValue(mixed $value, string $column, string $default): string'); diff --git a/tests/email-notifications.spec.js b/tests/email-notifications.spec.js index 2181b74d7..b7f321d83 100644 --- a/tests/email-notifications.spec.js +++ b/tests/email-notifications.spec.js @@ -281,8 +281,12 @@ test.describe.serial('Email Notifications E2E', () => { originalSettings.from_name = dbQuery("SELECT setting_value FROM system_settings WHERE category='email' AND setting_key='from_name' LIMIT 1"); } catch { originalSettings.from_name = ''; } try { + originalSettings.contact_notification_exists = dbQuery("SELECT COUNT(*) FROM system_settings WHERE category='contacts' AND setting_key='notification_email'") === '1'; originalSettings.contact_notification = dbQuery("SELECT setting_value FROM system_settings WHERE category='contacts' AND setting_key='notification_email' LIMIT 1"); - } catch { originalSettings.contact_notification = ''; } + } catch { + originalSettings.contact_notification_exists = false; + originalSettings.contact_notification = ''; + } }); test.afterAll(async () => { @@ -305,7 +309,11 @@ test.describe.serial('Email Notifications E2E', () => { ); formData.notification_email = notificationEmail; - const result = await page.request.post(`${BASE}/admin/settings/contacts`, { form: formData }); + const result = await page.request.post(`${BASE}/admin/settings/contacts`, { + form: formData, + maxRedirects: 0, + }); + expect(result.status()).toBeGreaterThanOrEqual(300); expect(result.status()).toBeLessThan(400); expect(result.headers().location || '').not.toContain('error='); }); @@ -1037,7 +1045,7 @@ test.describe.serial('Email Notifications E2E', () => { restore('email', 'from_email', originalSettings.from_email); restore('email', 'from_name', originalSettings.from_name); - if (originalSettings.contact_notification !== undefined) { + if (originalSettings.contact_notification_exists) { restore('contacts', 'notification_email', originalSettings.contact_notification); } clearConfigCache(); @@ -1045,9 +1053,14 @@ test.describe.serial('Email Notifications E2E', () => { // Invalidate the web process' APCu copy after the direct SQL restore. // This also prevents the following E2E shard from inheriting Mailpit SMTP. try { - await persistContactNotificationThroughApp(originalSettings.contact_notification || ''); + await persistContactNotificationThroughApp(originalSettings.contact_notification); } catch (err) { console.error('[Cleanup] Could not invalidate web settings cache:', err.message); + } finally { + if (!originalSettings.contact_notification_exists) { + dbQuery("DELETE FROM system_settings WHERE category='contacts' AND setting_key='notification_email'"); + clearConfigCache(); + } } // Clear Mailpit diff --git a/tests/installation-locale-users-238.unit.php b/tests/installation-locale-users-238.unit.php index d33891193..43daed7b8 100644 --- a/tests/installation-locale-users-238.unit.php +++ b/tests/installation-locale-users-238.unit.php @@ -2,9 +2,8 @@ declare(strict_types=1); /** - * Regression guard for discussion #238: user rows must inherit the language - * selected for the installation, never the historical it_IT schema default or - * a client-controlled form value. + * Regression guard for discussion #238: new users inherit the installation + * language, while each user can subsequently keep a validated preference. * * Run: php tests/installation-locale-users-238.unit.php */ @@ -14,6 +13,11 @@ $users = (string) file_get_contents($root . '/app/Controllers/UsersController.php'); $mobileRegistration = (string) file_get_contents($root . '/storage/plugins/mobile-api/src/Controllers/AuthController.php'); $editView = (string) file_get_contents($root . '/app/Views/utenti/modifica_utente.php'); +$createView = (string) file_get_contents($root . '/app/Views/utenti/crea_utente.php'); +$auth = (string) file_get_contents($root . '/app/Controllers/AuthController.php'); +$rememberMe = (string) file_get_contents($root . '/app/Middleware/RememberMeMiddleware.php'); +$languageController = (string) file_get_contents($root . '/app/Controllers/LanguageController.php'); +$profileController = (string) file_get_contents($root . '/app/Controllers/ProfileController.php'); $passed = 0; $failed = 0; @@ -38,12 +42,22 @@ 'public registration does not hard-code Italian as the normal path' ); $check( - substr_count($users, '$locale = $this->installationLocale();') === 2, - 'admin create and update both use the installation locale' + str_contains($users, "\$locale = \$this->localeFromInput(\$data['locale'] ?? null, \$this->installationLocale());"), + 'admin create accepts an active per-user locale with installation fallback' ); $check( - !str_contains($users, "\$data['locale']"), - 'admin endpoints ignore client-controlled locale values' + str_contains($users, "\$this->localeFromInput(\$data['locale'] ?? null, (string) (\$original['locale'] ?? ''))"), + 'admin update preserves the current user locale for omitted or invalid input' +); +$check( + str_contains($users, 'isset($availableLocales[$requested])') + && str_contains($users, 'isset($availableLocales[$current])'), + 'admin locale input and fallback are restricted to active languages' +); +$check( + str_contains($users, "\$_SESSION['locale'] = \$locale;") + && str_contains($users, "\$_SESSION['user']['locale'] = \$locale;"), + 'admin self-edit applies the selected locale immediately' ); $check( str_contains($mobileRegistration, 'I18n::getInstallationLocale()') @@ -51,15 +65,34 @@ 'Android/API registration explicitly persists the installation locale' ); $check( - str_contains($editView, 'id="installation_locale"') - && str_contains($editView, 'readonly') - && !str_contains($editView, 'name="locale"'), - 'admin edit exposes installation language as read-only information' + str_contains($editView, '