test(timeline): cover persistence, constraints, and FK cascade

@DataJpaTest against real Postgres (never H2): required-field round-trip,
YEAR default, linked persons/documents, eventDateEnd null/range round-trip,
TEXT description with no length cap, both RANGE-invariant rejections, the
UNKNOWN-precision rejection (NOT_SUPPORTED so the constraint violation does
not poison the test transaction), version null-before-persist/0-after-save,
and a parameterized accept-side proving DAY/MONTH/SEASON/YEAR/APPROX all
persist. makeEvent() defaults createdBy/updatedBy to random UUIDs so every
red is red for the intended reason.

@SpringBootTest cascade guard: deleting a linked Person/Document via the
domain service drops the join row (verified by direct COUNT) and leaves the
event intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-12 23:05:04 +02:00
parent 23e53bf30b
commit 8bc21d8680
2 changed files with 297 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
package org.raddatz.familienarchiv.timeline;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.raddatz.familienarchiv.person.PersonService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.transaction.annotation.Transactional;
import software.amazon.awssdk.services.s3.S3Client;
import java.time.LocalDate;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Proves V77's FK {@code ON DELETE CASCADE} on the join tables: deleting a linked Person or
* Document drops the join row and leaves the {@link TimelineEvent} intact (a person/document
* delete must never 500 — V71-class regression guard). Needs the full Spring context for
* {@link PersonService}/{@link DocumentService}, mirroring {@code PersonServiceIntegrationTest}.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
@ActiveProfiles("test")
@Import(PostgresContainerConfig.class)
@Transactional
class TimelineEventCascadeIntegrationTest {
@MockitoBean S3Client s3Client;
@Autowired TimelineEventRepository events;
@Autowired PersonRepository personRepository;
@Autowired PersonService personService;
@Autowired DocumentRepository documentRepository;
@Autowired DocumentService documentService;
@Autowired JdbcTemplate jdbc;
@PersistenceContext EntityManager em;
private TimelineEvent.TimelineEventBuilder makeEvent() {
return TimelineEvent.builder()
.title("Hochzeit von Anna und Otto")
.type(EventType.PERSONAL)
.eventDate(LocalDate.of(1914, 7, 28))
.createdBy(UUID.randomUUID())
.updatedBy(UUID.randomUUID());
}
@Test
void deleting_linked_person_keeps_event_and_drops_join_row() {
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Raddatz").build());
TimelineEvent event = events.save(makeEvent().persons(Set.of(anna)).build());
em.flush();
em.clear();
personService.deletePerson(anna.getId());
em.flush();
em.clear();
assertThat(events.findById(event.getId())).isPresent();
Integer joinRows = jdbc.queryForObject(
"SELECT COUNT(*) FROM timeline_event_persons WHERE timeline_event_id = ?",
Integer.class, event.getId());
assertThat(joinRows).isZero();
}
@Test
void deleting_linked_document_keeps_event_and_drops_join_row() {
Document letter = documentRepository.save(Document.builder()
.title("Brief").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
TimelineEvent event = events.save(makeEvent().documents(Set.of(letter)).build());
em.flush();
em.clear();
documentService.deleteDocument(letter.getId(), UUID.randomUUID());
em.flush();
em.clear();
assertThat(events.findById(event.getId())).isPresent();
Integer joinRows = jdbc.queryForObject(
"SELECT COUNT(*) FROM timeline_event_documents WHERE timeline_event_id = ?",
Integer.class, event.getId());
assertThat(joinRows).isZero();
}
}

View File

@@ -0,0 +1,202 @@
package org.raddatz.familienarchiv.timeline;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.raddatz.familienarchiv.PostgresContainerConfig;
import org.raddatz.familienarchiv.config.FlywayConfig;
import org.raddatz.familienarchiv.document.DatePrecision;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.document.DocumentRepository;
import org.raddatz.familienarchiv.document.DocumentStatus;
import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.person.PersonRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.Set;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Persistence + DB-constraint tests for {@link TimelineEvent} against real Postgres (V77).
* Mirrors {@code MigrationIntegrationTest}'s slice setup; never H2 — only the real DB proves
* enum-as-varchar storage, the RANGE/UNKNOWN CHECK constraints, FK cascade, and {@code @Version}.
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({PostgresContainerConfig.class, FlywayConfig.class})
class TimelineEventTest {
@Autowired TimelineEventRepository events;
@Autowired PersonRepository persons;
@Autowired DocumentRepository documents;
@Autowired EntityManager em;
/**
* Sensible defaults; each test overrides only what it asserts. {@code createdBy}/{@code updatedBy}
* default to random UUIDs — both columns are NOT NULL and not auto-populated, so without these
* every test would fail at flush with the same constraint violation (red for the wrong reason).
* Precision is intentionally left unset so {@code @Builder.Default YEAR} can be exercised.
*/
private TimelineEvent.TimelineEventBuilder makeEvent() {
return TimelineEvent.builder()
.title("Hochzeit von Anna und Otto")
.type(EventType.PERSONAL)
.eventDate(LocalDate.of(1914, 7, 28))
.createdBy(UUID.randomUUID())
.updatedBy(UUID.randomUUID());
}
@Test
void persists_and_loads_event_with_required_fields() {
TimelineEvent saved = events.save(makeEvent().build());
assertThat(saved.getId()).isNotNull();
}
@Test
void precision_defaults_to_YEAR_when_not_set() {
TimelineEvent saved = events.save(makeEvent().build());
assertThat(saved.getPrecision()).isEqualTo(DatePrecision.YEAR);
}
@Test
void persists_event_with_linked_persons() {
Person anna = persons.save(Person.builder().firstName("Anna").lastName("Raddatz").build());
TimelineEvent saved = events.save(makeEvent().persons(Set.of(anna)).build());
em.flush();
em.clear();
TimelineEvent reloaded = events.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getPersons()).extracting(Person::getId).containsExactly(anna.getId());
}
@Test
void persists_event_with_linked_documents() {
Document letter = documents.save(Document.builder()
.title("Brief").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build());
TimelineEvent saved = events.save(makeEvent().documents(Set.of(letter)).build());
em.flush();
em.clear();
TimelineEvent reloaded = events.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getDocuments()).extracting(Document::getId).containsExactly(letter.getId());
}
@Test
void eventDateEnd_round_trips_null_for_non_range() {
TimelineEvent saved = events.save(makeEvent().build()); // YEAR precision, no end
em.flush();
em.clear();
assertThat(events.findById(saved.getId()).orElseThrow().getEventDateEnd()).isNull();
}
@Test
void eventDateEnd_round_trips_value_for_range() {
TimelineEvent saved = events.save(makeEvent()
.precision(DatePrecision.RANGE)
.eventDate(LocalDate.of(1914, 1, 1))
.eventDateEnd(LocalDate.of(1918, 12, 31))
.build());
em.flush();
em.clear();
assertThat(events.findById(saved.getId()).orElseThrow().getEventDateEnd())
.isEqualTo(LocalDate.of(1918, 12, 31));
}
@Test
void description_round_trips_null() {
TimelineEvent saved = events.save(makeEvent().build());
em.flush();
em.clear();
assertThat(events.findById(saved.getId()).orElseThrow().getDescription()).isNull();
}
@Test
void description_round_trips_multi_kb_text() {
// Proves TEXT has no length cap — @Column(columnDefinition = "TEXT") overrides
// Hibernate's default VARCHAR(255).
String longText = "Sommertage am See. ".repeat(500); // ~9.5 KB
TimelineEvent saved = events.save(makeEvent().description(longText).build());
em.flush();
em.clear();
assertThat(events.findById(saved.getId()).orElseThrow().getDescription()).isEqualTo(longText);
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void range_invariant_rejects_non_null_end_without_range_precision() {
// precision YEAR + non-null end violates chk_timeline_event_range.
try {
assertThatThrownBy(() -> events.saveAndFlush(makeEvent()
.eventDateEnd(LocalDate.of(1918, 12, 31))
.build()))
.isInstanceOf(DataIntegrityViolationException.class);
} finally {
events.deleteAll(); // NOT_SUPPORTED opts out of the rollback; clean any leaked row
}
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void range_invariant_rejects_range_precision_without_end_date() {
// precision RANGE + null end violates chk_timeline_event_range.
try {
assertThatThrownBy(() -> events.saveAndFlush(makeEvent()
.precision(DatePrecision.RANGE)
.build()))
.isInstanceOf(DataIntegrityViolationException.class);
} finally {
events.deleteAll();
}
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void unknown_precision_is_rejected() {
// chk_timeline_event_precision forbids UNKNOWN — curated events are never undated.
try {
assertThatThrownBy(() -> events.saveAndFlush(makeEvent()
.precision(DatePrecision.UNKNOWN)
.build()))
.isInstanceOf(DataIntegrityViolationException.class);
} finally {
events.deleteAll();
}
}
@Test
void version_is_null_before_persist_and_zero_after_save() {
TimelineEvent fresh = makeEvent().build();
assertThat(fresh.getVersion()).isNull(); // @Version Long is null pre-persist
TimelineEvent saved = events.saveAndFlush(fresh);
assertThat(saved.getVersion()).isEqualTo(0L); // Hibernate sets 0 on insert
}
@ParameterizedTest
@EnumSource(value = DatePrecision.class, names = {"DAY", "MONTH", "SEASON", "YEAR", "APPROX"})
void all_non_unknown_precisions_are_accepted(DatePrecision precision) {
// Accept-side of chk_timeline_event_precision: every non-RANGE, non-UNKNOWN value persists.
// Documents that SEASON ("Sommer 1914") and APPROX ("ca. 1914") are intentionally legal,
// so an over-tight CHECK cannot ship green. (RANGE is covered by the round-trip test.)
TimelineEvent saved = events.saveAndFlush(makeEvent().precision(precision).build());
assertThat(saved.getId()).isNotNull();
}
}