fix(journey): detect duplicate document via PSQLException SQLState 23505

Replace fragile constraint-name string match in isDuplicateDocumentViolation()
with a check on PSQLException.getSQLState() == "23505" (unique_violation), so
constraint renames can never silently break the 409 response. Update and extend
JourneyItemServiceTest to use real PSQLException wrapping in all DIVE scenarios.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-12 13:30:28 +02:00
parent 8eb4a0ffde
commit cdea8af290
2 changed files with 47 additions and 9 deletions

View File

@@ -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) {

View File

@@ -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 matchingconstraint 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());