Skip to content

Offer Moderation (pre-check gate + human review) - Manual Test Plan โ€‹

FieldValue
Branch / PRfeat/autopilot-merchant-venue (session PR #792)
Issue#796 (LLM stage deferred to #799)
Environmentmerchant portal + admin portal (dev) -> merchants-api Cloud Run (dev); offers collection
Build (commit)fill after deploy
TesterMechelle (+ agent live run)
Datefill 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 rejected with 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) โ€‹

#ScenarioResultNotes
1Merchant publishes a clean offer -> pending_review, not live[ ] pass [ ] fail [ ] blockedhappy path
2Merchant publishes offensive/kid-targeting copy -> auto-rejected[ ] pass [ ] fail [ ] blockedthe gate
3Rejected offer shows the merchant the reason[ ] pass [ ] fail [ ] blocked
4Admin sees the pending queue and approves -> offer goes active[ ] pass [ ] fail [ ] blockedend-to-end
5Admin rejects a pending offer -> not live[ ] pass [ ] fail [ ] blocked
6pending_review / rejected offers never surface to a user[ ] pass [ ] fail [ ] blockedsafety
7Draft is unaffected (no gate)[ ] pass [ ] fail [ ] blockedregression
8Non-admin cannot approve[ ] pass [ ] fail [ ] blockedguardrail
9Editing 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_ASSOCIATED check.
  • 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, not active; 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.flags non-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; an adminActions entry records the approval.
  • Verify (data): status == 'active'; adminActions has 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 in adminActions.
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 one rejected) near a test user's location, fetch the user-facing active offers.
  • Expected: Neither appears; only active offers 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, not pending_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.js are a seed; expand them as real offers surface gaps.

Built with VitePress