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:
@@ -250,9 +250,11 @@ public class JourneyItemService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isDuplicateDocumentViolation(DataIntegrityViolationException e) {
|
private static boolean isDuplicateDocumentViolation(DataIntegrityViolationException e) {
|
||||||
Throwable cause = e.getMostSpecificCause();
|
Throwable cause = e.getCause();
|
||||||
String message = cause != null ? cause.getMessage() : e.getMessage();
|
if (cause instanceof java.sql.SQLException sql) {
|
||||||
return message != null && message.contains("uq_journey_items_geschichte_document");
|
return "23505".equals(sql.getSQLState());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String normalizeNote(String raw) {
|
private static String normalizeNote(String raw) {
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio
|
|||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
|
||||||
|
import org.postgresql.util.PSQLException;
|
||||||
|
import org.postgresql.util.PSQLState;
|
||||||
|
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -348,19 +351,22 @@ class JourneyItemServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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
|
// Two concurrent appends can both pass the exists() pre-check; the partial
|
||||||
// unique index then rejects the second INSERT at flush. The service must
|
// unique index then rejects the second INSERT at flush. The service must
|
||||||
// translate that into the same friendly 409 as the pre-check.
|
// 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);
|
Geschichte journey = journey(geschichteId);
|
||||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
|
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
|
||||||
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
|
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
|
||||||
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
|
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
|
||||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
|
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()))
|
when(journeyItemRepository.saveAndFlush(any()))
|
||||||
.thenThrow(new org.springframework.dao.DataIntegrityViolationException(
|
.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();
|
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||||
dto.setDocumentId(UUID.randomUUID());
|
dto.setDocumentId(UUID.randomUUID());
|
||||||
@@ -372,18 +378,48 @@ class JourneyItemServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void append_rethrows_unrelated_integrity_violations_instead_of_mislabeling_them() {
|
void append_maps_psql_sqlstate_23505_to_409_JOURNEY_DOCUMENT_ALREADY_ADDED() throws Exception {
|
||||||
// An FK violation (document deleted between load and flush) must NOT be
|
// B1: the dedup check must use PSQLException.getSQLState() == "23505", not
|
||||||
// translated into "already added" — only the dedup index earns that 409.
|
// constraint-name string matching — constraint renames must not regress this.
|
||||||
Geschichte journey = journey(geschichteId);
|
Geschichte journey = journey(geschichteId);
|
||||||
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||||
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
|
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
|
||||||
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
|
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(eq(geschichteId), any())).thenReturn(false);
|
||||||
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
|
when(documentService.findSummaryByIdInternal(any())).thenReturn(makeDoc(UUID.randomUUID(), null, List.of(), null, null));
|
||||||
when(journeyItemRepository.findMaxPositionByGeschichteId(geschichteId)).thenReturn(Optional.of(10));
|
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()))
|
when(journeyItemRepository.saveAndFlush(any()))
|
||||||
.thenThrow(new org.springframework.dao.DataIntegrityViolationException(
|
.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();
|
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||||
dto.setDocumentId(UUID.randomUUID());
|
dto.setDocumentId(UUID.randomUUID());
|
||||||
|
|||||||
Reference in New Issue
Block a user