From 39a462b2bb32177b14a8b9600acddea82cd50792 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 18:34:10 +0200 Subject: [PATCH] feat(document): add undatedOnly Specification for the undated-only filter undatedOnly(false) is a no-op (null predicate); undatedOnly(true) returns documentDate IS NULL, matching the existing hasStatus null-as-no-op pattern. Real-Postgres tests pin the load-bearing guarantees H2 cannot prove: ASC NULLS-LAST ordering, BETWEEN excludes null-dated rows, and that undated=true combined with a from/to range returns empty (the collision rule). Refs #668 --- .../document/DocumentSpecifications.java | 6 + .../document/DocumentSpecificationsTest.java | 17 +++ ...ndatedDocumentOrderingIntegrationTest.java | 104 ++++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/document/UndatedDocumentOrderingIntegrationTest.java diff --git a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentSpecifications.java b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentSpecifications.java index 22339a95..ff238a43 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentSpecifications.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/document/DocumentSpecifications.java @@ -55,6 +55,12 @@ public class DocumentSpecifications { return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status); } + // Filtert auf undatierte Dokumente (meta_date IS NULL) — für die "Nur undatierte"-Triage. + // false → kein Prädikat (no-op), true → documentDate IS NULL (issue #668). + public static Specification undatedOnly(boolean undated) { + return (root, query, cb) -> undated ? cb.isNull(root.get("documentDate")) : null; + } + /** * Filtert nach vorausgeweiteten Tag-ID-Sets mit AND- oder OR-Logik. * diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSpecificationsTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSpecificationsTest.java index 7af1ec22..b9f8a46d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSpecificationsTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/DocumentSpecificationsTest.java @@ -261,4 +261,21 @@ class DocumentSpecificationsTest { assertThat(result).isEmpty(); } + // ─── undatedOnly ────────────────────────────────────────────────────────── + + @Test + void undatedOnly_false_returnsAllDocuments() { + // false → no predicate (null), so the filter is a no-op (issue #668). + List result = documentRepository.findAll(Specification.where(undatedOnly(false))); + assertThat(result).hasSize(3); + } + + @Test + void undatedOnly_true_returnsOnlyDocumentsWithoutADate() { + // Only the placeholder photo has a null documentDate in the fixture. + List result = documentRepository.findAll(Specification.where(undatedOnly(true))); + assertThat(result).extracting(Document::getTitle).containsExactly("Familienfoto"); + assertThat(result).allMatch(d -> d.getDocumentDate() == null); + } + } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/document/UndatedDocumentOrderingIntegrationTest.java b/backend/src/test/java/org/raddatz/familienarchiv/document/UndatedDocumentOrderingIntegrationTest.java new file mode 100644 index 00000000..e7e50d74 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/document/UndatedDocumentOrderingIntegrationTest.java @@ -0,0 +1,104 @@ +package org.raddatz.familienarchiv.document; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.PostgresContainerConfig; +import org.raddatz.familienarchiv.config.FlywayConfig; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; +import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.raddatz.familienarchiv.document.DocumentSpecifications.isBetween; +import static org.raddatz.familienarchiv.document.DocumentSpecifications.undatedOnly; + +/** + * Real-Postgres assertions for issue #668. H2 disagrees with Postgres on + * {@code NULLS FIRST/LAST} defaults and on whether {@code BETWEEN} excludes + * NULL, so these guarantees MUST run against {@code postgres:16-alpine}, never + * an in-memory database. + */ +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import({PostgresContainerConfig.class, FlywayConfig.class}) +class UndatedDocumentOrderingIntegrationTest { + + @Autowired DocumentRepository documentRepository; + + @BeforeEach + void setUp() { + documentRepository.deleteAll(); + save("1916", LocalDate.of(1916, 6, 15)); + save("1943", LocalDate.of(1943, 12, 24)); + save("undated-a", null); + save("undated-b", null); + } + + private void save(String title, LocalDate date) { + documentRepository.save(Document.builder() + .title(title) + .originalFilename(title + ".pdf") + .status(DocumentStatus.UPLOADED) + .metaDatePrecision(date == null ? DatePrecision.UNKNOWN : DatePrecision.DAY) + .documentDate(date) + .build()); + } + + @Test + void dateAscWithNullsLast_returnsDatedFirstUndatedLast() { + Sort sort = Sort.by(new Sort.Order(Sort.Direction.ASC, "documentDate").nullsLast()); + + List result = documentRepository.findAll(sort); + + assertThat(result).hasSize(4); + assertThat(result.get(0).getDocumentDate()).isEqualTo(LocalDate.of(1916, 6, 15)); + assertThat(result.get(1).getDocumentDate()).isEqualTo(LocalDate.of(1943, 12, 24)); + assertThat(result.get(2).getDocumentDate()).isNull(); + assertThat(result.get(3).getDocumentDate()).isNull(); + } + + @Test + void undatedOnly_returnsExactlyTheNullDatedRows() { + List result = documentRepository.findAll(undatedOnly(true)); + + assertThat(result).hasSize(2); + assertThat(result).allMatch(d -> d.getDocumentDate() == null); + } + + @Test + void undatedOnly_false_returnsAllRows() { + Specification spec = Specification.where(undatedOnly(false)); + + List result = documentRepository.findAll(spec); + + assertThat(result).hasSize(4); + } + + @Test + void dateRange_excludesUndatedRows() { + List result = documentRepository.findAll(isBetween( + LocalDate.of(1900, 1, 1), LocalDate.of(2000, 12, 31))); + + assertThat(result).hasSize(2); + assertThat(result).allMatch(d -> d.getDocumentDate() != null); + } + + @Test + void undatedOnly_combinedWithDateRange_returnsEmpty() { + // The collision rule (#668): a from/to range and undated=true are mutually + // exclusive — a row cannot both have a null date and fall inside a range. + Specification spec = Specification + .where(undatedOnly(true)) + .and(isBetween(LocalDate.of(1900, 1, 1), LocalDate.of(2000, 12, 31))); + + List result = documentRepository.findAll(spec); + + assertThat(result).isEmpty(); + } +}