Friendly app-level validation for a RANGE with end-before-start (#678) #706
@@ -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<String> tagNames) {
|
||||
Document doc = documentRepository.findById(docId)
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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<ErrorResponse> 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<ErrorResponse> handleGeneric(Exception ex) {
|
||||
Sentry.captureException(ex);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ILoggingEvent> appender = new ListAppender<>();
|
||||
appender.start();
|
||||
handlerLogger.addAppender(appender);
|
||||
|
||||
try (MockedStatic<Sentry> sentryMock = mockStatic(Sentry.class)) {
|
||||
ResponseEntity<GlobalExceptionHandler.ErrorResponse> 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<ILoggingEvent> appender = new ListAppender<>();
|
||||
appender.start();
|
||||
handlerLogger.addAppender(appender);
|
||||
|
||||
try (MockedStatic<Sentry> sentryMock = mockStatic(Sentry.class)) {
|
||||
ResponseEntity<GlobalExceptionHandler.ErrorResponse> 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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}
|
||||
<!-- Non-colour cue (WCAG 1.4.1): warning glyph + text, not red alone. -->
|
||||
<p id="end-date-error" class="mt-1 text-xs text-red-600">
|
||||
<span aria-hidden="true">⚠ </span>{m.error_invalid_date_range()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user