From 2e9902e8a7b0ac2fdfc7e0c32819fe3d6fe2d2f6 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 11 Jun 2026 07:49:31 +0200 Subject: [PATCH] fix(journeyitem): translate only the dedup-index violation to 409 Any other DataIntegrityViolationException at flush (e.g. an FK violation on a concurrently deleted document) is rethrown instead of being mislabeled as JOURNEY_DOCUMENT_ALREADY_ADDED. Match on the uq_journey_items_geschichte_document constraint name. Review round 3: Markus (a), Felix suggestion. Co-Authored-By: Claude Fable 5 --- .../journeyitem/JourneyItemService.java | 12 +++++++++++ .../journeyitem/JourneyItemServiceTest.java | 21 +++++++++++++++++++ 2 files changed, 33 insertions(+) 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 c06fb72c..7b08c601 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 @@ -95,6 +95,12 @@ public class JourneyItemService { try { saved = journeyItemRepository.saveAndFlush(item); } catch (DataIntegrityViolationException e) { + // Only the dedup index earns the friendly 409 — any other integrity + // failure (e.g. an FK violation on a concurrently deleted document) + // must not be mislabeled as "already added". + if (!isDuplicateDocumentViolation(e)) { + throw e; + } throw DomainException.conflict(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED, "Document already in journey: " + dto.getDocumentId()); } @@ -249,6 +255,12 @@ public class JourneyItemService { .orElse(null); } + private static boolean isDuplicateDocumentViolation(DataIntegrityViolationException e) { + Throwable cause = e.getMostSpecificCause(); + String message = cause != null ? cause.getMessage() : e.getMessage(); + return message != null && message.contains("uq_journey_items_geschichte_document"); + } + private static String normalizeNote(String raw) { if (raw == null || raw.isBlank()) return null; return raw.trim(); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java index c309dcf5..5208e6ab 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/geschichte/journeyitem/JourneyItemServiceTest.java @@ -360,6 +360,27 @@ class JourneyItemServiceTest { .isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED)); } + @Test + void append_rethrows_unrelated_integrity_violations_instead_of_mislabeling_them() { + // An FK violation (document deleted between load and flush) must NOT be + // translated into "already added" — only the dedup index earns that 409. + Geschichte journey = journey(geschichteId); + when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey)); + when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L); + when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false); + when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null)); + when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10)); + when(journeyItemRepository.saveAndFlush(any())) + .thenThrow(new org.springframework.dao.DataIntegrityViolationException( + "insert or update on table \"journey_items\" violates foreign key constraint \"fk_journey_items_document\"")); + + JourneyItemCreateDTO dto = new JourneyItemCreateDTO(); + dto.setDocumentId(UUID.randomUUID()); + + assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto)) + .isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class); + } + @Test void append_audits_JOURNEY_ITEM_ADDED() { Geschichte journey = journey(geschichteId);