fix(timeline): reject reversed RANGE events; thread precision
All checks were successful
CI / Unit & Component Tests (push) Successful in 5m56s
CI / OCR Service Tests (push) Successful in 29s
CI / Backend Unit Tests (push) Successful in 5m49s
CI / fail2ban Regex (push) Successful in 49s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m6s

The DB CHECK chk_timeline_event_range enforces only the presence
biconditional (eventDateEnd non-null IFF RANGE), not date ordering, so a
RANGE event with eventDateEnd before eventDate persisted silently and
rendered as a negative span. validateRangeInvariant now also rejects
end-before-start (INVALID_DATE_RANGE); equal dates remain a valid one-day
closed range.

Also compute effectivePrecision once per create/update and thread it into
validateRangeInvariant and applyUpdate instead of recomputing.

Addresses review of #822 (#775).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit was merged in pull request #822.
This commit is contained in:
Marcel
2026-06-13 12:01:14 +02:00
committed by marcel
parent 3de4ff55ea
commit 210dde6562
2 changed files with 60 additions and 9 deletions

View File

@@ -153,6 +153,32 @@ class TimelineEventServiceTest {
verify(events, never()).saveAndFlush(any());
}
@Test
void create_rejects_RANGE_with_eventDateEnd_before_eventDate() {
TimelineEventRequest request = new TimelineEventRequest(
"Krieg", EventType.HISTORICAL, LocalDate.of(1918, 11, 11),
DatePrecision.RANGE, LocalDate.of(1914, 7, 28), null, null, null, null); // end < start
assertThatThrownBy(() -> service.create(request, actor))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE);
verify(events, never()).saveAndFlush(any());
}
@Test
void create_accepts_RANGE_with_eventDateEnd_equal_to_eventDate() {
stubFlushSetsVersion();
LocalDate sameDay = LocalDate.of(1914, 7, 28);
TimelineEventRequest request = new TimelineEventRequest(
"Eintägiges Ereignis", EventType.HISTORICAL, sameDay,
DatePrecision.RANGE, sameDay, null, null, null, null); // end == start is a valid closed range
TimelineEventView view = service.create(request, actor);
assertThat(view.eventDate()).isEqualTo(sameDay);
assertThat(view.eventDateEnd()).isEqualTo(sameDay);
}
// --- title-length service guard ---
@Test
@@ -287,6 +313,21 @@ class TimelineEventServiceTest {
assertThat(existing.getDocuments()).isEmpty();
}
@Test
void update_rejects_RANGE_with_eventDateEnd_before_eventDate_without_saving() {
UUID id = UUID.randomUUID();
TimelineEvent existing = existingEvent(id, UUID.randomUUID());
when(events.findById(id)).thenReturn(Optional.of(existing));
TimelineEventRequest request = new TimelineEventRequest(
"Krieg", EventType.HISTORICAL, LocalDate.of(1918, 11, 11),
DatePrecision.RANGE, LocalDate.of(1914, 7, 28), null, null, null, null); // end < start
assertThatThrownBy(() -> service.update(id, request, secondEditor))
.isInstanceOf(DomainException.class)
.extracting(e -> ((DomainException) e).getCode()).isEqualTo(ErrorCode.INVALID_DATE_RANGE);
verify(events, never()).saveAndFlush(any());
}
@Test
void update_of_missing_id_throws_TIMELINE_EVENT_NOT_FOUND() {
UUID id = UUID.randomUUID();