Skip to content

[WP10] Mapper test-coverage gaps: concurrency, nested-AND typo, inherited fields #182

Closed

Description

@shihyuho

此內容由 AI 產生(specification-mapper 全庫審查 2026-07-16)。

Work package WP10 · findings: TEST-06, TEST-05, TEST-03 · worst severity: medium

From the 2026-07-16 whole-codebase review (branch jakarta, modules mapper/ + starter/). Each finding below is CONFIRMED by independent adversarial verification. File paths and line numbers reflect the review snapshot and may drift — treat them as starting points, not exact coordinates.


TEST-06 — fireOnlyOnce concurrency test runs with a single thread, leaving the AtomicBoolean+CountDownLatch machinery untested

  • Category / severity: testing / medium · effort S · confidence high
  • Location: mapper/src/test/java/tw/com/softleader/data/jpa/spec/ReflectionDatabindTest.java:52
  • Evidence: The test creates var numberOfThreads = 1; and a thread pool of that size, so all getFieldValue calls happen sequentially on one worker thread; the latch.await() loser branch and the CAS race in ReflectionDatabind.getFieldValue are never exercised, and the times(1) verification would pass with no synchronization at all.
  • Failure scenario: A regression in the bespoke lazy-load code (e.g. removing the latch, or the existing missing-countDown-on-exception defect) keeps the suite green because the only test targeting this code path never runs it concurrently; a real double-load or hang only surfaces in production usage.
  • Suggested fix: Raise numberOfThreads (e.g. 32) with a start-barrier so threads hit getFieldValue simultaneously, and assert the underlying reflective read happened exactly once; add a test for a throwing field read not poisoning subsequent calls.
  • Verification: [CONFIRMED] Traced ReflectionDatabindTest.java:40-70 and ReflectionDatabind.getFieldValue(). The test sets numberOfThreads=1 (line 52), builds a 1-thread pool, and submits a single task calling databind.forEach(Databind::getFieldValue) exactly once. Therefore every bind's no-arg getFieldValue() is invoked once, sequentially, on one worker. In getFieldValue(), loaded.compareAndSet(false,true) always wins on that single first call, so the else/latch.await() loser branch is never entered and the CAS race is never contended. The verify(times(1)).getFieldValue(object, field) assertion is trivially satisfied even if the AtomicBoolean/latch dedup were removed, since the inner reflective read only f…

TEST-05 — NestedInNestedAnd.name is annotated @NestedSpec on a String (likely a @SPEC typo), so the three-level AND scenario is never actually tested

  • Category / severity: testing / medium · effort S · confidence high
  • Location: mapper/src/test/java/tw/com/softleader/data/jpa/spec/NestedSpecificationResolverTest.java:392
  • Evidence: NestedInNestedAnd declares @NestedSpec String name; while its OR twin NestedInNestedOr declares @Spec String name; (line 420). Recursing @NestedSpec into java.lang.String finds no annotated fields and yields null, so allAnd's assertion tree only expects the second-level Conjunction to have size 1 (lines 115-120) — i.e. the deepest level contributes nothing — whereas allOr asserts a full three-level Disjunction tree.
  • Failure scenario: The 'nested in nested' AND combination path in NestedSpecificationResolver/Conjunction is not covered: a regression that breaks spec composition at depth 3 for conjunctions passes this suite because the deepest AND level silently maps to null; the test also reflects over java.lang.String internals at runtime, which can trip JPMS warnings in future JDKs.
  • Suggested fix: Change NestedInNestedAnd.name to @SPEC (mirroring NestedInNestedOr) and update allAnd's assertions to expect the third-level Equals inside the nested Conjunction.
  • Verification: [CONFIRMED] Traced NestedSpecificationResolverTest.java:392 — NestedInNestedAnd declares @NestedSpec String name; vs NestedInNestedOr's @Spec String name; (line 420). In NestedSpecificationResolver.java:50-72, a @NestedSpec field recurses via codec.toSpec into the field value; for a String value the recursion finds no annotated fields and returns null, so NestedInNestedAnd yields null and NestedAnd.nin collapses to null. This matches allAnd's assertions (lines 114-120) where the second-level Conjunction is .hasSize(1) (only the @SPEC name Equals) — the deepest AND level is absent — whereas allOr (lines 154-163) asserts a real third-level Disjunction with an Equals. The three-level AND …

TEST-03 — Inherited fields of criteria POJOs are silently ignored (doWithLocalFields) and no test pins this contract

  • Category / severity: testing / medium · effort S · confidence high
  • Location: mapper/src/main/java/tw/com/softleader/data/jpa/spec/ReflectionDatabind.java:80
  • Evidence: ReflectionDatabind.of uses ReflectionUtils.doWithLocalFields (line 80), so only the concrete class's declared fields are mapped — @Spec/@Join fields on a superclass are dropped without warning. No test class in either module uses a criteria POJO with a superclass (grep for criteria classes extending anything finds none), so the behavior is neither asserted as intended nor guarded against regression.
  • Failure scenario: A team factors common filters (e.g., tenantId with @SPEC) into a BaseCriteria and extends it; the inherited filter silently never becomes a predicate, and every query returns rows across all tenants — a silent data-exposure bug the test suite cannot catch because inheritance is entirely uncovered.
  • Suggested fix: Add a test with a criteria subclass inheriting @SPEC fields pinning the chosen behavior; if local-only is intentional, also consider a debug/warn log when superclass declares annotated fields.
  • Verification: [CONFIRMED] Traced the code. ReflectionDatabind.of at mapper/.../ReflectionDatabind.java:80 uses Spring's doWithLocalFields, which iterates only the concrete class's getDeclaredFields() and does not walk superclasses (contrast doWithFields). This is the live criteria-mapping path: SpecMapper.java:108 calls ReflectionDatabind.of(rootObject, ...) to build the databind list that feeds the @Spec/@Join/@NestedSpec resolvers. Therefore annotated fields declared on a superclass of a criteria POJO are never enumerated and silently produce no predicate — the behavioral claim is accurate. The test-coverage claim also holds: grepping test sources, every extends combined with mapper annotations is a S…

Full report: see the codebase-review-2026-07-16 branch (linked on the parent tracking issue).

Activity

  1. added theissue type on Jul 16, 2026
  2. shihyuho commented on Jul 16, 2026

    @shihyuho
    MemberAuthor

    This was generated by AI during triage.

    Agent Brief

    Category: enhancement (test-coverage hardening)

    Summary: Strengthen two mapper tests so they actually exercise the code paths they claim to cover — the concurrent lazy-load dedup in ReflectionDatabind (TEST-06) and the three-level AND composition in the nested-spec resolver (TEST-05); the inherited-fields finding (TEST-03) is carved out as a separate design decision.

    Current behavior

    • TEST-06 (concurrency test is single-threaded). ReflectionDatabindTest.fireOnlyOnce builds a fixed thread pool sized numberOfThreads = 1 and submits one task that runs databind.forEach(Databind::getFieldValue) once. Because every Databind.getFieldValue() is therefore invoked sequentially on a single worker, the AtomicBoolean loaded CAS in ReflectionDatabind.getFieldValue() always wins on the first (only) call and the CountDownLatch.await() loser branch is never entered. The verify(..., times(1)).getFieldValue(object, field) assertion on the inner two-arg reflective read is trivially satisfied and would still pass even if the CAS+latch dedup were deleted. The dedup machinery is effectively untested.

    • TEST-05 (three-level AND never composed). In NestedSpecificationResolverTest, the depth-3 criteria type NestedInNestedAnd declares its name field as @NestedSpec String name, whereas its OR twin NestedInNestedOr declares @Spec String name. NestedSpecificationResolver.buildSpecification recurses a @NestedSpec field through SpecCodec.toSpec into the field value; for a java.lang.String value the recursion enumerates only local (String) fields, finds no annotated ones, and yields null. So NestedInNestedAnd contributes nothing and the second-level Conjunction in allAnd is asserted at hasSize(1) — the deepest AND level is silently absent — while the symmetric allOr asserts a genuine three-level Disjunction tree. The depth-3 conjunction path is not covered.

    Desired behavior

    • TEST-06. The concurrency test drives many threads that call getFieldValue() on the same ReflectionDatabind instances simultaneously (released together via a start barrier), so the CAS/latch is genuinely contended, and it asserts that the inner reflective read runs exactly once per bind regardless of the contention. This must pass reliably against the current (correct happy-path) dedup code and would fail if the dedup were removed.

    • TEST-05. NestedInNestedAnd.name is annotated @Spec (mirroring NestedInNestedOr.name) so the three-level AND scenario actually produces a depth-3 predicate, and allAnd's assertions expect a full three-level Conjunction tree symmetric to allOr's Disjunction tree.

    Key interfaces

    • ReflectionDatabind — static List<Databind> of(Object, SkippingStrategy, FieldDescriptorFactory, TargetOnlyFieldDescriptorFactory); Optional<Object> getFieldValue() (dedup entry point, backed by AtomicBoolean loaded + CountDownLatch latch); package-visible Object getFieldValue(Object target, Field field) (the spied inner reflective read).
    • ReflectionDatabindTest.fireOnlyOnce — the test to strengthen.
    • NestedSpecificationResolver.buildSpecification(Context, Databind); SpecCodec.toSpec(...); SpecMapper.toSpec(...).
    • NestedSpecificationResolverTest — tests allAnd / allOr; nested criteria types NestedInNestedAnd, NestedInNestedOr; annotations @Spec, @NestedSpec.
    • Domain spec types Conjunction, Disjunction, Equals (used in the assertion trees).

    Acceptance criteria

    • ReflectionDatabindTest.fireOnlyOnce (or a clearly-named replacement) launches a meaningful number of concurrent threads (e.g. 32) that are held at a start barrier and released together, so multiple threads call the same ReflectionDatabind.getFieldValue() concurrently.
    • For every Databind returned by ReflectionDatabind.of(...), the test asserts the inner getFieldValue(object, field) reflective read is invoked exactly once (times(1)) despite the concurrent access.
    • The strengthened concurrency test passes deterministically (no flakiness) against the current code and does not require any production-code change.
    • NestedInNestedAnd.name is annotated @Spec (matching NestedInNestedOr.name).
    • allAnd asserts a three-level Conjunction tree symmetric to allOr: top-level Conjunction of size 2 (leading Equals + nested Conjunction), the nested Conjunction of size 2 (leading Equals + deepest Conjunction), and the deepest Conjunction of size 1 containing an Equals.
    • allAnd's repository result assertion still holds (the query still returns exactly matt).
    • mvn test (mapper module) passes.

    Out of scope

    • TEST-03 (inherited criteria fields silently ignored). ReflectionDatabind.of maps fields via Spring ReflectionUtils.doWithLocalFields, which enumerates only the concrete class's declared fields, so @Spec/@Join/@NestedSpec fields declared on a superclass of a criteria POJO never become predicates, and no test pins this contract. Resolving it requires a human policy decision (see below) and is deliberately excluded from this work package. Do not change the reflection walk here.
    • Fixing the ReflectionDatabind.getFieldValue() behavior where a throwing reflective read skips latch.countDown() and can hang subsequent callers, and any test that asserts an exception does not poison later calls — that depends on a production-code fix tracked separately.

    Note on TEST-03 (needs a human decision, tracked separately)

    Whether inherited annotated fields should map is a policy/breaking-change call: pinning the current local-only behavior with a test documents a real multi-tenant data-exposure footgun as "intended", while switching to doWithFields (walk superclasses) changes query semantics for existing users. An autonomous agent must not choose this default; it should be decided by a human before any test or code lands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    status: ready-for-agentFully specified and ready for an autonomous AFK agent to implementtype: enhancementNew feature or request

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions