diff --git a/locale/de_DE.json b/locale/de_DE.json index 5e5bd7d5f..923e6dbd0 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -5658,6 +5658,9 @@ "proposto da": "vorgeschlagen von", "scade il": "endet am", "scaduta il": "endete am", + "Votazione terminata": "Abstimmung beendet", + "Questo libro è già collegato a un'altra proposta di questo club: non è stato acquisito di nuovo.": "Dieses Buch ist bereits mit einem anderen Vorschlag dieses Clubs verknüpft: Es wurde nicht erneut übernommen.", + "La scadenza della votazione è passata: la votazione è stata chiusa e il tuo voto non è stato registrato.": "Die Frist der Abstimmung ist abgelaufen: Die Abstimmung wurde geschlossen und deine Stimme wurde nicht gezählt.", "sì": "ja", "votanti": "Teilnehmende", "voti": "Stimmen", @@ -6577,5 +6580,27 @@ "Genere e sottogeneri eliminati con successo!": "Genre und Untergenres erfolgreich gelöscht!", "Questa azione elimina il genere e tutti i suoi sottogeneri in modo permanente. I libri associati verranno scollegati da questi generi.": "Diese Aktion löscht das Genre und alle seine Untergenres dauerhaft. Zugeordnete Bücher werden von diesen Genres getrennt.", "Sei sicuro di voler eliminare questo genere e tutti i suoi sottogeneri? I libri associati verranno scollegati da questi generi.": "Sind Sie sicher, dass Sie dieses Genre und alle seine Untergenres löschen möchten? Zugeordnete Bücher werden von diesen Genres getrennt.", - "Elimina genere e sottogeneri": "Genre und Untergenres löschen" + "Elimina genere e sottogeneri": "Genre und Untergenres löschen", + "Proposte non selezionate": "Nicht ausgewählte Vorschläge", + "Proposte ancora in attesa di essere scelte. La lista include i libri mai votati e quelli che non hanno mai vinto una votazione chiusa.": "Vorschläge, die noch auf eine Auswahl warten. Dazu gehören Bücher, über die noch nie abgestimmt wurde, und Bücher, die noch nie eine abgeschlossene Abstimmung gewonnen haben.", + "votazione": "Abstimmung", + "votazioni": "Abstimmungen", + "ultima": "zuletzt", + "Non in catalogo": "Nicht im Katalog", + "Votazioni chiuse": "Geschlossene Abstimmungen", + "mai votata": "noch nicht abgestimmt", + "Seleziona tutti i prestiti estendibili": "Alle verlängerbaren Ausleihen auswählen", + "Estendi di": "Verlängern um", + "Estendi prestiti selezionati": "Ausgewählte Ausleihen verlängern", + "%d prestiti selezionati": "%d Ausleihen ausgewählt", + "Estendere i prestiti selezionati?": "Ausgewählte Ausleihen verlängern?", + "%s prestiti verranno estesi di %d giorni.": "%s Ausleihen werden um %d Tage verlängert.", + "Prestiti estesi": "Ausleihen verlängert", + "Nessun prestito esteso": "Keine Ausleihe verlängert", + "%s prestiti estesi di %d giorni.": "%s Ausleihen um %d Tage verlängert.", + "Selezione o numero di giorni non validi.": "Ungültige Auswahl oder Anzahl der Tage.", + "Estensione non riuscita, riprova.": "Verlängerung fehlgeschlagen, bitte erneut versuchen.", + "Estensione annullata: almeno un prestito entrerebbe in conflitto con un altro prestito o una prenotazione.": "Verlängerung abgebrochen: Mindestens eine Ausleihe würde mit einer anderen Ausleihe oder Reservierung in Konflikt geraten.", + "Estendi": "Verlängern", + "Lingue non disponibili.": "Sprachen sind nicht verfügbar." } diff --git a/locale/en_US.json b/locale/en_US.json index 0bd39013f..ac9813d6f 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -5658,6 +5658,9 @@ "proposto da": "proposed by", "scade il": "closes on", "scaduta il": "closed on", + "Votazione terminata": "Voting ended", + "Questo libro è già collegato a un'altra proposta di questo club: non è stato acquisito di nuovo.": "This book is already linked to another proposal in this club: it was not acquired again.", + "La scadenza della votazione è passata: la votazione è stata chiusa e il tuo voto non è stato registrato.": "The poll's deadline has passed: the poll has been closed and your vote was not recorded.", "sì": "yes", "votanti": "voters", "voti": "votes", @@ -6577,5 +6580,27 @@ "Genere e sottogeneri eliminati con successo!": "Genre and subgenres deleted successfully!", "Questa azione elimina il genere e tutti i suoi sottogeneri in modo permanente. I libri associati verranno scollegati da questi generi.": "This action permanently deletes the genre and all of its subgenres. Associated books will be unlinked from these genres.", "Sei sicuro di voler eliminare questo genere e tutti i suoi sottogeneri? I libri associati verranno scollegati da questi generi.": "Are you sure you want to delete this genre and all of its subgenres? Associated books will be unlinked from these genres.", - "Elimina genere e sottogeneri": "Delete genre and subgenres" + "Elimina genere e sottogeneri": "Delete genre and subgenres", + "Proposte non selezionate": "Proposals not selected", + "Proposte ancora in attesa di essere scelte. La lista include i libri mai votati e quelli che non hanno mai vinto una votazione chiusa.": "Proposals still awaiting selection. The list includes books not yet voted on and books that have never won a closed poll.", + "votazione": "poll", + "votazioni": "polls", + "ultima": "last", + "Non in catalogo": "Not in catalog", + "Votazioni chiuse": "Closed polls", + "mai votata": "not yet voted on", + "Seleziona tutti i prestiti estendibili": "Select all extendable loans", + "Estendi di": "Extend by", + "Estendi prestiti selezionati": "Extend selected loans", + "%d prestiti selezionati": "%d loans selected", + "Estendere i prestiti selezionati?": "Extend the selected loans?", + "%s prestiti verranno estesi di %d giorni.": "%s loans will be extended by %d days.", + "Prestiti estesi": "Loans extended", + "Nessun prestito esteso": "No loan extended", + "%s prestiti estesi di %d giorni.": "%s loans extended by %d days.", + "Selezione o numero di giorni non validi.": "Invalid selection or number of days.", + "Estensione non riuscita, riprova.": "Extension failed, please try again.", + "Estensione annullata: almeno un prestito entrerebbe in conflitto con un altro prestito o una prenotazione.": "Extension cancelled: at least one loan would conflict with another loan or reservation.", + "Estendi": "Extend", + "Lingue non disponibili.": "Languages are unavailable." } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 2ea7a0674..117184a4d 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -5658,6 +5658,9 @@ "proposto da": "proposé par", "scade il": "expire le", "scaduta il": "expiré le", + "Votazione terminata": "Vote terminé", + "Questo libro è già collegato a un'altra proposta di questo club: non è stato acquisito di nuovo.": "Ce livre est déjà lié à une autre proposition de ce club : il n'a pas été acquis à nouveau.", + "La scadenza della votazione è passata: la votazione è stata chiusa e il tuo voto non è stato registrato.": "La date limite du vote est dépassée : le vote a été clôturé et votre vote n'a pas été enregistré.", "sì": "oui", "votanti": "votants", "voti": "votes", @@ -6577,5 +6580,27 @@ "Genere e sottogeneri eliminati con successo!": "Genre et sous-genres supprimés avec succès !", "Questa azione elimina il genere e tutti i suoi sottogeneri in modo permanente. I libri associati verranno scollegati da questi generi.": "Cette action supprime définitivement le genre et tous ses sous-genres. Les livres associés seront dissociés de ces genres.", "Sei sicuro di voler eliminare questo genere e tutti i suoi sottogeneri? I libri associati verranno scollegati da questi generi.": "Voulez-vous vraiment supprimer ce genre et tous ses sous-genres ? Les livres associés seront dissociés de ces genres.", - "Elimina genere e sottogeneri": "Supprimer le genre et les sous-genres" + "Elimina genere e sottogeneri": "Supprimer le genre et les sous-genres", + "Proposte non selezionate": "Propositions non retenues", + "Proposte ancora in attesa di essere scelte. La lista include i libri mai votati e quelli che non hanno mai vinto una votazione chiusa.": "Propositions encore en attente de sélection. La liste comprend les livres jamais soumis au vote et ceux qui n'ont jamais remporté un vote clos.", + "votazione": "vote", + "votazioni": "votes", + "ultima": "dernière", + "Non in catalogo": "Pas au catalogue", + "Votazioni chiuse": "Votes clos", + "mai votata": "pas encore soumise au vote", + "Seleziona tutti i prestiti estendibili": "Sélectionner tous les prêts prolongeables", + "Estendi di": "Prolonger de", + "Estendi prestiti selezionati": "Prolonger les prêts sélectionnés", + "%d prestiti selezionati": "%d prêts sélectionnés", + "Estendere i prestiti selezionati?": "Prolonger les prêts sélectionnés ?", + "%s prestiti verranno estesi di %d giorni.": "%s prêts seront prolongés de %d jours.", + "Prestiti estesi": "Prêts prolongés", + "Nessun prestito esteso": "Aucun prêt prolongé", + "%s prestiti estesi di %d giorni.": "%s prêts prolongés de %d jours.", + "Selezione o numero di giorni non validi.": "Sélection ou nombre de jours invalide.", + "Estensione non riuscita, riprova.": "La prolongation a échoué, veuillez réessayer.", + "Estensione annullata: almeno un prestito entrerebbe in conflitto con un altro prestito o una prenotazione.": "Prolongation annulée : au moins un prêt entrerait en conflit avec un autre prêt ou une réservation.", + "Estendi": "Prolonger", + "Lingue non disponibili.": "Les langues ne sont pas disponibles." } diff --git a/locale/it_IT.json b/locale/it_IT.json index 00a96034b..7a97a440d 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -5658,6 +5658,9 @@ "proposto da": "proposto da", "scade il": "scade il", "scaduta il": "scaduta il", + "Votazione terminata": "Votazione terminata", + "Questo libro è già collegato a un'altra proposta di questo club: non è stato acquisito di nuovo.": "Questo libro è già collegato a un'altra proposta di questo club: non è stato acquisito di nuovo.", + "La scadenza della votazione è passata: la votazione è stata chiusa e il tuo voto non è stato registrato.": "La scadenza della votazione è passata: la votazione è stata chiusa e il tuo voto non è stato registrato.", "sì": "sì", "votanti": "votanti", "voti": "voti", @@ -6577,5 +6580,27 @@ "Genere e sottogeneri eliminati con successo!": "Genere e sottogeneri eliminati con successo!", "Questa azione elimina il genere e tutti i suoi sottogeneri in modo permanente. I libri associati verranno scollegati da questi generi.": "Questa azione elimina il genere e tutti i suoi sottogeneri in modo permanente. I libri associati verranno scollegati da questi generi.", "Sei sicuro di voler eliminare questo genere e tutti i suoi sottogeneri? I libri associati verranno scollegati da questi generi.": "Sei sicuro di voler eliminare questo genere e tutti i suoi sottogeneri? I libri associati verranno scollegati da questi generi.", - "Elimina genere e sottogeneri": "Elimina genere e sottogeneri" + "Elimina genere e sottogeneri": "Elimina genere e sottogeneri", + "Proposte non selezionate": "Proposte non selezionate", + "Proposte ancora in attesa di essere scelte. La lista include i libri mai votati e quelli che non hanno mai vinto una votazione chiusa.": "Proposte ancora in attesa di essere scelte. La lista include i libri mai votati e quelli che non hanno mai vinto una votazione chiusa.", + "votazione": "votazione", + "votazioni": "votazioni", + "ultima": "ultima", + "Non in catalogo": "Non in catalogo", + "Votazioni chiuse": "Votazioni chiuse", + "mai votata": "mai votata", + "Seleziona tutti i prestiti estendibili": "Seleziona tutti i prestiti estendibili", + "Estendi di": "Estendi di", + "Estendi prestiti selezionati": "Estendi prestiti selezionati", + "%d prestiti selezionati": "%d prestiti selezionati", + "Estendere i prestiti selezionati?": "Estendere i prestiti selezionati?", + "%s prestiti verranno estesi di %d giorni.": "%s prestiti verranno estesi di %d giorni.", + "Prestiti estesi": "Prestiti estesi", + "Nessun prestito esteso": "Nessun prestito esteso", + "%s prestiti estesi di %d giorni.": "%s prestiti estesi di %d giorni.", + "Selezione o numero di giorni non validi.": "Selezione o numero di giorni non validi.", + "Estensione non riuscita, riprova.": "Estensione non riuscita, riprova.", + "Estensione annullata: almeno un prestito entrerebbe in conflitto con un altro prestito o una prenotazione.": "Estensione annullata: almeno un prestito entrerebbe in conflitto con un altro prestito o una prenotazione.", + "Estendi": "Estendi", + "Lingue non disponibili.": "Lingue non disponibili." } diff --git a/storage/plugins/mobile-api/MobileApiPlugin.php b/storage/plugins/mobile-api/MobileApiPlugin.php index c987d1550..2e4ce3e1b 100644 --- a/storage/plugins/mobile-api/MobileApiPlugin.php +++ b/storage/plugins/mobile-api/MobileApiPlugin.php @@ -539,6 +539,14 @@ public function registerRoutes(\Slim\App $app): void return (new CatalogController($db))->genres($request, $response); })->add($quotaMw())->add($authMw()); + // #282: real catalogue language values for the app's language filter. + $group->get('/catalog/languages', function ( + ServerRequestInterface $request, + ResponseInterface $response + ) use ($db): ResponseInterface { + return (new CatalogController($db))->languages($request, $response); + })->add($quotaMw())->add($authMw()); + // ── User actions (loans / reservations / wishlist / profile / msg) ── // Every handler is bearer-authenticated and strictly scoped to the // token-resolved user; loan/reservation overlap + availability reuse diff --git a/storage/plugins/mobile-api/plugin.json b/storage/plugins/mobile-api/plugin.json index 9846dc015..2d65c05ae 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.4.0", + "version": "1.4.1", "author": "Fabiodalez", "author_url": "", "plugin_url": "", @@ -22,5 +22,6 @@ ], "optional": true, "status": "stable" - } + }, + "changelog": "v1.4.1: endpoint GET /api/v1/catalog/languages che restituisce i valori di lingua reali del catalogo (con conteggi) per popolare il filtro lingua dell'app dai dati veri invece che da una lista fissa (#282); il filtro lingua della ricerca ora e' case/space-insensitive. " } diff --git a/storage/plugins/mobile-api/src/Controllers/CatalogController.php b/storage/plugins/mobile-api/src/Controllers/CatalogController.php index da0d394f6..ed1cd02d4 100644 --- a/storage/plugins/mobile-api/src/Controllers/CatalogController.php +++ b/storage/plugins/mobile-api/src/Controllers/CatalogController.php @@ -17,6 +17,7 @@ * GET /api/v1/catalog/search — filtered, cursor-paginated search. * GET /api/v1/catalog/books/{id} — full book detail + personal history. * GET /api/v1/catalog/genres — genre cascade tree for the filter UI. + * GET /api/v1/catalog/languages — real catalogue language values + counts. * * Design notes: * - Soft-delete is non-negotiable: EVERY query on `libri` carries @@ -365,6 +366,69 @@ public function genres( } } + // ─── GET /catalog/languages ────────────────────────────────────────────── + + /** + * Distinct language values actually present in the (non-deleted) catalogue, + * each with its book count. `libri.lingua` is unnormalized free text, so a + * hardcoded language list in the app cannot match it (e.g. the app sends + * "German" while the row says "Deutsch"): filtering by language then returns + * nothing, which is exactly issue #282. The app should populate its language + * filter from THIS endpoint — the real collection values — the same way it + * already sources genres / authors / publishers. Read-only, ETag-cached. + */ + public function languages( + ServerRequestInterface $request, + ResponseInterface $response + ): ResponseInterface { + try { + $rows = []; + // Static query, no user input; soft-delete honoured per CLAUDE.md rule 2. + $res = $this->db->query( + "SELECT MIN(TRIM(lingua)) AS language, COUNT(*) AS count + FROM libri + WHERE deleted_at IS NULL AND lingua IS NOT NULL AND TRIM(lingua) <> '' + GROUP BY LOWER(TRIM(lingua)) + ORDER BY count DESC, language ASC" + ); + if ($res !== false) { + while ($r = $res->fetch_assoc()) { + $rows[] = [ + 'language' => (string) $r['language'], + 'count' => (int) $r['count'], + ]; + } + } + + $etag = $this->computeLanguagesEtag($rows); + if ($this->notModified($request, $etag)) { + return $this->notModifiedResponse($response, $etag); + } + + $response = ResponseEnvelope::success($response, $rows, ['count' => count($rows)], 200); + + return $response + ->withHeader('ETag', $etag) + ->withHeader('Cache-Control', 'private, max-age=0, must-revalidate'); + } catch (\Throwable $e) { + SecureLogger::error('[MobileApi] catalog languages failed: ' . $e->getMessage()); + return ResponseEnvelope::error($response, 'internal_error', __('Lingue non disponibili.'), 500); + } + } + + /** + * @param list $rows + */ + private function computeLanguagesEtag(array $rows): string + { + $seed = ''; + foreach ($rows as $r) { + $seed .= $r['language'] . ':' . $r['count'] . '|'; + } + + return '"' . sha1('languages:' . $seed) . '"'; + } + // ─── Filters ───────────────────────────────────────────────────────────── /** @@ -460,7 +524,10 @@ private function buildFilters(array $params): array $language = isset($params['language']) ? trim((string) $params['language']) : ''; if ($language !== '') { - $conditions[] = 'l.lingua = ?'; + // libri.lingua is unnormalized free text (#282): tolerate case / + // surrounding whitespace so a value taken from /catalog/languages + // matches regardless of how the client echoes it back. + $conditions[] = 'LOWER(TRIM(l.lingua)) = LOWER(TRIM(?))'; $bind[] = $language; $types .= 's'; } diff --git a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php index 644e2452f..10bac5811 100644 --- a/storage/plugins/mobile-api/src/Controllers/OpenApiController.php +++ b/storage/plugins/mobile-api/src/Controllers/OpenApiController.php @@ -554,6 +554,30 @@ private function paths(): array ], ], + '/catalog/languages' => [ + 'get' => [ + 'tags' => ['catalog'], + 'summary' => 'Catalogue languages', + 'description' => 'Distinct language values present in the catalogue, each with a book count, for building the app language filter from real data rather than a hardcoded list (#282).', + 'operationId' => 'getCatalogLanguages', + 'security' => [['bearerAuth' => []]], + 'responses' => [ + '200' => ['description' => 'Languages.', 'content' => ['application/json' => ['schema' => [ + 'allOf' => [['$ref' => '#/components/schemas/Envelope']], + 'properties' => ['data' => ['type' => 'array', 'items' => [ + 'type' => 'object', + 'properties' => [ + 'language' => ['type' => 'string'], + 'count' => ['type' => 'integer'], + ], + ]]], + ]]]], + '401' => ['$ref' => '#/components/responses/Unauthorized'], + '500' => ['$ref' => '#/components/responses/InternalError'], + ], + ], + ], + '/me/loans' => [ 'get' => [ 'tags' => ['loans'], diff --git a/tests/code-quality.spec.js b/tests/code-quality.spec.js index 132ab0ac4..93ab51d20 100644 --- a/tests/code-quality.spec.js +++ b/tests/code-quality.spec.js @@ -311,15 +311,77 @@ test.describe.serial('Code Quality — 15 static analysis tests', () => { test('10. API endpoints documented in README.md exist in PHP code', () => { const readme = fs.readFileSync(path.join(ROOT, 'README.md'), 'utf-8'); - const phpSources = [ + const phpFiles = [ ...glob('app', '.php', ['vendor']), ...glob('storage/plugins', '.php'), - ].map(f => fs.readFileSync(f, 'utf-8')).join('\n'); + ].map(f => fs.readFileSync(f, 'utf-8')); + const phpSources = phpFiles.join('\n'); const DOC_RE = /`GET (\/(?:api|resync|oai|openurl|archives)[^\s`]+)/g; const violations = []; const normalizedPhpSources = phpSources.replace(/\{([^}:]+):[^}]+\}/g, '{$1}'); const normalizedRouteSources = normalizedPhpSources.replace(/\{[^}:]+(?::[^}]+)?\}/g, '{}'); + + // Return only the lexical body of each /api/v1 group callback. Looking + // for a group and a child route anywhere in the same file is too weak: + // an unrelated top-level GET later in that file would be a false pass. + const apiV1GroupBodies = source => { + const bodies = []; + const groupRe = /->group\(\s*(['"])\/api\/v1\1\s*,\s*function\b[^{]*\{/g; + let groupMatch; + while ((groupMatch = groupRe.exec(source)) !== null) { + const open = groupRe.lastIndex - 1; + let depth = 0; + let quote = null; + let escaped = false; + let lineComment = false; + let blockComment = false; + for (let i = open; i < source.length; i++) { + const ch = source[i]; + const next = source[i + 1] ?? ''; + if (lineComment) { + if (ch === '\n') lineComment = false; + continue; + } + if (blockComment) { + if (ch === '*' && next === '/') { blockComment = false; i++; } + continue; + } + if (quote !== null) { + if (escaped) { escaped = false; continue; } + if (ch === '\\') { escaped = true; continue; } + if (ch === quote) quote = null; + continue; + } + if (ch === '/' && next === '/') { lineComment = true; i++; continue; } + if (ch === '#') { lineComment = true; continue; } + if (ch === '/' && next === '*') { blockComment = true; i++; continue; } + if (ch === "'" || ch === '"' || ch === '`') { quote = ch; continue; } + if (ch === '{') depth++; + if (ch === '}' && --depth === 0) { + bodies.push(source.slice(open + 1, i)); + groupRe.lastIndex = i + 1; + break; + } + } + } + return bodies; + }; + const groupedGetExists = (source, child) => { + const escapedChild = child.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const getRe = new RegExp(`->get\\(\\s*(['"])${escapedChild}\\1`); + return apiV1GroupBodies(source).some(body => { + const normalized = body + .replace(/\{([^}:]+):[^}]+\}/g, '{$1}') + .replace(/\{[^}:]+(?::[^}]+)?\}/g, '{}'); + return getRe.test(normalized); + }); + }; + const falsePositiveFixture = "$app->group('/api/v1', function ($g) {}); $app->get('/auth/registration-fields', $handler);"; + expect(groupedGetExists(falsePositiveFixture, '/auth/registration-fields'), + 'a top-level GET outside the /api/v1 callback must not satisfy the route check' + ).toBe(false); + let m; while ((m = DOC_RE.exec(readme)) !== null) { // Strip the query string + hash before matching against the PHP @@ -330,7 +392,24 @@ test.describe.serial('Code Quality — 15 static analysis tests', () => { // `{}` to align with the PHP-side normalisation above. const pathOnly = m[1].split('?')[0].split('#')[0]; const search = pathOnly.replace(/\/$/, '').replace(/\{[^}:]+(?::[^}]+)?\}/g, '{}'); - if (!normalizedRouteSources.includes(search)) violations.push(m[1]); + let found = normalizedRouteSources.includes(search); + + // Slim plugins commonly register `/api/v1` once with group(), then + // declare child routes as `/auth/...`. Require the GET child literal + // inside that exact group's callback so unrelated routes elsewhere + // in the same PHP file cannot satisfy a documented API path. + if (search.startsWith('/api/v1/')) { + const child = search.slice('/api/v1'.length); + const escapedSearch = search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const directGetRe = new RegExp(`->get\\(\\s*(['"])${escapedSearch}\\1`); + found = phpFiles.some(source => { + const normalized = source + .replace(/\{([^}:]+):[^}]+\}/g, '{$1}') + .replace(/\{[^}:]+(?::[^}]+)?\}/g, '{}'); + return directGetRe.test(normalized) || groupedGetExists(source, child); + }); + } + if (!found) violations.push(m[1]); } expect(violations, `Endpoints documented in README but no matching route found in PHP:\n${violations.map(v => ' GET ' + v).join('\n')}` diff --git a/tests/mobile-api-idempotency.spec.js b/tests/mobile-api-idempotency.spec.js index c76afdc62..82bc7e49a 100644 --- a/tests/mobile-api-idempotency.spec.js +++ b/tests/mobile-api-idempotency.spec.js @@ -157,6 +157,7 @@ const ENDPOINTS = [ { name: 'GET /catalog/books/{bookId}', method: 'GET', path: '/catalog/books/{bookId}', auth: true, kind: 'etag' }, { name: 'GET /catalog/books/{bookId}/availability', method: 'GET', path: '/catalog/books/{bookId}/availability', auth: true, kind: 'safeGet' }, { name: 'GET /catalog/genres', method: 'GET', path: '/catalog/genres', auth: true, kind: 'etag' }, + { name: 'GET /catalog/languages', method: 'GET', path: '/catalog/languages', auth: true, kind: 'etag' }, { name: 'GET /me/loans', method: 'GET', path: '/me/loans', auth: true, kind: 'safeGet' }, { name: 'GET /me/reservations', method: 'GET', path: '/me/reservations', auth: true, kind: 'safeGet' }, { name: 'POST /reservations', method: 'POST', path: '/reservations', auth: true, kind: 'conflict2', diff --git a/tests/mobile-language-filter-282.unit.php b/tests/mobile-language-filter-282.unit.php new file mode 100644 index 000000000..af6e94055 --- /dev/null +++ b/tests/mobile-language-filter-282.unit.php @@ -0,0 +1,153 @@ +set_charset('utf8mb4'); +} catch (Throwable $e) { + fwrite(STDERR, "FAIL: database unreachable — mandatory for this test: {$e->getMessage()}\n"); + exit(1); +} + +$run = substr(hash('sha256', uniqid((string) getmypid(), true)), 0, 10); +$titlePrefix = 'ZZ_LANG282_' . $run; +$language = 'ZzLang' . $run; +$languageVariant = strtolower($language); + +$cleanup = static function () use ($db, $titlePrefix): void { + $like = $titlePrefix . '%'; + $stmt = $db->prepare('DELETE FROM libri WHERE titolo LIKE ?'); + $stmt->bind_param('s', $like); + $stmt->execute(); + $stmt->close(); +}; +$cleanup(); +set_exception_handler(static function (Throwable $e) use ($cleanup, $db): void { + try { + $cleanup(); + } catch (Throwable) { + } + fwrite(STDERR, "FAIL: {$e->getMessage()}\n"); + $db->close(); + exit(1); +}); + +$pass = 0; +$check = static function (bool $ok, string $label) use (&$pass): void { + if (!$ok) { + throw new RuntimeException($label); + } + $pass++; + echo " OK {$label}\n"; +}; + +$insertBook = static function (string $suffix, ?string $bookLanguage, bool $deleted = false) use ($db, $titlePrefix): int { + $title = $titlePrefix . '_' . $suffix; + $deletedAt = $deleted ? date('Y-m-d H:i:s') : null; + $stmt = $db->prepare( + "INSERT INTO libri (titolo, lingua, stato, copie_totali, copie_disponibili, deleted_at) + VALUES (?, ?, 'non_disponibile', 0, 0, ?)" + ); + $stmt->bind_param('sss', $title, $bookLanguage, $deletedAt); + $stmt->execute(); + $id = (int) $db->insert_id; + $stmt->close(); + return $id; +}; + +$firstId = $insertBook('one', ' ' . $language . ' '); +$secondId = $insertBook('two', $languageVariant); +$deletedId = $insertBook('deleted', strtoupper($language), true); +$insertBook('blank', ' '); +$insertBook('null', null); + +$controller = new CatalogController($db); +$requestFactory = new ServerRequestFactory(); +$responseFactory = new ResponseFactory(); + +echo "A. Catalogue language discovery\n"; +$request = $requestFactory->createServerRequest('GET', '/api/v1/catalog/languages'); +$response = $controller->languages($request, $responseFactory->createResponse()); +$check($response->getStatusCode() === 200, 'languages endpoint returns 200'); +$payload = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); +$rows = is_array($payload['data'] ?? null) ? $payload['data'] : []; +$match = null; +foreach ($rows as $row) { + if (strtolower(trim((string) ($row['language'] ?? ''))) === strtolower($language)) { + $match = $row; + break; + } +} +$check($match !== null && (int) ($match['count'] ?? 0) === 2, 'case/space variants collapse to one value with the correct active-book count'); +$check((int) ($payload['meta']['count'] ?? -1) === count($rows), 'response metadata reports the number of distinct values'); +$etag = $response->getHeaderLine('ETag'); +$check($etag !== '', 'languages response includes an ETag'); + +$conditional = $requestFactory + ->createServerRequest('GET', '/api/v1/catalog/languages') + ->withHeader('If-None-Match', $etag); +$notModified = $controller->languages($conditional, $responseFactory->createResponse()); +$check($notModified->getStatusCode() === 304, 'matching If-None-Match returns 304'); + +echo "B. Tolerant search filter\n"; +$searchRequest = $requestFactory + ->createServerRequest('GET', '/api/v1/catalog/search') + ->withQueryParams(['language' => ' ' . strtoupper($language) . ' ', 'limit' => 50]); +$searchResponse = $controller->search($searchRequest, $responseFactory->createResponse()); +$check($searchResponse->getStatusCode() === 200, 'case-insensitive language search returns 200'); +$searchPayload = json_decode((string) $searchResponse->getBody(), true, 512, JSON_THROW_ON_ERROR); +$ids = array_map('intval', array_column(is_array($searchPayload['data'] ?? null) ? $searchPayload['data'] : [], 'id')); +$check(in_array($firstId, $ids, true) && in_array($secondId, $ids, true), 'filter matches stored case and surrounding-space variants'); +$check(!in_array($deletedId, $ids, true), 'soft-deleted books are excluded from the filter'); + +echo "C. Localized error contract\n"; +foreach (['it_IT', 'en_US', 'de_DE', 'fr_FR'] as $locale) { + $catalogue = json_decode( + (string) file_get_contents($root . '/locale/' . $locale . '.json'), + true, + 512, + JSON_THROW_ON_ERROR + ); + $check(isset($catalogue['Lingue non disponibili.']) && trim((string) $catalogue['Lingue non disponibili.']) !== '', "{$locale} contains the endpoint error translation"); +} + +$cleanup(); +$db->close(); +echo "\n{$pass} PASS, 0 FAIL\n"; +exit(0);