From c215bc7301997f659397213773d57e1a34e01a48 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Fri, 14 Aug 2026 10:52:54 +0200 Subject: [PATCH 1/3] fix(sentry): don't report self-hosted backend outages as app errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sentry's OkHttp auto-instrumentation captures every backend HTTP error as an error-level SentryHttpClientException. This app points at the user's OWN self-hosted server, so a backend outage is the server's state, not a bug here — and it generated noise like two "HTTP Client Error 503" events (one on the /health probe, one on a cover image) when a QNAP-hosted instance was briefly unavailable. Add a beforeSend that drops the two clearly-not-our-fault cases: a failed /health probe (whose whole job is to detect a down server) and transient upstream 5xx (502/503/504). A real 500 or a 4xx — which can point at an app-side request bug — still comes through. --- .../com/pinakes/app/PinakesApplication.kt | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/app/src/main/java/com/pinakes/app/PinakesApplication.kt b/app/src/main/java/com/pinakes/app/PinakesApplication.kt index 51ce419..8dba196 100644 --- a/app/src/main/java/com/pinakes/app/PinakesApplication.kt +++ b/app/src/main/java/com/pinakes/app/PinakesApplication.kt @@ -13,6 +13,8 @@ import com.pinakes.app.data.network.NetworkEntryPoint import dagger.hilt.android.EntryPointAccessors import okhttp3.OkHttpClient import dagger.hilt.android.HiltAndroidApp +import io.sentry.SentryEvent +import io.sentry.SentryOptions import io.sentry.android.core.SentryAndroid import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -43,6 +45,17 @@ class PinakesApplication : Application(), ImageLoaderFactory { options.isDebug = false options.isSendDefaultPii = false // no IP / user data attached by default options.tracesSampleRate = 0.0 // crash reporting only — no performance tracing + + // Sentry's OkHttp auto-instrumentation reports every backend HTTP error as an + // error-level SentryHttpClientException. This app talks to the user's OWN + // self-hosted server, so a backend outage is the server's state, not an app + // bug. Drop the two clearly-not-our-fault cases so they don't create noise: + // the health probe (whose whole job is to detect a down server) and transient + // upstream 5xx (502 bad gateway / 503 unavailable / 504 timeout). A real 500 + // or a 4xx (which can point at an app-side request bug) still comes through. + options.beforeSend = SentryOptions.BeforeSendCallback { event, _ -> + if (isExpectedBackendHttpFailure(event)) null else event + } } // Refresh the cached catalog every time the app comes to the foreground, so the @@ -63,6 +76,30 @@ class PinakesApplication : Application(), ImageLoaderFactory { CatalogSyncWorker.schedule(this) } + /** + * True when a Sentry event is a backend HTTP failure that reflects the user's own + * server being unavailable rather than a bug in this app: a failed `/health` probe, + * or a transient upstream 5xx (502/503/504). Such events are pure noise for an app + * that points at self-hosted instances, so [onCreate]'s `beforeSend` drops them. + */ + private fun isExpectedBackendHttpFailure(event: SentryEvent): Boolean { + val detail = buildString { + event.throwable?.let { append(it.toString()) } + event.exceptions?.forEach { append(' ').append(it.type).append(' ').append(it.value) } + } + val isHttpClientError = detail.contains("SentryHttpClientException") || + detail.contains("HTTP Client Error with status code:") + if (!isHttpClientError) return false + + // The health probe's job is to detect a down server — a failure there is expected. + val path = event.request?.url?.substringBefore('?')?.trimEnd('/').orEmpty() + if (path.endsWith("/health")) return true + + // Transient upstream unavailability, not an app bug. + val status = Regex("""status code:\s*(\d{3})""").find(detail)?.groupValues?.get(1)?.toIntOrNull() + return status == 502 || status == 503 || status == 504 + } + /** * App-wide Coil loader with a persistent 256 MB disk cache that ignores server cache * headers, so book covers are downloaded once and reused across sessions instead of From b45785c5b02a0d35449a84aeba30170d3fa74ebf Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Fri, 14 Aug 2026 11:12:01 +0200 Subject: [PATCH 2/3] refactor(sentry): classify HTTP failures from structured data, add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback: - Classify OkHttp failures from the Sentry integration's structured signals — the Request/Response on the Hint (TypeCheckHint.OKHTTP_REQUEST/OKHTTP_RESPONSE) and contexts.response.statusCode — instead of matching exception text. A non-HTTP crash whose message happens to contain "HTTP Client Error with status code:" is no longer misclassified and dropped. - Extract the decision into a pure `isExpectedBackendFailure(...)` and cover it with a unit-test matrix: 502/503/504 dropped; 500 and 4xx kept; /health dropped for any status incl. query string and trailing slash; a non-HTTP failure kept even with a 503; unknown status/URL handled. testDebugUnitTest green. --- .../com/pinakes/app/PinakesApplication.kt | 60 +++++++++++++------ .../pinakes/app/SentryBackendFilterTest.kt | 52 ++++++++++++++++ 2 files changed, 94 insertions(+), 18 deletions(-) create mode 100644 app/src/test/java/com/pinakes/app/SentryBackendFilterTest.kt diff --git a/app/src/main/java/com/pinakes/app/PinakesApplication.kt b/app/src/main/java/com/pinakes/app/PinakesApplication.kt index 8dba196..7dbcc24 100644 --- a/app/src/main/java/com/pinakes/app/PinakesApplication.kt +++ b/app/src/main/java/com/pinakes/app/PinakesApplication.kt @@ -13,9 +13,13 @@ import com.pinakes.app.data.network.NetworkEntryPoint import dagger.hilt.android.EntryPointAccessors import okhttp3.OkHttpClient import dagger.hilt.android.HiltAndroidApp +import io.sentry.Hint import io.sentry.SentryEvent import io.sentry.SentryOptions +import io.sentry.TypeCheckHint import io.sentry.android.core.SentryAndroid +import okhttp3.Request +import okhttp3.Response import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -53,8 +57,8 @@ class PinakesApplication : Application(), ImageLoaderFactory { // the health probe (whose whole job is to detect a down server) and transient // upstream 5xx (502 bad gateway / 503 unavailable / 504 timeout). A real 500 // or a 4xx (which can point at an app-side request bug) still comes through. - options.beforeSend = SentryOptions.BeforeSendCallback { event, _ -> - if (isExpectedBackendHttpFailure(event)) null else event + options.beforeSend = SentryOptions.BeforeSendCallback { event, hint -> + if (isExpectedBackendHttpFailure(event, hint)) null else event } } @@ -81,23 +85,21 @@ class PinakesApplication : Application(), ImageLoaderFactory { * server being unavailable rather than a bug in this app: a failed `/health` probe, * or a transient upstream 5xx (502/503/504). Such events are pure noise for an app * that points at self-hosted instances, so [onCreate]'s `beforeSend` drops them. + * + * Classification is driven by Sentry's OkHttp integration data (the request/response + * carried on the [Hint], and the structured `contexts.response.statusCode`), never by + * matching exception text — so a non-HTTP crash whose message happens to mention an + * HTTP status is never discarded. */ - private fun isExpectedBackendHttpFailure(event: SentryEvent): Boolean { - val detail = buildString { - event.throwable?.let { append(it.toString()) } - event.exceptions?.forEach { append(' ').append(it.type).append(' ').append(it.value) } - } - val isHttpClientError = detail.contains("SentryHttpClientException") || - detail.contains("HTTP Client Error with status code:") - if (!isHttpClientError) return false - - // The health probe's job is to detect a down server — a failure there is expected. - val path = event.request?.url?.substringBefore('?')?.trimEnd('/').orEmpty() - if (path.endsWith("/health")) return true - - // Transient upstream unavailability, not an app bug. - val status = Regex("""status code:\s*(\d{3})""").find(detail)?.groupValues?.get(1)?.toIntOrNull() - return status == 502 || status == 503 || status == 504 + private fun isExpectedBackendHttpFailure(event: SentryEvent, hint: Hint): Boolean { + val response = hint.getAs(TypeCheckHint.OKHTTP_RESPONSE, Response::class.java) + val request = hint.getAs(TypeCheckHint.OKHTTP_REQUEST, Request::class.java) + // Only OkHttp-instrumented HTTP failures carry these signals. + val isHttpResponseFailure = + response != null || request != null || event.contexts.response?.statusCode != null + val statusCode = response?.code ?: event.contexts.response?.statusCode + val url = request?.url?.toString() ?: event.request?.url + return isExpectedBackendFailure(isHttpResponseFailure, statusCode, url) } /** @@ -128,3 +130,25 @@ class PinakesApplication : Application(), ImageLoaderFactory { .build() } } + +/** + * Pure classification for the Sentry `beforeSend` filter, extracted so it can be unit + * tested without constructing SDK/OkHttp objects. + * + * @param isHttpResponseFailure whether the event is an OkHttp-instrumented HTTP-response + * failure at all (false for any non-HTTP crash → never dropped, whatever its message). + * @param statusCode the structured HTTP status, or null when unknown. + * @param url the request URL, or null when unknown. + * @return true only for a failure that reflects the user's server state rather than an + * app bug: a `/health` probe (any status), or a transient upstream 5xx (502/503/504). + */ +internal fun isExpectedBackendFailure( + isHttpResponseFailure: Boolean, + statusCode: Int?, + url: String?, +): Boolean { + if (!isHttpResponseFailure) return false + val path = (url ?: "").substringBefore('?').trimEnd('/') + if (path.endsWith("/health")) return true + return statusCode == 502 || statusCode == 503 || statusCode == 504 +} diff --git a/app/src/test/java/com/pinakes/app/SentryBackendFilterTest.kt b/app/src/test/java/com/pinakes/app/SentryBackendFilterTest.kt new file mode 100644 index 0000000..923003b --- /dev/null +++ b/app/src/test/java/com/pinakes/app/SentryBackendFilterTest.kt @@ -0,0 +1,52 @@ +package com.pinakes.app + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Matrix for [isExpectedBackendFailure], the pure core of the Sentry `beforeSend` + * filter that drops self-hosted-backend outage noise while keeping real signal. + */ +class SentryBackendFilterTest { + + private val apiUrl = "https://lib.example.org/api/v1/books" + private val healthUrl = "https://lib.example.org/api/v1/health" + + // ── Transient upstream 5xx on a normal endpoint → dropped ─────────────── + @Test fun drops502() = assertTrue(isExpectedBackendFailure(true, 502, apiUrl)) + @Test fun drops503() = assertTrue(isExpectedBackendFailure(true, 503, apiUrl)) + @Test fun drops504() = assertTrue(isExpectedBackendFailure(true, 504, apiUrl)) + + // ── Real server / client errors on a normal endpoint → kept ───────────── + @Test fun keeps500() = assertFalse(isExpectedBackendFailure(true, 500, apiUrl)) + @Test fun keeps400() = assertFalse(isExpectedBackendFailure(true, 400, apiUrl)) + @Test fun keeps404() = assertFalse(isExpectedBackendFailure(true, 404, apiUrl)) + @Test fun keeps401() = assertFalse(isExpectedBackendFailure(true, 401, apiUrl)) + + // ── Health probe failures are expected → dropped for any status ───────── + @Test fun dropsHealth503() = assertTrue(isExpectedBackendFailure(true, 503, healthUrl)) + @Test fun dropsHealth500() = assertTrue(isExpectedBackendFailure(true, 500, healthUrl)) + @Test fun dropsHealthWithQueryString() = + assertTrue(isExpectedBackendFailure(true, 500, "$healthUrl?ts=123")) + @Test fun dropsHealthWithTrailingSlash() = + assertTrue(isExpectedBackendFailure(true, 500, "$healthUrl/")) + @Test fun dropsHealthUnknownStatus() = + assertTrue(isExpectedBackendFailure(true, null, healthUrl)) + + // ── A non-HTTP crash is NEVER dropped, even with a 5xx-looking status ──── + @Test fun keepsNonHttpEvenWith503() = + assertFalse(isExpectedBackendFailure(false, 503, apiUrl)) + @Test fun keepsNonHttpOnHealthUrl() = + assertFalse(isExpectedBackendFailure(false, 503, healthUrl)) + + // ── Unknowns on a normal endpoint → kept ──────────────────────────────── + @Test fun keepsUnknownStatusOnApi() = + assertFalse(isExpectedBackendFailure(true, null, apiUrl)) + @Test fun keepsNullUrlWith500() = + assertFalse(isExpectedBackendFailure(true, 500, null)) + + // ── A transient 5xx with an unknown URL is still transient → dropped ──── + @Test fun dropsNullUrlWith503() = + assertTrue(isExpectedBackendFailure(true, 503, null)) +} From ec7bd208b6c230782e10c6abe30b4555db64c031 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 27 Aug 2026 00:38:47 +0200 Subject: [PATCH 3/3] feat(release): align Android with Mobile API 1.4.4 Ship Android 1.4.3 with authoritative loan-versus-reservation outcomes, cancellable pending and pickup loans, honest request dates, server status fallbacks, due-attention cues, updated contracts, translations, and regression coverage. --- _contract/MOBILE_API_SPEC.md | 6 +- _contract/endpoint-manifest.spec.js | 17 ++++ _contract/openapi.json | 63 ++++++++++++- app/build.gradle.kts | 4 +- .../java/com/pinakes/app/data/model/Models.kt | 38 ++++++++ .../pinakes/app/data/network/PinakesApi.kt | 7 +- .../app/data/repository/LibraryRepository.kt | 8 +- .../pinakes/app/ui/common/StatusMapping.kt | 34 ++++++- .../app/ui/screens/detail/BookDetailScreen.kt | 23 ++++- .../ui/screens/detail/BookDetailViewModel.kt | 15 +++- .../app/ui/screens/library/LibraryScreen.kt | 69 ++++++++++---- .../ui/screens/library/LibraryViewModel.kt | 25 +++++- .../pinakes/app/LatestReleaseContractTest.kt | 90 +++++++++++++++++++ .../com/pinakes/app/StatusMappingMoreTest.kt | 18 ++++ i18n/de.json | 8 ++ i18n/en.json | 8 ++ i18n/fr.json | 8 ++ i18n/it.json | 8 ++ 18 files changed, 409 insertions(+), 40 deletions(-) create mode 100644 app/src/test/java/com/pinakes/app/LatestReleaseContractTest.kt diff --git a/_contract/MOBILE_API_SPEC.md b/_contract/MOBILE_API_SPEC.md index dc0964c..9cb0c7c 100644 --- a/_contract/MOBILE_API_SPEC.md +++ b/_contract/MOBILE_API_SPEC.md @@ -58,7 +58,7 @@ All FKs respect existing `utenti`/`libri` schema. Follow the soft-delete rule on ## Endpoint manifest (`/api/v1`) **Public (no token):** -- `GET /health` — discovery: `{ name, logo, version, api_version, features{...}, app_access_enabled, registration_enabled, private_mode }`. +- `GET /health` — discovery: `{ name, logo, version, api_version, features{...}, loan_approval_required, app_access_enabled, registration_enabled, private_mode }`. - `GET /openapi.json` — OpenAPI 3.1 document. - `GET /docs` — Swagger UI page. - `POST /auth/login` — `{ email, password, device_name, device_id, platform }` → `{ token, user{...} }`. Throttled. @@ -77,8 +77,8 @@ All FKs respect existing `utenti`/`libri` schema. Follow the soft-delete rule on - `DELETE /catalog/books/{id}/reviews` — delete the current user's review (idempotent). - `GET /me/reviews` — the user's own reviews across all titles (`book_id`, `book_title`, `book_author`, `cover_url`, `rating`, `text`, timestamps); cursor pagination. - `GET /catalog/genres` — genre cascade tree (for filter UI). -- `GET /me/loans` — own loans (active + history). `GET /me/reservations`. -- `POST /reservations` — request a loan/reservation (honor existing overlap/availability rules). `DELETE /reservations/{id}` — cancel own pending reservation. +- `GET /me/loans` — own loans (active + history), including `status_label`, `requested_at`, `due_attention`, and the server-authoritative `cancellable` hint. `GET /me/reservations`. +- `POST /reservations` — request a loan/reservation (honor existing overlap/availability rules); the 201 payload declares `type=loan|reservation`, `status`, and `auto_approved` so the client reflects the actual routing outcome. `DELETE /reservations/{id}` cancels an own pending reservation; `DELETE /loans/{id}` unambiguously cancels an own pending, scheduled, or `da_ritirare` loan. - `GET /me/wishlist`. `POST /me/wishlist` `{book_id}`. `DELETE /me/wishlist/{book_id}`. - `POST /messages` — send a contact message (same as web contact form). - `GET /me/notifications` — in-app notification feed (fallback when push off). diff --git a/_contract/endpoint-manifest.spec.js b/_contract/endpoint-manifest.spec.js index 604c0aa..2357290 100644 --- a/_contract/endpoint-manifest.spec.js +++ b/_contract/endpoint-manifest.spec.js @@ -153,6 +153,7 @@ const ENDPOINTS = [ { name: 'POST /reservations', method: 'POST', path: '/reservations', auth: true, kind: 'conflict2', body: (ctx) => ({ book_id: ctx.bookId }), firstAny: true /* 1st may 201 or 422 (availability); 2nd identical must be rejected */ }, { name: 'DELETE /reservations/{reservationId}', method: 'DELETE', path: '/reservations/{reservationId}', auth: true, kind: 'gone2' }, + { name: 'DELETE /loans/{loanId}', method: 'DELETE', path: '/loans/{loanId}', auth: true, kind: 'gone2' }, { name: 'GET /me/wishlist', method: 'GET', path: '/me/wishlist', auth: true, kind: 'safeGet' }, { name: 'POST /me/wishlist', method: 'POST', path: '/me/wishlist', auth: true, kind: 'write2xx', body: (ctx) => ({ book_id: ctx.bookId }) /* adding twice must not duplicate; both 2xx */ }, @@ -303,6 +304,21 @@ test.describe('Mobile API — two calls per endpoint (idempotency + ETag/304)', ctx.reservationId = parseInt(dbScalar(`SELECT id FROM prenotazioni WHERE utente_id=${ctx.userId} ORDER BY id DESC LIMIT 1`) || '0', 10); } catch { ctx.reservationId = 0; } + // A dedicated pending loan for the unambiguous Mobile API 1.4.4 + // DELETE /loans/{id} route. Prefer a different title from the active + // reservation above so cross-table duplicate guards cannot pre-empt it. + try { + const loanBookId = parseInt(dbScalar( + `SELECT id FROM libri WHERE deleted_at IS NULL AND id != ${ctx.bookId} ORDER BY id LIMIT 1` + ) || String(ctx.bookId), 10); + dbExec(`INSERT INTO prestiti + (utente_id, libro_id, data_prestito, data_scadenza, stato, attivo, created_at) + VALUES (${ctx.userId}, ${loanBookId}, CURDATE(), DATE_ADD(CURDATE(), INTERVAL 30 DAY), 'pendente', 0, NOW())`); + ctx.loanId = parseInt(dbScalar( + `SELECT id FROM prestiti WHERE utente_id=${ctx.userId} AND stato='pendente' ORDER BY id DESC LIMIT 1` + ) || '0', 10); + } catch { ctx.loanId = 0; } + // Pre-add the wishlist book so DELETE /me/wishlist/{bookId} has something to remove on call #1. try { dbExec(`INSERT IGNORE INTO wishlist (utente_id, libro_id) VALUES (${ctx.userId}, ${ctx.bookId})`); } catch {} }); @@ -312,6 +328,7 @@ test.describe('Mobile API — two calls per endpoint (idempotency + ETag/304)', try { dbExec(`DELETE FROM mobile_push_subscriptions WHERE user_id=${ctx.userId}`); } catch {} try { dbExec(`DELETE FROM wishlist WHERE utente_id=${ctx.userId}`); } catch {} try { dbExec(`DELETE FROM prenotazioni WHERE utente_id=${ctx.userId}`); } catch {} + try { dbExec(`DELETE FROM prestiti WHERE utente_id=${ctx.userId}`); } catch {} try { dbExec(`DELETE FROM utenti WHERE id=${ctx.userId}`); } catch {} await page?.close(); }); diff --git a/_contract/openapi.json b/_contract/openapi.json index b0f94e2..e4c7764 100644 --- a/_contract/openapi.json +++ b/_contract/openapi.json @@ -730,6 +730,15 @@ "type": "string", "description": "Raw prestiti.stato value." }, + "status_label": { + "type": "string", + "description": "Server-localized fallback label for the raw status." + }, + "requested_at": { + "type": ["string", "null"], + "format": "date", + "description": "Date the loan request was created." + }, "loaned_at": { "type": "string", "format": "date", @@ -740,6 +749,10 @@ "format": "date", "nullable": true }, + "due_attention": { + "type": "boolean", + "description": "Library-timezone cue that the active loan is due today or earlier." + }, "returned_at": { "type": "string", "format": "date", @@ -748,6 +761,10 @@ "renewals": { "type": "integer", "nullable": true + }, + "cancellable": { + "type": "boolean", + "description": "True when DELETE /loans/{id} currently accepts this owned row." } } }, @@ -1117,6 +1134,10 @@ "type": "boolean", "description": "True when the instance is in catalogue-only mode (loans, reservations and wishlist disabled)." }, + "loan_approval_required": { + "type": "boolean", + "description": "False when immediate requests are automatically approved." + }, "app_access_enabled": { "type": "boolean" }, @@ -2402,7 +2423,7 @@ "loans" ], "summary": "Request a reservation / loan", - "description": "Honors existing overlap, availability, and max-active-loans rules (same as the web form). Returns error codes for overlap, unavailable, or queue position.", + "description": "Honors existing overlap, availability, max-active-loans, and automatic-approval settings. The 201 payload declares the actual loan or FIFO-reservation outcome.", "operationId": "postReservation", "security": [ { @@ -2421,11 +2442,25 @@ }, "responses": { "201": { - "description": "Reservation created.", + "description": "Reservation or loan request created.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Envelope" + "allOf": [ + {"$ref": "#/components/schemas/Envelope"} + ], + "properties": { + "data": { + "type": "object", + "properties": { + "type": {"type": "string", "enum": ["loan", "reservation"]}, + "book_id": {"type": "integer"}, + "loan_id": {"type": "integer", "nullable": true}, + "auto_approved": {"type": "boolean", "nullable": true}, + "status": {"type": "string", "nullable": true, "enum": ["pendente", "da_ritirare"]} + } + } + } } } } @@ -2512,6 +2547,28 @@ } } }, + "/loans/{id}": { + "delete": { + "tags": ["loans"], + "summary": "Cancel own cancellable loan request", + "description": "Cancels an owned pending, scheduled, or ready-for-pickup loan through an id-space-safe route.", + "operationId": "deleteLoan", + "security": [{"bearerAuth": []}], + "parameters": [{ + "name": "id", + "in": "path", + "required": true, + "schema": {"type": "integer"} + }], + "responses": { + "200": {"description": "Loan cancelled."}, + "401": {"$ref": "#/components/responses/Unauthorized"}, + "404": {"$ref": "#/components/responses/NotFound"}, + "409": {"description": "Loan can no longer be cancelled."}, + "500": {"$ref": "#/components/responses/InternalError"} + } + } + }, "/me/wishlist": { "get": { "tags": [ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 50cd80a..3ff067b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -165,8 +165,8 @@ android { applicationId = "com.pinakes.app" minSdk = 26 targetSdk = 35 - versionCode = 12 - versionName = "1.4.2" + versionCode = 13 + versionName = "1.4.3" vectorDrawables { useSupportLibrary = true diff --git a/app/src/main/java/com/pinakes/app/data/model/Models.kt b/app/src/main/java/com/pinakes/app/data/model/Models.kt index ea8c17c..f4954c3 100644 --- a/app/src/main/java/com/pinakes/app/data/model/Models.kt +++ b/app/src/main/java/com/pinakes/app/data/model/Models.kt @@ -36,6 +36,8 @@ data class HealthPayload( // When true the instance is a read-only browsable catalog: loans, reservations and // wishlist are disabled (and the matching booleans in [features] are false). @SerialName("catalogue_mode") val catalogueMode: Boolean = false, + // Server-authoritative approval mode (Mobile API since Pinakes 0.7.46). + @SerialName("loan_approval_required") val loanApprovalRequired: Boolean = true, @SerialName("app_access_enabled") val appAccessEnabled: Boolean = false, @SerialName("registration_enabled") val registrationEnabled: Boolean = false, @SerialName("private_mode") val privateMode: Boolean = false, @@ -339,10 +341,19 @@ data class LoanItem( val title: String = "", @SerialName("cover_url") val coverUrl: String? = null, val status: String = "", // in_corso | concluso | in_scadenza | scaduto | prenotato | in_attesa + // Additive Mobile API 1.4.3 fallback for states unknown to this app version. + @SerialName("status_label") val statusLabel: String? = null, + // Date the request was created. This is the honest timeline date for + // cancelled/expired requests that never became a physical loan. + @SerialName("requested_at") val requestedAt: String? = null, @SerialName("loaned_at") val loanedAt: String? = null, @SerialName("due_at") val dueAt: String? = null, + // Computed in the library timezone; do not re-derive from device LocalDate. + @SerialName("due_attention") val dueAttention: Boolean = false, @SerialName("returned_at") val returnedAt: String? = null, val renewals: Int? = null, + // Mobile API 1.4.4 presentation hint; DELETE /loans/{id} revalidates it. + val cancellable: Boolean = false, ) @Serializable @@ -389,6 +400,33 @@ data class ReservationRequest( @SerialName("end_date") val endDate: String? = null, // legacy/compat, yyyy-MM-dd ) +/** Authoritative result of POST /reservations (loan request or FIFO reservation). */ +@Serializable +data class CirculationRequestResult( + val type: String = "", // loan | reservation + @SerialName("book_id") val bookId: Int = 0, + @SerialName("loan_id") val loanId: Int? = null, + @SerialName("auto_approved") val autoApproved: Boolean? = null, + val status: String? = null, // pendente | da_ritirare +) { + val kind: CirculationRequestKind + get() = when { + type == "reservation" -> CirculationRequestKind.Reservation + type == "loan" && (autoApproved == true || status == "da_ritirare") -> + CirculationRequestKind.LoanReadyForPickup + type == "loan" && (autoApproved == false || status in setOf("pendente", "in_attesa")) -> + CirculationRequestKind.LoanPending + else -> CirculationRequestKind.Unknown + } +} + +enum class CirculationRequestKind { + Reservation, + LoanPending, + LoanReadyForPickup, + Unknown, +} + // ---------- Wishlist ---------- @Serializable data class WishlistItem( diff --git a/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt b/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt index 0003c22..402cb28 100644 --- a/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt +++ b/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt @@ -6,6 +6,7 @@ import com.pinakes.app.data.model.BookReviews import com.pinakes.app.data.model.BookSummary import com.pinakes.app.data.model.CatalogLanguage import com.pinakes.app.data.model.ChangePasswordRequest +import com.pinakes.app.data.model.CirculationRequestResult import com.pinakes.app.data.model.DeviceItem import com.pinakes.app.data.model.Envelope import com.pinakes.app.data.model.ForgotRequest @@ -158,11 +159,15 @@ interface PinakesApi { suspend fun reservations(): Envelope> @POST("reservations") - suspend fun createReservation(@Body body: ReservationRequest): Envelope + suspend fun createReservation(@Body body: ReservationRequest): Envelope @DELETE("reservations/{id}") suspend fun cancelReservation(@Path("id") id: Int): Envelope + /** Explicit, id-space-safe cancellation for pending/scheduled/ready loans (#381). */ + @DELETE("loans/{id}") + suspend fun cancelLoan(@Path("id") id: Int): Envelope + // ---- Wishlist ---- @GET("me/wishlist") suspend fun wishlist(): Envelope> diff --git a/app/src/main/java/com/pinakes/app/data/repository/LibraryRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/LibraryRepository.kt index 3abec83..b9f84a1 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/LibraryRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/LibraryRepository.kt @@ -1,5 +1,6 @@ package com.pinakes.app.data.repository +import com.pinakes.app.data.model.CirculationRequestResult import com.pinakes.app.data.model.LoansData import com.pinakes.app.data.model.ReservationItem import com.pinakes.app.data.model.ReservationRequest @@ -30,7 +31,7 @@ class LibraryRepository(private val network: NetworkModule) { suspend fun reserve( bookId: Int, desiredDate: String? = null, - ): ApiResult { + ): ApiResult { val api = network.api() return apiCall { api.createReservation(ReservationRequest(bookId = bookId, desiredDate = desiredDate)) } } @@ -39,4 +40,9 @@ class LibraryRepository(private val network: NetworkModule) { val api = network.api() return apiCall { api.cancelReservation(reservationId) } } + + suspend fun cancelLoan(loanId: Int): ApiResult { + val api = network.api() + return apiCall { api.cancelLoan(loanId) } + } } diff --git a/app/src/main/java/com/pinakes/app/ui/common/StatusMapping.kt b/app/src/main/java/com/pinakes/app/ui/common/StatusMapping.kt index c6f9fb7..e7d48f8 100644 --- a/app/src/main/java/com/pinakes/app/ui/common/StatusMapping.kt +++ b/app/src/main/java/com/pinakes/app/ui/common/StatusMapping.kt @@ -2,6 +2,7 @@ package com.pinakes.app.ui.common import androidx.annotation.StringRes import com.pinakes.app.R +import com.pinakes.app.data.model.LoanItem import com.pinakes.app.ui.components.AvailabilityStatus /** @@ -20,7 +21,7 @@ data class StatusLabel(@param:StringRes val resId: Int?, val fallback: String? = */ object StatusMapping { - fun loan(stato: String): Pair = when (stato) { + fun loan(stato: String, serverLabel: String? = null): Pair = when (stato) { // Active, on time — available-green (this is the good state). "in_corso" -> AvailabilityStatus.Available to StatusLabel(R.string.loan_status_on_loan) // Overdue — RED, the most important alert state. @@ -43,7 +44,11 @@ object StatusMapping { "concluso" -> AvailabilityStatus.Returned to StatusLabel(R.string.loan_status_returned) "in_attesa" -> AvailabilityStatus.DueSoon to StatusLabel(R.string.loan_status_pending_approval) else -> AvailabilityStatus.LoanActive to - StatusLabel(null, stato.replace('_', ' ').replaceFirstChar { it.uppercase() }) + StatusLabel( + null, + serverLabel?.takeIf { it.isNotBlank() } + ?: stato.replace('_', ' ').replaceFirstChar { it.uppercase() }, + ) } fun reservation(stato: String): Pair = when (stato) { @@ -79,4 +84,29 @@ object StatusMapping { "pendente", "in_attesa" -> LoanGroup.Pending else -> LoanGroup.Pending } + + /** Server computes this in the library timezone; stale `in_corso` rows may need attention too. */ + fun loanNeedsAttention(loan: LoanItem): Boolean = + loan.dueAttention || loanGroup(loan.status) == LoanGroup.Overdue + + enum class LoanDateKind { Overdue, Returned, Requested, Due, Borrowed } + + data class LoanDate(val kind: LoanDateKind, val value: String? = null) + + /** + * Choose an honest timeline label for recent Mobile API payloads. In + * particular, pending/cancelled/expired rows were never borrowed, so their + * requested_at must win over the requested loan interval's due date. + */ + fun loanDate(loan: LoanItem): LoanDate? { + val overdue = loanGroup(loan.status) == LoanGroup.Overdue + if (overdue) return LoanDate(LoanDateKind.Overdue, loan.dueAt) + if (loan.status in setOf("pendente", "in_attesa", "annullato", "scaduto")) { + return loan.requestedAt?.let { LoanDate(LoanDateKind.Requested, it) } + } + if (loan.returnedAt != null) return LoanDate(LoanDateKind.Returned, loan.returnedAt) + if (loan.dueAt != null) return LoanDate(LoanDateKind.Due, loan.dueAt) + if (loan.loanedAt != null) return LoanDate(LoanDateKind.Borrowed, loan.loanedAt) + return null + } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt index 5580f7b..12c70c6 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt @@ -63,6 +63,7 @@ import coil.compose.SubcomposeAsyncImage import com.pinakes.app.R import com.pinakes.app.data.model.AvailabilityCalendar import com.pinakes.app.data.model.BookDetail +import com.pinakes.app.data.model.CirculationRequestKind import com.pinakes.app.data.model.PersonalHistory import com.pinakes.app.ui.common.AppViewModel import com.pinakes.app.ui.common.UiState @@ -107,14 +108,28 @@ fun BookDetailScreen( } } - // Success confirmation that includes the chosen date ("Loan requested for "). - val requestedConfirmation = state.requestedDate?.let { - stringResource(R.string.snackbar_request_for_date, formatDisplayDate(it)) + // Use the authoritative server outcome. With #384 an apparent immediate + // loan may correctly become a FIFO reservation; never tell the user it was + // a loan merely because that was the button label before submission. + val requestedConfirmation = state.requestConfirmation?.let { confirmation -> + when (confirmation.kind) { + CirculationRequestKind.Reservation -> stringResource( + R.string.snackbar_reservation_for_date, + formatDisplayDate(confirmation.desiredDate), + ) + CirculationRequestKind.LoanReadyForPickup -> + stringResource(R.string.snackbar_loan_ready_for_pickup) + CirculationRequestKind.LoanPending -> stringResource( + R.string.snackbar_request_for_date, + formatDisplayDate(confirmation.desiredDate), + ) + CirculationRequestKind.Unknown -> stringResource(R.string.snackbar_request_submitted) + } } LaunchedEffect(requestedConfirmation) { requestedConfirmation?.let { snackbarHost.showSnackbar(it) - vm.consumeRequestedDate() + vm.consumeRequestConfirmation() } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt index d9f0d59..b7108e3 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.pinakes.app.data.model.AvailabilityCalendar import com.pinakes.app.data.model.BookDetail +import com.pinakes.app.data.model.CirculationRequestKind import com.pinakes.app.data.network.ApiResult import com.pinakes.app.data.network.ErrorCodes import com.pinakes.app.data.repository.CatalogRepository @@ -28,8 +29,9 @@ data class BookDetailUiState( val reserveBusy: Boolean = false, val snackbar: String? = null, val snackbarRes: Int? = null, - // Set after a successful loan request so the UI can confirm "Loan requested for ". - val requestedDate: String? = null, + // Authoritative POST /reservations outcome: #384 may turn an apparent loan + // request into a FIFO reservation, while auto-approval can make it ready. + val requestConfirmation: RequestConfirmation? = null, // Loan-request calendar: availability fetch lifecycle for the date-picker sheet. val availabilityLoading: Boolean = false, val availability: AvailabilityCalendar? = null, @@ -39,6 +41,11 @@ data class BookDetailUiState( val showLoanSheet: Boolean = false, ) +data class RequestConfirmation( + val desiredDate: String, + val kind: CirculationRequestKind, +) + @HiltViewModel class BookDetailViewModel @Inject constructor( savedStateHandle: SavedStateHandle, @@ -134,7 +141,7 @@ class BookDetailViewModel @Inject constructor( reserveBusy = false, snackbar = null, snackbarRes = null, - requestedDate = desiredDate, + requestConfirmation = RequestConfirmation(desiredDate, res.data.kind), showLoanSheet = false, availability = null, availabilityFallback = false, @@ -160,5 +167,5 @@ class BookDetailViewModel @Inject constructor( fun consumeSnackbar() = _state.update { it.copy(snackbar = null, snackbarRes = null) } - fun consumeRequestedDate() = _state.update { it.copy(requestedDate = null) } + fun consumeRequestConfirmation() = _state.update { it.copy(requestConfirmation = null) } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt index 64c0589..91c5215 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt @@ -50,7 +50,7 @@ fun LibraryScreen(onBookClick: (Int) -> Unit) { val vm: LibraryViewModel = hiltViewModel() val state by vm.state.collectAsStateWithLifecycle() var tab by rememberSaveable { mutableIntStateOf(0) } - var confirmCancelId by remember { mutableStateOf(null) } + var confirmCancel by remember { mutableStateOf(null) } val snackbarHost = remember { androidx.compose.material3.SnackbarHostState() } val snackbarMessage = state.snackbar ?: state.snackbarRes?.let { stringResource(it) } @@ -92,7 +92,9 @@ fun LibraryScreen(onBookClick: (Int) -> Unit) { loans = data.loans.active + data.loans.pending, emptyTitle = stringResource(R.string.library_empty_active_title), emptySubtitle = stringResource(R.string.library_empty_active_subtitle), + cancelingId = state.cancelingId, onBookClick = onBookClick, + onCancel = { confirmCancel = CancelTarget(it, isLoan = true) }, ) 1 -> LoanList( loans = data.loans.history, @@ -104,7 +106,7 @@ fun LibraryScreen(onBookClick: (Int) -> Unit) { reservations = data.reservations, cancelingId = state.cancelingId, onBookClick = onBookClick, - onCancel = { confirmCancelId = it }, + onCancel = { confirmCancel = CancelTarget(it, isLoan = false) }, ) } } @@ -113,18 +115,23 @@ fun LibraryScreen(onBookClick: (Int) -> Unit) { } } - confirmCancelId?.let { id -> + confirmCancel?.let { target -> ConfirmDialog( - title = stringResource(R.string.library_confirm_cancel_title), - body = stringResource(R.string.library_confirm_cancel_body), + title = stringResource(if (target.isLoan) R.string.library_confirm_cancel_loan_title else R.string.library_confirm_cancel_title), + body = stringResource(if (target.isLoan) R.string.library_confirm_cancel_loan_body else R.string.library_confirm_cancel_body), confirmLabel = stringResource(R.string.library_confirm_cancel_confirm), dismissLabel = stringResource(R.string.library_confirm_cancel_keep), - onConfirm = { vm.cancelReservation(id); confirmCancelId = null }, - onDismiss = { confirmCancelId = null }, + onConfirm = { + if (target.isLoan) vm.cancelLoan(target.id) else vm.cancelReservation(target.id) + confirmCancel = null + }, + onDismiss = { confirmCancel = null }, ) } } +private data class CancelTarget(val id: Int, val isLoan: Boolean) + /** * "Active" tab: shows the user's current loan situation in urgency order with small section * headers — OVERDUE (red) first, then ON LOAN (with due date), then READY/SCHEDULED, then @@ -135,7 +142,9 @@ private fun ActiveLoanList( loans: List, emptyTitle: String, emptySubtitle: String, + cancelingId: Int?, onBookClick: (Int) -> Unit, + onCancel: (Int) -> Unit, ) { if (loans.isEmpty()) { EmptyState(title = emptyTitle, subtitle = emptySubtitle, icon = Icons.AutoMirrored.Outlined.LibraryBooks) @@ -169,7 +178,12 @@ private fun ActiveLoanList( ) } items(groupLoans, key = { it.id }) { loan -> - LoanRow(loan = loan, onBookClick = onBookClick) + LoanRow( + loan = loan, + cancelingId = cancelingId, + onBookClick = onBookClick, + onCancel = onCancel, + ) } } } @@ -206,29 +220,46 @@ private fun LoanList( @Composable private fun androidx.compose.foundation.lazy.LazyItemScope.LoanRow( loan: LoanItem, + cancelingId: Int? = null, onBookClick: (Int) -> Unit, + onCancel: ((Int) -> Unit)? = null, ) { - val (status, statusLabel) = StatusMapping.loan(loan.status) + val (status, statusLabel) = StatusMapping.loan(loan.status, loan.statusLabel) val label = statusLabel.resId?.let { stringResource(it) } ?: statusLabel.fallback - val overdue = StatusMapping.loanGroup(loan.status) == StatusMapping.LoanGroup.Overdue - val dateLine = when { - overdue && loan.dueAt != null -> - stringResource(R.string.library_overdue_since, DateFormat.date(loan.dueAt)) - overdue -> stringResource(R.string.library_overdue_label) - loan.returnedAt != null -> stringResource(R.string.library_returned_on, DateFormat.date(loan.returnedAt)) - loan.dueAt != null -> stringResource(R.string.library_due_label, DateFormat.date(loan.dueAt)) - loan.loanedAt != null -> stringResource(R.string.library_borrowed_on, DateFormat.date(loan.loanedAt)) - else -> null + val attention = StatusMapping.loanNeedsAttention(loan) + val dateLine = when (val date = StatusMapping.loanDate(loan)) { + null -> null + else -> when (date.kind) { + StatusMapping.LoanDateKind.Overdue -> date.value?.let { + stringResource(R.string.library_overdue_since, DateFormat.date(it)) + } ?: stringResource(R.string.library_overdue_label) + StatusMapping.LoanDateKind.Returned -> + stringResource(R.string.library_returned_on, DateFormat.date(requireNotNull(date.value))) + StatusMapping.LoanDateKind.Requested -> + stringResource(R.string.library_requested_on, DateFormat.date(requireNotNull(date.value))) + StatusMapping.LoanDateKind.Due -> + stringResource(R.string.library_due_label, DateFormat.date(requireNotNull(date.value))) + StatusMapping.LoanDateKind.Borrowed -> + stringResource(R.string.library_borrowed_on, DateFormat.date(requireNotNull(date.value))) + } } MediaRow( modifier = Modifier.animateItem(), title = loan.title, coverUrl = loan.coverUrl, line1 = dateLine, - line1Color = if (overdue) MaterialTheme.colorScheme.error else null, + line1Color = if (attention) MaterialTheme.colorScheme.error else null, status = status, statusLabel = label, onClick = { onBookClick(loan.bookId) }, + trailing = if (loan.cancellable && onCancel != null) { + { + TextButton( + onClick = { onCancel(loan.id) }, + enabled = cancelingId != loan.id, + ) { Text(stringResource(R.string.library_cancel)) } + } + } else null, ) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryViewModel.kt index 5d972b4..c6e590b 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryViewModel.kt @@ -88,7 +88,7 @@ class LibraryViewModel @Inject constructor(private val library: LibraryRepositor } is ApiResult.Failure -> { when { - res.code == ErrorCodes.CONFLICT -> + res.code == ErrorCodes.CONFLICT || res.httpStatus == 409 -> _state.update { it.copy(cancelingId = null, snackbar = null, snackbarRes = R.string.snackbar_reservation_cancel_conflict) } res.message.isNotBlank() -> _state.update { it.copy(cancelingId = null, snackbar = res.message, snackbarRes = null) } @@ -100,5 +100,28 @@ class LibraryViewModel @Inject constructor(private val library: LibraryRepositor } } + fun cancelLoan(id: Int) { + if (_state.value.cancelingId != null) return + _state.update { it.copy(cancelingId = id) } + viewModelScope.launch { + when (val res = library.cancelLoan(id)) { + is ApiResult.Success -> { + _state.update { it.copy(cancelingId = null, snackbar = null, snackbarRes = R.string.snackbar_loan_cancelled) } + load(initial = false) + } + is ApiResult.Failure -> { + when { + res.code == ErrorCodes.CONFLICT || res.httpStatus == 409 -> + _state.update { it.copy(cancelingId = null, snackbar = null, snackbarRes = R.string.snackbar_loan_cancel_conflict) } + res.message.isNotBlank() -> + _state.update { it.copy(cancelingId = null, snackbar = res.message, snackbarRes = null) } + else -> + _state.update { it.copy(cancelingId = null, snackbar = null, snackbarRes = R.string.snackbar_loan_cancel_error) } + } + } + } + } + } + fun consumeSnackbar() = _state.update { it.copy(snackbar = null, snackbarRes = null) } } diff --git a/app/src/test/java/com/pinakes/app/LatestReleaseContractTest.kt b/app/src/test/java/com/pinakes/app/LatestReleaseContractTest.kt new file mode 100644 index 0000000..4299c42 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/LatestReleaseContractTest.kt @@ -0,0 +1,90 @@ +package com.pinakes.app + +import com.pinakes.app.data.model.CirculationRequestKind +import com.pinakes.app.data.model.CirculationRequestResult +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.HealthPayload +import com.pinakes.app.data.model.LoanItem +import com.pinakes.app.ui.common.StatusMapping +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Wire and presentation regressions for Pinakes 0.7.59–0.7.68 / Mobile API 1.4.4. */ +class LatestReleaseContractTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test fun requestResultKeepsThe384ReservationOutcome() { + val envelope = json.decodeFromString>( + """{"data":{"type":"reservation","book_id":381},"meta":{},"error":null}""", + ) + + assertEquals(CirculationRequestKind.Reservation, envelope.data?.kind) + assertEquals(381, envelope.data?.bookId) + } + + @Test fun requestResultDistinguishesPendingAndReadyLoans() { + val pending = CirculationRequestResult(type = "loan", status = "pendente", autoApproved = false) + val ready = CirculationRequestResult(type = "loan", status = "da_ritirare", autoApproved = true) + + assertEquals(CirculationRequestKind.LoanPending, pending.kind) + assertEquals(CirculationRequestKind.LoanReadyForPickup, ready.kind) + assertEquals(CirculationRequestKind.Unknown, CirculationRequestResult(type = "future").kind) + assertEquals( + CirculationRequestKind.Unknown, + CirculationRequestResult(type = "loan", status = "future_status").kind, + ) + } + + @Test fun loanPayloadReadsRecentAdditiveFields() { + val envelope = json.decodeFromString>( + """{"data":{"id":7,"book_id":9,"title":"T","status":"da_ritirare","status_label":"Da ritirare","requested_at":"2026-08-27","due_attention":true,"cancellable":true},"error":null}""", + ) + val loan = requireNotNull(envelope.data) + + assertEquals("Da ritirare", loan.statusLabel) + assertEquals("2026-08-27", loan.requestedAt) + assertTrue(loan.dueAttention) + assertTrue(loan.cancellable) + } + + @Test fun cancelledAndPendingRowsUseRequestDateInsteadOfFakeDueDate() { + val cancelled = LoanItem( + status = "annullato", + requestedAt = "2026-08-20", + loanedAt = "2026-08-25", + dueAt = "2026-09-25", + returnedAt = "2026-08-26", + ) + val pending = cancelled.copy(status = "pendente") + + assertEquals(StatusMapping.LoanDateKind.Requested, StatusMapping.loanDate(cancelled)?.kind) + assertEquals("2026-08-20", StatusMapping.loanDate(cancelled)?.value) + assertEquals(StatusMapping.LoanDateKind.Requested, StatusMapping.loanDate(pending)?.kind) + } + + @Test fun dueAttentionUsesTheLibraryTimezoneCue() { + assertTrue(StatusMapping.loanNeedsAttention(LoanItem(status = "in_corso", dueAttention = true))) + assertFalse(StatusMapping.loanNeedsAttention(LoanItem(status = "in_corso", dueAttention = false))) + } + + @Test fun unknownLoanStatusUsesServerFallbackLabel() { + val label = StatusMapping.loan("nuovo_stato", "Etichetta server").second + assertEquals("Etichetta server", label.fallback) + } + + @Test fun healthReadsApprovalModeWithoutBreakingOlderServers() { + val recent = json.decodeFromString>( + """{"data":{"loan_approval_required":false},"error":null}""", + ) + val legacy = json.decodeFromString>( + """{"data":{},"error":null}""", + ) + + assertFalse(requireNotNull(recent.data).loanApprovalRequired) + assertTrue(requireNotNull(legacy.data).loanApprovalRequired) + } +} diff --git a/app/src/test/java/com/pinakes/app/StatusMappingMoreTest.kt b/app/src/test/java/com/pinakes/app/StatusMappingMoreTest.kt index 23f6140..18f54f0 100644 --- a/app/src/test/java/com/pinakes/app/StatusMappingMoreTest.kt +++ b/app/src/test/java/com/pinakes/app/StatusMappingMoreTest.kt @@ -23,6 +23,24 @@ class StatusMappingMoreTest { assertEquals("Qualche stato strano", label.fallback) } + @Test fun loanUnknownStatePrefersServerLabelOverHumanizedRaw() { + val label = StatusMapping.loan("stato_futuro", serverLabel = "Etichetta dal server").second + assertNull(label.resId) + assertEquals("Etichetta dal server", label.fallback) + } + + @Test fun loanUnknownStateIgnoresBlankServerLabel() { + val label = StatusMapping.loan("stato_futuro", serverLabel = " ").second + assertEquals("Stato futuro", label.fallback) + } + + @Test fun loanKnownStateKeepsLocalizedResourceEvenWithServerLabel() { + val (status, label) = StatusMapping.loan("annullato", serverLabel = "Cancelled") + assertEquals(AvailabilityStatus.Returned, status) + assertNull(label.fallback) + assertTrue(label.resId != null) + } + @Test fun reservationMapsEnglishAndItalianSpellings() { assertEquals(AvailabilityStatus.Available, StatusMapping.reservation("attiva").first) assertEquals(AvailabilityStatus.Available, StatusMapping.reservation("active").first) diff --git a/i18n/de.json b/i18n/de.json index d6986c8..f23a5e4 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -144,6 +144,7 @@ "library_empty_reservations_title": "Keine Reservierungen", "library_empty_reservations_subtitle": "Bücher, die du angefragt hast, erscheinen hier.", "library_returned_on": "Zurückgegeben am %1$s", + "library_requested_on": "Angefragt am %1$s", "library_due_on": "Fällig %1$s", "library_borrowed_on": "Ausgeliehen am %1$s", "library_queue_position": "Warteschlange #%1$d", @@ -152,6 +153,8 @@ "library_confirm_cancel_body": "Dadurch wird deine offene Anfrage für dieses Buch zurückgezogen.", "library_confirm_cancel_confirm": "Reservierung stornieren", "library_confirm_cancel_keep": "Behalten", + "library_confirm_cancel_loan_title": "Ausleihanfrage stornieren?", + "library_confirm_cancel_loan_body": "Die ausstehende, geplante oder abholbereite Ausleihe wird storniert.", "wishlist_loading": "Merkliste wird geladen…", "wishlist_empty_title": "Deine Merkliste ist leer", "wishlist_empty_subtitle": "Tippe auf das Herz bei einem Buch, um es hier zu speichern.", @@ -259,6 +262,8 @@ "cd_prev_month": "Voriger Monat", "cd_next_month": "Nächster Monat", "snackbar_request_for_date": "Ausleihe angefragt für %1$s", + "snackbar_reservation_for_date": "Reservierung für den %1$s eingetragen", + "snackbar_loan_ready_for_pickup": "Ausleihe genehmigt — zur Abholung bereit.", "book_section_audiobook": "Hörbuch", "audio_play": "Abspielen", "audio_pause": "Pause", @@ -320,6 +325,9 @@ "snackbar_reservation_cancelled": "Reservierung storniert", "snackbar_reservation_cancel_conflict": "Diese Reservierung kann nicht mehr storniert werden.", "snackbar_reservation_cancel_error": "Stornierung fehlgeschlagen.", + "snackbar_loan_cancelled": "Ausleihanfrage storniert", + "snackbar_loan_cancel_conflict": "Diese Ausleihe kann nicht mehr storniert werden.", + "snackbar_loan_cancel_error": "Ausleihe konnte nicht storniert werden.", "wishlist_error_load": "Deine Wunschliste konnte nicht geladen werden.", "snackbar_wishlist_remove_error": "Entfernen fehlgeschlagen.", "notifications_error_load": "Benachrichtigungen konnten nicht geladen werden.", diff --git a/i18n/en.json b/i18n/en.json index f379a3d..9d14a36 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -144,6 +144,7 @@ "library_empty_reservations_title": "No reservations", "library_empty_reservations_subtitle": "Books you've requested will appear here.", "library_returned_on": "Returned %1$s", + "library_requested_on": "Requested %1$s", "library_due_on": "Due %1$s", "library_borrowed_on": "Borrowed %1$s", "library_queue_position": "Queue #%1$d", @@ -152,6 +153,8 @@ "library_confirm_cancel_body": "This will withdraw your pending request for this book.", "library_confirm_cancel_confirm": "Cancel reservation", "library_confirm_cancel_keep": "Keep", + "library_confirm_cancel_loan_title": "Cancel loan request?", + "library_confirm_cancel_loan_body": "This will cancel the pending, scheduled, or ready-for-pickup loan.", "wishlist_loading": "Loading wishlist…", "wishlist_empty_title": "Your wishlist is empty", "wishlist_empty_subtitle": "Tap the heart on any book to save it here.", @@ -259,6 +262,8 @@ "cd_prev_month": "Previous month", "cd_next_month": "Next month", "snackbar_request_for_date": "Loan requested for %1$s", + "snackbar_reservation_for_date": "Reservation added for %1$s", + "snackbar_loan_ready_for_pickup": "Loan approved — ready for pickup.", "book_section_audiobook": "Audiobook", "audio_play": "Play", "audio_pause": "Pause", @@ -320,6 +325,9 @@ "snackbar_reservation_cancelled": "Reservation cancelled", "snackbar_reservation_cancel_conflict": "This reservation can no longer be cancelled.", "snackbar_reservation_cancel_error": "Couldn't cancel.", + "snackbar_loan_cancelled": "Loan request cancelled", + "snackbar_loan_cancel_conflict": "This loan can no longer be cancelled.", + "snackbar_loan_cancel_error": "Couldn't cancel the loan.", "wishlist_error_load": "Couldn't load your wishlist.", "snackbar_wishlist_remove_error": "Couldn't remove.", "notifications_error_load": "Couldn't load notifications.", diff --git a/i18n/fr.json b/i18n/fr.json index 851efb3..ae2cd9f 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -144,6 +144,7 @@ "library_empty_reservations_title": "Aucune réservation", "library_empty_reservations_subtitle": "Les livres que vous avez demandés apparaîtront ici.", "library_returned_on": "Rendu le %1$s", + "library_requested_on": "Demandé le %1$s", "library_due_on": "Échéance %1$s", "library_borrowed_on": "Emprunté le %1$s", "library_queue_position": "File d'attente #%1$d", @@ -152,6 +153,8 @@ "library_confirm_cancel_body": "Cela retirera votre demande en attente pour ce livre.", "library_confirm_cancel_confirm": "Annuler la réservation", "library_confirm_cancel_keep": "Conserver", + "library_confirm_cancel_loan_title": "Annuler la demande de prêt ?", + "library_confirm_cancel_loan_body": "Le prêt en attente, programmé ou prêt à retirer sera annulé.", "wishlist_loading": "Chargement des favoris…", "wishlist_empty_title": "Votre liste de favoris est vide", "wishlist_empty_subtitle": "Touchez le cœur sur un livre pour l'enregistrer ici.", @@ -259,6 +262,8 @@ "cd_prev_month": "Mois précédent", "cd_next_month": "Mois suivant", "snackbar_request_for_date": "Prêt demandé pour le %1$s", + "snackbar_reservation_for_date": "Réservation enregistrée pour le %1$s", + "snackbar_loan_ready_for_pickup": "Prêt approuvé — prêt à être retiré.", "book_section_audiobook": "Livre audio", "audio_play": "Lecture", "audio_pause": "Pause", @@ -320,6 +325,9 @@ "snackbar_reservation_cancelled": "Réservation annulée", "snackbar_reservation_cancel_conflict": "Cette réservation ne peut plus être annulée.", "snackbar_reservation_cancel_error": "Impossible d'annuler.", + "snackbar_loan_cancelled": "Demande de prêt annulée", + "snackbar_loan_cancel_conflict": "Ce prêt ne peut plus être annulé.", + "snackbar_loan_cancel_error": "Impossible d'annuler le prêt.", "wishlist_error_load": "Impossible de charger votre liste de souhaits.", "snackbar_wishlist_remove_error": "Impossible de retirer.", "notifications_error_load": "Impossible de charger les notifications.", diff --git a/i18n/it.json b/i18n/it.json index d5bf393..2231fae 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -144,6 +144,7 @@ "library_empty_reservations_title": "Nessuna prenotazione", "library_empty_reservations_subtitle": "I libri che hai richiesto appariranno qui.", "library_returned_on": "Restituito il %1$s", + "library_requested_on": "Richiesto il %1$s", "library_due_on": "Scadenza %1$s", "library_borrowed_on": "Preso in prestito il %1$s", "library_queue_position": "In coda #%1$d", @@ -152,6 +153,8 @@ "library_confirm_cancel_body": "Verrà ritirata la tua richiesta in attesa per questo libro.", "library_confirm_cancel_confirm": "Annulla prenotazione", "library_confirm_cancel_keep": "Mantieni", + "library_confirm_cancel_loan_title": "Annullare la richiesta di prestito?", + "library_confirm_cancel_loan_body": "Verrà annullato il prestito in attesa, programmato o pronto per il ritiro.", "wishlist_loading": "Caricamento preferiti…", "wishlist_empty_title": "I tuoi preferiti sono vuoti", "wishlist_empty_subtitle": "Tocca il cuore su un libro per salvarlo qui.", @@ -259,6 +262,8 @@ "cd_prev_month": "Mese precedente", "cd_next_month": "Mese successivo", "snackbar_request_for_date": "Prestito richiesto per il %1$s", + "snackbar_reservation_for_date": "Prenotazione inserita per il %1$s", + "snackbar_loan_ready_for_pickup": "Prestito approvato — pronto per il ritiro.", "book_section_audiobook": "Audiolibro", "audio_play": "Riproduci", "audio_pause": "Pausa", @@ -320,6 +325,9 @@ "snackbar_reservation_cancelled": "Prenotazione annullata", "snackbar_reservation_cancel_conflict": "Questa prenotazione non può più essere annullata.", "snackbar_reservation_cancel_error": "Impossibile annullare.", + "snackbar_loan_cancelled": "Richiesta di prestito annullata", + "snackbar_loan_cancel_conflict": "Questo prestito non può più essere annullato.", + "snackbar_loan_cancel_error": "Impossibile annullare il prestito.", "wishlist_error_load": "Impossibile caricare la tua wishlist.", "snackbar_wishlist_remove_error": "Impossibile rimuovere.", "notifications_error_load": "Impossibile caricare le notifiche.",