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 893c1b12..70e46718 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 @@ -250,9 +250,11 @@ public class JourneyItemService { } 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"); + Throwable cause = e.getCause(); + if (cause instanceof java.sql.SQLException sql) { + return "23505".equals(sql.getSQLState()); + } + return false; } private static String normalizeNote(String raw) { 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 2c520625..eb902a53 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 @@ -25,6 +25,9 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; +import org.postgresql.util.PSQLException; +import org.postgresql.util.PSQLState; + import java.util.HashSet; import java.util.List; import java.util.Optional; @@ -348,19 +351,22 @@ class JourneyItemServiceTest { } @Test - void append_maps_unique_index_violation_to_409_JOURNEY_DOCUMENT_ALREADY_ADDED() { + void append_maps_unique_index_violation_to_409_JOURNEY_DOCUMENT_ALREADY_ADDED() throws Exception { // Two concurrent appends can both pass the exists() pre-check; the partial // unique index then rejects the second INSERT at flush. The service must // translate that into the same friendly 409 as the pre-check. + // Uses PSQLException with SQLState 23505 — the real payload Postgres delivers. 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)); + PSQLException psqlEx = new PSQLException("duplicate key value violates unique constraint", + PSQLState.UNIQUE_VIOLATION); when(journeyItemRepository.saveAndFlush(any())) .thenThrow(new org.springframework.dao.DataIntegrityViolationException( - "duplicate key value violates unique constraint \"uq_journey_items_geschichte_document\"")); + "could not execute statement", psqlEx)); JourneyItemCreateDTO dto = new JourneyItemCreateDTO(); dto.setDocumentId(UUID.randomUUID()); @@ -372,18 +378,48 @@ class JourneyItemServiceTest { } @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. + void append_maps_psql_sqlstate_23505_to_409_JOURNEY_DOCUMENT_ALREADY_ADDED() throws Exception { + // B1: the dedup check must use PSQLException.getSQLState() == "23505", not + // constraint-name string matching — constraint renames must not regress this. 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)); + + // Simulate a real Postgres unique-violation: PSQLException with SQLState 23505 + // wrapped by Spring's DataIntegrityViolationException. + PSQLException psqlEx = new PSQLException("duplicate key value violates unique constraint", + PSQLState.UNIQUE_VIOLATION); + org.springframework.dao.DataIntegrityViolationException dive = + new org.springframework.dao.DataIntegrityViolationException("could not execute statement", psqlEx); + when(journeyItemRepository.saveAndFlush(any())).thenThrow(dive); + + JourneyItemCreateDTO dto = new JourneyItemCreateDTO(); + dto.setDocumentId(UUID.randomUUID()); + + assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).getCode()) + .isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED)); + } + + @Test + void append_rethrows_unrelated_integrity_violations_instead_of_mislabeling_them() throws Exception { + // An FK violation (document deleted between load and flush) must NOT be + // translated into "already added" — only the dedup unique index (23505) earns that 409. + // FK violations arrive as PSQLException with SQLState 23503 (foreign_key_violation). + 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)); + PSQLException psqlEx = new PSQLException("foreign key violation", PSQLState.FOREIGN_KEY_VIOLATION); 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\"")); + "could not execute statement", psqlEx)); JourneyItemCreateDTO dto = new JourneyItemCreateDTO(); dto.setDocumentId(UUID.randomUUID());