fix(journey-item): enforce duplicate-document guard in append()
Adds JOURNEY_DOCUMENT_ALREADY_ADDED to ErrorCode, an existsByGeschichteIdAndDocumentId() repo method, and a 409 guard in JourneyItemService.append() — the error code was registered on the frontend but never thrown on the backend, allowing concurrent tabs to add the same document twice. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -128,6 +128,8 @@ public enum ErrorCode {
|
|||||||
JOURNEY_ITEM_POSITION_CONFLICT,
|
JOURNEY_ITEM_POSITION_CONFLICT,
|
||||||
/** The journey already has the maximum allowed number of items (100). 400 */
|
/** The journey already has the maximum allowed number of items (100). 400 */
|
||||||
JOURNEY_AT_CAPACITY,
|
JOURNEY_AT_CAPACITY,
|
||||||
|
/** The document is already present in this journey — duplicate items are not allowed. 409 */
|
||||||
|
JOURNEY_DOCUMENT_ALREADY_ADDED,
|
||||||
/** The Geschichte is not of type JOURNEY — journey-item operations are not allowed on it. 400 */
|
/** The Geschichte is not of type JOURNEY — journey-item operations are not allowed on it. 400 */
|
||||||
GESCHICHTE_TYPE_MISMATCH,
|
GESCHICHTE_TYPE_MISMATCH,
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ public interface JourneyItemRepository extends JpaRepository<JourneyItem, UUID>
|
|||||||
/** COUNT for the 100-item cap check — COUNT(*)-based, never MAX(position)-derived. */
|
/** COUNT for the 100-item cap check — COUNT(*)-based, never MAX(position)-derived. */
|
||||||
long countByGeschichteId(UUID geschichteId);
|
long countByGeschichteId(UUID geschichteId);
|
||||||
|
|
||||||
|
/** Dedup guard: true when the document is already linked to this journey. */
|
||||||
|
boolean existsByGeschichteIdAndDocumentId(UUID geschichteId, UUID documentId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads journey items with their linked Document in a single JOIN FETCH query,
|
* Loads journey items with their linked Document in a single JOIN FETCH query,
|
||||||
* eliminating the N+1 SELECT that would occur when accessing item.getDocument()
|
* eliminating the N+1 SELECT that would occur when accessing item.getDocument()
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ public class JourneyItemService {
|
|||||||
|
|
||||||
Document doc = null;
|
Document doc = null;
|
||||||
if (dto.getDocumentId() != null) {
|
if (dto.getDocumentId() != null) {
|
||||||
|
if (journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, dto.getDocumentId())) {
|
||||||
|
throw DomainException.conflict(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED,
|
||||||
|
"Document already in journey: " + dto.getDocumentId());
|
||||||
|
}
|
||||||
doc = documentService.findSummaryByIdInternal(dto.getDocumentId());
|
doc = documentService.findSummaryByIdInternal(dto.getDocumentId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -288,6 +288,22 @@ class JourneyItemServiceTest {
|
|||||||
.isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY));
|
.isEqualTo(ErrorCode.JOURNEY_AT_CAPACITY));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void append_returns409_when_document_already_in_journey() {
|
||||||
|
Geschichte journey = journey(geschichteId);
|
||||||
|
when(geschichteQueryService.findById(geschichteId)).thenReturn(Optional.of(journey));
|
||||||
|
when(journeyItemRepository.countByGeschichteId(geschichteId)).thenReturn(1L);
|
||||||
|
when(journeyItemRepository.existsByGeschichteIdAndDocumentId(geschichteId, docId)).thenReturn(true);
|
||||||
|
|
||||||
|
JourneyItemCreateDTO dto = new JourneyItemCreateDTO();
|
||||||
|
dto.setDocumentId(docId);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> journeyItemService.append(geschichteId, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getCode())
|
||||||
|
.isEqualTo(ErrorCode.JOURNEY_DOCUMENT_ALREADY_ADDED));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void cap_is_COUNT_based_not_MAX_position_based() {
|
void cap_is_COUNT_based_not_MAX_position_based() {
|
||||||
// 99 rows with MAX(position)=2000 should still accept the 100th append
|
// 99 rows with MAX(position)=2000 should still accept the 100th append
|
||||||
|
|||||||
Reference in New Issue
Block a user