fix(timeline): add @Transactional(readOnly=true) to TimelineService.assemble()
Without the annotation, Hibernate closes its sub-transaction after eventRepository.findAll() returns, leaving TimelineEvent entities detached. Accessing ev.getPersons() or doc.getReceivers() on those detached entities throws LazyInitializationException in production (constitution §1.6). @DataJpaTest and @Transactional test classes masked the bug by keeping an outer session alive. Fixes: @felix / @markus review blockers on PR #826 Refs #777 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import org.raddatz.familienarchiv.person.Person;
|
|||||||
import org.raddatz.familienarchiv.person.PersonService;
|
import org.raddatz.familienarchiv.person.PersonService;
|
||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
@@ -56,7 +57,14 @@ public class TimelineService {
|
|||||||
* Assembles the timeline for the given filter. All filters are ANDed.
|
* Assembles the timeline for the given filter. All filters are ANDed.
|
||||||
* Throws {@link DomainException} (bad request) when fromYear > toYear.
|
* Throws {@link DomainException} (bad request) when fromYear > toYear.
|
||||||
* Throws {@link DomainException} (not found) when personId refers to an unknown person.
|
* Throws {@link DomainException} (not found) when personId refers to an unknown person.
|
||||||
|
*
|
||||||
|
* <p>{@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) {
|
public TimelineDTO assemble(TimelineFilter filter) {
|
||||||
if (filter.fromYear() != null && filter.toYear() != null
|
if (filter.fromYear() != null && filter.toYear() != null
|
||||||
&& filter.fromYear() > filter.toYear()) {
|
&& filter.fromYear() > filter.toYear()) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user