From a574d963511f4a3d91f63c376a2920873b7868e3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 1 Jun 2026 09:12:54 +0200 Subject: [PATCH] feat(document): reject RANGE with end before start (#678) Add ErrorCode.INVALID_DATE_RANGE and a validateDateRange guard on DocumentService.updateDocument, run right after applyDatePrecision so it fires before any save (updateDocumentTags persists earlier in the method). Mirrors the V69 chk_meta_date_end_after_start CHECK: end >= start with a null start allowed, using isBefore so equal dates stay valid. Turns a user date typo into a clean 400 instead of a 500 + Sentry alert. Co-Authored-By: Claude Opus 4.8 --- .../document/DocumentService.java | 20 ++++++++++++ .../familienarchiv/exception/ErrorCode.java | 2 ++ .../document/DocumentServiceTest.java | 31 +++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java index faa24de3..e947ddc8 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentService.java @@ -381,6 +381,7 @@ public class DocumentService { doc.setTitle(dto.getTitle()); doc.setDocumentDate(dto.getDocumentDate()); applyDatePrecision(doc, dto); + validateDateRange(doc); // guard before any save (updateDocumentTags below persists) doc.setLocation(dto.getLocation()); doc.setTranscription(dto.getTranscription()); doc.setSummary(dto.getSummary()); @@ -468,6 +469,25 @@ public class DocumentService { } } + /** + * Friendly guard for the two V69 date-range CHECK constraints, run before save so a + * user date typo returns a clean 400 INVALID_DATE_RANGE instead of falling through to + * the generic handler (HTTP 500 + Sentry + ERROR log). Validates the post-apply {@code doc} + * state, not the DTO, because precision/end may have been carried over from the stored row + * when the DTO field was null. The DB CHECK remains the backstop; this never weakens it. + */ + private void validateDateRange(Document doc) { + // Mirrors chk_meta_date_end_after_start: end >= start, with null start allowed. + // Use isBefore (equal dates are valid) — never !isAfter, which would contradict the DB's >=. + if (doc.getMetaDatePrecision() == DatePrecision.RANGE + && doc.getDocumentDate() != null + && doc.getMetaDateEnd() != null + && doc.getMetaDateEnd().isBefore(doc.getDocumentDate())) { + throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE, + "meta_date_end must not be before meta_date"); + } + } + @Transactional public Document updateDocumentTags(UUID docId, List tagNames) { Document doc = documentRepository.findById(docId) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index d9d0d8b2..3eb5287d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -26,6 +26,8 @@ public enum ErrorCode { FILE_UPLOAD_FAILED, /** The uploaded file's content type is not supported (PDF/JPEG/PNG/TIFF only). 400 */ UNSUPPORTED_FILE_TYPE, + /** A RANGE date is invalid: meta_date_end is before meta_date, or an end date is set without RANGE precision. 400 */ + INVALID_DATE_RANGE, // --- Users --- /** A user with the given ID or username does not exist. 404 */ diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java index 9257aafe..0f05950e 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java @@ -20,6 +20,7 @@ import org.raddatz.familienarchiv.document.MatchOffset; import org.raddatz.familienarchiv.document.SearchMatchData; import org.raddatz.familienarchiv.tag.TagOperator; import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.document.Document; import org.raddatz.familienarchiv.document.DocumentStatus; import org.raddatz.familienarchiv.person.Person; @@ -225,6 +226,36 @@ class DocumentServiceTest { assertThat(doc.getMetaDateRaw()).isEqualTo("Juni 1916"); } + // ─── updateDocument date-range validation (#678) ────────────────────────── + + /** Builds a stored doc ready for an updateDocument call (collections initialised). */ + private static Document docForRangeUpdate(UUID id) { + return Document.builder().id(id).receivers(new HashSet<>()).tags(new HashSet<>()).build(); + } + + private static DocumentUpdateDTO rangeDto(LocalDate start, LocalDate end) { + DocumentUpdateDTO dto = new DocumentUpdateDTO(); + dto.setDocumentDate(start); + dto.setMetaDatePrecision(DatePrecision.RANGE); + dto.setMetaDateEnd(end); + return dto; + } + + @Test + void updateDocument_rejectsRange_whenEndBeforeStart() { + UUID id = UUID.randomUUID(); + Document doc = docForRangeUpdate(id); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + + DocumentUpdateDTO dto = rangeDto(LocalDate.of(1917, 1, 11), LocalDate.of(1917, 1, 10)); + + assertThatThrownBy(() -> documentService.updateDocument(id, dto, null, null)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.INVALID_DATE_RANGE); + verify(documentRepository, never()).save(any()); + } + // ─── deleteTagCascading ─────────────────────────────────────────────────── @Test