refactor(document): extract a SearchFilters record for the document search signatures (#683) #702

Merged
marcel merged 5 commits from feat/issue-683-search-filters-record into main 2026-05-31 16:17:46 +02:00
Owner

Closes #683.

What

Threads a filter-only SearchFilters record through the entire document search call chain so no positional 10-field filter list survives anywhere in the path. Pure refactor — no behaviour change.

  • searchDocuments(SearchFilters, DocumentSort, String dir, Pageable) — sort/dir/pageable stay separate (not filter predicates).
  • findIdsForFilter(SearchFilters) — clean single-arg.
  • The four private helpers (buildSearchSpec, runSearch, countUndatedForFilter, isPureTextRelevance) no longer carry the positional 10-field list.
  • Controller builds the record after its existing tagOp/undated coercions.

Why

searchDocuments had 13 positional params where sender/receiver (both UUID) and from/to (both LocalDate) sat adjacent — a transposition compiles cleanly and silently returns the wrong rows. A named record kills the entire class. Mirrors the existing DensityFilters precedent; the two records are kept separate as decided in the issue.

Key invariants preserved

  • Forced-undated count (#668): searchDocuments builds the base filter once, passes filters.withUndated(true) to the count path while runSearch honours the user's toggle. searchDocuments_undatedTrue_usesSpecificationPath_notPureTextRelevanceShortcut stays green, unmodified.
  • @RequirePermission(WRITE_ALL) on getDocumentIds — untouched (controller method annotation, independent of the service signature).
  • generate:api zero diff — the controller diff touches no @RequestParam, mapping, @RequestBody, or response type, so the OpenAPI contract is byte-for-byte identical.

Scope note

buildSearchSpec is shared with the density path (loadFilteredDates). Changing its signature forced that call site too — the DensityFilters is adapted into a SearchFilters (null dates, undated=false) right at the call, keeping the two records separate.

Tests

No new tests (pure refactor). Controller binding tests swap positional any()/eq() matchers for ArgumentCaptor<SearchFilters>, asserting captured.undated()/.status()/.sender() — strictly stronger than the previous any()-soup.

All green: DocumentServiceTest 168 · DocumentControllerTest 104 · DocumentServiceSortTest 7 · DocumentSearchPagedIntegrationTest 8 · UndatedDocumentOrderingIntegrationTest 6 · DocumentListItemIntegrationTest 4 · DocumentLazyLoadingTest 5 · DocumentDensityIntegrationTest 7. ./mvnw clean package -DskipTests

🤖 Generated with Claude Code

Closes #683. ## What Threads a filter-only `SearchFilters` record through the **entire** document search call chain so no positional 10-field filter list survives anywhere in the path. **Pure refactor — no behaviour change.** - `searchDocuments(SearchFilters, DocumentSort, String dir, Pageable)` — sort/dir/pageable stay separate (not filter predicates). - `findIdsForFilter(SearchFilters)` — clean single-arg. - The four private helpers (`buildSearchSpec`, `runSearch`, `countUndatedForFilter`, `isPureTextRelevance`) no longer carry the positional 10-field list. - Controller builds the record **after** its existing `tagOp`/`undated` coercions. ## Why `searchDocuments` had **13 positional params** where `sender`/`receiver` (both `UUID`) and `from`/`to` (both `LocalDate`) sat adjacent — a transposition compiles cleanly and silently returns the wrong rows. A named record kills the entire class. Mirrors the existing `DensityFilters` precedent; the two records are kept **separate** as decided in the issue. ## Key invariants preserved - **Forced-undated count (#668):** `searchDocuments` builds the base filter once, passes `filters.withUndated(true)` to the count path while `runSearch` honours the user's toggle. `searchDocuments_undatedTrue_usesSpecificationPath_notPureTextRelevanceShortcut` stays green, unmodified. - **`@RequirePermission(WRITE_ALL)` on `getDocumentIds`** — untouched (controller method annotation, independent of the service signature). - **`generate:api` zero diff** — the controller diff touches no `@RequestParam`, mapping, `@RequestBody`, or response type, so the OpenAPI contract is byte-for-byte identical. ## Scope note `buildSearchSpec` is shared with the **density** path (`loadFilteredDates`). Changing its signature forced that call site too — the `DensityFilters` is adapted into a `SearchFilters` (null dates, `undated=false`) right at the call, keeping the two records separate. ## Tests No new tests (pure refactor). Controller binding tests swap positional `any()`/`eq()` matchers for `ArgumentCaptor<SearchFilters>`, asserting `captured.undated()`/`.status()`/`.sender()` — strictly stronger than the previous `any()`-soup. All green: `DocumentServiceTest` 168 · `DocumentControllerTest` 104 · `DocumentServiceSortTest` 7 · `DocumentSearchPagedIntegrationTest` 8 · `UndatedDocumentOrderingIntegrationTest` 6 · `DocumentListItemIntegrationTest` 4 · `DocumentLazyLoadingTest` 5 · `DocumentDensityIntegrationTest` 7. `./mvnw clean package -DskipTests` ✅ 🤖 Generated with [Claude Code](https://claude.com/claude-code)
marcel added 2 commits 2026-05-31 15:43:35 +02:00
Filter-only value object bundling the ten search predicates so the long
positional argument lists on the document search chain can be replaced
with one named record — killing the sender/receiver and from/to swap-bug
class. Mirrors the existing DensityFilters; carries a withUndated copy
accessor for the forced-undated count path. Unused as of this commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
refactor(document): thread SearchFilters through the search chain (#683)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m21s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m26s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 19s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
dcb57ffacd
Replace the long positional filter lists on the document search chain
with the SearchFilters record. searchDocuments now takes
(SearchFilters, DocumentSort, String dir, Pageable) and findIdsForFilter
takes a single SearchFilters; the four private helpers (buildSearchSpec,
runSearch, countUndatedForFilter, isPureTextRelevance) no longer carry a
positional 10-field filter list. The controller builds the record after
its existing tagOp/undated coercions; the density path adapts its
DensityFilters into a SearchFilters at the shared buildSearchSpec call.

The forced-undated count path is preserved via filters.withUndated(true),
so countUndatedForFilter still ignores the user's toggle (#668) while
runSearch honours it. No behaviour change.

Controller binding tests swap their positional any()/eq() matchers for
ArgumentCaptor<SearchFilters>, asserting captured.undated()/.status()/
.sender() — strictly stronger than the previous any()-soup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

🏛️ Markus Keller — Senior Application Architect

Approved

A textbook parameter-object refactor. Ten positional arguments — including two UUIDs (sender/receiver) and two LocalDates (from/to) that compile cleanly when transposed — are now bundled into a named, immutable record threaded through the whole search chain (searchDocuments / findIdsForFilter / buildSearchSpec / runSearch / countUndatedForFilter / isPureTextRelevance + controller). Pure behavior-preserving refactor: no SQL, no specification logic, no endpoint signature changes. The accidental-transposition risk this removes is real, and the call sites are now legible.

Architecture lens

Layer boundaries — clean. SearchFilters lives in document/ alongside DensityFilters, DocumentSort, and the rest of the domain. It is an intra-domain value object, never crosses a module boundary, imports only tag.TagOperator (already a legitimate cross-domain dependency the search path used before). No new coupling introduced.

Value-object purity — correct. SearchFilters.java:28-43 is a plain record: no validation, no coercion, no business logic. The single helper withUndated(boolean) is a pure copy-with-override returning a new instance — the canonical immutable-record idiom, not behavior. The boolean-default / Boolean.TRUE.equals(undated) coercion stays at the HTTP boundary in the controller where it belongs (DocumentController.java:319,392), not inside the record. Exactly right.

The DensityFilters → SearchFilters adapter (DocumentService.java:170-173) — this is the right minimal bridge, not unwanted coupling. The adaptation is local to the density path's private loadFilteredDates; it widens the seven density fields into the ten-field search bundle by supplying from=null, to=null, undated=false at the one shared buildSearchSpec call. DensityFilters itself remains unaware of SearchFilters — the dependency points one way, density → search, and only inside a single private method. Field mapping verified correct (text/sender/receiver/tags/tagQ/status/tagOperator line up one-to-one).

Keep-records-separate decision — endorsed, and the Javadoc on both records documents the why (SearchFilters.java:21-23, DensityFilters.java:9-10): density deliberately excludes date/undated semantics, so merging the records would force the density path to reason about — and pass dummy values for — fields it has no business knowing. Three null/false literals at one call site is a far cheaper price than a shared type that leaks date semantics into the density domain. This is the correct DRY-vs-coupling trade: a tiny duplication beats an over-general shared type. I'd resist any later "consolidate these two records" suggestion for exactly this reason.

Documentation currency — no updates triggered, correctly. No new package, route, controller, service, migration, ErrorCode, or Permission. SearchFilters is a new value object inside the existing document package, not a new domain module — the C4 l3-backend diagrams and the CLAUDE.md package table operate at package/service granularity, not per-record, so nothing is stale. No ADR required: this is a clean-code refactor, not a structural decision with lasting consequences (and it conforms to the existing DensityFilters precedent rather than setting a new one).

Blockers

None.

Suggestions

None blocking. One optional note: the test fixtures now construct new SearchFilters(null, null, …, false) with ten positional args in many places — the very positional-fragility this record removes from production code is now concentrated in tests. Not worth churning this PR, but if a SearchFilters.builder() or a test helper (emptyFilters()) emerges later, the unfiltered-search test setups would read better. Production call sites are already clear; this is test ergonomics only.

Solid, well-scoped, well-documented refactor. Ship it.

## 🏛️ Markus Keller — Senior Application Architect **✅ Approved** A textbook parameter-object refactor. Ten positional arguments — including two `UUID`s (sender/receiver) and two `LocalDate`s (from/to) that compile cleanly when transposed — are now bundled into a named, immutable record threaded through the whole search chain (`searchDocuments` / `findIdsForFilter` / `buildSearchSpec` / `runSearch` / `countUndatedForFilter` / `isPureTextRelevance` + controller). Pure behavior-preserving refactor: no SQL, no specification logic, no endpoint signature changes. The accidental-transposition risk this removes is real, and the call sites are now legible. ### Architecture lens **Layer boundaries** — clean. `SearchFilters` lives in `document/` alongside `DensityFilters`, `DocumentSort`, and the rest of the domain. It is an intra-domain value object, never crosses a module boundary, imports only `tag.TagOperator` (already a legitimate cross-domain dependency the search path used before). No new coupling introduced. **Value-object purity** — correct. `SearchFilters.java:28-43` is a plain record: no validation, no coercion, no business logic. The single helper `withUndated(boolean)` is a pure copy-with-override returning a new instance — the canonical immutable-record idiom, not behavior. The boolean-default / `Boolean.TRUE.equals(undated)` coercion stays at the HTTP boundary in the controller where it belongs (`DocumentController.java:319,392`), not inside the record. Exactly right. **The DensityFilters → SearchFilters adapter** (`DocumentService.java:170-173`) — this is the right minimal bridge, not unwanted coupling. The adaptation is local to the density path's private `loadFilteredDates`; it widens the seven density fields into the ten-field search bundle by supplying `from=null, to=null, undated=false` at the one shared `buildSearchSpec` call. `DensityFilters` itself remains unaware of `SearchFilters` — the dependency points one way, density → search, and only inside a single private method. Field mapping verified correct (text/sender/receiver/tags/tagQ/status/tagOperator line up one-to-one). **Keep-records-separate decision** — endorsed, and the Javadoc on both records documents the *why* (`SearchFilters.java:21-23`, `DensityFilters.java:9-10`): density deliberately excludes date/undated semantics, so merging the records would force the density path to reason about — and pass dummy values for — fields it has no business knowing. Three null/false literals at one call site is a far cheaper price than a shared type that leaks date semantics into the density domain. This is the correct DRY-vs-coupling trade: a tiny duplication beats an over-general shared type. I'd resist any later "consolidate these two records" suggestion for exactly this reason. **Documentation currency** — no updates triggered, correctly. No new package, route, controller, service, migration, `ErrorCode`, or `Permission`. `SearchFilters` is a new value object inside the existing `document` package, not a new domain module — the C4 `l3-backend` diagrams and the `CLAUDE.md` package table operate at package/service granularity, not per-record, so nothing is stale. No ADR required: this is a clean-code refactor, not a structural decision with lasting consequences (and it conforms to the existing `DensityFilters` precedent rather than setting a new one). ### Blockers None. ### Suggestions None blocking. One optional note: the test fixtures now construct `new SearchFilters(null, null, …, false)` with ten positional args in many places — the very positional-fragility this record removes from production code is now concentrated in tests. Not worth churning this PR, but if a `SearchFilters.builder()` or a test helper (`emptyFilters()`) emerges later, the unfiltered-search test setups would read better. Production call sites are already clear; this is test ergonomics only. Solid, well-scoped, well-documented refactor. Ship it.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility

Approved — nothing in my domain to evaluate. This is a pure backend Java refactor with zero client-visible surface.

What I checked

  • Frontend files: git diff 2cc43c3c HEAD --name-only filtered for .svelte/.ts/.js/.css/.jsonnone. The diff touches only 9 Java files (DocumentController, DocumentService, the new SearchFilters record, and their tests).
  • API contract / client-visible shape: The two affected endpoints (findIdsForFilter, searchDocuments) keep their exact @RequestParam bindings at the HTTP boundary. The change only collapses the parameter list into an internal SearchFilters record passed between controller and service. Query-param names, types, and the JSON response shape are unchanged — generate:api is expected to produce a zero diff. No old client breaks.
  • Routes / UI / brand / responsive / accessibility: No routes, components, markup, tokens, touch targets, contrast, focus, or ARIA in scope. Nothing rendered changes.

No UX, accessibility, brand, or responsive concerns. Clean refactor from my seat.

## 🎨 Leonie Voss — UX Designer & Accessibility ✅ **Approved** — nothing in my domain to evaluate. This is a pure backend Java refactor with zero client-visible surface. ### What I checked - **Frontend files:** `git diff 2cc43c3c HEAD --name-only` filtered for `.svelte/.ts/.js/.css/.json` → **none**. The diff touches only 9 Java files (`DocumentController`, `DocumentService`, the new `SearchFilters` record, and their tests). - **API contract / client-visible shape:** The two affected endpoints (`findIdsForFilter`, `searchDocuments`) keep their exact `@RequestParam` bindings at the HTTP boundary. The change only collapses the parameter list into an internal `SearchFilters` record passed *between controller and service*. Query-param names, types, and the JSON response shape are unchanged — `generate:api` is expected to produce a zero diff. No old client breaks. - **Routes / UI / brand / responsive / accessibility:** No routes, components, markup, tokens, touch targets, contrast, focus, or ARIA in scope. Nothing rendered changes. No UX, accessibility, brand, or responsive concerns. Clean refactor from my seat.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Approved

This is exactly what a behaviour-preserving refactor should look like. The 13-positional-param signature was a swap-bug magnet (two UUIDs sender/receiver, two LocalDates from/to), and SearchFilters collapses it into one named, immutable bundle. No behaviour change, no lost coverage.

Behaviour preservation — verified

I traced the two paths that could plausibly drift:

  • buildSearchSpec never reads filters.text() (DocumentService.java:520-531). The text predicate is driven by hasText + ftsIds, exactly as before. So the new SearchFilters carries text purely as a passenger for the helpers that do need it (searchDocuments, findIdsForFilter). Putting filters.text() into the record in loadFilteredDates is inert for the spec — identical to the old call that started at from.
  • withUndated(true) is a faithful 1:1 replacement for the old forced buildSearchSpec(..., true) flag (DocumentService.java:685), while runSearch still receives the original filters carrying the user's real toggle. The #668 undated-count semantics are untouched, and the comment block at 659-673 still guards the pure-text RELEVANCE fast path against the undated flag correctly.

Test rewrites — faithful, and in two spots stronger

The mechanical any(), any(), … collapses are correct. Two call-sites improved rather than just survived:

  • search_withStatusParam_passesItToService (DocumentControllerTest.java:142): old eq(DocumentStatus.REVIEWED) matcher-in-stub → explicit assertThat(filtersCaptor.getValue().status()).isEqualTo(REVIEWED). Clearer failure message, same guarantee.
  • getDocumentIds_passesSenderIdParamToService (:404-406): old eq(senderId)filtersCaptor .sender() assertion. Same.

No assertions were dropped; the integration tests (e.g. the date-range case at DocumentSearchPagedIntegrationTest.java:178) preserve positional order into the record faithfully — (null, from, to, null…) maps to text, from, to, sender… correctly.

Clean code

  • SearchFilters.java — record + single withUndated copy method, no logic. The Javadoc earns its place: it documents why sort/dir/pagination are excluded and why DensityFilters stays a separate sibling rather than being folded in. That's a "why" comment, not a "what" — correct per our style.
  • isPureTextRelevance reads cleanly off the record; guard-clause shape preserved.
  • No positional 10-arg list left anywhere — grepped searchDocuments(/findIdsForFilter( across src/main and src/test; every call goes through SearchFilters.

Is the DensityFilters→SearchFilters bridge a smell?

loadFilteredDates (DocumentService.java:168-175) adapting DensityFiltersSearchFilters with literal null, null for from/to and false for undated is acceptable, not a smell. The two records are deliberately kept distinct (density excludes date/undated by design — documented in both Javadocs), so the bridge is the honest seam between "I have no date concept" and "the shared spec builder wants the full filter shape". The literals are explicit and local. The only marginally nicer alternative would be a SearchFilters.fromDensity(DensityFilters) factory to name the bridge — but that's a taste call, not a defect, and KISS argues against adding a factory used exactly once.

Suggestions (non-blocking, optional)

  • SearchFilters.fromDensity(...) factory — would name the bridge and keep the null, null, …, false literals out of loadFilteredDates. Worth it only if a second density-style adapter ever appears; today YAGNI.

Nothing to change. Ship it.

## 👨‍💻 Felix Brandt — Senior Fullstack Developer **✅ Approved** This is exactly what a behaviour-preserving refactor should look like. The 13-positional-param signature was a swap-bug magnet (two UUIDs `sender`/`receiver`, two `LocalDate`s `from`/`to`), and `SearchFilters` collapses it into one named, immutable bundle. No behaviour change, no lost coverage. ### Behaviour preservation — verified I traced the two paths that could plausibly drift: - **`buildSearchSpec` never reads `filters.text()`** (`DocumentService.java:520-531`). The text predicate is driven by `hasText` + `ftsIds`, exactly as before. So the new `SearchFilters` carries `text` purely as a passenger for the helpers that *do* need it (`searchDocuments`, `findIdsForFilter`). Putting `filters.text()` into the record in `loadFilteredDates` is inert for the spec — identical to the old call that started at `from`. - **`withUndated(true)` is a faithful 1:1 replacement** for the old forced `buildSearchSpec(..., true)` flag (`DocumentService.java:685`), while `runSearch` still receives the *original* `filters` carrying the user's real toggle. The `#668` undated-count semantics are untouched, and the comment block at `659-673` still guards the pure-text RELEVANCE fast path against the undated flag correctly. ### Test rewrites — faithful, and in two spots *stronger* The mechanical `any(), any(), …` collapses are correct. Two call-sites improved rather than just survived: - `search_withStatusParam_passesItToService` (`DocumentControllerTest.java:142`): old `eq(DocumentStatus.REVIEWED)` matcher-in-stub → explicit `assertThat(filtersCaptor.getValue().status()).isEqualTo(REVIEWED)`. Clearer failure message, same guarantee. - `getDocumentIds_passesSenderIdParamToService` (`:404-406`): old `eq(senderId)` → `filtersCaptor` `.sender()` assertion. Same. No assertions were dropped; the integration tests (e.g. the date-range case at `DocumentSearchPagedIntegrationTest.java:178`) preserve positional order into the record faithfully — `(null, from, to, null…)` maps to `text, from, to, sender…` correctly. ### Clean code - `SearchFilters.java` — record + single `withUndated` copy method, no logic. The Javadoc earns its place: it documents *why* sort/dir/pagination are excluded and why `DensityFilters` stays a separate sibling rather than being folded in. That's a "why" comment, not a "what" — correct per our style. - `isPureTextRelevance` reads cleanly off the record; guard-clause shape preserved. - No positional 10-arg list left anywhere — grepped `searchDocuments(`/`findIdsForFilter(` across `src/main` and `src/test`; every call goes through `SearchFilters`. ### Is the DensityFilters→SearchFilters bridge a smell? `loadFilteredDates` (`DocumentService.java:168-175`) adapting `DensityFilters` → `SearchFilters` with literal `null, null` for from/to and `false` for undated is **acceptable, not a smell**. The two records are deliberately kept distinct (density excludes date/undated by design — documented in both Javadocs), so the bridge is the honest seam between "I have no date concept" and "the shared spec builder wants the full filter shape". The literals are explicit and local. The only marginally nicer alternative would be a `SearchFilters.fromDensity(DensityFilters)` factory to name the bridge — but that's a taste call, not a defect, and KISS argues against adding a factory used exactly once. ### Suggestions (non-blocking, optional) - **`SearchFilters.fromDensity(...)` factory** — would name the bridge and keep the `null, null, …, false` literals out of `loadFilteredDates`. Worth it only if a second density-style adapter ever appears; today YAGNI. Nothing to change. Ship it.
Author
Owner

🔒 Nora Steiner ("NullX") — Application Security Engineer

Verdict: Approve — no security delta. This is a clean signature-only refactor that bundles ten already-typed search predicates into a SearchFilters record. Every security boundary I checked is preserved bit-for-bit. No blockers, one minor observation.

I reviewed the full diff (2cc43c3c..HEAD) and read DocumentController.java in full.

Verified controls (all intact)

1. @RequirePermission(Permission.WRITE_ALL) on /ids — present and unmoved.
DocumentController.java:304-305 — the annotation sits directly above getDocumentIds, exactly where it was. The bulk-edit ID enumeration path stays write-gated, so a READ_ALL-only user still gets a 403 (enforced by PermissionAspect). The BULK_EDIT_FILTER_MAX_IDS cap (line 321) that prevents materialising the whole table is also untouched.

2. Null-Boolean and raw-String coercion stay at the HTTP boundary — fail-safe to false/AND.

  • DocumentController.java:319 and :392Boolean.TRUE.equals(undated) collapses a null boxed Boolean to primitive false before the record is constructed. Boolean.TRUE.equals(null) returns false by contract — no NPE, no auto-unboxing risk. Correct: an omitted undated param resolves to "not undated-only", not an exception.
  • DocumentController.java:318 and :391tagOp (raw String) is mapped to the TagOperator enum at the boundary; any non-OR value defaults to AND. The threat-model comment at :389-390 is retained.

3. No new injection surface.
Queries remain JPA Specification predicates assembled in buildSearchSpec (DocumentService.java:81-99) — isBetween, hasSender, hasReceiver, hasTags, hasTagPartial, hasStatus, undatedOnly. All parameterized via the Criteria API; zero string concatenation introduced. FTS still routes through findAllMatchingIdsByFts(filters.text()) (:59, :126), i.e. the existing named-parameter repository method. No raw SQL/JPQL added. (CWE-89: not present.)

4. The record carries only already-typed values.
SearchFilters (SearchFilters.java:228-238) declares boolean undated (primitive) and TagOperator tagOperator — no raw String operator and no boxed Boolean leaks past the controller into the service. The boundary→typed conversion happens once, at the controller, which is exactly where I want it. withUndated(true) (:241-243) is an immutable copy used only for the undated-count path — no mutation of the original filter, no shared-state aliasing.

5. No auth check dropped or weakened on /search vs /ids.
/search (:367) was and remains intentionally unannotated — it is a READ_ALL read path, reachable by any authenticated user (the two search_undated* tests at the controller level still assert this, guarding against an accidental write-guard per #668). /ids stays WRITE_ALL. The asymmetry is deliberate and unchanged.

Test surface — strengthened, not weakened

The captor-based assertions actually improve the security posture of the suite: getDocumentIds_passesSenderIdParamToService now asserts filtersCaptor.getValue().sender() (DocumentControllerTest.java), and the undated forwarding tests assert filtersCaptor.getValue().undated() is true/false — semantic field checks that survive future signature churn better than positional anyBoolean() matchers did.

Minor (non-blocking) — detection note for the future

The fail-safe at :319/:392 is correct but implicit. A one-line guard against a future "simplification" that swaps Boolean.TRUE.equals(undated) for undated (which would NPE-on-unbox for the common omitted-param case):

// SearchFiltersBoundaryTest
@Test
void undatedOmitted_coercesToFalse_noNpe() {
    SearchFilters f = new SearchFilters(null, null, null, null, null, null, null, null,
            TagOperator.AND, Boolean.TRUE.equals((Boolean) null));
    assertThat(f.undated()).isFalse();
}

A Semgrep rule pattern: new SearchFilters(..., $B) where $B is a boxed Boolean would catch the regression class at scale, but that is overkill for a 10-field record — the unit test is sufficient.

Nothing here changes who can reach what or how input is parameterized. Ship it.

## 🔒 Nora Steiner ("NullX") — Application Security Engineer **Verdict:** Approve — no security delta. This is a clean signature-only refactor that bundles ten already-typed search predicates into a `SearchFilters` record. Every security boundary I checked is preserved bit-for-bit. No blockers, one minor observation. I reviewed the full diff (`2cc43c3c..HEAD`) and read `DocumentController.java` in full. ### Verified controls (all intact) **1. `@RequirePermission(Permission.WRITE_ALL)` on `/ids` — present and unmoved.** `DocumentController.java:304-305` — the annotation sits directly above `getDocumentIds`, exactly where it was. The bulk-edit ID enumeration path stays write-gated, so a `READ_ALL`-only user still gets a 403 (enforced by `PermissionAspect`). The `BULK_EDIT_FILTER_MAX_IDS` cap (line 321) that prevents materialising the whole table is also untouched. **2. Null-Boolean and raw-String coercion stay at the HTTP boundary — fail-safe to `false`/`AND`.** - `DocumentController.java:319` and `:392` — `Boolean.TRUE.equals(undated)` collapses a null boxed `Boolean` to primitive `false` *before* the record is constructed. `Boolean.TRUE.equals(null)` returns `false` by contract — no NPE, no auto-unboxing risk. Correct: an omitted `undated` param resolves to "not undated-only", not an exception. - `DocumentController.java:318` and `:391` — `tagOp` (raw `String`) is mapped to the `TagOperator` enum at the boundary; any non-`OR` value defaults to `AND`. The threat-model comment at `:389-390` is retained. **3. No new injection surface.** Queries remain JPA `Specification` predicates assembled in `buildSearchSpec` (`DocumentService.java:81-99`) — `isBetween`, `hasSender`, `hasReceiver`, `hasTags`, `hasTagPartial`, `hasStatus`, `undatedOnly`. All parameterized via the Criteria API; zero string concatenation introduced. FTS still routes through `findAllMatchingIdsByFts(filters.text())` (`:59`, `:126`), i.e. the existing named-parameter repository method. No raw SQL/JPQL added. (CWE-89: not present.) **4. The record carries only already-typed values.** `SearchFilters` (`SearchFilters.java:228-238`) declares `boolean undated` (primitive) and `TagOperator tagOperator` — no raw `String` operator and no boxed `Boolean` leaks past the controller into the service. The boundary→typed conversion happens once, at the controller, which is exactly where I want it. `withUndated(true)` (`:241-243`) is an immutable copy used only for the undated-count path — no mutation of the original filter, no shared-state aliasing. **5. No auth check dropped or weakened on `/search` vs `/ids`.** `/search` (`:367`) was and remains intentionally unannotated — it is a `READ_ALL` read path, reachable by any authenticated user (the two `search_undated*` tests at the controller level still assert this, guarding against an accidental write-guard per #668). `/ids` stays `WRITE_ALL`. The asymmetry is deliberate and unchanged. ### Test surface — strengthened, not weakened The captor-based assertions actually *improve* the security posture of the suite: `getDocumentIds_passesSenderIdParamToService` now asserts `filtersCaptor.getValue().sender()` (`DocumentControllerTest.java`), and the undated forwarding tests assert `filtersCaptor.getValue().undated()` is `true`/`false` — semantic field checks that survive future signature churn better than positional `anyBoolean()` matchers did. ### Minor (non-blocking) — detection note for the future The fail-safe at `:319`/`:392` is correct but implicit. A one-line guard against a future "simplification" that swaps `Boolean.TRUE.equals(undated)` for `undated` (which would NPE-on-unbox for the common omitted-param case): ```java // SearchFiltersBoundaryTest @Test void undatedOmitted_coercesToFalse_noNpe() { SearchFilters f = new SearchFilters(null, null, null, null, null, null, null, null, TagOperator.AND, Boolean.TRUE.equals((Boolean) null)); assertThat(f.undated()).isFalse(); } ``` A Semgrep rule `pattern: new SearchFilters(..., $B)` where `$B` is a boxed `Boolean` would catch the regression class at scale, but that is overkill for a 10-field record — the unit test is sufficient. Nothing here changes who can reach what or how input is parameterized. Ship it.
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Verdict: Approved — no concerns in my domain.

I reviewed this PR strictly for infrastructure/platform impact. It is a pure backend Java refactor introducing a SearchFilters record; nothing touches my domain.

What I checked (all clear):

  • Docker / Compose — no Dockerfile, docker-compose*.yml, or overlay changes
  • CI workflows — nothing under .gitea/ or .github/; no runner, cache, or action-version changes
  • Env vars / ports — no new or changed environment variables, exposed ports, or secrets
  • Database — no Flyway migrations, no .sql files (the refactor doesn't alter the schema)
  • Observability — no Prometheus / Grafana / Loki / Tempo / Caddy / Renovate changes
  • Properties / config — no application*.properties or build-config changes

The full diff is confined to backend/src/main/java/.../document/ (DocumentController, DocumentService, new SearchFilters.java) and the corresponding src/test/java files. The only CI-visible effect is the existing test suite, which the author reports green — exactly what I'd expect from a behavior-preserving refactor.

Nothing for me to flag. Ship it.

## ⚙️ Tobias Wendt — DevOps & Platform Engineer **Verdict: ✅ Approved — no concerns in my domain.** I reviewed this PR strictly for infrastructure/platform impact. It is a pure backend Java refactor introducing a `SearchFilters` record; nothing touches my domain. **What I checked (all clear):** - **Docker / Compose** — no `Dockerfile`, `docker-compose*.yml`, or overlay changes - **CI workflows** — nothing under `.gitea/` or `.github/`; no runner, cache, or action-version changes - **Env vars / ports** — no new or changed environment variables, exposed ports, or secrets - **Database** — no Flyway migrations, no `.sql` files (the refactor doesn't alter the schema) - **Observability** — no Prometheus / Grafana / Loki / Tempo / Caddy / Renovate changes - **Properties / config** — no `application*.properties` or build-config changes The full diff is confined to `backend/src/main/java/.../document/` (`DocumentController`, `DocumentService`, new `SearchFilters.java`) and the corresponding `src/test/java` files. The only CI-visible effect is the existing test suite, which the author reports green — exactly what I'd expect from a behavior-preserving refactor. Nothing for me to flag. Ship it.
Author
Owner

🧪 Sara Holt — Senior QA Engineer

Verdict: Approve. The matcher→ArgumentCaptor<SearchFilters> conversion is faithful — every value assertion from the old positional eq()/captor tests survives as a field assertion on the captured record. No test was silently downgraded to all-any(). This is a clean, behaviour-preserving refactor.

Critical check: were any value assertions lost? — No.

I traced each load-bearing assertion across the conversion:

Test Old assertion New assertion Status
search_undatedTrue_isForwardedToServiceAsTrue undatedCaptorisTrue() filtersCaptor.getValue().undated()isTrue() preserved
search_withoutUndatedParam_forwardsFalseToService undatedCaptorisFalse() .undated()isFalse() preserved
search_withStatusParam_passesItToService eq(DocumentStatus.REVIEWED) .status()isEqualTo(REVIEWED) preserved (upgraded from inline matcher to explicit field assert)
getDocumentIds_passesSenderIdParamToService eq(senderId) .sender()isEqualTo(senderId) preserved
search_passesPageRequestToService pageable captor at arg 13 pageable captor at arg 4 preserved

The when(...).thenReturn(...) stub lines that collapsed to all-any() are harmless — the assertion lives in the paired verify(...captor.capture()...) + field assert, not the stub. No test left asserting nothing.

Transposition check (the real risk in this refactor) — clean.

The record order (text, from, to, sender, receiver, tags, tagQ, status, tagOperator, undated) matches the old findIdsForFilter positional order exactly, so those conversions are a straight 1:1. The searchDocuments conversions are the tricky ones because the old signature interleaved sort, dir between status and tagOperator; I spot-checked each reshuffle:

  • DocumentServiceTest status test (searchDocuments_withStatusFilter..., was line ~1600): DocumentStatus.REVIEWED lands in record slot 8 (status), sort/dir/pageable stay out.
  • Undated relevance-bypass test (was ~1818): undated=true correctly in slot 10, RELEVANCE stays as the sort arg — still proves the FTS-id path runs (findAllMatchingIdsByFts("brief") verified) and the raw-page SQL shortcut is skipped because undated short-circuits the fast path.
  • DocumentServiceSortTest relevance + active filter (...uses_inMemory_path): from correctly placed in slot 2, keeping the date filter active so isPureTextRelevance returns false and the in-memory path is taken — never() findFtsPageRaw still asserted.
  • findIdsForFilter tag-OR test: tags=List.of("Brief") in slot 6, TagOperator.OR in slot 9.

#668 undated-count tests — intact.

In DocumentSearchPagedIntegrationTest, both #668 guards keep their assertions through the call-shape change only:

  • unfiltered vs undated=true both still assert undatedCount == undatedTotal (count ignores the toggle);
  • the date-range case still asserts undatedCount().isZero() (collision rule).

The production side backs this: searchDocuments passes filters.withUndated(true) into countUndatedForFilter, and withUndated() copies all ten fields — verified field-for-field, no field dropped.

Relevance fast-path bypass — preserved.

isPureTextRelevance(hasText, sort, filters) maps from/to/sender/receiver/tags/tagQ/status one-to-one from the old param list, and searchDocuments still gates it behind !filters.undated() (was !undated). No behavioural drift.

Blockers

None.

Suggestions (non-blocking, optional)

  1. The 10-arg new SearchFilters(null, null, null, null, null, null, null, null, null, false) literal now appears ~20 times across the integration/unit tests. A small private factory — private static SearchFilters noFilters() / filters().undated(true).build() — would kill the positional-null wall and make the intent (which field is non-null in a given test) jump out. This is exactly the transposition footgun the production record was introduced to remove; mirroring it in the test fixtures would finish the job. Pure readability, no behaviour change.
  2. No new assertions were added (correct for a "no behaviour change" refactor), but the controller status/sender tests are now stronger than before (explicit isEqualTo vs inline eq() matcher) — a nice side benefit worth keeping in mind as the pattern for future captor conversions.

Confidence is high that "no behaviour change" holds at the test level. Ship it.

## 🧪 Sara Holt — Senior QA Engineer **Verdict:** Approve. The matcher→`ArgumentCaptor<SearchFilters>` conversion is faithful — every value assertion from the old positional `eq()`/captor tests survives as a field assertion on the captured record. No test was silently downgraded to all-`any()`. This is a clean, behaviour-preserving refactor. ### Critical check: were any value assertions lost? — No. I traced each load-bearing assertion across the conversion: | Test | Old assertion | New assertion | Status | |------|---------------|---------------|--------| | `search_undatedTrue_isForwardedToServiceAsTrue` | `undatedCaptor` → `isTrue()` | `filtersCaptor.getValue().undated()` → `isTrue()` | ✅ preserved | | `search_withoutUndatedParam_forwardsFalseToService` | `undatedCaptor` → `isFalse()` | `.undated()` → `isFalse()` | ✅ preserved | | `search_withStatusParam_passesItToService` | `eq(DocumentStatus.REVIEWED)` | `.status()` → `isEqualTo(REVIEWED)` | ✅ preserved (upgraded from inline matcher to explicit field assert) | | `getDocumentIds_passesSenderIdParamToService` | `eq(senderId)` | `.sender()` → `isEqualTo(senderId)` | ✅ preserved | | `search_passesPageRequestToService` | pageable captor at arg 13 | pageable captor at arg 4 | ✅ preserved | The `when(...).thenReturn(...)` stub lines that collapsed to all-`any()` are harmless — the assertion lives in the paired `verify(...captor.capture()...)` + field assert, not the stub. No test left asserting nothing. ### Transposition check (the real risk in this refactor) — clean. The record order `(text, from, to, sender, receiver, tags, tagQ, status, tagOperator, undated)` matches the **old `findIdsForFilter` positional order exactly**, so those conversions are a straight 1:1. The `searchDocuments` conversions are the tricky ones because the old signature interleaved `sort, dir` *between* `status` and `tagOperator`; I spot-checked each reshuffle: - **`DocumentServiceTest` status test** (`searchDocuments_withStatusFilter...`, was line ~1600): `DocumentStatus.REVIEWED` lands in record slot 8 (`status`), `sort`/`dir`/`pageable` stay out. ✅ - **Undated relevance-bypass test** (was ~1818): `undated=true` correctly in slot 10, `RELEVANCE` stays as the `sort` arg — still proves the FTS-id path runs (`findAllMatchingIdsByFts("brief")` verified) and the raw-page SQL shortcut is skipped because `undated` short-circuits the fast path. ✅ - **`DocumentServiceSortTest` relevance + active filter** (`...uses_inMemory_path`): `from` correctly placed in slot 2, keeping the date filter active so `isPureTextRelevance` returns false and the in-memory path is taken — `never() findFtsPageRaw` still asserted. ✅ - **`findIdsForFilter` tag-OR test**: `tags=List.of("Brief")` in slot 6, `TagOperator.OR` in slot 9. ✅ ### #668 undated-count tests — intact. In `DocumentSearchPagedIntegrationTest`, both #668 guards keep their assertions through the call-shape change only: - unfiltered vs `undated=true` both still assert `undatedCount == undatedTotal` (count ignores the toggle); - the date-range case still asserts `undatedCount().isZero()` (collision rule). The production side backs this: `searchDocuments` passes `filters.withUndated(true)` into `countUndatedForFilter`, and `withUndated()` copies all ten fields — verified field-for-field, no field dropped. ### Relevance fast-path bypass — preserved. `isPureTextRelevance(hasText, sort, filters)` maps `from/to/sender/receiver/tags/tagQ/status` one-to-one from the old param list, and `searchDocuments` still gates it behind `!filters.undated()` (was `!undated`). No behavioural drift. ### Blockers None. ### Suggestions (non-blocking, optional) 1. The 10-arg `new SearchFilters(null, null, null, null, null, null, null, null, null, false)` literal now appears ~20 times across the integration/unit tests. A small private factory — `private static SearchFilters noFilters()` / `filters().undated(true).build()` — would kill the positional-null wall and make the *intent* (which field is non-null in a given test) jump out. This is exactly the transposition footgun the production record was introduced to remove; mirroring it in the test fixtures would finish the job. Pure readability, no behaviour change. 2. No new assertions were added (correct for a "no behaviour change" refactor), but the controller status/sender tests are now *stronger* than before (explicit `isEqualTo` vs inline `eq()` matcher) — a nice side benefit worth keeping in mind as the pattern for future captor conversions. Confidence is high that "no behaviour change" holds at the test level. Ship it.
Author
Owner

📋 Elicit — Requirements Engineer

Verdict: Approve. Every acceptance criterion and every DoD checkbox in #683 traces to evidence in the diff. The one scope addition (density call site) is correctly handled and stays inside the "no behaviour change" contract. No blockers.

I reviewed this purely against the requirements I helped lock in the issue's persona round — does the PR deliver exactly what was specified, no more, no less.

AC-by-AC traceability

# Acceptance criterion Status Evidence
(a) Public signatures take SearchFilters; four private helpers lose the positional 10-field list Met searchDocuments(SearchFilters, DocumentSort, String dir, Pageable) and findIdsForFilter(SearchFilters) are the new public signatures. All four helpers (buildSearchSpec, runSearch, countUndatedForFilter, isPureTextRelevance) now take SearchFilters — no positional 10-field list survives anywhere in the chain. Verified line-by-line in DocumentService.java.
(b) No behaviour change / existing suite green Met (evidence: structural + reported run) Diff is a mechanical arg-rebundling — every field reads through the same accessor onto the same predicate. The undated-count invariant (#668) is preserved: searchDocuments builds the base filter once, passes filters.withUndated(true) to countUndatedForFilter while runSearch honours the user toggle — exactly the edge case I flagged in my issue-round comment. The !undated && relevance guard stays at the caller; isPureTextRelevance reads 9 fields and does not consume undated. Felix reports the full named suite green. Caveat: I cannot independently re-run the suite from a diff; this rests on the reported run + CI.
(c) No new tests; controller tests use ArgumentCaptor preserving value assertions Met Zero new test methods. Positional any()/eq() matchers swapped to ArgumentCaptor<SearchFilters> with captured.undated(), .status(), .sender() assertions. The prior eq(senderId) / eq(REVIEWED) / undatedCaptor value checks are preserved as field assertions on the captured record — strictly stronger than the old any()-soup, and the behaviour-preservation guard is intact.
(d) generate:api zero diff 🟡 Met — structural evidence accepted The controller diff touches no @RequestParam, @GetMapping/@PostMapping, @RequestBody, @Parameter, or response type — I confirmed all bindings present and unchanged in DocumentController.java. SearchFilters is server-internal, built after param binding. The HTTP contract is byte-identical, so the OpenAPI spec cannot change. Judgement: structural verification is acceptable evidence here. generate:api reflects the binding surface, not internal call shapes; with zero binding-surface delta there is no mechanism by which the spec could drift. Spinning up the stack would add cost without changing the falsifiable conclusion.
(e) WRITE_ALL preserved on getDocumentIds Met @RequirePermission(Permission.WRITE_ALL) sits at DocumentController.java:305 on the controller method, untouched by the diff. The aspect binds to the controller, not the service signature.
(f) No doc/diagram/migration/ErrorCode/Permission updates triggered Met No new package (record lives in existing document), no route, no migration, no ErrorCode, no Permission, no new glossary term (it renames nothing — it bundles an existing parameter list). Nothing to update.

In-PR scope addition — density buildSearchSpec call site

This is the one thing not in the issue's enumerated six-site list. Because buildSearchSpec is shared with the density path (loadFilteredDatesDensityFilters), threading SearchFilters through it forced that call site too. Felix adapted DensityFilters into a SearchFilters right at the call (from=null, to=null, undated=false, mapping filters.text()).

Assessment: correctly handled, and within "no behaviour change." I traced the old vs. new args: the prior call passed buildSearchSpec(hasFts, ftsIds, null, null, sender, receiver, tags, tagQ, status, tagOperator, false); the new SearchFilters carries identical values in identical positions. The two records stay separate as decided (no consolidation, no coupling). This is a necessary consequence of the chosen Option-A threading — not scope creep — and the issue's "all sites in the path" framing arguably already covers it. Worth a one-line PR-description note (Felix added one), which keeps traceability honest.

Requirements-quality observations (non-blocking)

  • The SearchFilters Javadoc carries the same swap-bug rationale and the DensityFilters sibling note I asked for in the issue round — the why is documented at the artifact, not just in the issue. Good.
  • The withUndated(boolean) copy method is the exact mechanism I called out as necessary to preserve the dual-undated invariant without mutating a shared record. It reads as intent. Resolved correctly.

Suggestions (optional, non-blocking)

  1. Density adaptation visibility. The DensityFilters → SearchFilters bridge in loadFilteredDates is a small piece of glue that wasn't in the issue's enumerated scope. A one-line code comment at that call site (// density path has no date/undated predicates → null/null/false) would spare a future reader the trip back to this PR thread. Cosmetic.

No open questions. This is a clean, well-bounded P3 refactor that delivers its spec exactly.

## 📋 Elicit — Requirements Engineer **Verdict: Approve.** Every acceptance criterion and every DoD checkbox in #683 traces to evidence in the diff. The one scope addition (density call site) is correctly handled and stays inside the "no behaviour change" contract. No blockers. I reviewed this purely against the requirements I helped lock in the issue's persona round — does the PR deliver exactly what was specified, no more, no less. ### AC-by-AC traceability | # | Acceptance criterion | Status | Evidence | |---|----------------------|--------|----------| | (a) | Public signatures take `SearchFilters`; four private helpers lose the positional 10-field list | ✅ **Met** | `searchDocuments(SearchFilters, DocumentSort, String dir, Pageable)` and `findIdsForFilter(SearchFilters)` are the new public signatures. All four helpers (`buildSearchSpec`, `runSearch`, `countUndatedForFilter`, `isPureTextRelevance`) now take `SearchFilters` — no positional 10-field list survives anywhere in the chain. Verified line-by-line in `DocumentService.java`. | | (b) | No behaviour change / existing suite green | ✅ **Met (evidence: structural + reported run)** | Diff is a mechanical arg-rebundling — every field reads through the same accessor onto the same predicate. The undated-count invariant (#668) is preserved: `searchDocuments` builds the base filter once, passes `filters.withUndated(true)` to `countUndatedForFilter` while `runSearch` honours the user toggle — exactly the edge case I flagged in my issue-round comment. The `!undated &&` relevance guard stays at the caller; `isPureTextRelevance` reads 9 fields and does not consume `undated`. Felix reports the full named suite green. *Caveat: I cannot independently re-run the suite from a diff; this rests on the reported run + CI.* | | (c) | No new tests; controller tests use `ArgumentCaptor` preserving value assertions | ✅ **Met** | Zero new test methods. Positional `any()`/`eq()` matchers swapped to `ArgumentCaptor<SearchFilters>` with `captured.undated()`, `.status()`, `.sender()` assertions. The prior `eq(senderId)` / `eq(REVIEWED)` / `undatedCaptor` value checks are **preserved as field assertions on the captured record** — strictly stronger than the old `any()`-soup, and the behaviour-preservation guard is intact. | | (d) | `generate:api` zero diff | 🟡 **Met — structural evidence accepted** | The controller diff touches no `@RequestParam`, `@GetMapping`/`@PostMapping`, `@RequestBody`, `@Parameter`, or response type — I confirmed all bindings present and unchanged in `DocumentController.java`. `SearchFilters` is server-internal, built *after* param binding. The HTTP contract is byte-identical, so the OpenAPI spec **cannot** change. **Judgement: structural verification is acceptable evidence here.** `generate:api` reflects the binding surface, not internal call shapes; with zero binding-surface delta there is no mechanism by which the spec could drift. Spinning up the stack would add cost without changing the falsifiable conclusion. | | (e) | `WRITE_ALL` preserved on `getDocumentIds` | ✅ **Met** | `@RequirePermission(Permission.WRITE_ALL)` sits at `DocumentController.java:305` on the controller method, untouched by the diff. The aspect binds to the controller, not the service signature. | | (f) | No doc/diagram/migration/`ErrorCode`/`Permission` updates triggered | ✅ **Met** | No new package (record lives in existing `document`), no route, no migration, no `ErrorCode`, no `Permission`, no new glossary term (it renames nothing — it bundles an existing parameter list). Nothing to update. | ### In-PR scope addition — density `buildSearchSpec` call site This is the one thing **not** in the issue's enumerated six-site list. Because `buildSearchSpec` is shared with the density path (`loadFilteredDates` → `DensityFilters`), threading `SearchFilters` through it forced that call site too. Felix adapted `DensityFilters` into a `SearchFilters` right at the call (`from=null, to=null, undated=false`, mapping `filters.text()`). **Assessment: correctly handled, and within "no behaviour change."** I traced the old vs. new args: the prior call passed `buildSearchSpec(hasFts, ftsIds, null, null, sender, receiver, tags, tagQ, status, tagOperator, false)`; the new `SearchFilters` carries identical values in identical positions. The two records stay **separate** as decided (no consolidation, no coupling). This is a *necessary consequence* of the chosen Option-A threading — not scope creep — and the issue's "all sites in the path" framing arguably already covers it. Worth a one-line PR-description note (Felix added one), which keeps traceability honest. ### Requirements-quality observations (non-blocking) - The `SearchFilters` Javadoc carries the same swap-bug rationale and the `DensityFilters` sibling note I asked for in the issue round — the *why* is documented at the artifact, not just in the issue. Good. - The `withUndated(boolean)` copy method is the exact mechanism I called out as necessary to preserve the dual-`undated` invariant without mutating a shared record. It reads as intent. Resolved correctly. ### Suggestions (optional, non-blocking) 1. **Density adaptation visibility.** The `DensityFilters → SearchFilters` bridge in `loadFilteredDates` is a small piece of glue that wasn't in the issue's enumerated scope. A one-line code comment at that call site (`// density path has no date/undated predicates → null/null/false`) would spare a future reader the trip back to this PR thread. Cosmetic. No open questions. This is a clean, well-bounded P3 refactor that delivers its spec exactly.
marcel added 3 commits 2026-05-31 15:55:37 +02:00
Replace the ~29 repeated `new SearchFilters(null, null, null, null, null,
null, null, null, null, false)` literals across the search test suites with
a shared SearchFiltersFixtures.noFilters() factory (and noFilters()
.withUndated(true) for the undated-only case). Tests that pin a specific
field keep their explicit `new SearchFilters(...)` so intent stays visible.
Pure test-ergonomics cleanup raised in the #702 review; no behaviour change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Clarify at loadFilteredDates why the density path constructs a SearchFilters:
the two filter records are kept separate (density has no date/undated fields),
so it adapts here to reuse buildSearchSpec. Raised in the #702 review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
test(document): pin undated null->false coercion on /ids (#683)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m16s
CI / OCR Service Tests (pull_request) Successful in 20s
CI / Backend Unit Tests (pull_request) Successful in 3m31s
CI / fail2ban Regex (pull_request) Successful in 43s
CI / Semgrep Security Scan (pull_request) Successful in 20s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m2s
CI / Unit & Component Tests (push) Successful in 3m22s
CI / OCR Service Tests (push) Successful in 21s
CI / Backend Unit Tests (push) Successful in 3m25s
CI / fail2ban Regex (push) Successful in 44s
CI / Semgrep Security Scan (push) Successful in 20s
CI / Compose Bucket Idempotency (push) Successful in 1m4s
3b594c0b0b
The /search path already pins the Boolean-undated->primitive coercion via
search_withoutUndatedParam_forwardsFalseToService; add the symmetric pin for
getDocumentIds so an absent param provably resolves to undated=false on the
record (never NPE). Raised in the #702 review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Follow-up — addressed the review's optional suggestions

All three "nice to have" items from the review are now in (3 commits pushed):

  • 4c2f036test ergonomics (Sara/Markus/Felix): collapsed the ~29 repeated all-null new SearchFilters(null, …) literals into a shared SearchFiltersFixtures.noFilters() factory (+ noFilters().withUndated(true) for the undated-only case). Tests that pin a specific field keep their explicit constructor so intent stays visible.
  • 2e44cabdensity bridge comment (Elicit): a 3-line "why" comment at loadFilteredDates explaining the DensityFilters → SearchFilters adaptation and the keep-separate rationale.
  • 3b594c0coercion pin (Nora): getDocumentIds_withoutUndatedParam_coercesNullToFalse — the symmetric /ids pin for the Boolean undated → false boundary coercion (the /search side was already pinned).

Still no behaviour change. Suites green: DocumentControllerTest 105 (+1), DocumentServiceTest 168, DocumentServiceSortTest 7, DocumentSearchPagedIntegrationTest 8, DocumentListItemIntegrationTest 4, DocumentLazyLoadingTest 5.

## Follow-up — addressed the review's optional suggestions All three "nice to have" items from the review are now in (3 commits pushed): - `4c2f036` — **test ergonomics** (Sara/Markus/Felix): collapsed the ~29 repeated all-null `new SearchFilters(null, …)` literals into a shared `SearchFiltersFixtures.noFilters()` factory (+ `noFilters().withUndated(true)` for the undated-only case). Tests that pin a specific field keep their explicit constructor so intent stays visible. - `2e44cab` — **density bridge comment** (Elicit): a 3-line "why" comment at `loadFilteredDates` explaining the `DensityFilters → SearchFilters` adaptation and the keep-separate rationale. - `3b594c0` — **coercion pin** (Nora): `getDocumentIds_withoutUndatedParam_coercesNullToFalse` — the symmetric `/ids` pin for the `Boolean undated → false` boundary coercion (the `/search` side was already pinned). Still no behaviour change. Suites green: `DocumentControllerTest` 105 (+1), `DocumentServiceTest` 168, `DocumentServiceSortTest` 7, `DocumentSearchPagedIntegrationTest` 8, `DocumentListItemIntegrationTest` 4, `DocumentLazyLoadingTest` 5.
marcel merged commit 3b594c0b0b into main 2026-05-31 16:17:46 +02:00
marcel deleted branch feat/issue-683-search-filters-record 2026-05-31 16:17:46 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#702