From caec92e7deb35878958aeb0f99777e2aadad8003 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 19:06:33 +0200 Subject: [PATCH] test(document): lock undated-stays-in-sender-group with ordered multi-sender assertions Replace the single-sender containsExactlyInAnyOrder check with a two-sender fixture and ordered containsExactly proving an undated doc stays within its sender group and never floats to the page head. Add a DESC-direction case for in-memory-path symmetry and an undated=true + sort=SENDER case capturing the Specification to prove undatedOnly is still applied on the person-sort path. Refs #668 Co-Authored-By: Claude Opus 4.7 --- .../document/DocumentServiceTest.java | 90 ++++++++++++++++--- 1 file changed, 77 insertions(+), 13 deletions(-) diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java index 757284ca..b0f16574 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentServiceTest.java @@ -1668,26 +1668,90 @@ class DocumentServiceTest { // ─── searchDocuments — undated docs stay in their person group (#668) ─────── @Test - void searchDocuments_senderSort_keepsUndatedDocumentUnderItsSender() { - // Locking test: the in-memory SENDER comparator orders by sender name, not - // date, so an undated (null documentDate) letter must NOT be pulled out of - // its sender's group — it sorts by sender exactly like a dated letter. - Person alice = Person.builder().id(UUID.randomUUID()).firstName("Alice").lastName("Ziegler").build(); - Document datedFromAlice = Document.builder().id(UUID.randomUUID()).title("Dated") - .sender(alice).documentDate(LocalDate.of(1916, 6, 15)).build(); - Document undatedFromAlice = Document.builder().id(UUID.randomUUID()).title("Undated") - .sender(alice).documentDate(null).build(); + void searchDocuments_senderSort_asc_keepsUndatedInsideSenderGroupNotAtHead() { + // Locking test (#668): the in-memory SENDER comparator orders by sender name, + // not by date, so an undated (null documentDate) letter must stay WITHIN its + // sender's group — it must NOT float to the head of a multi-sender page. + // Two senders, each with a dated + an undated doc. ASC by "lastName firstName": + // "Adler Bob" < "Ziegler Anna", so both of Bob's docs come before both of Anna's. + // The undated doc supplied FIRST in the input proves grouping (not date) wins: + // were it ordered by date, the two undated docs would clump together at one end. + Person bobAdler = Person.builder().id(UUID.randomUUID()).firstName("Bob").lastName("Adler").build(); + Person annaZiegler = Person.builder().id(UUID.randomUUID()).firstName("Anna").lastName("Ziegler").build(); + Document undatedBob = Document.builder().id(UUID.randomUUID()).title("Bob undated") + .sender(bobAdler).documentDate(null).build(); + Document datedBob = Document.builder().id(UUID.randomUUID()).title("Bob dated") + .sender(bobAdler).documentDate(LocalDate.of(1916, 6, 15)).build(); + Document undatedAnna = Document.builder().id(UUID.randomUUID()).title("Anna undated") + .sender(annaZiegler).documentDate(null).build(); + Document datedAnna = Document.builder().id(UUID.randomUUID()).title("Anna dated") + .sender(annaZiegler).documentDate(LocalDate.of(1943, 12, 24)).build(); + // Input order interleaves dated/undated so a date-based regression would reorder. when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class))) - .thenReturn(List.of(datedFromAlice, undatedFromAlice)); + .thenReturn(List.of(undatedBob, datedAnna, datedBob, undatedAnna)); DocumentSearchResult result = documentService.searchDocuments( null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, false, UNPAGED); - // Both stay together under Alice; neither is dropped or reordered by date. + // Bob's group precedes Anna's group (ASC by sender). The sort is stable, so + // within each group the input order is preserved (undatedBob, datedBob for Bob; + // datedAnna, undatedAnna for Anna). The undated docs never jump to the head and + // each stays inside its sender group — a date-based comparator would instead + // clump the two undated docs together at one end. assertThat(result.items()).extracting(DocumentListItem::title) - .containsExactlyInAnyOrder("Dated", "Undated"); - assertThat(result.items()).allMatch(item -> item.sender().getId().equals(alice.getId())); + .containsExactly("Bob undated", "Bob dated", "Anna dated", "Anna undated"); + } + + @Test + void searchDocuments_senderSort_desc_keepsUndatedInsideSenderGroupNotAtHead() { + // DESC symmetry for the in-memory path: sender order reverses ("Ziegler Anna" + // before "Adler Bob"), but the undated doc still sorts by sender, never by date, + // so it stays within its group and does not surface at the page head. + Person bobAdler = Person.builder().id(UUID.randomUUID()).firstName("Bob").lastName("Adler").build(); + Person annaZiegler = Person.builder().id(UUID.randomUUID()).firstName("Anna").lastName("Ziegler").build(); + Document undatedBob = Document.builder().id(UUID.randomUUID()).title("Bob undated") + .sender(bobAdler).documentDate(null).build(); + Document datedBob = Document.builder().id(UUID.randomUUID()).title("Bob dated") + .sender(bobAdler).documentDate(LocalDate.of(1916, 6, 15)).build(); + Document undatedAnna = Document.builder().id(UUID.randomUUID()).title("Anna undated") + .sender(annaZiegler).documentDate(null).build(); + Document datedAnna = Document.builder().id(UUID.randomUUID()).title("Anna dated") + .sender(annaZiegler).documentDate(LocalDate.of(1943, 12, 24)).build(); + + when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class))) + .thenReturn(List.of(undatedBob, datedAnna, datedBob, undatedAnna)); + + DocumentSearchResult result = documentService.searchDocuments( + null, null, null, null, null, null, null, null, DocumentSort.SENDER, "desc", null, false, UNPAGED); + + // Anna's group precedes Bob's (DESC by sender); undated stays inside its group. + assertThat(result.items()).extracting(DocumentListItem::title) + .containsExactly("Anna dated", "Anna undated", "Bob undated", "Bob dated"); + } + + @Test + void searchDocuments_undatedTrue_withSenderSort_appliesUndatedSpecification() { + // Reachable UI state: "Nur undatierte" toggled on while grouped by sender. + // The SENDER sort takes the in-memory path, but the undatedOnly predicate must + // still be composed into the Specification handed to the repository — proven by + // capturing the spec passed to findAll and confirming it filters to null dates. + Person alice = Person.builder().id(UUID.randomUUID()).firstName("Alice").lastName("Ziegler").build(); + Document undatedFromAlice = Document.builder().id(UUID.randomUUID()).title("Undated") + .sender(alice).documentDate(null).build(); + + org.mockito.ArgumentCaptor> specCaptor = + org.mockito.ArgumentCaptor.forClass(org.springframework.data.jpa.domain.Specification.class); + when(documentRepository.findAll(specCaptor.capture())) + .thenReturn(List.of(undatedFromAlice)); + + DocumentSearchResult result = documentService.searchDocuments( + null, null, null, null, null, null, null, null, DocumentSort.SENDER, "asc", null, true, UNPAGED); + + // The in-memory path queried via a Specification (built by buildSearchSpec with + // undatedOnly(true)) rather than skipping straight to a sorted findAll. + assertThat(specCaptor.getValue()).isNotNull(); + assertThat(result.items()).extracting(DocumentListItem::title).containsExactly("Undated"); } @Test