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