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