Offer moderation close-out (#807 tail + #805) - Test Plan โ
| Field | Value |
|---|---|
| Branch / PR | fix/offer-moderation-807 (session PR, draft; body carries Closes #807, Closes #805) |
| Environment | Emulator 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 / config | n/a (no flags; one new composite index in firestore.indexes.json) |
| Build (commit) | fill at run time |
| Tester | agent (all scenarios are automatable here; nothing handed to the operator) |
| Date | 2026-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 โ
| # | Scenario | Result | Notes |
|---|---|---|---|
| 1 | firestore.rules: /offers is server-only (deny-all) | [x] pass | re-verify shipped fix (207/207) |
| 2 | Moderation wordlist precision (phrases fix) | [x] pass | re-verify shipped fix |
| 3 | PUT moderation-gate unit matrix | [x] pass | re-verify shipped fix |
| 4 | #805: effective liveEvent/expiresAt bound on PUT | [x] pass | new fix (4 unit + 3 integration cases) |
| 5 | POST /offers integration (emulator) | [x] pass | 6 cases |
| 6 | PUT /offers integration (emulator) | [x] pass | 6 cases |
| 7 | Users see offers only via rotation API | [x] pass | 1 case (mixed-status seed) |
| 8 | Admin review flow integration (emulator) | [x] pass | 4 cases |
| 9 | adminOffers ordered query + index fallback | [x] pass | 2 integration + 2 unit cases |
| 10 | serializeOffer dedup regression sweep | [x] pass | 101 unit + 22 integration green |
| 11 | Review queue clears stale error banner | [x] pass | test added; found + fixed a NEW bug (see 11) |
| 12 | computeRatio renders a real zero | [x] pass | 2 tests added |
| 13 | Browser pass: review queue on local stack | [ ] pass [ ] fail [x] blocked | blocked by #819; folded into 14 |
| 14 | Post-deploy: index live + ordered admin list + review-queue browser pass on dev | [ ] pass [ ] fail [ ] blocked | SCHEDULED: the 6-9pm close step |
Setup / preconditions โ
- Emulator suites run through
firebase emulators:exec --only firestore; nothing else needs to be running. New root scriptnpm run test:offers:emulatorruns the offers integration suite (skips cleanly withoutFIRESTORE_EMULATOR_HOST, same pattern astest:sever:emulator). - Firestore emulator binds :8080 (conflicts with venues-api; do not run venues-api simultaneously). Check
lsof -i :8080 -i :8085 -i :3001before 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_reviewoffer via Admin SDK against the emulator, run merchants-api withFIRESTORE_EMULATOR_HOST=localhost:8080and 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:
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:
npx vitest run src/lib/__tests__/offerModeration.test.jsinservices/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:
npx vitest run src/routes/__tests__/offers.updateHandler.test.js src/routes/__tests__/offers.publishGate.test.jsinservices/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):
- 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. - PUT
{expiresAt: earlier than stored liveEvent.endsAt}with NO liveEvent in the body. Expect 400. - PUT both halves valid together. Expect 200.
- PUT
{liveEvent: null}plus an earlier expiresAt. Expect 200 (clearing the event removes the bound). - Create-path regression: POST with liveEvent.endsAt > expiresAt still 400s.
- PUT
- 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:
- Merchant POST with clean copy,
status: 'active'. Expect 201, stored + returned statuspending_review,moderation.autoOk: true. - Merchant POST with flagged copy,
status: 'active'. Expect 201, statusrejected,moderation.flagsnaming category/field/term. - Merchant POST draft with flagged copy. Expect 201, status
draft, no moderation record (drafts are never gated). - POST with a venueId not on the merchant. Expect 400 VENUE_NOT_ASSOCIATED.
- POST with invalid body (bad enum). Expect 400 VALIDATION (ZodError branch), not 500.
- Merchant POST with clean copy,
- 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:
- Seed an
activeoffer. Merchant PUT{description: <clean new copy>}(no status). Expect 200, statuspending_review(offline until re-approved). - Seed an
activeoffer. Merchant PUT{description: <flagged copy>}. Expect 200, statusrejectedwith flags. - Seed an
activeoffer. Merchant PUT{budget: 500}(metadata only). Expect 200, status stillactive, no moderation record change. - Seed an
archivedoffer. Merchant PUT{title: ...}. Expect archived stays archived (no resurrection). - Admin PUT
{status: 'active'}on apending_reviewoffer via the merchant route. Expect 200, statusactive(admin is the human reviewer). - Merchant GET/PUT responses NEVER contain
reviewNote,reviewedBy,reviewedAt(seed them on the doc first).
- Seed an
- 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:
- Seed one offer in each status (
draft,pending_review,rejected,archived,active, plus an expiredactive) at the same venue with a lantern-adjacent audience. - GET /offers/active with matching lat/lng.
- Assert exactly the one live
activeoffer returns; no other status ever appears, and the payload carries no moderation/review internals.
- Seed one offer in each status (
- 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:
- Approve a
pending_reviewoffer via POST /admin/offers/:id/status{status:'active'}. Expect 200; doc statusactive,reviewedBy/reviewedAtset; anadminActionsdoc exists with actionreviewOffer(batch committed together). - Reject with a reviewNote. Expect doc
rejected+ note stored. - Decide on a non-pending offer. Expect 409 NOT_PENDING, doc unchanged.
- Approve a pending offer whose expiresAt already passed. Expect 409 OFFER_EXPIRED.
- Approve a
- Verify (data):
adminActionsaudit 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:
- Unit: query now orders by createdAt desc before limit;
firestore.indexes.jsoncarries the (status ASC, createdAt DESC) composite index onoffers. - Integration: seed more
activeoffers thanlimit, list with a small limit, assert the NEWEST ones return (the old code dropped them on doc-id order). - 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).
- Unit: query now orders by createdAt desc before limit;
- 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:
npx vitest runinservices/api/merchants(whole unit suite).- Integration scenarios 5, 6, 8 double as behavior proof: merchant path strips review fields, admin path keeps them (ISO-converted
reviewedAt), both deriveexpired.
- 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:
- Add a case to
OfferReviewQueue.test.jsx: first decision rejects (API error, banner appears), second decision succeeds, banner is gone. npx vitest run src/admin/merchants/__tests__/OfferReviewQueue.test.jsxinapps/admin.
- Add a case to
- 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:
- Add a case to
VenueActivityDashboard.test.jsxdriving the overview mock withlanterns: 0, activeUsers: 5, assert0.0renders; and withactiveUsers: 0, assert the placeholder. npx vitest run src/admin/analytics/__tests__/VenueActivityDashboard.test.jsxinapps/admin.
- Add a case to
- 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:
- Check ports (
lsof -i :8080 -i :8085 -i :3001), then start Firestore emulator, merchants-api (FIRESTORE_EMULATOR_HOST), admin app. - Seed one clean
pending_reviewoffer via Admin SDK. - Headless Playwright: open the review queue, verify the offer renders, click Approve, verify it leaves the list; verify the emulator doc flipped to
activewith anadminActionsrow.
- Check ports (
- 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):
gcloud firestore indexes composite list --project lantern-app-dev(or console) shows the offers (status, createdAt DESC) index READY.- 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.
- 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:
- Archived offers were resurrectable via an explicit
statusin the PUT body (the gate only covered statusless edits). Now 409 OFFER_ARCHIVED for any status-bearing update, admin included; unit + integration cases added. liveEvent: nullpersisted 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.- 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). - The Date-or-Timestamp coercion existed in triplicate; now one exported
toDatein serializeOffer.js. - 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.
- The admin list's in-memory sort now runs only on the index-fallback path (the DB order is authoritative on the primary path).
- 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.