diff --git a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java index 50b008bd..f55ccd91 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/timeline/TimelineService.java @@ -12,6 +12,7 @@ import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.person.PersonService; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.Comparator; @@ -56,7 +57,14 @@ public class TimelineService { * Assembles the timeline for the given filter. All filters are ANDed. * Throws {@link DomainException} (bad request) when fromYear > toYear. * Throws {@link DomainException} (not found) when personId refers to an unknown person. + * + *
{@code @Transactional(readOnly=true)} is required here — unlike simple scalar reads, + * this method accesses lazy collections ({@link TimelineEvent#getPersons()}, + * {@link org.raddatz.familienarchiv.document.Document#getReceivers()}) after the + * repository sub-transaction closes. Without this annotation those accesses throw + * {@link org.hibernate.LazyInitializationException} in production (constitution §1.6). */ + @Transactional(readOnly = true) public TimelineDTO assemble(TimelineFilter filter) { if (filter.fromYear() != null && filter.toYear() != null && filter.fromYear() > filter.toYear()) { diff --git a/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceLazyLoadTest.java b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceLazyLoadTest.java new file mode 100644 index 00000000..39c25fd3 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/timeline/TimelineServiceLazyLoadTest.java @@ -0,0 +1,72 @@ +package org.raddatz.familienarchiv.timeline; + +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.document.DatePrecision; +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.support.TransactionTemplate; +import software.amazon.awssdk.services.s3.S3Client; + +import java.time.LocalDate; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +/** + * Verifies that {@link TimelineService#assemble} does not throw + * {@link org.hibernate.LazyInitializationException} when events have linked persons. + * + *
No class-level {@code @Transactional} — each test method runs without an outer + * transaction, matching production behaviour (controller has no {@code @Transactional}). + * If {@code assemble()} lacks {@code @Transactional(readOnly=true)}, accessing + * {@code ev.getPersons()} on detached entities throws LazyInitializationException. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) +@ActiveProfiles("test") +@Import(PostgresContainerConfig.class) +class TimelineServiceLazyLoadTest { + + @MockitoBean + S3Client s3Client; + + @Autowired + TransactionTemplate transactionTemplate; + + @Autowired + TimelineService timelineService; + + @Autowired + TimelineEventRepository timelineEventRepository; + + @Autowired + PersonRepository personRepository; + + @Test + void assemble_does_not_throw_when_event_has_linked_persons() { + UUID actorId = UUID.randomUUID(); + // Commit outside any test-managed transaction so entities are detached on return + transactionTemplate.execute(status -> { + Person person = personRepository.save(Person.builder().lastName("Müller").build()); + timelineEventRepository.save(TimelineEvent.builder() + .title("Linked event") + .type(EventType.HISTORICAL) + .eventDate(LocalDate.of(1914, 7, 28)) + .precision(DatePrecision.DAY) + .createdBy(actorId) + .updatedBy(actorId) + .persons(new HashSet<>(Set.of(person))) + .build()); + return null; + }); + + assertDoesNotThrow(() -> timelineService.assemble(new TimelineFilter(null, null, null, null, null))); + } +}