test(timeline): add service integration tests (Testcontainers)

Two service-level integration tests against real Postgres (V77 CHECKs are
Postgres-specific): (1) view-assembly round-trip proving the
@Transactional(readOnly=true) LazyInit guard populates persons/documents after
an em.clear()ed fresh getEvent, with a serialized-JSON assertion that no
notes/provisional/password leak; (2) real optimistic-lock 409 — editor B's
stale version yields TIMELINE_EVENT_CONFLICT end-to-end (the unit test only
proves the catch/guard branches).

Per #775.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-13 11:09:14 +02:00
committed by marcel
parent 209f223b9f
commit d7f8abd6c4

View File

@@ -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<UUID> personIds, List<UUID> 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);
}
}