Skip to content

[2C-03] Make notification response endpoint idempotent on (notification_id, response) tuple - #210

Merged
WilliM233 merged 1 commit into
developfrom
feat/2C-03-response-idempotency
Apr 27, 2026
Merged

WilliM233 merged 1 commit into
developfrom
feat/2C-03-response-idempotency

Conversation

@WilliM233

Copy link
Copy Markdown
Owner

Closes #202

Verification

  • ruff check . — ✅ Pass (All checks passed!)
  • pytest -v — ✅ Pass (1362 passed)
  • Migration applied locally on brain3-dev: ✅ N/A (no schema change — idempotency computed from existing columns)
  • Postgres-backed test confirmed: ✅ N/A (in-memory SQLite suite, no Postgres-specific behavior touched)

Ran locally against develop HEAD at 2ec8521 immediately before opening this PR.

Summary

POST /api/notifications/{id}/respond is now idempotent on the
(notification_id, response, response_note) tuple. A retry that
matches the recorded response and note returns 200 with the existing
row (no DB write, responded_at unchanged). A retry with a different
response, or the same response with a different note, returns 409 —
that's a legitimate conflict (the user tapped a different action a
second time). The pending (409), expired (410), and not-found
(404) branches are unchanged.

Changes

  • app/routers/notification.py — respond_to_notification now
    short-circuits the responded branch when the new payload's
    response and response_note match the recorded values, returning
    the existing notification with no write. Docstring documents the
    idempotency contract and the client-facing implication that 200
    means "applied" regardless of first-vs-retry.
  • tests/test_notification_respond.py — adds TestRespondIdempotency
    with the three acceptance-criteria cases plus a no-note variant
    (covers idempotency when response_note is null on both sides).
    Renames and updates the previous test_409_already_responded (see
    Deviations).

How to Verify

  1. Boot brain3 locally against develop HEAD 2ec8521.
  2. Create + deliver a notification, then POST /respond with
    {"response": "Done", "response_note": "Got it"}. Observe 200 and
    capture the responded_at timestamp.
  3. POST the same body again. Observe 200, identical body, identical
    responded_at (no bump).
  4. POST with {"response": "Skip", "response_note": "Got it"}.
    Observe 409 with detail "Notification has already been responded to". GET the notification — response, response_note, and
    responded_at are unchanged from step 2.
  5. Repeat step 4 with {"response": "Done", "response_note": "different"}.
    Observe 409, row unchanged.
  6. Run pytest tests/test_notification_respond.py -v — 27 tests pass.

Deviations

D-10 — test_409_already_responded was renamed and modified.
The pre-existing test posted {"response": "Done"} twice and asserted
409. Under the new contract that case is the canonical idempotent
success path (200), so the test cannot survive unchanged. Renamed to
test_409_already_responded_with_different_response and changed the
second call to {"response": "Skip"} so it still represents a 409
conflict path under the new semantics. The spec's acceptance line
"Existing 409/410/404 tests continue to pass" is otherwise honored —
all the other 409 (pending), 410 (expired), and 404 (not found) tests pass without change. Flagging because the spec's
acceptance language read as if no test changes were needed in the
existing block; in practice this one case is the exact behavior being
flipped, so a test edit was unavoidable. Net test count: +4 (three
acceptance cases + a no-note idempotency variant).

Test Results

$ python -m pytest -v
============================ 1362 passed in 21.68s ============================

$ python -m ruff check .
All checks passed!

Targeted run on the changed file:

$ python -m pytest tests/test_notification_respond.py -v
============================ 27 passed in 0.54s ============================

Acceptance Checklist

  • respond_to_notification returns 200 + existing row when
    (response, response_note) match the recorded values; no DB
    write, responded_at not bumped.
  • respond_to_notification returns 409 when response differs
    from the recorded value.
  • respond_to_notification returns 409 when response matches
    but response_note differs.
  • pending → 409, expired → 410, not found → 404 unchanged.
  • No schema change.
  • tests/test_notification_respond.py covers the three
    acceptance-criteria cases.
  • Existing 409/410/404 tests pass (with one rename + one input
    change documented in Deviations).

…note) (#202)

Per [2C-03]. The /api/notifications/{id}/respond endpoint now returns
200 with the existing row when the same response + response_note are
re-posted (no DB write, responded_at unchanged), and 409 only when
the retry diverges (different response, or same response with a
different note). Pending stays 409, expired stays 410, not-found
stays 404 — those branches are unchanged.

Adds TestRespondIdempotency with the three acceptance-criteria cases
plus a no-note variant. Existing test_409_already_responded was
renamed and updated to use a different response on the second call,
since the original test embodied the pre-idempotency contract.
@WilliM233
WilliM233 merged commit 8e21355 into develop Apr 27, 2026
2 checks passed
@WilliM233
WilliM233 deleted the feat/2C-03-response-idempotency branch April 27, 2026 18:08
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.

[2C-03] Make notification response endpoint idempotent on (notification_id, response) tuple

1 participant