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.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.
|
||||
*
|
||||
* <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) {
|
||||
if (filter.fromYear() != null && filter.toYear() != null
|
||||
&& 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