feat(timeline): GET /api/timeline assembly endpoint (#777) #826
@@ -59,3 +59,23 @@
|
||||
| REQ-014 | empty family-member list → empty result, no error | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` | `DerivedEventsAssemblyTest#should_emit_zero_events_when_no_family_members` | Done |
|
||||
| REQ-015 | assembleDerivedEvents() annotated @Transactional(readOnly=true) | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` | code-review check (annotation visible in source) | Done |
|
||||
| REQ-016 | no PII (names, dates) in log statements — only aggregate counts | #776 | derive-person-life-events | `timeline/TimelineEventService#assembleDerivedEvents` | log-audit: single log.debug with result.size() and persons.size() only | Done |
|
||||
| REQ-001 | GET /api/timeline requires READ_ALL permission; 401 unauthenticated, 403 wrong permission | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_401_when_unauthenticated`, `#returns_403_when_authenticated_without_read_all`, `#returns_200_with_read_all_permission` | Done |
|
||||
| REQ-002 | within-band sort: precision rank desc (DAY>MONTH>SEASON>YEAR>APPROX), then date asc, then title alpha, then id tiebreak | #777 | timeline-assembly | `timeline/TimelineService#WITHIN_BAND_ORDER` | `TimelineServiceTest#within_band_order_day_precision_sorts_before_year`, `#within_band_order_same_precision_and_date_sorts_alphabetically`, `#within_band_order_same_title_uses_document_id_as_tiebreak`, `#test5_day_precision_sorts_before_year_in_same_year_band`, `#test6_same_precision_same_date_sorted_alphabetically_by_title` | Done |
|
||||
| REQ-003 | null eventDate OR UNKNOWN precision → undated bucket (never in a year band) | #777 | timeline-assembly | `timeline/TimelineService#bucketByYear` | `TimelineServiceTest#test3a_null_date_letter_goes_to_undated`, `#test3b_unknown_precision_letter_goes_to_undated` | Done |
|
||||
| REQ-004 | RANGE events placed in start-year band only; null eventDateEnd does not crash; start year outside [fromYear,toYear] → excluded | #777 | timeline-assembly | `timeline/TimelineService#assembleCuratedEvents` | `TimelineServiceTest#test7a_range_event_placed_only_in_start_year_band`, `#test7b_range_event_with_null_eventDateEnd_does_not_crash`, `#test8_range_event_excluded_when_start_year_before_fromYear`, `#test15_range_event_start_year_equal_to_fromYear_is_included` | Done |
|
||||
| REQ-005 | null sender and null senderText on a document → senderName="" in the TimelineEntryDTO | #777 | timeline-assembly | `timeline/TimelineService#toLetterEntry` | `TimelineServiceTest#test4_letter_with_null_sender_and_null_senderText_produces_empty_names` | Done |
|
||||
| REQ-006 | personId filter: include document when personId is sender OR receiver | #777 | timeline-assembly | `timeline/TimelineService#fetchDocuments` | `TimelineServiceTest#test11_personId_scoping_deduplicates_letter_appearing_as_sender_and_receiver` | Done |
|
||||
| REQ-007 | documents domain letters always included (no type filter applied to LETTER kind) | #777 | timeline-assembly | `timeline/TimelineService#fetchDocuments`, `#assemble` | `TimelineServiceTest#test9a_type_filter_does_not_exclude_letters_but_excludes_wrong_type_events`, `#test1_empty_archive_returns_empty_dto`, `#test2_one_year_letter_returns_one_year_band` | Done |
|
||||
| REQ-008 | personId filter dedup: sender+receiver same person → document appears exactly once | #777 | timeline-assembly | `timeline/TimelineService#fetchDocuments` | `TimelineServiceTest#test11_personId_scoping_deduplicates_letter_appearing_as_sender_and_receiver` | Done |
|
||||
| REQ-009 | type filter applies to events only; letters (LETTER kind) always pass | #777 | timeline-assembly | `timeline/TimelineService#assembleCuratedEvents`, `#assembleDerivedEventsLayer` | `TimelineServiceTest#test9a_type_filter_does_not_exclude_letters_but_excludes_wrong_type_events` | Done |
|
||||
| REQ-010 | generation filter: PersonService.getPersonsByGeneration(N) used to build person-id set; filters all three layers | #777 | timeline-assembly | `timeline/TimelineService#assemble`, `person/PersonService#getPersonsByGeneration`, `person/PersonRepository#findByGeneration` | `TimelineServiceTest#test9b_generation_filter_includes_letter_when_sender_matches_generation`, `TimelineServiceIntegrationTest#findByGeneration_returns_matching_persons`, `#findByGeneration_returns_empty_list_not_npe_when_no_match`, `#findByGeneration_does_not_return_null_generation_persons` | Done |
|
||||
| REQ-011 | fromYear/toYear inclusive year-range filter; single-year window (fromYear==toYear); one-sided filter (fromYear only) | #777 | timeline-assembly | `timeline/TimelineService#passesYearFilter` | `TimelineServiceTest#test9c_fromYear_toYear_inclusive_single_year_window`, `#test16_fromYear_without_toYear_returns_all_items_from_that_year_onwards` | Done |
|
||||
| REQ-012 | combined filters AND logic — entry must pass all active filters | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineServiceTest#test10_adversarial_and_logic_neither_event_passes_both_filters`, `#test12_personId_plus_generation_filter_returns_empty_when_generations_do_not_match` | Done |
|
||||
| REQ-013 | empty archive (no events, no persons, no documents) → TimelineDTO { years=[], undated=[] } | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineServiceTest#test1_empty_archive_returns_empty_dto` | Done |
|
||||
| REQ-014 | unauthenticated request → 401 Unauthorized | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_401_when_unauthenticated` | Done |
|
||||
| REQ-015 | authenticated without READ_ALL → 403 Forbidden | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_403_when_authenticated_without_read_all` | Done |
|
||||
| REQ-016 | fromYear > toYear → 400 Bad Request | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineServiceTest#fromYear_greater_than_toYear_throws_bad_request`, `TimelineControllerTest#returns_400_when_fromYear_greater_than_toYear` | Done |
|
||||
| REQ-017 | generation < 0 → 400 Bad Request (@Min(0) on controller param) | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_400_when_generation_is_negative` | Done |
|
||||
| REQ-018 | unknown EventType string → 400 Bad Request (Spring enum binding) | #777 | timeline-assembly | `timeline/TimelineController#getTimeline` | `TimelineControllerTest#returns_400_on_bad_type_value` | Done |
|
||||
| REQ-019 | unknown personId → 404 Not Found | #777 | timeline-assembly | `timeline/TimelineService#assemble` | `TimelineControllerTest#returns_404_when_person_not_found` | Done |
|
||||
| REQ-020 | null-generation persons excluded from generation-filter results | #777 | timeline-assembly | `person/PersonRepository#findByGeneration` | `TimelineServiceTest#test13_null_generation_sender_not_returned_by_generation_filter`, `TimelineServiceIntegrationTest#findByGeneration_does_not_return_null_generation_persons` | Done |
|
||||
|
||||
@@ -99,7 +99,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
||||
│ └── relationship/ PersonRelationship sub-domain
|
||||
├── security/ SecurityConfig, Permission, @RequirePermission, PermissionAspect
|
||||
├── tag/ Tag domain
|
||||
├── timeline/ Timeline (Zeitstrahl) domain — TimelineEvent, TimelineEventService, TimelineEntryDTO, DerivedEventType, EventType, TimelineEventRepository; TimelineEventService.assembleDerivedEvents() returns derived life-events (Geburt/Tod/Heirat) computed on read from Person/relationship data
|
||||
├── timeline/ Timeline (Zeitstrahl) domain — TimelineEvent, TimelineEventService, TimelineEntryDTO, DerivedEventType, EventType, TimelineEventRepository; TimelineEventService.assembleDerivedEvents() returns derived life-events (Geburt/Tod/Heirat) computed on read from Person/relationship data; TimelineService assembles year-bucketed TimelineDTO (curated events + derived events + archive letters); TimelineController exposes GET /api/timeline
|
||||
└── user/ User domain — AppUser, UserGroup, UserService
|
||||
```
|
||||
|
||||
@@ -121,7 +121,7 @@ backend/src/main/java/org/raddatz/familienarchiv/
|
||||
| `Geschichte` | `geschichten` | `GeschichteType` (`STORY`/`JOURNEY`); ManyToMany `persons` (Person); OneToMany `items` (JourneyItem) |
|
||||
| `JourneyItem` | `journey_items` | ManyToOne `geschichte` (Geschichte, ON DELETE CASCADE); ManyToOne `document` (Document, ON DELETE SET NULL); `position`, optional `note` |
|
||||
| `TimelineEvent` | `timeline_events` | `EventType` (`PERSONAL`/`HISTORICAL`); ManyToMany `persons` (Person) + `documents` (Document), both join FKs ON DELETE CASCADE; `DatePrecision` date block; `@Version` + NOT NULL `createdBy`/`updatedBy` audit trail |
|
||||
| `TimelineEntryDTO` | _(computed — no table)_ | Unified DTO for curated + derived timeline events (`derived=false/true`); `id: String` (UUID for curated, prefixed synthetic for derived: `birth:`, `death:`, `marriage:`); `DerivedEventType` (`BIRTH`/`DEATH`/`MARRIAGE`) discriminator; `primaryPersonName` + `relatedPersonName` for localized label composition in #6/#7 |
|
||||
| `TimelineEntryDTO` | _(computed — no table)_ | Unified DTO for all timeline entries assembled by `TimelineService`; 13 fields: `kind` (`EVENT`\|`LETTER`), `precision` (raw `DatePrecision` enum), `derived` (boolean), `senderName` (non-null `String`, `""` = unknown), `receiverName` (non-null `String`, `""` = unknown), `eventDate`, `eventDateEnd`, `title`, `type` (`EventType`, null for LETTER), `eventId` (null for derived entries and letters), `documentId` (set for letters), `linkedPersonIds: List<UUID>`, `derivedType` (`DerivedEventType`, null for curated/letters); edit-affordance contract: `derived == true \|\| eventId == null` → no edit link |
|
||||
|
||||
**`DocumentStatus` lifecycle:** `PLACEHOLDER → UPLOADED → TRANSCRIBED → REVIEWED → ARCHIVED`
|
||||
|
||||
|
||||
@@ -56,6 +56,11 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
||||
// Prüft effizient, ob ein Dateiname schon existiert (gibt true/false zurück)
|
||||
boolean existsByOriginalFilename(String originalFilename);
|
||||
|
||||
// Bulk-fetch for global timeline path — single query with sender+receivers eager-loaded.
|
||||
@EntityGraph("Document.list")
|
||||
@Query("SELECT d FROM Document d")
|
||||
List<Document> findAllForTimeline();
|
||||
|
||||
// lazy – @BatchSize(50) fallback active; see ADR-022
|
||||
@EntityGraph("Document.full")
|
||||
List<Document> findBySenderId(UUID senderId);
|
||||
|
||||
@@ -1051,6 +1051,10 @@ public class DocumentService {
|
||||
return documentRepository.findDocumentsWithoutVersions();
|
||||
}
|
||||
|
||||
public List<Document> getAllForTimeline() {
|
||||
return documentRepository.findAllForTimeline();
|
||||
}
|
||||
|
||||
public List<Document> getDocumentsBySender(UUID senderId) {
|
||||
return documentRepository.findBySenderId(senderId);
|
||||
}
|
||||
|
||||
@@ -242,4 +242,7 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
||||
)
|
||||
""", nativeQuery = true)
|
||||
void insertMissingReceiverReference(@Param("source") UUID source, @Param("target") UUID target);
|
||||
|
||||
// Boxed Integer — matches the nullable person.generation column (primitive int would reject null rows).
|
||||
List<Person> findByGeneration(Integer generation);
|
||||
}
|
||||
|
||||
@@ -210,6 +210,10 @@ public class PersonService {
|
||||
return personRepository.findByFamilyMemberTrueOrderByLastNameAscFirstNameAsc();
|
||||
}
|
||||
|
||||
public List<Person> getPersonsByGeneration(Integer generation) {
|
||||
return personRepository.findByGeneration(generation);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Person setFamilyMember(UUID personId, boolean familyMember) {
|
||||
Person person = getById(personId);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
/** Discriminates curated/derived events from archive letters in {@link TimelineEntryDTO}. */
|
||||
public enum Kind {
|
||||
EVENT,
|
||||
LETTER
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/timeline")
|
||||
@Validated
|
||||
@RequiredArgsConstructor
|
||||
public class TimelineController {
|
||||
|
||||
private final TimelineService timelineService;
|
||||
|
||||
@GetMapping
|
||||
@RequirePermission(Permission.READ_ALL)
|
||||
public TimelineDTO getTimeline(
|
||||
@RequestParam(required = false) UUID personId,
|
||||
@RequestParam(required = false) @Min(0) Integer generation,
|
||||
@RequestParam(required = false) EventType type,
|
||||
@RequestParam(required = false) Integer fromYear,
|
||||
@RequestParam(required = false) Integer toYear) {
|
||||
return timelineService.assemble(new TimelineFilter(personId, generation, type, fromYear, toYear));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Assembled timeline response. Year bands are sorted ascending (oldest first).
|
||||
* Undated entries have no usable date or {@code UNKNOWN} precision.
|
||||
*/
|
||||
public record TimelineDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<TimelineYearDTO> years,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<TimelineEntryDTO> undated
|
||||
) {
|
||||
}
|
||||
@@ -4,28 +4,39 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Unified DTO for timeline entries — covers both curated {@link TimelineEvent} rows
|
||||
* ({@code derived=false}) and derived life-events assembled from Person/relationship data
|
||||
* ({@code derived=true}).
|
||||
* Unified DTO for timeline entries — covers curated {@link TimelineEvent} rows, derived
|
||||
* life-events ({@link DerivedEventType}), and archive letters (Documents).
|
||||
*
|
||||
* <p>The {@code id} field is typed {@code String}, not {@code UUID}, because derived events
|
||||
* carry synthetic prefixed ids ({@code birth:{uuid}}, {@code death:{uuid}},
|
||||
* {@code marriage:{uuid}}) that are structurally non-UUID by construction. Any write endpoint
|
||||
* must reject ids that do not parse as {@code UUID} — enforced and tested in issue #5.
|
||||
* <p><b>Edit-affordance contract (for issue #7):</b> {@code derived == true || eventId == null}
|
||||
* means no edit link should be rendered by the frontend.
|
||||
*
|
||||
* <p>Callers of {@code TimelineService.assembleDerivedEvents()} must independently enforce
|
||||
* <p><b>Letter display fields:</b> {@code senderName} — {@code ""} means unknown/unlinked
|
||||
* correspondent; frontend renders {@code 'Unbekannt'} fallback. Only populated for
|
||||
* {@link Kind#LETTER} entries.
|
||||
*
|
||||
* <p><b>Type field:</b> {@code null} for {@link Kind#LETTER} entries; frontend must not render
|
||||
* an event-type badge for letters.
|
||||
*
|
||||
* <p>Callers of {@code TimelineEventService.assembleDerivedEvents()} must independently enforce
|
||||
* {@code READ_ALL} authorization before invoking that method (see ADR-043).
|
||||
*/
|
||||
public record TimelineEntryDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) EventType type,
|
||||
LocalDate eventDate,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) Kind kind,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) DatePrecision precision,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean derived,
|
||||
DerivedEventType derivedType,
|
||||
String primaryPersonName,
|
||||
String relatedPersonName
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String senderName,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String receiverName,
|
||||
LocalDate eventDate,
|
||||
LocalDate eventDateEnd,
|
||||
String title,
|
||||
EventType type,
|
||||
UUID eventId,
|
||||
UUID documentId,
|
||||
List<UUID> linkedPersonIds,
|
||||
DerivedEventType derivedType
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -263,14 +263,10 @@ public class TimelineEventService {
|
||||
return persons.stream()
|
||||
.filter(p -> p.getBirthDate() != null)
|
||||
.map(p -> new TimelineEntryDTO(
|
||||
"birth:" + p.getId(),
|
||||
EventType.PERSONAL,
|
||||
p.getBirthDate(),
|
||||
p.getBirthDatePrecision(),
|
||||
true,
|
||||
DerivedEventType.BIRTH,
|
||||
p.getDisplayName(),
|
||||
null))
|
||||
Kind.EVENT, p.getBirthDatePrecision(), true, "", "",
|
||||
p.getBirthDate(), null,
|
||||
p.getDisplayName(), EventType.PERSONAL,
|
||||
null, null, List.of(p.getId()), DerivedEventType.BIRTH))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -278,14 +274,10 @@ public class TimelineEventService {
|
||||
return persons.stream()
|
||||
.filter(p -> p.getDeathDate() != null)
|
||||
.map(p -> new TimelineEntryDTO(
|
||||
"death:" + p.getId(),
|
||||
EventType.PERSONAL,
|
||||
p.getDeathDate(),
|
||||
p.getDeathDatePrecision(),
|
||||
true,
|
||||
DerivedEventType.DEATH,
|
||||
p.getDisplayName(),
|
||||
null))
|
||||
Kind.EVENT, p.getDeathDatePrecision(), true, "", "",
|
||||
p.getDeathDate(), null,
|
||||
p.getDisplayName(), EventType.PERSONAL,
|
||||
null, null, List.of(p.getId()), DerivedEventType.DEATH))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -303,15 +295,15 @@ public class TimelineEventService {
|
||||
DatePrecision precision = r.getFromYear() != null
|
||||
? DatePrecision.YEAR
|
||||
: DatePrecision.UNKNOWN;
|
||||
String title = r.getPerson().getDisplayName()
|
||||
+ " & " + r.getRelatedPerson().getDisplayName();
|
||||
result.add(new TimelineEntryDTO(
|
||||
"marriage:" + r.getId(),
|
||||
EventType.PERSONAL,
|
||||
eventDate,
|
||||
precision,
|
||||
true,
|
||||
DerivedEventType.MARRIAGE,
|
||||
r.getPerson().getDisplayName(),
|
||||
r.getRelatedPerson().getDisplayName()));
|
||||
Kind.EVENT, precision, true, "", "",
|
||||
eventDate, null,
|
||||
title, EventType.PERSONAL,
|
||||
null, null,
|
||||
List.of(r.getPerson().getId(), r.getRelatedPerson().getId()),
|
||||
DerivedEventType.MARRIAGE));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Immutable filter bag for {@link TimelineService#assemble(TimelineFilter)}.
|
||||
* All fields are nullable — null means "no constraint on this dimension".
|
||||
*/
|
||||
public record TimelineFilter(
|
||||
UUID personId,
|
||||
Integer generation,
|
||||
EventType type,
|
||||
Integer fromYear,
|
||||
Integer toYear
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
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;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Assembles the family timeline from three sources — curated {@link TimelineEvent} rows,
|
||||
* derived person life-events, and archive letters — into a year-bucketed {@link TimelineDTO}.
|
||||
*
|
||||
* <p>Cross-domain data is reached exclusively through domain services (PersonService,
|
||||
* DocumentService). The only repository injected directly is {@link TimelineEventRepository}
|
||||
* (same domain — constitution §1.3).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TimelineService {
|
||||
|
||||
/** Primary: precision rank descending (DAY first). Secondary: date ascending. Tertiary: title. Final: id. */
|
||||
static final Comparator<TimelineEntryDTO> WITHIN_BAND_ORDER =
|
||||
Comparator.comparingInt((TimelineEntryDTO e) -> precisionRank(e.precision())).reversed()
|
||||
.thenComparing(e -> e.eventDate() != null ? e.eventDate() : java.time.LocalDate.MAX)
|
||||
.thenComparing(e -> e.title() != null ? e.title() : "")
|
||||
.thenComparing(e -> {
|
||||
if (e.eventId() != null) return e.eventId().toString();
|
||||
if (e.documentId() != null) return e.documentId().toString();
|
||||
return "";
|
||||
});
|
||||
|
||||
private final TimelineEventRepository eventRepository;
|
||||
private final TimelineEventService timelineEventService;
|
||||
private final DocumentService documentService;
|
||||
private final PersonService personService;
|
||||
|
||||
/**
|
||||
* 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()) {
|
||||
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"toYear must not be before fromYear");
|
||||
}
|
||||
|
||||
// Resolve generation person IDs once — used across all three layers
|
||||
Set<UUID> genPersonIds = resolveGenerationPersonIds(filter.generation());
|
||||
|
||||
// ── curated events ───────────────────────────────────────────────────
|
||||
List<TimelineEntryDTO> entries = new ArrayList<>();
|
||||
for (TimelineEvent ev : eventRepository.findAll()) {
|
||||
if (!passesTypeFilter(ev.getType(), filter.type())) continue;
|
||||
if (!passesPersonFilter(ev.getPersons(), filter.personId())) continue;
|
||||
if (!passesGenerationFilter(ev.getPersons(), genPersonIds)) continue;
|
||||
if (!passesYearFilter(ev.getEventDate(), ev.getPrecision(), filter)) continue;
|
||||
entries.add(mapEvent(ev));
|
||||
}
|
||||
|
||||
// ── derived events ───────────────────────────────────────────────────
|
||||
for (TimelineEntryDTO derived : timelineEventService.assembleDerivedEvents()) {
|
||||
if (!passesTypeFilter(derived.type(), filter.type())) continue;
|
||||
if (!passesDerivedPersonFilter(derived.linkedPersonIds(), filter.personId())) continue;
|
||||
if (!passesDerivedGenerationFilter(derived.linkedPersonIds(), genPersonIds)) continue;
|
||||
if (!passesYearFilter(derived.eventDate(), derived.precision(), filter)) continue;
|
||||
entries.add(derived);
|
||||
}
|
||||
|
||||
// ── letters ─────────────────────────────────────────────────────────
|
||||
List<Document> docs = fetchDocuments(filter.personId());
|
||||
for (Document doc : docs) {
|
||||
if (!passesLetterGenerationFilter(doc, genPersonIds)) continue;
|
||||
if (!passesYearFilter(doc.getDocumentDate(), doc.getMetaDatePrecision(), filter)) continue;
|
||||
entries.add(mapDocument(doc));
|
||||
}
|
||||
|
||||
return bucket(entries);
|
||||
}
|
||||
|
||||
// ─── Bucketing ───────────────────────────────────────────────────────────
|
||||
|
||||
Map<Integer, List<TimelineEntryDTO>> bucketByYear(List<TimelineEntryDTO> entries) {
|
||||
Map<Integer, List<TimelineEntryDTO>> map = new TreeMap<>();
|
||||
for (TimelineEntryDTO e : entries) {
|
||||
if (e.eventDate() == null || e.precision() == DatePrecision.UNKNOWN) continue;
|
||||
map.computeIfAbsent(e.eventDate().getYear(), k -> new ArrayList<>()).add(e);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private TimelineDTO bucket(List<TimelineEntryDTO> entries) {
|
||||
List<TimelineEntryDTO> undated = entries.stream()
|
||||
.filter(e -> e.eventDate() == null || e.precision() == DatePrecision.UNKNOWN)
|
||||
.sorted(WITHIN_BAND_ORDER)
|
||||
.toList();
|
||||
|
||||
Map<Integer, List<TimelineEntryDTO>> byYear = bucketByYear(entries);
|
||||
List<TimelineYearDTO> years = byYear.entrySet().stream()
|
||||
.map(e -> new TimelineYearDTO(e.getKey(),
|
||||
e.getValue().stream().sorted(WITHIN_BAND_ORDER).toList()))
|
||||
.toList();
|
||||
|
||||
return new TimelineDTO(years, undated);
|
||||
}
|
||||
|
||||
// ─── Document fetch (global vs personId path) ────────────────────────────
|
||||
|
||||
private List<Document> fetchDocuments(UUID personId) {
|
||||
if (personId == null) {
|
||||
return documentService.getAllForTimeline();
|
||||
}
|
||||
// personId path: validate existence, then union sender+receiver (dedup by id)
|
||||
personService.getById(personId);
|
||||
Map<UUID, Document> seen = new LinkedHashMap<>();
|
||||
for (Document d : documentService.getDocumentsBySender(personId)) seen.put(d.getId(), d);
|
||||
for (Document d : documentService.getDocumentsByReceiver(personId)) seen.putIfAbsent(d.getId(), d);
|
||||
return new ArrayList<>(seen.values());
|
||||
}
|
||||
|
||||
// ─── Filter predicates ───────────────────────────────────────────────────
|
||||
|
||||
private boolean passesTypeFilter(EventType entryType, EventType filterType) {
|
||||
return filterType == null || filterType == entryType;
|
||||
}
|
||||
|
||||
private boolean passesYearFilter(java.time.LocalDate date, DatePrecision precision, TimelineFilter filter) {
|
||||
if (date == null || precision == DatePrecision.UNKNOWN) return true; // undated → always passes
|
||||
int year = date.getYear();
|
||||
if (filter.fromYear() != null && year < filter.fromYear()) return false;
|
||||
if (filter.toYear() != null && year > filter.toYear()) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean passesPersonFilter(Set<Person> persons, UUID personId) {
|
||||
if (personId == null) return true;
|
||||
return persons != null && persons.stream().anyMatch(p -> personId.equals(p.getId()));
|
||||
}
|
||||
|
||||
private boolean passesDerivedPersonFilter(List<UUID> linkedIds, UUID personId) {
|
||||
if (personId == null) return true;
|
||||
return linkedIds != null && linkedIds.contains(personId);
|
||||
}
|
||||
|
||||
private Set<UUID> resolveGenerationPersonIds(Integer generation) {
|
||||
if (generation == null) return null;
|
||||
return personService.getPersonsByGeneration(generation).stream()
|
||||
.map(Person::getId)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private boolean passesGenerationFilter(Set<Person> persons, Set<UUID> genPersonIds) {
|
||||
if (genPersonIds == null) return true;
|
||||
if (persons == null || persons.isEmpty()) return false;
|
||||
return persons.stream().anyMatch(p -> genPersonIds.contains(p.getId()));
|
||||
}
|
||||
|
||||
private boolean passesDerivedGenerationFilter(List<UUID> linkedIds, Set<UUID> genPersonIds) {
|
||||
if (genPersonIds == null) return true;
|
||||
if (linkedIds == null || linkedIds.isEmpty()) return false;
|
||||
return linkedIds.stream().anyMatch(genPersonIds::contains);
|
||||
}
|
||||
|
||||
private boolean passesLetterGenerationFilter(Document doc, Set<UUID> genPersonIds) {
|
||||
if (genPersonIds == null) return true;
|
||||
Person sender = doc.getSender();
|
||||
if (sender != null && genPersonIds.contains(sender.getId())) return true;
|
||||
Set<Person> receivers = doc.getReceivers();
|
||||
if (receivers != null) {
|
||||
return receivers.stream().anyMatch(r -> genPersonIds.contains(r.getId()));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Mapping ─────────────────────────────────────────────────────────────
|
||||
|
||||
private TimelineEntryDTO mapEvent(TimelineEvent ev) {
|
||||
List<UUID> personIds = ev.getPersons() == null ? List.of()
|
||||
: ev.getPersons().stream().map(Person::getId).toList();
|
||||
return new TimelineEntryDTO(
|
||||
Kind.EVENT,
|
||||
ev.getPrecision(),
|
||||
false,
|
||||
"",
|
||||
"",
|
||||
ev.getEventDate(),
|
||||
ev.getEventDateEnd(),
|
||||
ev.getTitle(),
|
||||
ev.getType(),
|
||||
ev.getId(),
|
||||
null,
|
||||
personIds,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private TimelineEntryDTO mapDocument(Document doc) {
|
||||
return new TimelineEntryDTO(
|
||||
Kind.LETTER,
|
||||
doc.getMetaDatePrecision(),
|
||||
false,
|
||||
resolveSenderName(doc),
|
||||
resolveReceiverName(doc),
|
||||
doc.getDocumentDate(),
|
||||
null,
|
||||
doc.getTitle(),
|
||||
null,
|
||||
null,
|
||||
doc.getId(),
|
||||
List.of(),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private String resolveSenderName(Document doc) {
|
||||
if (doc.getSender() != null) return doc.getSender().getDisplayName();
|
||||
String text = doc.getSenderText();
|
||||
return (text != null && !text.isBlank()) ? text : "";
|
||||
}
|
||||
|
||||
private String resolveReceiverName(Document doc) {
|
||||
Set<Person> receivers = doc.getReceivers();
|
||||
if (receivers != null && !receivers.isEmpty()) {
|
||||
return receivers.stream().findFirst().map(Person::getDisplayName).orElse("");
|
||||
}
|
||||
String text = doc.getReceiverText();
|
||||
return (text != null && !text.isBlank()) ? text : "";
|
||||
}
|
||||
|
||||
private static int precisionRank(DatePrecision precision) {
|
||||
if (precision == null) return 0;
|
||||
return switch (precision) {
|
||||
case DAY -> 5;
|
||||
case MONTH -> 4;
|
||||
case SEASON -> 3;
|
||||
case YEAR -> 2;
|
||||
case APPROX -> 1;
|
||||
default -> 0;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** One year's worth of timeline entries, sorted by {@link TimelineService#WITHIN_BAND_ORDER}. */
|
||||
public record TimelineYearDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int year,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) List<TimelineEntryDTO> entries
|
||||
) {
|
||||
}
|
||||
@@ -2943,4 +2943,17 @@ class DocumentServiceTest {
|
||||
assertThat(result.buckets()).isEmpty();
|
||||
verify(documentRepository, org.mockito.Mockito.never()).findAll(any(Specification.class));
|
||||
}
|
||||
|
||||
// --- getAllForTimeline ---
|
||||
|
||||
@Test
|
||||
void getAllForTimeline_delegates_bulk_fetch_to_repository() {
|
||||
Document doc = Document.builder().id(UUID.randomUUID()).title("Brief").build();
|
||||
when(documentRepository.findAllForTimeline()).thenReturn(List.of(doc));
|
||||
|
||||
List<Document> result = documentService.getAllForTimeline();
|
||||
|
||||
assertThat(result).containsExactly(doc);
|
||||
verify(documentRepository).findAllForTimeline();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1105,4 +1105,25 @@ class PersonServiceTest {
|
||||
assertThat(result.direct()).hasSize(1);
|
||||
assertThat(result.partial()).isEmpty();
|
||||
}
|
||||
|
||||
// --- getPersonsByGeneration ---
|
||||
|
||||
@Test
|
||||
void getPersonsByGeneration_delegates_to_repository() {
|
||||
Person p = Person.builder().id(UUID.randomUUID()).lastName("Müller").generation(2).build();
|
||||
when(personRepository.findByGeneration(2)).thenReturn(List.of(p));
|
||||
|
||||
List<Person> result = personService.getPersonsByGeneration(2);
|
||||
|
||||
assertThat(result).containsExactly(p);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getPersonsByGeneration_returns_emptyList_when_no_match() {
|
||||
when(personRepository.findByGeneration(99)).thenReturn(List.of());
|
||||
|
||||
List<Person> result = personService.getPersonsByGeneration(99);
|
||||
|
||||
assertThat(result).isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,13 @@ class ArchitectureTest {
|
||||
.and().resideInAPackage("..audit..")
|
||||
.should().dependOnClassesThat(foreignJpaRepositoryFor("audit"));
|
||||
|
||||
@ArchTest
|
||||
static final ArchRule services_only_access_own_domain_repositories_timeline =
|
||||
noClasses()
|
||||
.that().areAnnotatedWith(Service.class)
|
||||
.and().resideInAPackage("..timeline..")
|
||||
.should().dependOnClassesThat(foreignJpaRepositoryFor("timeline"));
|
||||
|
||||
// Rule 3: Infrastructure @Configuration classes must not end up scattered in domain packages.
|
||||
// Keeps cross-cutting setup (security, async, DB, storage) in dedicated packages
|
||||
// where it can be audited and reasoned about independently.
|
||||
|
||||
@@ -18,7 +18,6 @@ import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@@ -108,7 +107,7 @@ class DerivedEventsAssemblyTest {
|
||||
assertThat(event.derivedType()).isEqualTo(DerivedEventType.BIRTH);
|
||||
assertThat(event.eventDate()).isEqualTo(LocalDate.of(1901, 3, 12));
|
||||
assertThat(event.precision()).isEqualTo(DatePrecision.DAY);
|
||||
assertThat(event.primaryPersonName()).isEqualTo(anna.getDisplayName());
|
||||
assertThat(event.title()).isEqualTo(anna.getDisplayName());
|
||||
}
|
||||
|
||||
// --- REQ-003: null birthDate → no Geburt event ---
|
||||
@@ -162,7 +161,7 @@ class DerivedEventsAssemblyTest {
|
||||
assertThat(event.derivedType()).isEqualTo(DerivedEventType.DEATH);
|
||||
assertThat(event.eventDate()).isEqualTo(LocalDate.of(1965, 7, 4));
|
||||
assertThat(event.precision()).isEqualTo(DatePrecision.DAY);
|
||||
assertThat(event.primaryPersonName()).isEqualTo(hans.getDisplayName());
|
||||
assertThat(event.title()).isEqualTo(hans.getDisplayName());
|
||||
}
|
||||
|
||||
// --- REQ-002 + REQ-003 combined ---
|
||||
@@ -285,10 +284,10 @@ class DerivedEventsAssemblyTest {
|
||||
List<TimelineEntryDTO> result = service.assembleDerivedEvents();
|
||||
|
||||
assertThat(result).hasSize(1);
|
||||
String id = result.get(0).id();
|
||||
assertThat(id).startsWith("birth:");
|
||||
assertThatThrownBy(() -> UUID.fromString(id))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
TimelineEntryDTO entry = result.get(0);
|
||||
assertThat(entry.derived()).isTrue();
|
||||
assertThat(entry.eventId()).isNull();
|
||||
assertThat(entry.documentId()).isNull();
|
||||
}
|
||||
|
||||
// --- REQ-010: display names on Heirat ---
|
||||
@@ -307,8 +306,8 @@ class DerivedEventsAssemblyTest {
|
||||
|
||||
assertThat(heiraten).hasSize(1);
|
||||
TimelineEntryDTO heirat = heiraten.get(0);
|
||||
assertThat(heirat.primaryPersonName()).isNotNull().isNotBlank();
|
||||
assertThat(heirat.relatedPersonName()).isNotNull().isNotBlank();
|
||||
assertThat(heirat.title()).isNotNull().isNotBlank();
|
||||
assertThat(heirat.linkedPersonIds()).hasSize(2);
|
||||
}
|
||||
|
||||
// --- REQ-007 note: assumption/documentation test ---
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||
import org.raddatz.familienarchiv.security.SecurityConfig;
|
||||
import org.raddatz.familienarchiv.user.AppUser;
|
||||
import org.raddatz.familienarchiv.user.CustomUserDetailsService;
|
||||
import org.raddatz.familienarchiv.user.UserService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.hamcrest.Matchers.is;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(TimelineController.class)
|
||||
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||
class TimelineControllerTest {
|
||||
|
||||
@Autowired MockMvc mockMvc;
|
||||
|
||||
@MockitoBean TimelineService timelineService;
|
||||
@MockitoBean UserService userService;
|
||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||
|
||||
private static final TimelineDTO EMPTY = new TimelineDTO(List.of(), List.of());
|
||||
|
||||
@BeforeEach
|
||||
void resolveDefaultPrincipal() {
|
||||
when(userService.findByEmail("user"))
|
||||
.thenReturn(AppUser.builder().id(UUID.randomUUID()).email("user").build());
|
||||
}
|
||||
|
||||
// ─── Security ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void returns_401_when_unauthenticated() throws Exception {
|
||||
// REQ-014
|
||||
mockMvc.perform(get("/api/timeline"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.springframework.security.test.context.support.WithMockUser(authorities = "WRITE_ALL")
|
||||
void returns_403_when_authenticated_without_read_all() throws Exception {
|
||||
// REQ-015
|
||||
mockMvc.perform(get("/api/timeline"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
|
||||
void returns_200_with_read_all_permission() throws Exception {
|
||||
// REQ-001
|
||||
when(timelineService.assemble(any())).thenReturn(EMPTY);
|
||||
|
||||
mockMvc.perform(get("/api/timeline"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.years").isArray())
|
||||
.andExpect(jsonPath("$.undated").isArray());
|
||||
}
|
||||
|
||||
// ─── Parameter binding ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
|
||||
void valid_params_are_forwarded_to_service() throws Exception {
|
||||
UUID personId = UUID.randomUUID();
|
||||
when(timelineService.assemble(any())).thenReturn(EMPTY);
|
||||
|
||||
mockMvc.perform(get("/api/timeline")
|
||||
.param("personId", personId.toString())
|
||||
.param("generation", "2")
|
||||
.param("type", "HISTORICAL")
|
||||
.param("fromYear", "1914")
|
||||
.param("toYear", "1918"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(timelineService).assemble(new TimelineFilter(personId, 2, EventType.HISTORICAL, 1914, 1918));
|
||||
}
|
||||
|
||||
// ─── Validation errors ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
|
||||
void returns_400_on_bad_type_value() throws Exception {
|
||||
// REQ-018 — Spring enum binding rejects unknown value
|
||||
mockMvc.perform(get("/api/timeline").param("type", "NOT_A_TYPE"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
|
||||
void returns_400_when_fromYear_greater_than_toYear() throws Exception {
|
||||
// REQ-016 — service throws bad request, controller propagates it
|
||||
when(timelineService.assemble(any()))
|
||||
.thenThrow(DomainException.badRequest(ErrorCode.VALIDATION_ERROR,
|
||||
"toYear must not be before fromYear"));
|
||||
|
||||
mockMvc.perform(get("/api/timeline")
|
||||
.param("fromYear", "1920")
|
||||
.param("toYear", "1914"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
|
||||
void returns_400_when_generation_is_negative() throws Exception {
|
||||
// REQ-017 — @Min(0) on generation parameter
|
||||
mockMvc.perform(get("/api/timeline").param("generation", "-1"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.springframework.security.test.context.support.WithMockUser(authorities = "READ_ALL")
|
||||
void returns_404_when_person_not_found() throws Exception {
|
||||
// REQ-019
|
||||
when(timelineService.assemble(any()))
|
||||
.thenThrow(DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found"));
|
||||
|
||||
mockMvc.perform(get("/api/timeline").param("personId", UUID.randomUUID().toString()))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code", is("PERSON_NOT_FOUND")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
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.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;
|
||||
|
||||
/**
|
||||
* Integration tests for {@link TimelineService} and {@link PersonRepository#findByGeneration}
|
||||
* against real Postgres. Verifies that assembled output reflects persisted curated events and
|
||||
* that the generation query handles null-generation rows correctly.
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||
@ActiveProfiles("test")
|
||||
@Import(PostgresContainerConfig.class)
|
||||
@Transactional
|
||||
class TimelineServiceIntegrationTest {
|
||||
|
||||
@MockitoBean S3Client s3Client;
|
||||
|
||||
@Autowired TimelineService timelineService;
|
||||
@Autowired TimelineEventRepository timelineEventRepository;
|
||||
@Autowired PersonRepository personRepository;
|
||||
|
||||
@PersistenceContext EntityManager em;
|
||||
|
||||
// ─── PersonRepository.findByGeneration ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void findByGeneration_returns_matching_persons() {
|
||||
personRepository.save(Person.builder().lastName("Gen2A").generation(2).build());
|
||||
personRepository.save(Person.builder().lastName("Gen2B").generation(2).build());
|
||||
personRepository.save(Person.builder().lastName("Gen3").generation(3).build());
|
||||
em.flush();
|
||||
|
||||
List<Person> result = personRepository.findByGeneration(2);
|
||||
|
||||
assertThat(result).extracting(Person::getLastName)
|
||||
.containsExactlyInAnyOrder("Gen2A", "Gen2B");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByGeneration_returns_empty_list_not_npe_when_no_match() {
|
||||
personRepository.save(Person.builder().lastName("Gen1").generation(1).build());
|
||||
em.flush();
|
||||
|
||||
List<Person> result = personRepository.findByGeneration(99);
|
||||
|
||||
assertThat(result).isNotNull().isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByGeneration_does_not_return_null_generation_persons() {
|
||||
personRepository.save(Person.builder().lastName("NullGen").build()); // generation stays null
|
||||
em.flush();
|
||||
|
||||
List<Person> result = personRepository.findByGeneration(1);
|
||||
|
||||
assertThat(result).extracting(Person::getLastName).doesNotContain("NullGen");
|
||||
}
|
||||
|
||||
// ─── TimelineService.assemble end-to-end ─────────────────────────────────
|
||||
|
||||
@Test
|
||||
void assemble_includes_persisted_curated_event_in_correct_year_band() {
|
||||
UUID actorId = UUID.randomUUID();
|
||||
TimelineEvent event = timelineEventRepository.save(TimelineEvent.builder()
|
||||
.title("Sarajevo")
|
||||
.type(EventType.HISTORICAL)
|
||||
.eventDate(LocalDate.of(1914, 6, 28))
|
||||
.precision(DatePrecision.DAY)
|
||||
.createdBy(actorId)
|
||||
.updatedBy(actorId)
|
||||
.build());
|
||||
em.flush();
|
||||
em.clear();
|
||||
|
||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, null, null));
|
||||
|
||||
assertThat(result.years()).anySatisfy(y -> {
|
||||
assertThat(y.year()).isEqualTo(1914);
|
||||
assertThat(y.entries()).anySatisfy(e -> {
|
||||
assertThat(e.title()).isEqualTo("Sarajevo");
|
||||
assertThat(e.kind()).isEqualTo(Kind.EVENT);
|
||||
assertThat(e.eventId()).isEqualTo(event.getId());
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
package org.raddatz.familienarchiv.timeline;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.raddatz.familienarchiv.document.DatePrecision;
|
||||
import org.raddatz.familienarchiv.document.Document;
|
||||
import org.raddatz.familienarchiv.document.DocumentService;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.person.Person;
|
||||
import org.raddatz.familienarchiv.person.PersonService;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TimelineServiceTest {
|
||||
|
||||
@Mock TimelineEventRepository eventRepository;
|
||||
@Mock TimelineEventService timelineEventService;
|
||||
@Mock DocumentService documentService;
|
||||
@Mock PersonService personService;
|
||||
|
||||
@InjectMocks TimelineService timelineService;
|
||||
|
||||
// ─── WITHIN_BAND_ORDER standalone tests (REQ-002) ────────────────────────
|
||||
|
||||
@Test
|
||||
void within_band_order_day_precision_sorts_before_year() {
|
||||
var dayEntry = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "B-brief");
|
||||
var yearEntry = letter(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "A-brief");
|
||||
|
||||
var sorted = List.of(yearEntry, dayEntry).stream()
|
||||
.sorted(TimelineService.WITHIN_BAND_ORDER)
|
||||
.toList();
|
||||
|
||||
assertThat(sorted).containsExactly(dayEntry, yearEntry);
|
||||
}
|
||||
|
||||
@Test
|
||||
void within_band_order_same_precision_and_date_sorts_alphabetically() {
|
||||
var entryZ = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Zimmer");
|
||||
var entryA = letter(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Adler");
|
||||
|
||||
var sorted = List.of(entryZ, entryA).stream()
|
||||
.sorted(TimelineService.WITHIN_BAND_ORDER)
|
||||
.toList();
|
||||
|
||||
assertThat(sorted).containsExactly(entryA, entryZ);
|
||||
}
|
||||
|
||||
@Test
|
||||
void within_band_order_same_title_uses_document_id_as_tiebreak() {
|
||||
UUID id1 = UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||
UUID id2 = UUID.fromString("00000000-0000-0000-0000-000000000002");
|
||||
var e1 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
|
||||
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id1, List.of(), null);
|
||||
var e2 = new TimelineEntryDTO(Kind.LETTER, DatePrecision.DAY, false, "", "",
|
||||
LocalDate.of(1914, 7, 28), null, "Same Title", null, null, id2, List.of(), null);
|
||||
|
||||
var sorted = List.of(e2, e1).stream()
|
||||
.sorted(TimelineService.WITHIN_BAND_ORDER)
|
||||
.toList();
|
||||
|
||||
assertThat(sorted.get(0).documentId()).isEqualTo(id1);
|
||||
}
|
||||
|
||||
// ─── Assembly tests (issue-spec order) ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
void test1_empty_archive_returns_empty_dto() {
|
||||
// REQ-013, REQ-007
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of());
|
||||
|
||||
TimelineDTO result = timelineService.assemble(noFilters());
|
||||
|
||||
assertThat(result.years()).isEmpty();
|
||||
assertThat(result.undated()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void test2_one_year_letter_returns_one_year_band() {
|
||||
// REQ-007
|
||||
var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR);
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
|
||||
|
||||
TimelineDTO result = timelineService.assemble(noFilters());
|
||||
|
||||
assertThat(result.years()).hasSize(1);
|
||||
assertThat(result.years().get(0).year()).isEqualTo(1914);
|
||||
assertThat(result.years().get(0).entries()).hasSize(1);
|
||||
assertThat(result.years().get(0).entries().get(0).kind()).isEqualTo(Kind.LETTER);
|
||||
assertThat(result.undated()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void test3a_null_date_letter_goes_to_undated() {
|
||||
// REQ-003
|
||||
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
|
||||
.metaDatePrecision(DatePrecision.YEAR).build(); // documentDate stays null
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
|
||||
|
||||
TimelineDTO result = timelineService.assemble(noFilters());
|
||||
|
||||
assertThat(result.years()).isEmpty();
|
||||
assertThat(result.undated()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void test3b_unknown_precision_letter_goes_to_undated() {
|
||||
// REQ-003
|
||||
var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.UNKNOWN);
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
|
||||
|
||||
TimelineDTO result = timelineService.assemble(noFilters());
|
||||
|
||||
assertThat(result.years()).isEmpty();
|
||||
assertThat(result.undated()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void test4_letter_with_null_sender_and_null_senderText_produces_empty_names() {
|
||||
// REQ-005
|
||||
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
|
||||
.metaDatePrecision(DatePrecision.YEAR)
|
||||
.documentDate(LocalDate.of(1914, 1, 1))
|
||||
.build(); // no sender, no senderText, no receivers, no receiverText
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
|
||||
|
||||
TimelineDTO result = timelineService.assemble(noFilters());
|
||||
|
||||
var entry = result.years().get(0).entries().get(0);
|
||||
assertThat(entry.senderName()).isEqualTo("");
|
||||
assertThat(entry.receiverName()).isEqualTo("");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test5_day_precision_sorts_before_year_in_same_year_band() {
|
||||
// REQ-002
|
||||
var dayLetter = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "B-brief");
|
||||
var yearLetter = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "A-brief");
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(yearLetter, dayLetter));
|
||||
|
||||
TimelineDTO result = timelineService.assemble(noFilters());
|
||||
|
||||
var entries = result.years().get(0).entries();
|
||||
assertThat(entries).hasSize(2);
|
||||
assertThat(entries.get(0).precision()).isEqualTo(DatePrecision.DAY);
|
||||
assertThat(entries.get(1).precision()).isEqualTo(DatePrecision.YEAR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void test6_same_precision_same_date_sorted_alphabetically_by_title() {
|
||||
// REQ-002
|
||||
var letterZ = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Zimmer");
|
||||
var letterA = docWithDate(LocalDate.of(1914, 7, 28), DatePrecision.DAY, "Adler");
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(letterZ, letterA));
|
||||
|
||||
TimelineDTO result = timelineService.assemble(noFilters());
|
||||
|
||||
var entries = result.years().get(0).entries();
|
||||
assertThat(entries).hasSize(2);
|
||||
assertThat(entries.get(0).title()).isEqualTo("Adler");
|
||||
assertThat(entries.get(1).title()).isEqualTo("Zimmer");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test7a_range_event_placed_only_in_start_year_band() {
|
||||
// REQ-004
|
||||
var rangeEvent = event("WW1", EventType.HISTORICAL,
|
||||
LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11));
|
||||
when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of());
|
||||
|
||||
TimelineDTO result = timelineService.assemble(noFilters());
|
||||
|
||||
assertThat(result.years()).hasSize(1);
|
||||
assertThat(result.years().get(0).year()).isEqualTo(1914);
|
||||
assertThat(result.years().stream().noneMatch(y -> y.year() == 1918)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void test7b_range_event_with_null_eventDateEnd_does_not_crash() {
|
||||
// REQ-004
|
||||
var rangeEvent = event("Offener Zeitraum", EventType.PERSONAL,
|
||||
LocalDate.of(1914, 1, 1), DatePrecision.RANGE, null);
|
||||
when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of());
|
||||
|
||||
assertThatCode(() -> timelineService.assemble(noFilters())).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
@Test
|
||||
void test8_range_event_excluded_when_start_year_before_fromYear() {
|
||||
// REQ-004
|
||||
var rangeEvent = event("WW1", EventType.HISTORICAL,
|
||||
LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11));
|
||||
when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of());
|
||||
|
||||
// fromYear=1915 → start year 1914 is outside → excluded
|
||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1915, null));
|
||||
|
||||
assertThat(result.years()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void test9a_type_filter_does_not_exclude_letters_but_excludes_wrong_type_events() {
|
||||
// REQ-009
|
||||
var doc = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "Brief");
|
||||
var historicalEvent = event("Sarajevo", EventType.HISTORICAL,
|
||||
LocalDate.of(1914, 6, 28), DatePrecision.DAY, null);
|
||||
var personalEvent = event("Geburt", EventType.PERSONAL,
|
||||
LocalDate.of(1914, 8, 1), DatePrecision.DAY, null);
|
||||
when(eventRepository.findAll()).thenReturn(List.of(historicalEvent, personalEvent));
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
|
||||
|
||||
// filter: only HISTORICAL events
|
||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, EventType.HISTORICAL, null, null));
|
||||
|
||||
long letters = result.years().stream().flatMap(y -> y.entries().stream())
|
||||
.filter(e -> e.kind() == Kind.LETTER).count();
|
||||
long personalEvents = result.years().stream().flatMap(y -> y.entries().stream())
|
||||
.filter(e -> e.kind() == Kind.EVENT && e.type() == EventType.PERSONAL).count();
|
||||
assertThat(letters).isEqualTo(1);
|
||||
assertThat(personalEvents).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void test9b_generation_filter_includes_letter_when_sender_matches_generation() {
|
||||
// REQ-010
|
||||
var sender = Person.builder().id(UUID.randomUUID())
|
||||
.lastName("Mustermann").firstName("Max").generation(2).build();
|
||||
var included = Document.builder().id(UUID.randomUUID()).title("Treffer")
|
||||
.metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
|
||||
.sender(sender).build();
|
||||
var excluded = docWithDate(LocalDate.of(1914, 1, 1), DatePrecision.YEAR, "Kein Treffer"); // no sender
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(included, excluded));
|
||||
when(personService.getPersonsByGeneration(2)).thenReturn(List.of(sender));
|
||||
|
||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, 2, null, null, null));
|
||||
|
||||
assertThat(result.years()).hasSize(1);
|
||||
assertThat(result.years().get(0).entries()).hasSize(1);
|
||||
assertThat(result.years().get(0).entries().get(0).title()).isEqualTo("Treffer");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test9c_fromYear_toYear_inclusive_single_year_window() {
|
||||
// REQ-011
|
||||
var before = docWithDate(LocalDate.of(1913, 12, 31), DatePrecision.YEAR, "Vorher");
|
||||
var inYear = docWithDate(LocalDate.of(1914, 6, 1), DatePrecision.MONTH, "Im Jahr");
|
||||
var after = docWithDate(LocalDate.of(1915, 1, 1), DatePrecision.YEAR, "Nachher");
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(before, inYear, after));
|
||||
|
||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1914, 1914));
|
||||
|
||||
assertThat(result.years()).hasSize(1);
|
||||
assertThat(result.years().get(0).year()).isEqualTo(1914);
|
||||
assertThat(result.years().get(0).entries().get(0).title()).isEqualTo("Im Jahr");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test10_adversarial_and_logic_neither_event_passes_both_filters() {
|
||||
// REQ-012 — type AND year must both pass
|
||||
var wrongType = event("Personal", EventType.PERSONAL,
|
||||
LocalDate.of(1914, 1, 1), DatePrecision.YEAR, null);
|
||||
var wrongYear = event("Historical outside", EventType.HISTORICAL,
|
||||
LocalDate.of(1920, 1, 1), DatePrecision.YEAR, null);
|
||||
when(eventRepository.findAll()).thenReturn(List.of(wrongType, wrongYear));
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of());
|
||||
|
||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, EventType.HISTORICAL, 1914, 1914));
|
||||
|
||||
assertThat(result.years()).isEmpty();
|
||||
assertThat(result.undated()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void test11_personId_scoping_deduplicates_letter_appearing_as_sender_and_receiver() {
|
||||
// REQ-008
|
||||
UUID personId = UUID.randomUUID();
|
||||
var person = Person.builder().id(personId).lastName("Mustermann").build();
|
||||
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
|
||||
.metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
|
||||
.sender(person)
|
||||
.receivers(Set.of(person))
|
||||
.build();
|
||||
when(personService.getById(personId)).thenReturn(person);
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getDocumentsBySender(personId)).thenReturn(List.of(doc));
|
||||
when(documentService.getDocumentsByReceiver(personId)).thenReturn(List.of(doc));
|
||||
|
||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(personId, null, null, null, null));
|
||||
|
||||
long total = result.years().stream().mapToLong(y -> y.entries().size()).sum()
|
||||
+ result.undated().size();
|
||||
assertThat(total).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void test12_personId_plus_generation_filter_returns_empty_when_generations_do_not_match() {
|
||||
// REQ-012
|
||||
UUID personId = UUID.randomUUID();
|
||||
var person = Person.builder().id(personId).lastName("Mustermann").generation(1).build();
|
||||
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
|
||||
.metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
|
||||
.sender(person).build();
|
||||
var gen2person = Person.builder().id(UUID.randomUUID()).lastName("Schmidt").generation(2).build();
|
||||
when(personService.getById(personId)).thenReturn(person);
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getDocumentsBySender(personId)).thenReturn(List.of(doc));
|
||||
when(documentService.getDocumentsByReceiver(personId)).thenReturn(List.of());
|
||||
when(personService.getPersonsByGeneration(2)).thenReturn(List.of(gen2person)); // person not in gen2
|
||||
|
||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(personId, 2, null, null, null));
|
||||
|
||||
assertThat(result.years()).isEmpty();
|
||||
assertThat(result.undated()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void test13_null_generation_sender_not_returned_by_generation_filter() {
|
||||
// REQ-020 — both sender and receiver have null generation → excluded
|
||||
var nullGenSender = Person.builder().id(UUID.randomUUID()).lastName("Sender").build(); // generation = null
|
||||
var doc = Document.builder().id(UUID.randomUUID()).title("Brief")
|
||||
.metaDatePrecision(DatePrecision.YEAR).documentDate(LocalDate.of(1914, 1, 1))
|
||||
.sender(nullGenSender).build();
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(doc));
|
||||
when(personService.getPersonsByGeneration(1)).thenReturn(List.of()); // nobody in generation 1
|
||||
|
||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, 1, null, null, null));
|
||||
|
||||
assertThat(result.years()).isEmpty();
|
||||
assertThat(result.undated()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void test14_year_band_contains_only_event_when_no_letters_in_that_year() {
|
||||
var ev = event("Ausbruch", EventType.HISTORICAL, LocalDate.of(1914, 7, 28), DatePrecision.DAY, null);
|
||||
when(eventRepository.findAll()).thenReturn(List.of(ev));
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of());
|
||||
|
||||
TimelineDTO result = timelineService.assemble(noFilters());
|
||||
|
||||
assertThat(result.years()).hasSize(1);
|
||||
assertThat(result.years().get(0).entries()).hasSize(1);
|
||||
assertThat(result.years().get(0).entries().get(0).kind()).isEqualTo(Kind.EVENT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void test15_range_event_start_year_equal_to_fromYear_is_included() {
|
||||
// REQ-004 — inclusive lower bound
|
||||
var rangeEvent = event("WW1", EventType.HISTORICAL,
|
||||
LocalDate.of(1914, 7, 28), DatePrecision.RANGE, LocalDate.of(1918, 11, 11));
|
||||
when(eventRepository.findAll()).thenReturn(List.of(rangeEvent));
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of());
|
||||
|
||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1914, null));
|
||||
|
||||
assertThat(result.years()).hasSize(1);
|
||||
assertThat(result.years().get(0).year()).isEqualTo(1914);
|
||||
}
|
||||
|
||||
@Test
|
||||
void test16_fromYear_without_toYear_returns_all_items_from_that_year_onwards() {
|
||||
// REQ-011
|
||||
var old = docWithDate(LocalDate.of(1919, 12, 31), DatePrecision.YEAR, "Alt");
|
||||
var first = docWithDate(LocalDate.of(1920, 1, 1), DatePrecision.YEAR, "Erst");
|
||||
var newer = docWithDate(LocalDate.of(1921, 6, 1), DatePrecision.YEAR, "Newer");
|
||||
when(eventRepository.findAll()).thenReturn(List.of());
|
||||
when(timelineEventService.assembleDerivedEvents()).thenReturn(List.of());
|
||||
when(documentService.getAllForTimeline()).thenReturn(List.of(old, first, newer));
|
||||
|
||||
TimelineDTO result = timelineService.assemble(new TimelineFilter(null, null, null, 1920, null));
|
||||
|
||||
assertThat(result.years()).hasSize(2);
|
||||
assertThat(result.years().stream().noneMatch(y -> y.year() == 1919)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromYear_greater_than_toYear_throws_bad_request() {
|
||||
// REQ-016 (service-layer guard)
|
||||
assertThatThrownBy(() -> timelineService.assemble(new TimelineFilter(null, null, null, 1920, 1914)))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static TimelineFilter noFilters() {
|
||||
return new TimelineFilter(null, null, null, null, null);
|
||||
}
|
||||
|
||||
private static TimelineEntryDTO letter(LocalDate date, DatePrecision precision, String title) {
|
||||
return new TimelineEntryDTO(Kind.LETTER, precision, false, "", "",
|
||||
date, null, title, null, null, UUID.randomUUID(), List.of(), null);
|
||||
}
|
||||
|
||||
private static Document docWithDate(LocalDate date, DatePrecision precision) {
|
||||
return Document.builder().id(UUID.randomUUID()).title("Brief")
|
||||
.metaDatePrecision(precision).documentDate(date).build();
|
||||
}
|
||||
|
||||
private static Document docWithDate(LocalDate date, DatePrecision precision, String title) {
|
||||
return Document.builder().id(UUID.randomUUID()).title(title)
|
||||
.metaDatePrecision(precision).documentDate(date).build();
|
||||
}
|
||||
|
||||
private static TimelineEvent event(String title, EventType type, LocalDate date,
|
||||
DatePrecision precision, LocalDate endDate) {
|
||||
return TimelineEvent.builder().id(UUID.randomUUID())
|
||||
.title(title).type(type)
|
||||
.eventDate(date).precision(precision).eventDateEnd(endDate)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,14 @@ System_Boundary(backend, "API Backend (Spring Boot)") {
|
||||
Component(timelineRepo, "TimelineEventRepository", "Spring Data JPA", "Reads and writes TimelineEvent rows and their persons/documents join tables (timeline_event_persons, timeline_event_documents).")
|
||||
|
||||
Component(timelineSvc, "TimelineEventService", "Spring Service", "Owns curated-event CRUD: assembles TimelineEventView inside the transaction (lazy ManyToMany + open-in-view=false, ADR-036/ADR-040), populates createdBy/updatedBy from the session principal, and translates optimistic-lock conflicts to DomainException.conflict. Also exposes assembleDerivedEvents(): computes Geburt/Tod/Heirat TimelineEntryDTOs on read from Person/PersonRelationship data — never persisted (ADR-043).")
|
||||
Component(timelineCtrl, "TimelineEventController", "Spring MVC", "Exposes /api/timeline reads (READ_ALL) and writes (WRITE_ALL). createdBy/updatedBy are never bound from request bodies (CWE-639).")
|
||||
Component(timelineCtrl, "TimelineEventController", "Spring MVC", "Exposes /api/timeline/events reads (READ_ALL) and writes (WRITE_ALL). createdBy/updatedBy are never bound from request bodies (CWE-639).")
|
||||
|
||||
Component(timelineAssemblySvc, "TimelineService", "Spring Service", "Assembles GET /api/timeline response: merges curated TimelineEvent rows, derived life-events (via TimelineEventService), and archive letters (via DocumentService) into a year-bucketed TimelineDTO. Applies personId, generation, type, fromYear/toYear filters. WITHIN_BAND_ORDER: precision rank desc → date asc → title alpha → id tiebreak.")
|
||||
Component(timelineAssemblyCtrl, "TimelineController", "Spring MVC", "Exposes GET /api/timeline (READ_ALL). Five optional query params: personId, generation (@Min(0)), type (EventType enum), fromYear, toYear. @Validated on class for constraint enforcement.")
|
||||
}
|
||||
|
||||
System_Ext(documentDomain, "Document domain", "Provides DatePrecision (shared value type) and Document references for linked letters")
|
||||
System_Ext(personDomain, "Person domain", "Provides Person references (PersonService.findAllFamilyMembers) and SPOUSE_OF edges (RelationshipService.findAllSpouseEdges) for derived-event assembly and curated-event links")
|
||||
System_Ext(documentDomain, "Document domain", "Provides DatePrecision (shared value type), Document references for linked letters, and getAllForTimeline() bulk fetch")
|
||||
System_Ext(personDomain, "Person domain", "Provides Person references (PersonService.findAllFamilyMembers, getPersonsByGeneration, getById) and SPOUSE_OF edges (RelationshipService.findAllSpouseEdges) for derived-event assembly and generation filtering")
|
||||
|
||||
Rel(timelineRepo, db, "SQL queries", "JDBC")
|
||||
Rel(timelineSvc, timelineRepo, "Reads / writes events")
|
||||
@@ -21,5 +24,10 @@ Rel(timelineCtrl, timelineSvc, "Delegates to")
|
||||
Rel(timelineRepo, personDomain, "References persons via join table")
|
||||
Rel(timelineRepo, documentDomain, "References documents via join table")
|
||||
Rel(timelineSvc, personDomain, "findAllFamilyMembers() + findAllSpouseEdges() for derived-event assembly")
|
||||
Rel(timelineAssemblyCtrl, timelineAssemblySvc, "Delegates to")
|
||||
Rel(timelineAssemblySvc, timelineRepo, "findAll() for curated events")
|
||||
Rel(timelineAssemblySvc, timelineSvc, "assembleDerivedEvents() for derived life-events")
|
||||
Rel(timelineAssemblySvc, personDomain, "getPersonsByGeneration(), getById() for generation/personId filters")
|
||||
Rel(timelineAssemblySvc, documentDomain, "getAllForTimeline(), getDocumentsBySender(), getDocumentsByReceiver() for letter layer")
|
||||
|
||||
@enduml
|
||||
|
||||
@@ -1032,6 +1032,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/timeline": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getTimeline"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/tags": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2413,6 +2429,38 @@ export interface components {
|
||||
contributors: components["schemas"]["ActivityActorDTO"][];
|
||||
hasMoreContributors: boolean;
|
||||
};
|
||||
TimelineDTO: {
|
||||
years: components["schemas"]["TimelineYearDTO"][];
|
||||
undated: components["schemas"]["TimelineEntryDTO"][];
|
||||
};
|
||||
TimelineEntryDTO: {
|
||||
/** @enum {string} */
|
||||
kind: "EVENT" | "LETTER";
|
||||
/** @enum {string} */
|
||||
precision: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
|
||||
derived: boolean;
|
||||
senderName: string;
|
||||
receiverName: string;
|
||||
/** Format: date */
|
||||
eventDate?: string;
|
||||
/** Format: date */
|
||||
eventDateEnd?: string;
|
||||
title?: string;
|
||||
/** @enum {string} */
|
||||
type?: "PERSONAL" | "HISTORICAL";
|
||||
/** Format: uuid */
|
||||
eventId?: string;
|
||||
/** Format: uuid */
|
||||
documentId?: string;
|
||||
linkedPersonIds?: string[];
|
||||
/** @enum {string} */
|
||||
derivedType?: "BIRTH" | "DEATH" | "MARRIAGE";
|
||||
};
|
||||
TimelineYearDTO: {
|
||||
/** Format: int32 */
|
||||
year: number;
|
||||
entries: components["schemas"]["TimelineEntryDTO"][];
|
||||
};
|
||||
TagTreeNodeDTO: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
@@ -2468,10 +2516,10 @@ export interface components {
|
||||
birthDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
|
||||
/** Format: date */
|
||||
deathDate?: string;
|
||||
/** @enum {string} */
|
||||
deathDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
|
||||
personType?: string;
|
||||
familyMember?: boolean;
|
||||
/** @enum {string} */
|
||||
deathDatePrecision?: "DAY" | "MONTH" | "SEASON" | "YEAR" | "RANGE" | "APPROX" | "UNKNOWN";
|
||||
provisional?: boolean;
|
||||
/** Format: int32 */
|
||||
birthYear?: number;
|
||||
@@ -4993,6 +5041,32 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getTimeline: {
|
||||
parameters: {
|
||||
query?: {
|
||||
personId?: string;
|
||||
generation?: number;
|
||||
type?: "PERSONAL" | "HISTORICAL";
|
||||
fromYear?: number;
|
||||
toYear?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["TimelineDTO"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
searchTags: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
Reference in New Issue
Block a user