feat(documents): return global undated count in search response
The undated bucket count was page-local — derived from the year-grouping of the current page's items, so it could never exceed the page size. The owner's decision is for it to reflect ALL undated documents matching the active filter across every page. Add an undatedCount field to DocumentSearchResult, computed once per search via a COUNT over the same filter spec with undatedOnly(true) forced — independent of the "Nur undatierte" toggle so it never collapses to the page slice or double-counts. A from/to range excludes undated rows by the collision rule, so the count is legitimately 0 inside a date range. Refs #668 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -108,6 +108,83 @@ class DocumentSearchPagedIntegrationTest {
|
||||
assertThat(result.totalPages()).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_undatedCount_isGlobalFilteredTotal_notPageSlice() {
|
||||
// Seed 70 undated docs on top of the 120 dated ones. With a 50-per-page
|
||||
// window the undated rows span multiple pages, so a page-local count could
|
||||
// never exceed 50 — the global count must be the full 70 (issue #668).
|
||||
int undatedTotal = 70;
|
||||
for (int i = 0; i < undatedTotal; i++) {
|
||||
documentRepository.save(Document.builder()
|
||||
.title("Undatiert-" + String.format("%03d", i))
|
||||
.originalFilename("undatiert-" + i + ".pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.metaDatePrecision(DatePrecision.UNKNOWN)
|
||||
.documentDate(null)
|
||||
.build());
|
||||
}
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
||||
|
||||
// Global undated count is the full undated total, independent of page size.
|
||||
assertThat(result.undatedCount()).isEqualTo(undatedTotal);
|
||||
// Total matches both dated + undated (no undated-only filter applied).
|
||||
assertThat(result.totalElements()).isEqualTo(FIXTURE_SIZE + undatedTotal);
|
||||
// The first DATE-DESC page is all dated rows (nulls last), so a page-local
|
||||
// tally would report 0 undated — proving the count is not page-derived.
|
||||
assertThat(result.items()).allMatch(item -> item.documentDate() != null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_undatedCount_ignoresUndatedOnlyToggle() {
|
||||
// The "Nur undatierte" toggle must not skew the count: whether undated=true or
|
||||
// false, the global undated count for the same filter is identical (issue #668).
|
||||
int undatedTotal = 12;
|
||||
for (int i = 0; i < undatedTotal; i++) {
|
||||
documentRepository.save(Document.builder()
|
||||
.title("U-" + i)
|
||||
.originalFilename("u-" + i + ".pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.metaDatePrecision(DatePrecision.UNKNOWN)
|
||||
.documentDate(null)
|
||||
.build());
|
||||
}
|
||||
|
||||
DocumentSearchResult unfiltered = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
||||
DocumentSearchResult undatedOnly = documentService.searchDocuments(
|
||||
null, null, null, null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null, true, PageRequest.of(0, 50));
|
||||
|
||||
assertThat(unfiltered.undatedCount()).isEqualTo(undatedTotal);
|
||||
assertThat(undatedOnly.undatedCount()).isEqualTo(undatedTotal);
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_undatedCount_isZero_insideDateRange() {
|
||||
// A from/to range excludes undated rows by the collision rule (#668), so the
|
||||
// global undated count inside a range is legitimately 0 even when undated docs exist.
|
||||
for (int i = 0; i < 5; i++) {
|
||||
documentRepository.save(Document.builder()
|
||||
.title("U-range-" + i)
|
||||
.originalFilename("u-range-" + i + ".pdf")
|
||||
.status(DocumentStatus.UPLOADED)
|
||||
.metaDatePrecision(DatePrecision.UNKNOWN)
|
||||
.documentDate(null)
|
||||
.build());
|
||||
}
|
||||
|
||||
DocumentSearchResult result = documentService.searchDocuments(
|
||||
null, LocalDate.of(1900, 1, 1), LocalDate.of(2000, 12, 31),
|
||||
null, null, null, null, null,
|
||||
DocumentSort.DATE, "DESC", null, false, PageRequest.of(0, 50));
|
||||
|
||||
assertThat(result.undatedCount()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void search_differentPagesReturnDisjointSlices() {
|
||||
DocumentSearchResult page0 = documentService.searchDocuments(
|
||||
|
||||
@@ -99,4 +99,32 @@ class DocumentSearchResultTest {
|
||||
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void undatedCount_component_is_annotated_as_required_in_openapi_schema() throws NoSuchFieldException {
|
||||
Schema schema = DocumentSearchResult.class.getDeclaredField("undatedCount").getAnnotation(Schema.class);
|
||||
assertThat(schema).isNotNull();
|
||||
assertThat(schema.requiredMode()).isEqualTo(Schema.RequiredMode.REQUIRED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void factories_default_undatedCount_to_zero() {
|
||||
assertThat(DocumentSearchResult.of(List.of()).undatedCount()).isZero();
|
||||
assertThat(DocumentSearchResult.paged(List.of(), PageRequest.of(0, 50), 0L).undatedCount()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void withUndatedCount_overlays_count_and_preserves_other_fields() {
|
||||
DocumentSearchResult base = DocumentSearchResult.paged(
|
||||
List.of(item(UUID.randomUUID())), PageRequest.of(1, 50), 120L);
|
||||
|
||||
DocumentSearchResult withCount = base.withUndatedCount(7L);
|
||||
|
||||
assertThat(withCount.undatedCount()).isEqualTo(7L);
|
||||
assertThat(withCount.items()).isEqualTo(base.items());
|
||||
assertThat(withCount.totalElements()).isEqualTo(120L);
|
||||
assertThat(withCount.pageNumber()).isEqualTo(1);
|
||||
assertThat(withCount.pageSize()).isEqualTo(50);
|
||||
assertThat(withCount.totalPages()).isEqualTo(3);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user