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..2f3edbc2 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,31 @@ 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"); + } + // Mirrors chk_meta_date_end_only_for_range. API-only: the edit form clears the + // end field off-RANGE, so this branch closes the same 500 class for direct clients. + if (doc.getMetaDateEnd() != null && doc.getMetaDatePrecision() != DatePrecision.RANGE) { + throw DomainException.badRequest(ErrorCode.INVALID_DATE_RANGE, + "meta_date_end is only allowed when meta_date_precision is RANGE"); + } + } + @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/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java index 87838d5c..686ef457 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandler.java @@ -6,6 +6,7 @@ import io.sentry.Sentry; import jakarta.validation.ConstraintViolationException; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -64,6 +65,38 @@ public class GlobalExceptionHandler { .body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, ex.getReason())); } + /** + * Backstop for any database integrity violation that slips past the explicit upstream + * guards (e.g. a future constraint, or the import path emitting a bad range). Turns it into + * a clean 400 instead of a 500 + Sentry alert. The known date-range cases are caught upstream + * and never reach here; this only catches the unanticipated ones — so it logs the constraint + * NAME at WARN to stay debuggable, without re-leaking SQL and without branching the response + * on it (the response stays generic, which is the non-brittle part). + */ + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity handleDataIntegrityViolation(DataIntegrityViolationException ex) { + // Log the constraint NAME only — schema metadata, safe for Loki, and enough to tell which + // constraint fired at 2am. Never pass `ex` / `ex.getMessage()`: those embed the SQL + the + // offending values (CWE-209). No Sentry: an integrity violation is a 400, not a system fault. + log.warn("Rejected a request that violated a database integrity constraint: {}", constraintNameOf(ex)); + return ResponseEntity.badRequest() + .body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, "The submitted data violated a database constraint")); + } + + /** + * Returns the offending constraint's name from the cause chain, or {@code "unknown"}. + * Reads only the name (a non-sensitive schema identifier) — never the SQL or the values. + */ + private static String constraintNameOf(Throwable ex) { + for (Throwable t = ex; t != null && t != t.getCause(); t = t.getCause()) { + if (t instanceof org.hibernate.exception.ConstraintViolationException cve + && cve.getConstraintName() != null) { + return cve.getConstraintName(); + } + } + return "unknown"; + } + @ExceptionHandler(Exception.class) public ResponseEntity handleGeneric(Exception ex) { Sentry.captureException(ex); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentRepositoryTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentRepositoryTest.java index 4b2d1b70..05a69895 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentRepositoryTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentRepositoryTest.java @@ -38,7 +38,10 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; +import org.springframework.dao.DataIntegrityViolationException; + import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) @@ -612,6 +615,48 @@ class DocumentRepositoryTest { .isLessThanOrEqualTo(5); } + // ─── V69 date-range CHECK constraints (#678) ────────────────────────────── + + @Test + void save_acceptsRange_whenEndEqualsStart() { + // chk_meta_date_end_after_start is end >= start, so equal dates are valid. + // Real Postgres + Flyway here (H2 would not enforce the CHECK) pins the + // app guard's isBefore semantics to the actual constraint — guards drift (AC2). + LocalDate day = LocalDate.of(1917, 1, 10); + Document saved = documentRepository.saveAndFlush(Document.builder() + .title("Gleicher Tag") + .originalFilename("gleicher_tag.pdf") + .status(DocumentStatus.UPLOADED) + .documentDate(day) + .metaDatePrecision(DatePrecision.RANGE) + .metaDateEnd(day) + .build()); + + Document found = documentRepository.findById(saved.getId()).orElseThrow(); + assertThat(found.getDocumentDate()).isEqualTo(day); + assertThat(found.getMetaDateEnd()).isEqualTo(day); + assertThat(found.getMetaDatePrecision()).isEqualTo(DatePrecision.RANGE); + } + + @Test + void save_rejectsRange_whenEndBeforeStart_atDbLevel() { + // The app guard normally intercepts this, so the DB CHECK never fires in practice. + // Persisting directly proves chk_meta_date_end_after_start actually rejects end < start + // (H2 would not) — if the app guard ever regresses, a bad row still can't reach the table, + // and this is exactly the violation the GlobalExceptionHandler backstop turns into a 400. + Document doc = Document.builder() + .title("Verdrehte Spanne") + .originalFilename("verdreht.pdf") + .status(DocumentStatus.UPLOADED) + .documentDate(LocalDate.of(1917, 1, 11)) + .metaDatePrecision(DatePrecision.RANGE) + .metaDateEnd(LocalDate.of(1917, 1, 10)) + .build(); + + assertThatThrownBy(() -> documentRepository.saveAndFlush(doc)) + .isInstanceOf(DataIntegrityViolationException.class); + } + // ─── seeding helpers ───────────────────────────────────────────────────── private Document uploaded(String title) { 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..0ed058a8 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; @@ -203,10 +204,12 @@ class DocumentServiceTest { // Editing a doc (e.g. fixing a location typo) without touching the precision // controls must NOT fabricate a precision. The form omits the three precision // fields → they arrive null on the DTO → the stored values must be preserved. + // Stored combo is RANGE + end: the only DB-valid way to have a non-null end + // (chk_meta_date_end_only_for_range), so the carried-over state passes the guard. UUID id = UUID.randomUUID(); Document doc = Document.builder() .id(id) - .metaDatePrecision(DatePrecision.MONTH) + .metaDatePrecision(DatePrecision.RANGE) .metaDateEnd(LocalDate.of(1916, 6, 30)) .metaDateRaw("Juni 1916") .receivers(new HashSet<>()) @@ -220,11 +223,119 @@ class DocumentServiceTest { documentService.updateDocument(id, dto, null, null); - assertThat(doc.getMetaDatePrecision()).isEqualTo(DatePrecision.MONTH); + assertThat(doc.getMetaDatePrecision()).isEqualTo(DatePrecision.RANGE); assertThat(doc.getMetaDateEnd()).isEqualTo(LocalDate.of(1916, 6, 30)); 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()); + } + + @Test + void updateDocument_acceptsRange_whenEndEqualsStart() throws Exception { + // AC2: the DB CHECK is end >= start, so equal dates are valid. + UUID id = UUID.randomUUID(); + Document doc = docForRangeUpdate(id); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(documentRepository.save(any())).thenReturn(doc); + + LocalDate same = LocalDate.of(1917, 1, 10); + documentService.updateDocument(id, rangeDto(same, same), null, null); + + assertThat(doc.getMetaDateEnd()).isEqualTo(same); + verify(documentRepository, atLeastOnce()).save(any()); + } + + @Test + void updateDocument_acceptsRange_whenEndAfterStart() throws Exception { + UUID id = UUID.randomUUID(); + Document doc = docForRangeUpdate(id); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(documentRepository.save(any())).thenReturn(doc); + + documentService.updateDocument(id, + rangeDto(LocalDate.of(1917, 1, 10), LocalDate.of(1917, 1, 11)), null, null); + + assertThat(doc.getMetaDateEnd()).isEqualTo(LocalDate.of(1917, 1, 11)); + verify(documentRepository, atLeastOnce()).save(any()); + } + + @Test + void updateDocument_acceptsRange_whenEndIsNull_openEnded() throws Exception { + // AC3: an open-ended range (no end) is valid. + UUID id = UUID.randomUUID(); + Document doc = docForRangeUpdate(id); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(documentRepository.save(any())).thenReturn(doc); + + documentService.updateDocument(id, + rangeDto(LocalDate.of(1917, 1, 10), null), null, null); + + verify(documentRepository, atLeastOnce()).save(any()); + } + + @Test + void updateDocument_acceptsRange_whenStartNullAndEndSet() throws Exception { + // AC4: mirrors the DB "meta_date IS NULL" escape — must NOT reject (and must not NPE). + UUID id = UUID.randomUUID(); + Document doc = docForRangeUpdate(id); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(documentRepository.save(any())).thenReturn(doc); + + documentService.updateDocument(id, + rangeDto(null, LocalDate.of(1917, 1, 11)), null, null); + + assertThat(doc.getMetaDateEnd()).isEqualTo(LocalDate.of(1917, 1, 11)); + verify(documentRepository, atLeastOnce()).save(any()); + } + + @Test + void updateDocument_rejectsEndDate_whenPrecisionNotRange() { + // AC6: an end date only makes sense for RANGE (mirrors chk_meta_date_end_only_for_range). + // API-only — the edit form clears the end field off-RANGE — so close the 500 class here too. + UUID id = UUID.randomUUID(); + Document doc = docForRangeUpdate(id); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + + DocumentUpdateDTO dto = new DocumentUpdateDTO(); + dto.setDocumentDate(LocalDate.of(1917, 1, 10)); + dto.setMetaDatePrecision(DatePrecision.MONTH); + dto.setMetaDateEnd(LocalDate.of(1917, 1, 31)); + + 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 diff --git a/backend/src/test/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandlerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandlerTest.java index a12933b8..c75e9fae 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandlerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/exception/GlobalExceptionHandlerTest.java @@ -1,11 +1,17 @@ package org.raddatz.familienarchiv.exception; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import io.sentry.Sentry; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; +import org.slf4j.LoggerFactory; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.ResponseEntity; import static org.assertj.core.api.Assertions.assertThat; @@ -30,4 +36,84 @@ class GlobalExceptionHandlerTest { assertThat(response.getBody().code()).isEqualTo(ErrorCode.INTERNAL_ERROR); } } + + @Test + void handleDataIntegrityViolation_returns400_withoutLeakingConstraint_orSentry() { + // A DataIntegrityViolationException carries the constraint name + SQL in its message; + // the response and logs must never echo it (CWE-209). It must become a clean 400, not a 500. + DataIntegrityViolationException ex = new DataIntegrityViolationException( + "could not execute statement; constraint [chk_meta_date_end_after_start]; " + + "column meta_date_end of relation documents"); + + Logger handlerLogger = (Logger) LoggerFactory.getLogger(GlobalExceptionHandler.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + handlerLogger.addAppender(appender); + + try (MockedStatic sentryMock = mockStatic(Sentry.class)) { + ResponseEntity response = + handler.handleDataIntegrityViolation(ex); + + assertThat(response.getStatusCode().value()).isEqualTo(400); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().code()).isEqualTo(ErrorCode.VALIDATION_ERROR); + assertThat(response.getBody().message()) + .doesNotContain("chk_") + .doesNotContain("meta_date"); + + // Defense-in-depth: an unanticipated integrity violation is not a system fault, + // so it must NOT fabricate a Sentry alert. + sentryMock.verifyNoInteractions(); + } finally { + handlerLogger.detachAppender(appender); + } + + assertThat(appender.list) + .as("logs a WARN line") + .anySatisfy(e -> assertThat(e.getLevel()).isEqualTo(Level.WARN)); + assertThat(appender.list) + .as("never logs the SQL statement / values (would re-leak to Loki)") + .noneSatisfy(e -> { + assertThat(e.getFormattedMessage()).contains("could not execute statement"); + }); + } + + @Test + void handleDataIntegrityViolation_logsConstraintName_butNotTheSql() { + // Debuggability (DevOps): the WARN must name *which* constraint fired so an + // unanticipated violation isn't a silent mystery — but it must carry the name only, + // never the SQL statement or the offending values that the SQLException message holds. + java.sql.SQLException sql = new java.sql.SQLException( + "ERROR: violates check constraint; could not execute statement; values (1917-01-10)"); + org.hibernate.exception.ConstraintViolationException cve = + new org.hibernate.exception.ConstraintViolationException( + "constraint violation", sql, "chk_meta_date_end_after_start"); + DataIntegrityViolationException ex = new DataIntegrityViolationException("wrapper", cve); + + Logger handlerLogger = (Logger) LoggerFactory.getLogger(GlobalExceptionHandler.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + handlerLogger.addAppender(appender); + + try (MockedStatic sentryMock = mockStatic(Sentry.class)) { + ResponseEntity response = + handler.handleDataIntegrityViolation(ex); + + // Response stays generic and leak-free (CWE-209) regardless of what we log. + assertThat(response.getStatusCode().value()).isEqualTo(400); + assertThat(response.getBody().message()) + .doesNotContain("chk_") + .doesNotContain("meta_date"); + sentryMock.verifyNoInteractions(); + } finally { + handlerLogger.detachAppender(appender); + } + + assertThat(appender.list) + .as("WARN names the constraint for debuggability") + .anySatisfy(e -> assertThat(e.getFormattedMessage()).contains("chk_meta_date_end_after_start")); + assertThat(appender.list) + .as("but never the SQL statement or values") + .noneSatisfy(e -> assertThat(e.getFormattedMessage()).contains("could not execute statement")); + } } diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 14269eed..5590c6b7 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -655,6 +655,7 @@ "person_alias_btn_delete": "Entfernen", "error_alias_not_found": "Der Namensalias wurde nicht gefunden.", "error_invalid_person_type": "Der angegebene Personentyp ist ungültig.", + "error_invalid_date_range": "Das Enddatum darf nicht vor dem Startdatum liegen.", "validation_last_name_required": "Nachname ist Pflichtfeld.", "validation_first_name_required": "Vorname ist Pflichtfeld.", "error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index b7de0948..5b7c2698 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -655,6 +655,7 @@ "person_alias_btn_delete": "Remove", "error_alias_not_found": "The name alias was not found.", "error_invalid_person_type": "The specified person type is not valid.", + "error_invalid_date_range": "The end date must not be before the start date.", "validation_last_name_required": "Last name is required.", "validation_first_name_required": "First name is required.", "error_ocr_service_unavailable": "The OCR service is not available.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index ee584c40..4e856892 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -655,6 +655,7 @@ "person_alias_btn_delete": "Eliminar", "error_alias_not_found": "No se encontro el alias de nombre.", "error_invalid_person_type": "El tipo de persona especificado no es válido.", + "error_invalid_date_range": "La fecha final no puede ser anterior a la inicial.", "validation_last_name_required": "El apellido es obligatorio.", "validation_first_name_required": "El nombre es obligatorio.", "error_ocr_service_unavailable": "El servicio OCR no está disponible.", diff --git a/frontend/src/lib/document/WhoWhenSection.svelte b/frontend/src/lib/document/WhoWhenSection.svelte index 1a312e63..8c49a72b 100644 --- a/frontend/src/lib/document/WhoWhenSection.svelte +++ b/frontend/src/lib/document/WhoWhenSection.svelte @@ -70,6 +70,13 @@ onMount(() => { const dateInvalid = $derived(dateDirty && dateDisplay.length > 0 && dateIso === ''); +// Inline mirror of the server guard (#678). ISO YYYY-MM-DD strings compare +// lexicographically, so no Date object is needed. Server stays the gate — +// this only surfaces the error early; it never disables Save. +const endBeforeStart = $derived( + showEndDate && endDateIso !== '' && dateIso !== '' && endDateIso < dateIso +); + function handleDateInput(e: Event) { const result = handleGermanDateInput(e); dateDisplay = result.display; @@ -155,8 +162,19 @@ $effect(() => { oninput={handleEndDateInput} placeholder={m.form_placeholder_date()} maxlength="10" - class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring" + aria-invalid={endBeforeStart ? 'true' : undefined} + aria-describedby={endBeforeStart ? 'end-date-error' : undefined} + class="block min-h-[48px] w-full rounded border border-line px-2 py-3 text-sm shadow-sm + {endBeforeStart + ? 'border-red-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500' + : 'focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring'}" /> + {#if endBeforeStart} + +

+ {m.error_invalid_date_range()} +

+ {/if} {/if} diff --git a/frontend/src/lib/document/WhoWhenSection.svelte.test.ts b/frontend/src/lib/document/WhoWhenSection.svelte.test.ts index d3a05147..f2d7746f 100644 --- a/frontend/src/lib/document/WhoWhenSection.svelte.test.ts +++ b/frontend/src/lib/document/WhoWhenSection.svelte.test.ts @@ -102,3 +102,49 @@ describe('WhoWhenSection — precision controls', () => { expect(raw?.querySelector('b')).toBeNull(); }); }); + +describe('WhoWhenSection — end-before-start inline validation (#678)', () => { + it('shows an inline error on the end-date field when end is before start (AC1)', async () => { + render(WhoWhenSection, { + precision: 'RANGE', + dateIso: '1917-01-11', + endDateIso: '1917-01-10' + }); + + const end = document.querySelector('input#metaDateEnd') as HTMLInputElement; + await vi.waitFor(() => { + expect(document.querySelector('#end-date-error')).not.toBeNull(); + expect(end.getAttribute('aria-invalid')).toBe('true'); + expect(end.className).toContain('border-red-400'); + }); + }); + + it('clears the inline error once the end date is corrected, without reload (AC5)', async () => { + render(WhoWhenSection, { + precision: 'RANGE', + dateIso: '1917-01-11', + endDateIso: '1917-01-10' + }); + + await vi.waitFor(() => expect(document.querySelector('#end-date-error')).not.toBeNull()); + + const end = document.querySelector('input#metaDateEnd') as HTMLInputElement; + end.value = '12.01.1917'; // now after the start + end.dispatchEvent(new Event('input', { bubbles: true })); + + await vi.waitFor(() => { + expect(document.querySelector('#end-date-error')).toBeNull(); + expect(end.getAttribute('aria-invalid')).not.toBe('true'); + }); + }); + + it('does not show the inline error when precision is not RANGE', async () => { + render(WhoWhenSection, { + precision: 'DAY', + dateIso: '1917-01-11', + endDateIso: '1917-01-10' + }); + + expect(document.querySelector('#end-date-error')).toBeNull(); + }); +}); diff --git a/frontend/src/lib/shared/errors.ts b/frontend/src/lib/shared/errors.ts index dcdb9f25..6efec2a7 100644 --- a/frontend/src/lib/shared/errors.ts +++ b/frontend/src/lib/shared/errors.ts @@ -8,6 +8,7 @@ export type ErrorCode = | 'PERSON_NOT_FOUND' | 'ALIAS_NOT_FOUND' | 'INVALID_PERSON_TYPE' + | 'INVALID_DATE_RANGE' | 'DOCUMENT_NOT_FOUND' | 'DOCUMENT_NO_FILE' | 'FILE_NOT_FOUND' @@ -87,6 +88,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string { return m.error_alias_not_found(); case 'INVALID_PERSON_TYPE': return m.error_invalid_person_type(); + case 'INVALID_DATE_RANGE': + return m.error_invalid_date_range(); case 'DOCUMENT_NOT_FOUND': return m.error_document_not_found(); case 'DOCUMENT_NO_FILE':