diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventServiceIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventServiceIntegrationTest.java new file mode 100644 index 00000000..1ee456cc --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineEventServiceIntegrationTest.java @@ -0,0 +1,109 @@ +package org.raddatz.familienarchiv.timeline; + +import com.fasterxml.jackson.databind.ObjectMapper; +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.DocumentStatus; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.person.Person; +import org.raddatz.familienarchiv.person.PersonRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +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.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Service-level integration scope the entity/DB tests ({@link TimelineEventTest}) don't reach: the + * in-transaction view assembly survives a context clear (the {@code @Transactional(readOnly = true)} + * LazyInit guard), the serialized view leaks no curator-internal fields, and the {@code @Version} + * optimistic lock engages end-to-end (the Mockito test only proves the catch branch). Real Postgres + * (V77 CHECK constraints are Postgres-specific) via {@link PostgresContainerConfig}. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("test") +@Import(PostgresContainerConfig.class) +@Transactional +class TimelineEventServiceIntegrationTest { + + @MockitoBean S3Client s3Client; + @Autowired TimelineEventService service; + @Autowired PersonRepository personRepository; + @Autowired DocumentRepository documentRepository; + @PersistenceContext EntityManager em; + + // Built locally — the webEnvironment=NONE context has no auto-configured ObjectMapper bean. + // findAndRegisterModules() pulls in JavaTimeModule so the view's LocalDate/LocalDateTime serialize. + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); + + private TimelineEventRequest request(String title, Long version, List personIds, List documentIds) { + return new TimelineEventRequest(title, EventType.PERSONAL, LocalDate.of(1914, 7, 28), + null, null, null, version, personIds, documentIds); + } + + @Test + void getEvent_after_context_clear_populates_links_and_leaks_no_internal_fields() throws Exception { + Person anna = personRepository.save(Person.builder() + .firstName("Anna").lastName("Müller").notes("GEHEIM-NOTIZ").provisional(true).build()); + Document letter = documentRepository.save(Document.builder() + .title("Brief an Anna").originalFilename("brief.pdf").status(DocumentStatus.UPLOADED).build()); + UUID eventId = service.create( + request("Hochzeit", null, List.of(anna.getId()), List.of(letter.getId())), UUID.randomUUID()).id(); + em.flush(); + em.clear(); + + // Fresh read — NOT the create return value (that view was assembled while the entity was + // managed). Only a separate read after the clear proves the readOnly LazyInit guard. + TimelineEventView fresh = service.getEvent(eventId); + + assertThat(fresh.persons()).singleElement().satisfies(p -> { + assertThat(p.firstName()).isEqualTo("Anna"); + assertThat(p.lastName()).isEqualTo("Müller"); + }); + assertThat(fresh.documents()).singleElement().satisfies(d -> + assertThat(d.title()).isEqualTo("Brief an Anna")); + + // Assert on the SERIALIZED JSON: a getter re-introducing a leaked field later would slip + // past a field-level check. + String json = objectMapper.writeValueAsString(fresh); + assertThat(json) + .doesNotContain("GEHEIM-NOTIZ") + .doesNotContain("notes") + .doesNotContain("provisional") + .doesNotContain("password"); + } + + @Test + void concurrent_update_with_stale_version_yields_TIMELINE_EVENT_CONFLICT() { + UUID editorA = UUID.randomUUID(); + UUID editorB = UUID.randomUUID(); + UUID eventId = service.create(request("Original", null, null, null), editorA).id(); + em.flush(); + em.clear(); + + // Editor A saves with the version they last saw (0) → succeeds, version advances to 1. + service.update(eventId, request("Edit A", 0L, null, null), editorA); + em.flush(); + em.clear(); + + // Editor B still holds the stale version 0 → the versioned UPDATE matches no row → 409. + assertThatThrownBy(() -> service.update(eventId, request("Edit B", 0L, null, null), editorB)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.TIMELINE_EVENT_CONFLICT); + } +}