Offer Moderation (pre-check gate + human review) - Manual Test Plan โ
| Field | Value |
|---|---|
| Branch / PR | feat/autopilot-merchant-venue (session PR #792) |
| Issue | #796 (LLM stage deferred to #799) |
| Environment | merchant portal + admin portal (dev) -> merchants-api Cloud Run (dev); offers collection |
| Build (commit) | fill after deploy |
| Tester | Mechelle (+ agent live run) |
| Date | fill on run |
What this gate does โ
A merchant's Publish no longer sends an offer straight live. Requesting active runs an automated pre-check on the offer text:
- fail -> stored
rejectedwith the reasons, never enters the human queue. - pass -> stored
pending_review, awaiting an admin approval. - an admin publish goes straight to
active(the admin is the reviewer).
Only active offers surface to users (@lantern/shared/ads selectOffers queries status == 'active'), so nothing unreviewed can appear. draft is never gated.
Layers already verified (pre-merge, automated) โ
- Pre-check module (
offerModeration.test.js, 11 unit tests): clean pass, each category (offensive / minor_targeting / explicit), multi-word phrases, liveEvent scanning, case-insensitivity, empty/null inputs, Scunthorpe boundary guards. - Publish gate (
offers.publishGate.test.js, 5 unit tests): draft not gated, merchant clean -> pending_review, merchant bad -> rejected + reasons, admin -> active, merchant can never reach active directly.
Summary (browser + data layers) โ
| # | Scenario | Result | Notes |
|---|---|---|---|
| 1 | Merchant publishes a clean offer -> pending_review, not live | [ ] pass [ ] fail [ ] blocked | happy path |
| 2 | Merchant publishes offensive/kid-targeting copy -> auto-rejected | [ ] pass [ ] fail [ ] blocked | the gate |
| 3 | Rejected offer shows the merchant the reason | [ ] pass [ ] fail [ ] blocked | |
| 4 | Admin sees the pending queue and approves -> offer goes active | [ ] pass [ ] fail [ ] blocked | end-to-end |
| 5 | Admin rejects a pending offer -> not live | [ ] pass [ ] fail [ ] blocked | |
| 6 | pending_review / rejected offers never surface to a user | [ ] pass [ ] fail [ ] blocked | safety |
| 7 | Draft is unaffected (no gate) | [ ] pass [ ] fail [ ] blocked | regression |
| 8 | Non-admin cannot approve | [ ] pass [ ] fail [ ] blocked | guardrail |
| 9 | Editing copy while publishing re-checks the final text | [ ] pass [ ] fail [ ] blocked |
Setup / preconditions โ
- A merchant account with an associated venue (see the merchant-venue-association plan) so offer create passes the
VENUE_NOT_ASSOCIATEDcheck. - An admin account (Agent Probe for the agent run) for the review queue.
- Data lives in
lantern-app-dev:offers(status,moderation),adminActions.
Scenarios โ
1. Merchant publishes a clean offer -> pending_review โ
- Steps: As a merchant, create an offer with clean copy and Publish (status active).
- Expected: The offer is saved as
pending_review, notactive; the merchant is told it is awaiting review. - Verify (data):
offers/{id}.status == 'pending_review',moderation.autoOk == true.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence:
Follow-up:2. Merchant publishes disallowed copy -> auto-rejected โ
- Steps: As a merchant, Publish an offer whose title/description contains offensive language or kid-targeting (e.g. "free stuff for the kids").
- Expected: Stored
rejected; not live; the flags name the category + field. - Verify (data):
status == 'rejected',moderation.autoOk == false,moderation.flagsnon-empty.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence:
Follow-up:3. Rejected offer shows the merchant the reason โ
- Steps: After scenario 2, view the offer in the merchant portal.
- Expected: The merchant sees it was rejected and why (the flagged categories), so they can fix and resubmit.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence:
Follow-up:4. Admin approves a pending offer -> active (end-to-end) โ
- Steps: As an admin, open the offer review queue, find the scenario-1 offer, Approve.
- Expected: Status flips to
active; it now surfaces to nearby users; anadminActionsentry records the approval. - Verify (data):
status == 'active';adminActionshas an offer-review entry with your uid.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence:
Follow-up:5. Admin rejects a pending offer โ
- Steps: As an admin, Reject a pending offer.
- Expected: Status
rejected; not live; recorded inadminActions.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence:
Follow-up:6. pending_review / rejected never surface to a user (safety) โ
- Steps: With an offer in
pending_review(and onerejected) near a test user's location, fetch the user-facing active offers. - Expected: Neither appears; only
activeoffers are returned. - Verify:
GET /offers/active(or the in-app nearby offers) excludes them.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence:
Follow-up:7. Draft is unaffected โ
- Steps: Save an offer as draft (do not publish).
- Expected: Stored
draft, no moderation record, no gate.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence:
Follow-up:8. Non-admin cannot approve (guardrail) โ
- Steps: As a merchant / non-admin, call the admin approve endpoint directly.
- Expected: 401/403; no status change.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence:
Follow-up:9. Editing copy while publishing re-checks the final text โ
- Steps: Create a clean draft, then PUT an update that both rewrites the description to contain a banned term AND sets status active.
- Expected: The merged (final) text is moderated ->
rejected, notpending_review. - Verify (data):
status == 'rejected'with flags on the new text.
Result: [ ] pass [ ] fail [ ] blocked
Actual:
Evidence:
Follow-up:Agent live run โ
To be filled by the agent (emulator integration for the route writes + deployed browser pass for the admin queue at the session close). Record the actual result or the specific blocker here.
Notes โ
- The pre-check is deterministic word/pattern matching for the alpha (fast, no dependency); an LLM stage is deferred to #799. The human queue is the backstop for anything subtler.
- The word lists in
offerModeration.jsare a seed; expand them as real offers surface gaps.