diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventCascadeIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventCascadeIntegrationTest.java new file mode 100644 index 00000000..66b1df3e --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventCascadeIntegrationTest.java @@ -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(); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventTest.java new file mode 100644 index 00000000..2d6f6435 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventTest.java @@ -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(); + } +}