From ae0b14368521cfcb77f01d26bd00ab78a3a115a2 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 10 Jun 2026 00:05:27 +0200 Subject: [PATCH] =?UTF-8?q?fix(journeyitem):=20explicit=20JPQL=20for=20the?= =?UTF-8?q?=20document-dedup=20guard=20=E2=80=94=20derived=20query=20unmap?= =?UTF-8?q?pable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/geschichten/{id}/items with a documentId failed 500: Spring Data resolved the derived existsByGeschichteIdAndDocumentId path as a direct documentId attribute (shadowed by the transient getDocumentId() getter) instead of document.id, producing JPQL Hibernate cannot map. Existing tests only appended note items, so the document branch was never exercised. Co-Authored-By: Claude Fable 5 --- .../journeyitem/JourneyItemRepository.java | 14 +++++++++-- .../JourneyItemIntegrationTest.java | 24 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java index 34e7e26e..66b9ab27 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemRepository.java @@ -30,8 +30,18 @@ public interface JourneyItemRepository extends JpaRepository /** COUNT for the 100-item cap check — COUNT(*)-based, never MAX(position)-derived. */ long countByGeschichteId(UUID geschichteId); - /** Dedup guard: true when the document is already linked to this journey. */ - boolean existsByGeschichteIdAndDocumentId(UUID geschichteId, UUID documentId); + /** + * Dedup guard: true when the document is already linked to this journey. + * Explicit JPQL, not a derived query: the transient {@code getDocumentId()} + * getter on JourneyItem makes Spring Data resolve the derived path as a + * direct {@code documentId} attribute, which Hibernate cannot map. + */ + @Query(""" + SELECT COUNT(i) > 0 FROM JourneyItem i + WHERE i.geschichte.id = :geschichteId AND i.document.id = :documentId + """) + boolean existsByGeschichteIdAndDocumentId( + @Param("geschichteId") UUID geschichteId, @Param("documentId") UUID documentId); /** * Loads journey items with their linked Document in a single JOIN FETCH query, diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemIntegrationTest.java index b6c9fcbe..b839e113 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemIntegrationTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemIntegrationTest.java @@ -258,6 +258,30 @@ class JourneyItemIntegrationTest { assertThat(persisted.get(0).getNote()).isEqualTo("First stop"); } + @Test + void append_document_persists_and_rejects_duplicate() { + // Covers the document branch of append, including the duplicate guard — + // the derived exists query must resolve document.id, which the transient + // getDocumentId() getter on JourneyItem shadows for Spring Data. + authenticateAs(writer, Permission.BLOG_WRITE); + + JourneyItemCreateDTO dto = new JourneyItemCreateDTO(); + dto.setDocumentId(doc.getId()); + + JourneyItemView view = journeyItemService.append(journey.getId(), dto); + em.flush(); + em.clear(); + + assertThat(view.document()).isNotNull(); + assertThat(view.document().id()).isEqualTo(doc.getId()); + + JourneyItemCreateDTO duplicate = new JourneyItemCreateDTO(); + duplicate.setDocumentId(doc.getId()); + assertThatThrownBy(() -> journeyItemService.append(journey.getId(), duplicate)) + .hasFieldOrPropertyWithValue("code", + org.raddatz.familienarchiv.exception.ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED); + } + // ─── JourneyItemService.reorder — atomicity check ──────────────────────── @Test