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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user