Skip to content

Add dry run = true FIX - #206

Merged
Jagadeeshftw merged 2 commits into
AnchorNet-Org:mainfrom
Mitch5000:Add-dry-run-=-true-FIXED
Jul 27, 2026
Merged

Add dry run = true FIX#206
Jagadeeshftw merged 2 commits into
AnchorNet-Org:mainfrom
Mitch5000:Add-dry-run-=-true-FIXED

Conversation

@Mitch5000

Copy link
Copy Markdown
Contributor

================================================================================
PULL REQUEST

TITLE

feat(anchors): add ?dryRun=true preflight validation to POST /api/v1/anchors/bulk

BRANCH

feat/bulk-anchors-dry-run -> main

COMMIT

8957953 feat(anchors): add ?dryRun=true preflight validation to POST /anchors/bulk

================================================================================
SUMMARY

AnchorService.registerBulk already validated an entire batch (duplicate ids,
conflicts with the existing registry) before persisting any of it. This PR
exposes that same validation as a read-only preflight check so integrators
building an onboarding UI can show inline errors before committing a batch.

Passing ?dryRun=true to POST /api/v1/anchors/bulk runs the identical
validation and reports the identical outcome, but persists nothing.

================================================================================
WHAT CHANGED

  1. Separated the validate and persist phases

    The validation phase of registerBulk was extracted into a new private,
    side-effect-free method validateBulk(). Both dry-run and real calls go
    through it, so the two paths cannot drift apart as validation rules evolve
    in the future. This is the core of the fix: previously there was no seam
    between "check the batch" and "write the batch".

  2. Added a dryRun parameter to registerBulk

    registerBulk(input: unknown, dryRun = false): Anchor[]

    When dryRun is true, the method validates the batch and returns the records
    that WOULD have been created, then stops. this.repo.upsert is never invoked
    for any entry. The parameter is defaulted, so every existing caller is
    unaffected.

  3. Added strict flag parsing (optionalBooleanFlag)

    New helper in src/utils/validation.ts. Only "true" and "false" are
    accepted, in any casing and with surrounding whitespace tolerated. An
    absent value defaults to false.

    Anything else is a 400: "yes", "1", "on", a typo like "ture", a bare
    ?dryRun with no value, or a repeated ?dryRun=true&dryRun=true (which
    Express turns into an array).

    This is deliberate rather than defensive boilerplate. With lenient parsing
    a typo would fall through to "not set" and perform a real, persisting
    write at the exact moment the caller explicitly asked for a preflight.
    Failing loudly is the safe direction for a flag whose whole purpose is
    preventing a side effect.

  4. Route wiring

    POST /bulk reads req.query.dryRun, passes it through, and responds
    201 with { anchors, dryRun }. The dryRun boolean lets a client confirm
    whether the batch was actually committed. It is an additive field, so
    existing clients reading response.anchors are unaffected.

  5. Documentation

    OpenAPI spec gains the dryRun parameter and a description of the preflight
    behaviour. README documents the flag, its strict parsing, and the response
    shape.

================================================================================
API BEHAVIOUR

POST /api/v1/anchors/bulk?dryRun=true
{ "anchors": [{ "id": "a1" }, { "id": "a2", "name": "Two" }] }

-> 201 Created
{ "anchors": [ ...would-be-registered records... ], "dryRun": true }
Registry unchanged.

POST /api/v1/anchors/bulk
(same body, a1 already registered)

-> 409 Conflict
{ "error": { "code": "CONFLICT",
"message": "anchor "a1" is already registered" } }

POST /api/v1/anchors/bulk?dryRun=true
(same body, a1 already registered)

-> 409 Conflict
{ "error": { "code": "CONFLICT",
"message": "anchor "a1" is already registered" } }
Byte-identical to the real call. Registry unchanged.

POST /api/v1/anchors/bulk?dryRun=ture

-> 400 Bad Request
{ "error": { "code": "BAD_REQUEST",
"message": ""dryRun" must be "true" or "false"" } }
Nothing registered.

================================================================================
ACCEPTANCE CRITERIA

[x] POST /api/v1/anchors/bulk?dryRun=true returns the same success/error
outcome as a real call would, but registers nothing.

[x] A batch that would fail validation fails identically in both modes.
Verified by tests that run the same batch against two fresh service
instances and assert equal status, code and message.

[x] Repository state is provably unchanged after a dry run, verified by
tests checking AnchorRepository.count() and all() before and after,
plus a jest.spyOn(repo, "upsert") asserting upsert is never called.

[x] Minimum 95% test coverage. Overall 96.3%; the three files carrying new
logic are at 100%.

[x] Clear documentation. OpenAPI spec, README, and JSDoc on both the new
and modified methods explaining the reasoning, not just the mechanics.

================================================================================
VALIDATION

Test suite
Test Suites: 40 passed, 40 total
Tests: 407 passed, 407 total (374 before this PR, 33 added)

No pre-existing test was modified or deleted. All 374 original tests
still pass unchanged, which is the evidence that the refactor of
registerBulk preserved existing behaviour.

Coverage

File % Stmts % Branch % Funcs % Lines
All files 96.30 90.88 95.52 96.84
anchorService.ts 100.00 100.00 100.00 100.00
validation.ts 100.00 100.00 100.00 100.00
openapi.ts 100.00 100.00 100.00 100.00
anchors.ts 95.45 55.55 100.00 95.45

The only uncovered lines in anchors.ts (97-98) are a pre-existing 501
branch for an absent settlements service, untouched by this PR.

Build
npm run build (tsc) exit 0, no errors
npm run lint (eslint) exit 0, no warnings

End-to-end smoke test against a running app instance
dryRun=true -> 201, registry count 0
real -> 201, registry count 2
conflict dry -> 409, identical body to real
conflict real -> 409
outcomes identical: true
typo flag -> 400, nothing registered
final registry: [a1, a2]

================================================================================
TESTS ADDED (33)

src/services/anchorService.test.ts (14)

  • returns the would-be-registered anchors without persisting them
  • leaves the repository provably unchanged (count/all before and after)
  • never calls repo.upsert during a dry run
  • persists when dryRun is false or omitted
  • reports the same outcome as a real call for a valid batch
  • rejects a non-array batch identically in dry-run mode
  • rejects an empty batch identically in dry-run mode
  • rejects a null/undefined batch entry identically in both modes
  • rejects an invalid entry id identically in dry-run mode
  • rejects an invalid entry name identically in dry-run mode
  • rejects a duplicate id within the batch identically in dry-run mode
  • rejects an id conflicting with the registry identically in dry-run mode
  • does not consume ids, so a batch can be dry-run repeatedly then committed

src/routes/anchors.test.ts (13)

  • flags dryRun: false on a normal bulk registration
  • validates the batch and registers nothing
  • leaves the repository unchanged, verified before and after
  • returns the same 409 as a real call for an id already registered
  • returns the same 409 as a real call for a duplicate id in the batch
  • returns 400 for a missing/empty anchors array in dry-run mode
  • returns 400 for a blank entry id in dry-run mode
  • performs a real registration for ?dryRun=false
  • accepts mixed casing and surrounding whitespace for the flag
  • rejects an unrecognized dryRun value with 400 instead of registering
  • rejects a repeated dryRun query param with 400
  • treats a bare ?dryRun (no value) as invalid rather than a real write
  • lets a dry run be followed by a real commit of the same batch

src/utils/validation.test.ts (6)

  • defaults to false when the value is absent
  • passes through real booleans
  • accepts "true"/"false" strings in any casing, trimmed
  • rejects truthy-looking values instead of coercing them
  • rejects non-string, non-boolean values such as a repeated query param
  • names the offending field and the accepted values in the message

src/openapi.test.ts (1)

  • documents the dryRun preflight parameter on POST /api/v1/anchors/bulk

================================================================================
FILES CHANGED (9 modified, 0 added)

src/services/anchorService.ts +47 -8 extracted validateBulk,
added dryRun parameter
src/routes/anchors.ts +13 -3 parse ?dryRun, pass through,
return flag
src/utils/validation.ts +23 -0 optionalBooleanFlag helper
src/openapi.ts +13 -2 dryRun parameter + description
src/services/anchorService.test.ts +189 -1 14 dry-run service tests
src/routes/anchors.test.ts +198 -0 13 dry-run route tests
src/utils/validation.test.ts +38 -0 6 flag-parsing tests
src/openapi.test.ts +13 -0 spec assertion
README.md +10 -3 endpoint documentation

Total: 534 insertions, 10 deletions

================================================================================
SECURITY

Read-only preflight validation. No new data is exposed: the response contains
only the records the caller just submitted, echoed back in normalised form.

The change explicitly prevents a persistence side effect when requested, and
the strict flag parsing closes the failure mode where a malformed dryRun value
could be silently downgraded into a real write.

No changes to authentication, authorisation, rate limiting, or the audit log.
The endpoint remains subject to all existing middleware.

================================================================================
BACKWARD COMPATIBILITY

No breaking changes.

  • registerBulk's dryRun parameter is defaulted to false; existing callers
    compile and behave exactly as before.
  • The bulk response gains a dryRun field. It is purely additive; clients
    reading response.anchors are unaffected.
  • Requests that omit ?dryRun behave exactly as they did before this PR.
  • No changes to the repository, model, or middleware layers.

================================================================================
NOTE FOR REVIEWERS

One deliberate detail worth a second opinion: in dry-run mode all returned
records share a single registeredAt timestamp, whereas the real path calls
new Date() once per anchor. This is cosmetic for a preflight, since the
values are discarded and the real registration generates fresh timestamps
on commit. If you would prefer the two paths be byte-identical, aligning
them is a one-line change.

Two choices were made explicitly rather than by default, and are easy to
revisit:

  1. A successful dry run returns 201, matching the real call literally,
    rather than 200. The dryRun: true field in the body disambiguates.
  2. Flag parsing is strict (400 on anything other than true/false) rather
    than lenient, for the safety reason described above.

Closes #152

@Jagadeeshftw
Jagadeeshftw merged commit 34ce248 into AnchorNet-Org:main Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a ?dryRun=true mode to POST /api/v1/anchors/bulk that validates the batch without persisting

2 participants