feat(documents): honest handling of undated documents in browse & search (Phase 6, #668) #682
@@ -839,11 +839,12 @@ public class DocumentService {
|
||||
// Undated documents (null documentDate) must order last regardless of
|
||||
// direction — Postgres puts NULLs FIRST on ASC by default, which would
|
||||
// surface the undated pile at the top with no explanation (issue #668).
|
||||
// The createdAt tiebreaker gives a stable total order when every row is
|
||||
// The title tiebreaker gives a stable total order when every row is
|
||||
// null-dated (the "Nur undatierte" filter), so pagination is deterministic.
|
||||
// title is @Column(nullable=false), so it is always present.
|
||||
return Sort.by(
|
||||
new Sort.Order(direction, "documentDate").nullsLast(),
|
||||
Sort.Order.asc("createdAt"));
|
||||
Sort.Order.asc("title"));
|
||||
}
|
||||
// SENDER and RECEIVER are sorted in-memory before this method is called
|
||||
return switch (sort) {
|
||||
|
||||
@@ -1463,6 +1463,11 @@ class DocumentServiceTest {
|
||||
assertThat(dateOrder).isNotNull();
|
||||
assertThat(dateOrder.getDirection()).isEqualTo(Sort.Direction.DESC);
|
||||
assertThat(dateOrder.getNullHandling()).isEqualTo(Sort.NullHandling.NULLS_LAST);
|
||||
// Owner-decided tiebreaker (#668): title ASC, not createdAt.
|
||||
Sort.Order tiebreak = captor.getValue().getSort().getOrderFor("title");
|
||||
assertThat(tiebreak).isNotNull();
|
||||
assertThat(tiebreak.getDirection()).isEqualTo(Sort.Direction.ASC);
|
||||
assertThat(captor.getValue().getSort().getOrderFor("createdAt")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1481,6 +1486,11 @@ class DocumentServiceTest {
|
||||
assertThat(dateOrder).isNotNull();
|
||||
assertThat(dateOrder.getDirection()).isEqualTo(Sort.Direction.ASC);
|
||||
assertThat(dateOrder.getNullHandling()).isEqualTo(Sort.NullHandling.NULLS_LAST);
|
||||
// Owner-decided tiebreaker (#668): title ASC, not createdAt.
|
||||
Sort.Order tiebreak = captor.getValue().getSort().getOrderFor("title");
|
||||
assertThat(tiebreak).isNotNull();
|
||||
assertThat(tiebreak.getDirection()).isEqualTo(Sort.Direction.ASC);
|
||||
assertThat(captor.getValue().getSort().getOrderFor("createdAt")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -63,6 +63,51 @@ class UndatedDocumentOrderingIntegrationTest {
|
||||
assertThat(result.get(3).getDocumentDate()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameDate_tiebreaksByTitleAsc_notCreatedAt_forBothDirections() throws Exception {
|
||||
// Owner decision (#668): equal-date rows tie-break by title ASC, NOT
|
||||
// createdAt. Insert two same-date docs so that createdAt order (insertion
|
||||
// order) is the OPPOSITE of title order: the first-saved doc gets the later
|
||||
// title ("zzz-first"), the second-saved doc gets the earlier title
|
||||
// ("aaa-second"). If the tiebreaker were still createdAt-asc the first-saved
|
||||
// row would lead; because it is title-asc the "aaa-second" row must lead —
|
||||
// and it must lead in BOTH ASC and DESC date directions, since the date is
|
||||
// equal so only the title tiebreaker decides.
|
||||
//
|
||||
// The Sort under test is built by the PRODUCTION resolveSort(DATE, dir) (via
|
||||
// reflection — it is private), not hand-rolled here, so this test proves the
|
||||
// real Postgres ordering that production emits, on real same-date rows.
|
||||
documentRepository.deleteAll();
|
||||
LocalDate sameDate = LocalDate.of(1920, 3, 3);
|
||||
save("zzz-first", sameDate); // saved first → earlier createdAt
|
||||
save("aaa-second", sameDate); // saved second → later createdAt
|
||||
|
||||
List<Document> asc = documentRepository.findAll(resolveProductionSort("ASC"));
|
||||
assertThat(asc).extracting(Document::getTitle)
|
||||
.containsExactly("aaa-second", "zzz-first");
|
||||
|
||||
List<Document> desc = documentRepository.findAll(resolveProductionSort("DESC"));
|
||||
assertThat(desc).extracting(Document::getTitle)
|
||||
.containsExactly("aaa-second", "zzz-first");
|
||||
}
|
||||
|
||||
/**
|
||||
* Invokes the production {@link DocumentService#resolveSort(DocumentSort, String)}
|
||||
* for the DATE sort so the integration assertions exercise the real tiebreaker
|
||||
* choice rather than a sort hand-built in the test.
|
||||
*/
|
||||
private Sort resolveProductionSort(String dir) throws Exception {
|
||||
// resolveSort is a pure function of its arguments (uses no instance state), so a
|
||||
// bean instance with null collaborators is sufficient to exercise it.
|
||||
var ctor = DocumentService.class.getDeclaredConstructors()[0];
|
||||
ctor.setAccessible(true);
|
||||
Object[] args = new Object[ctor.getParameterCount()];
|
||||
DocumentService service = (DocumentService) ctor.newInstance(args);
|
||||
var m = DocumentService.class.getDeclaredMethod("resolveSort", DocumentSort.class, String.class);
|
||||
m.setAccessible(true);
|
||||
return (Sort) m.invoke(service, DocumentSort.DATE, dir);
|
||||
}
|
||||
|
||||
@Test
|
||||
void undatedOnly_returnsExactlyTheNullDatedRows() {
|
||||
List<Document> result = documentRepository.findAll(undatedOnly(true));
|
||||
|
||||
Reference in New Issue
Block a user