feat(documents): honest handling of undated documents in browse & search (Phase 6, #668) #682
@@ -55,6 +55,12 @@ public class DocumentSpecifications {
|
|||||||
return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status);
|
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.
|
* Filtert nach vorausgeweiteten Tag-ID-Sets mit AND- oder OR-Logik.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -261,4 +261,21 @@ class DocumentSpecificationsTest {
|
|||||||
assertThat(result).isEmpty();
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user