Skip to content

Offer moderation close-out (#807 tail + #805) - Test Plan โ€‹

FieldValue
Branch / PRfix/offer-moderation-807 (session PR, draft; body carries Closes #807, Closes #805)
EnvironmentEmulator suites: Firestore emulator via firebase emulators:exec (project ids demo-lantern-rules / demo-lantern-offers). Browser pass: local admin :3001 + merchants-api :8085 against the Firestore emulator :8080.
Build flags / confign/a (no flags; one new composite index in firestore.indexes.json)
Build (commit)fill at run time
Testeragent (all scenarios are automatable here; nothing handed to the operator)
Date2026-08-10

Scope note. PM verification on #807 found both criticals and three mediums already fixed on dev inside the #792 merge. This session re-verifies those via tests (scenarios 1-3, 11, 12) and builds the remaining tail: route-level integration tests (5-8), #805 (4), adminOffers ordering (9), serializeOffer dedup (10). Operator intent encoded by scenarios 5-7: offers reach users ONLY via the server rotation API, and text edits to a live offer pull it back through review while metadata-only edits do not.

Summary โ€‹

#ScenarioResultNotes
1firestore.rules: /offers is server-only (deny-all)[x] passre-verify shipped fix (207/207)
2Moderation wordlist precision (phrases fix)[x] passre-verify shipped fix
3PUT moderation-gate unit matrix[x] passre-verify shipped fix
4#805: effective liveEvent/expiresAt bound on PUT[x] passnew fix (4 unit + 3 integration cases)
5POST /offers integration (emulator)[x] pass6 cases
6PUT /offers integration (emulator)[x] pass6 cases
7Users see offers only via rotation API[x] pass1 case (mixed-status seed)
8Admin review flow integration (emulator)[x] pass4 cases
9adminOffers ordered query + index fallback[x] pass2 integration + 2 unit cases
10serializeOffer dedup regression sweep[x] pass101 unit + 22 integration green
11Review queue clears stale error banner[x] passtest added; found + fixed a NEW bug (see 11)
12computeRatio renders a real zero[x] pass2 tests added
13Browser pass: review queue on local stack[ ] pass [ ] fail [x] blockedblocked by #819; folded into 14
14Post-deploy: index live + ordered admin list + review-queue browser pass on dev[ ] pass [ ] fail [ ] blockedSCHEDULED: the 6-9pm close step

Setup / preconditions โ€‹

  • Emulator suites run through firebase emulators:exec --only firestore; nothing else needs to be running. New root script npm run test:offers:emulator runs the offers integration suite (skips cleanly without FIRESTORE_EMULATOR_HOST, same pattern as test:sever:emulator).
  • Firestore emulator binds :8080 (conflicts with venues-api; do not run venues-api simultaneously). Check lsof -i :8080 -i :8085 -i :3001 before the browser pass; parallel sessions may hold ports.
  • Integration tests stub the auth middleware (inject req.user / req.isAdmin) and mount the real routers + the real error handler. Rationale: the invariants under test live in the handlers; real token verification needs the Auth emulator and adds nothing to these assertions. App Check is not mounted in the test app.
  • Browser pass (13): seed a pending_review offer via Admin SDK against the emulator, run merchants-api with FIRESTORE_EMULATOR_HOST=localhost:8080 and auth pointing at dev, admin app on :3001. If admin-portal login against the local stack proves impractical pre-merge, record the blocker and fold the check into scenario 14 on deployed dev instead.
  • No live dev/prod data is touched by any scenario; everything is emulator or local.

Scenarios โ€‹

1. firestore.rules: /offers is server-only โ€‹

  • Goal: re-verify the #807 critical-1 fix (deny-all client reads/writes on /offers) still holds.
  • Steps:
    1. npm run test:rules
  • Expected: suite green, including the "offers are server-only" block (client read of an active offer denied, write denied).
  • Verify: vitest output lists the offers deny cases as passing.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    207/207 rules tests green, including "offers/{offerId} are server-only (Phase 5)" ("any authed user CANNOT read offers directly (server-only, #807)").
Evidence:  npm run test:rules, 2026-08-10 11:46, emulators:exec exit 0, 17.5s.
Follow-up:

2. Moderation wordlist precision โ€‹

  • Goal: re-verify the medium fix: "under 18"/"under 21" removed; legit venue copy passes; real profanity/minor-targeting/explicit content still flags.
  • Steps:
    1. npx vitest run src/lib/__tests__/offerModeration.test.js in services/api/merchants
  • Expected: all cases green, including "does not flag legitimate venue copy (precision-tuned, #807)" and the Scunthorpe case.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    12/12 offerModeration cases green (legit-copy precision + Scunthorpe included).
Evidence:  vitest run in services/api/merchants, 2026-08-10 11:45 (27 tests across the 3 suites).
Follow-up:

3. PUT moderation-gate unit matrix โ€‹

  • Goal: re-verify the #807 critical-2 fix at the unit layer: merged-content re-moderation, metadata-only pass-through, archived no-resurrect, admin bypass.
  • Steps:
    1. npx vitest run src/routes/__tests__/offers.updateHandler.test.js src/routes/__tests__/offers.publishGate.test.js in services/api/merchants
  • Expected: all green, including "BYPASS CLOSED" and "metadata-only edit does NOT re-gate".
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    updateHandler 8/8 + publishGate 7/7 green, including BYPASS CLOSED, metadata-only pass-through, archived no-resurrect.
Evidence:  vitest run in services/api/merchants, 2026-08-10 11:45.
Follow-up:

4. #805: effective liveEvent/expiresAt bound on PUT โ€‹

  • Goal: a PUT can no longer store a liveEvent window that outlives the offer's expiry, regardless of which half of the pair the request carries.
  • Steps (unit + integration):
    1. PUT {liveEvent: {endsAt: after stored expiresAt}} with NO expiresAt in the body, on an offer whose stored expiresAt is earlier. Expect 400 with the bound message.
    2. PUT {expiresAt: earlier than stored liveEvent.endsAt} with NO liveEvent in the body. Expect 400.
    3. PUT both halves valid together. Expect 200.
    4. PUT {liveEvent: null} plus an earlier expiresAt. Expect 200 (clearing the event removes the bound).
    5. Create-path regression: POST with liveEvent.endsAt > expiresAt still 400s.
  • Expected: the merged (stored + patch) pair is validated; violations 400 before any write.
  • Verify (data): after each 400, the emulator doc is unchanged (no partial write).
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    Both directions 400 with LIVE_EVENT_OUT_OF_BOUNDS and the doc verified unchanged (updatedAt/expiresAt read back); valid pair and liveEvent:null clear both 200. Create-path Zod refine regression still 400s.
Evidence:  offers.updateHandler.test.js (#805 describe, 4 cases) + offers.routes.integration.test.js (#805 describe, 3 cases), npm run test:offers:emulator 2026-08-10 11:53, 22/22.
Follow-up:

5. POST /offers integration (emulator) โ€‹

  • Goal: route-level proof of the create-path moderation gate against real Firestore.
  • Steps: via supertest against the mounted router:
    1. Merchant POST with clean copy, status: 'active'. Expect 201, stored + returned status pending_review, moderation.autoOk: true.
    2. Merchant POST with flagged copy, status: 'active'. Expect 201, status rejected, moderation.flags naming category/field/term.
    3. Merchant POST draft with flagged copy. Expect 201, status draft, no moderation record (drafts are never gated).
    4. POST with a venueId not on the merchant. Expect 400 VENUE_NOT_ASSOCIATED.
    5. POST with invalid body (bad enum). Expect 400 VALIDATION (ZodError branch), not 500.
  • Verify (data): statuses and moderation records read back from the emulator doc, not just the HTTP response.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    Clean publish -> pending_review (doc + response), flagged publish -> rejected with offensive + minor_targeting flags on the doc, flagged draft stays draft with NO moderation record, foreign venueId -> 400 VENUE_NOT_ASSOCIATED, bad enum -> 400 VALIDATION via the real errorHandler (extracted to middleware/errorHandler.js so the test mounts the production code).
Evidence:  offers.routes.integration.test.js POST describe (6 cases), run 2026-08-10 11:53.
Follow-up:

6. PUT /offers integration (emulator) โ€‹

  • Goal: route-level proof of the #807 critical-2 semantics on real Firestore: text edits pull a live offer back through review; metadata edits do not.
  • Steps:
    1. Seed an active offer. Merchant PUT {description: <clean new copy>} (no status). Expect 200, status pending_review (offline until re-approved).
    2. Seed an active offer. Merchant PUT {description: <flagged copy>}. Expect 200, status rejected with flags.
    3. Seed an active offer. Merchant PUT {budget: 500} (metadata only). Expect 200, status still active, no moderation record change.
    4. Seed an archived offer. Merchant PUT {title: ...}. Expect archived stays archived (no resurrection).
    5. Admin PUT {status: 'active'} on a pending_review offer via the merchant route. Expect 200, status active (admin is the human reviewer).
    6. Merchant GET/PUT responses NEVER contain reviewNote, reviewedBy, reviewedAt (seed them on the doc first).
  • Verify (data): statuses read back from the emulator after each call.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    Text edit on live -> pending_review, flagged edit -> rejected, metadata-only edit stays active, archived stays archived, admin publish -> active, and neither GET nor PUT merchant responses carry reviewNote/reviewedBy/reviewedAt (seeded on the doc first).
Evidence:  offers.routes.integration.test.js PUT describe (6 cases), run 2026-08-10 11:53.
Follow-up:

7. Users see offers only via rotation API โ€‹

  • Goal: encode the operator's spec: the user surface is GET /offers/active, and it only ever returns active, unexpired offers.
  • Steps:
    1. Seed one offer in each status (draft, pending_review, rejected, archived, active, plus an expired active) at the same venue with a lantern-adjacent audience.
    2. GET /offers/active with matching lat/lng.
    3. Assert exactly the one live active offer returns; no other status ever appears, and the payload carries no moderation/review internals.
  • Note: the other half of the invariant (no direct client read path at all) is scenario 1's deny-all rule.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    Seeded all six statuses at one venue; GET /offers/active returned exactly the one live active offer, and the payload had no moderation, no reviewNote, no budget (selectOffers serializes a fixed field list).
Evidence:  offers.routes.integration.test.js rotation describe, run 2026-08-10 11:53.
Follow-up:

8. Admin review flow integration (emulator) โ€‹

  • Goal: the human half of the gate works end to end on real Firestore, atomically.
  • Steps:
    1. Approve a pending_review offer via POST /admin/offers/:id/status {status:'active'}. Expect 200; doc status active, reviewedBy/reviewedAt set; an adminActions doc exists with action reviewOffer (batch committed together).
    2. Reject with a reviewNote. Expect doc rejected + note stored.
    3. Decide on a non-pending offer. Expect 409 NOT_PENDING, doc unchanged.
    4. Approve a pending offer whose expiresAt already passed. Expect 409 OFFER_EXPIRED.
  • Verify (data): adminActions audit doc contents; offer doc fields.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    Approve -> active with reviewedBy + exactly one adminActions doc (reviewOffer, correct offerId/newStatus/performedBy); reject stores the note and the reviewer payload keeps it; non-pending -> 409 NOT_PENDING doc unchanged; expired-while-pending approve -> 409 OFFER_EXPIRED.
Evidence:  offers.routes.integration.test.js admin-review describe (4 cases), run 2026-08-10 11:53.
Follow-up:

9. adminOffers ordered query + index fallback โ€‹

  • Goal: GET /admin/offers no longer truncates the newest offers when a bucket exceeds limit.
  • Steps:
    1. Unit: query now orders by createdAt desc before limit; firestore.indexes.json carries the (status ASC, createdAt DESC) composite index on offers.
    2. Integration: seed more active offers than limit, list with a small limit, assert the NEWEST ones return (the old code dropped them on doc-id order).
    3. Unit: when Firestore raises FAILED_PRECONDITION (index still building right after deploy), the handler falls back to the old unordered fetch + in-memory sort instead of 500ing (the #790 lesson).
  • Expected: ordered, newest-first, limit-safe; graceful during index build.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    Integration: 5 seeded active offers, limit=3 returns the newest three newest-first (old code dropped them on doc-id order). Unit: orderBy('createdAt','desc') asserted; simulated FAILED_PRECONDITION serves 200 via the unordered fallback with in-memory sort. Index added to firestore.indexes.json (status ASC, createdAt DESC).
Evidence:  offers.routes.integration.test.js ordering describe + adminOffers.test.js (2 new cases), runs 2026-08-10 11:53.
Follow-up:  Scenario 14 confirms the index is READY on lantern-app-dev post-merge.

10. serializeOffer dedup regression sweep โ€‹

  • Goal: the shared serializer behaves identically to both former copies.
  • Steps:
    1. npx vitest run in services/api/merchants (whole unit suite).
    2. Integration scenarios 5, 6, 8 double as behavior proof: merchant path strips review fields, admin path keeps them (ISO-converted reviewedAt), both derive expired.
  • Expected: all green with the shared serializer in place.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    Whole merchants unit suite 101/101 with src/lib/serializeOffer.js in place; integration proves both variants (merchant strip in scenario 6, admin keep + ISO reviewedAt in scenario 8 and the adminOffers unit case).
Evidence:  vitest run 2026-08-10 11:53 (11 files passed, integration file skipped without emulator as designed).
Follow-up:

11. Review queue clears stale error banner โ€‹

  • Goal: re-verify the shipped fix and pin it with a test: a failed decision shows the banner, the next successful decision clears it.
  • Steps:
    1. Add a case to OfferReviewQueue.test.jsx: first decision rejects (API error, banner appears), second decision succeeds, banner is gone.
    2. npx vitest run src/admin/merchants/__tests__/OfferReviewQueue.test.jsx in apps/admin.
  • Expected: new case green alongside the existing six.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    The shipped setError(null)-on-success fix verified. The new test also EXPOSED a second bug: any decision error hid the entire table (the error-suppresses-empty-state branch also suppressed populated tables), stranding the admin with a banner and no retry buttons until refresh. Fixed in the same file: the error now suppresses only the misleading empty state; a populated table always renders. 7/7 green after the fix.
Evidence:  OfferReviewQueue.test.jsx new case "a successful decision clears the stale error banner from a failed one (#807)"; run 2026-08-10 11:56.
Follow-up:

12. computeRatio renders a real zero โ€‹

  • Goal: re-verify the shipped fix and pin it with a test: 0 lanterns with active users renders 0.0, not the placeholder; nullish or 0-denominator still renders the placeholder.
  • Steps:
    1. Add a case to VenueActivityDashboard.test.jsx driving the overview mock with lanterns: 0, activeUsers: 5, assert 0.0 renders; and with activeUsers: 0, assert the placeholder.
    2. npx vitest run src/admin/analytics/__tests__/VenueActivityDashboard.test.jsx in apps/admin.
  • Expected: both cases green.
Result:    [x] pass   [ ] fail   [ ] blocked
Actual:    lanterns:0 / activeUsers:5 renders 0.0; lanterns:3 / activeUsers:0 renders the placeholder. Shipped fix confirmed at the rendered-DOM level.
Evidence:  VenueActivityDashboard.test.jsx computeRatio describe (2 cases); run 2026-08-10 11:56, 6/6 in file.
Follow-up:

13. Browser pass: review queue on local stack โ€‹

  • Goal: drive the shipped UI against the real (local) server path once, per the browser-pass preference.
  • Steps:
    1. Check ports (lsof -i :8080 -i :8085 -i :3001), then start Firestore emulator, merchants-api (FIRESTORE_EMULATOR_HOST), admin app.
    2. Seed one clean pending_review offer via Admin SDK.
    3. Headless Playwright: open the review queue, verify the offer renders, click Approve, verify it leaves the list; verify the emulator doc flipped to active with an adminActions row.
  • Expected: queue renders real data, approve round-trips, no console errors.
  • Fallback: if local admin-portal auth blocks the drive, record the blocker here and run this against deployed dev in scenario 14 instead.
Result:    [ ] pass   [ ] fail   [x] blocked
Actual:    Blocked pre-merge by #819 (confirmed OPEN 2026-08-10): local auth-api cannot mint custom tokens (user ADC lacks iam.serviceAccounts.signBlob), so local admin-portal login is impossible and stubbing auth would test a modified app. Every server-side layer beneath this UI is covered by the 22 emulator integration tests (scenarios 5-9) and the UI logic by the 7 OfferReviewQueue unit tests.
Evidence:  gh issue view 819 (state OPEN).
Follow-up:  Folded into scenario 14: the browser pass runs against DEPLOYED dev in the post-merge 6-9pm close window. This is scheduled, not dropped.
Addendum (2026-08-10 ~12:05): operator connected her Chrome; ran the PRE-MERGE deployed baseline instead. Review queue at admin.dev.ourlantern.app loaded signed-in, GET https://merchants-api-.../admin/offers?status=pending_review returned 200, correct empty state rendered, zero console errors. Queue empty on dev so no decision exercised (would be a real write pre-merge anyway). Post-merge re-run in scenario 14 remains the real check for this PR's changes.

14. Post-deploy: index live + ordered admin list + review-queue browser pass on dev (session-close window) โ€‹

  • Goal: activation check (the composite index actually exists on lantern-app-dev, the ordered query serves without the fallback) plus the browser pass #819 blocked locally.
  • Steps (after merge, in the 6-9pm window):
    1. gcloud firestore indexes composite list --project lantern-app-dev (or console) shows the offers (status, createdAt DESC) index READY.
    2. Headless Playwright against the deployed dev admin portal: log in, open the review queue, verify it renders live data with no console errors; if a pending offer exists (or can be created via a dev merchant), exercise Approve and verify the row leaves the queue and the doc flips.
    3. Check merchants-api logs for the FAILED_PRECONDITION fallback warning; it should not be firing once the index is READY.
  • Expected: index READY, ordered path serving, no fallback warnings, review queue drivable in the browser.
Result:    [ ] pass   [ ] fail   [ ] blocked
Actual:
Evidence:
Follow-up:

Notes โ€‹

(operator remarks / mid-run additions land here)

Review round 2 (2026-08-10 ~12:50, /code-review medium on the full branch). 4 confirmed + 3 actionable findings, all fixed on-branch in the same commit:

  1. Archived offers were resurrectable via an explicit status in the PUT body (the gate only covered statusless edits). Now 409 OFFER_ARCHIVED for any status-bearing update, admin included; unit + integration cases added.
  2. liveEvent: null persisted a literal null, contradicting the new non-nullable LiveEvent response schema. Now maps to FieldValue.delete(); the serializer also normalizes legacy stored nulls away. Unit + integration cases added.
  3. openapi Placement enum advertised chat, which the Zod enum rejects; dropped from both spec sites (the openapi-sync linter is path/method-only and can never catch enum drift).
  4. The Date-or-Timestamp coercion existed in triplicate; now one exported toDate in serializeOffer.js.
  5. The defaulted-fields re-declare list (the radius-bug mechanism) is gone structurally: OfferBaseShape is default-free, defaults are create-path-only, so UpdateOfferSchema is patch-safe by construction.
  6. The admin list's in-memory sort now runs only on the index-fallback path (the DB order is authoritative on the primary path).
  7. CI's two back-to-back emulator boots merged into one npm run test:emulators:ci (single boot serves both suites' project ids), paying back the wall-clock this PR added.

Post-fix: merchants unit 103/103, combined emulator run 6/6 + 24/24 on one boot, scoped validate 16/16.

Built with VitePress