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
This commit is contained in:
Marcel
2026-05-27 18:34:10 +02:00
parent 5f2ef823e1
commit 39a462b2bb
3 changed files with 127 additions and 0 deletions

View File

@@ -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<Document> 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.
*

View File

@@ -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<Document> 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<Document> result = documentRepository.findAll(Specification.where(undatedOnly(true)));
assertThat(result).extracting(Document::getTitle).containsExactly("Familienfoto");
assertThat(result).allMatch(d -> d.getDocumentDate() == null);
}
}

View File

@@ -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<Document> 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<Document> result = documentRepository.findAll(undatedOnly(true));
assertThat(result).hasSize(2);
assertThat(result).allMatch(d -> d.getDocumentDate() == null);
}
@Test
void undatedOnly_false_returnsAllRows() {
Specification<Document> spec = Specification.where(undatedOnly(false));
List<Document> result = documentRepository.findAll(spec);
assertThat(result).hasSize(4);
}
@Test
void dateRange_excludesUndatedRows() {
List<Document> 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<Document> spec = Specification
.where(undatedOnly(true))
.and(isBetween(LocalDate.of(1900, 1, 1), LocalDate.of(2000, 12, 31)));
List<Document> result = documentRepository.findAll(spec);
assertThat(result).isEmpty();
}
}