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