From 02efe738a0290fda2d31bd019d9ae45bf6d8d7b3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 10 Jun 2026 07:14:35 +0200 Subject: [PATCH] fix(geschichte): make the documented error-code contract real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GESCHICHTE_TYPE_IMMUTABLE and JOURNEY_NOTE_TOO_LONG were declared in errors.ts, translated, and documented — but never existed in the backend. update() now rejects a type change with 409 (omitted/same type still pass); note length is enforced at 2000 with its own code, matching the frontend maxlength and the i18n message (resolves the #793 discrepancy in favour of the spec). JOURNEY_ITEM_NOT_IN_JOURNEY is deleted everywhere instead — the deliberate 404 posture for cross-journey item ids must not leak existence via a distinct code. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 4 +- .../familienarchiv/exception/ErrorCode.java | 4 ++ .../geschichte/GeschichteService.java | 4 ++ .../journeyitem/JourneyItemService.java | 7 ++-- .../geschichte/GeschichteServiceTest.java | 37 +++++++++++++++++++ .../journeyitem/JourneyItemServiceTest.java | 28 +++++++++++--- docs/ARCHITECTURE.md | 2 +- frontend/messages/de.json | 1 - frontend/messages/en.json | 1 - frontend/messages/es.json | 1 - frontend/src/lib/shared/errors.ts | 3 -- 11 files changed, 74 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2bbc4b24..632213cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -163,7 +163,7 @@ Input DTOs live flat in the domain package. Response types are the model entitie → See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling) -**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_ITEM_NOT_IN_JOURNEY`, `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE` (journey/geschichte domain constraints). +**LLM reminder:** use `DomainException.notFound/forbidden/conflict/internal()` from service methods — never throw raw exceptions. When adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE` (journey/geschichte domain constraints). ### Security / Permissions @@ -271,7 +271,7 @@ Back button pattern — use the shared `` component from `$lib/share → See [CONTRIBUTING.md §Error handling](./CONTRIBUTING.md#error-handling) -**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_ITEM_NOT_IN_JOURNEY`, `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE` (journey/geschichte domain constraints). +**LLM reminder:** when adding a new `ErrorCode`: (1) add to `ErrorCode.java`, (2) add to `ErrorCode` type in `frontend/src/lib/shared/errors.ts`, (3) add a `case` in `getErrorMessage()`, (4) add i18n keys in `messages/{de,en,es}.json`. Valid error codes include: `TOO_MANY_LOGIN_ATTEMPTS` (returned by `LoginRateLimiter` as HTTP 429 when a brute-force threshold is exceeded); `JOURNEY_NOTE_TOO_LONG`, `JOURNEY_DOCUMENT_ALREADY_ADDED`, `GESCHICHTE_TYPE_IMMUTABLE` (journey/geschichte domain constraints). --- diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index e38f8b0b..ba8eac3b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -132,6 +132,10 @@ public enum ErrorCode { JOURNEY_DOCUMENT_ALREADY_ADDED, /** The Geschichte is not of type JOURNEY — journey-item operations are not allowed on it. 400 */ GESCHICHTE_TYPE_MISMATCH, + /** The type of an existing Geschichte cannot be changed via PATCH. 409 */ + GESCHICHTE_TYPE_IMMUTABLE, + /** A journey-item note exceeds the maximum length (2000 characters). 400 */ + JOURNEY_NOTE_TOO_LONG, // --- Tags --- /** A tag with the given ID does not exist. 404 */ diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java index a683b3cc..fb1164ea 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/GeschichteService.java @@ -155,6 +155,10 @@ public class GeschichteService { Geschichte g = geschichteRepository.findById(id) .orElseThrow(() -> DomainException.notFound( ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id)); + if (dto.getType() != null && dto.getType() != g.getType()) { + throw DomainException.conflict(ErrorCode.GESCHICHTE_TYPE_IMMUTABLE, + "The type of a Geschichte cannot be changed after creation"); + } if (dto.getTitle() != null) { requireTitle(dto.getTitle()); g.setTitle(dto.getTitle().trim()); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java index 2a9b5484..5fe40e7e 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemService.java @@ -30,7 +30,8 @@ public class JourneyItemService { static final int MAX_ITEMS = 100; static final int POSITION_STEP = 10; - static final int MAX_NOTE_LENGTH = 5000; + // 2000 per the editor spec — frontend maxlength and the i18n error message agree (#793). + static final int MAX_NOTE_LENGTH = 2000; private final JourneyItemRepository journeyItemRepository; private final GeschichteQueryService geschichteQueryService; @@ -63,7 +64,7 @@ public class JourneyItemService { } if (note != null && note.length() > MAX_NOTE_LENGTH) { - throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, + throw DomainException.badRequest(ErrorCode.JOURNEY_NOTE_TOO_LONG, "Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters"); } @@ -110,7 +111,7 @@ public class JourneyItemService { String note = normalizeNote(noteField.orElse(null)); if (note != null && note.length() > MAX_NOTE_LENGTH) { - throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, + throw DomainException.badRequest(ErrorCode.JOURNEY_NOTE_TOO_LONG, "Note exceeds maximum length of " + MAX_NOTE_LENGTH + " characters"); } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java index a8364645..a51408a4 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/GeschichteServiceTest.java @@ -476,6 +476,43 @@ class GeschichteServiceTest { assertThat(saved.body()).doesNotContain("