Ad Network as a first class section - Manual Test Plan โ
| Field | Value |
|---|---|
| Branch / PR | feat/admin-and-merchant-portals (#986) |
| Environment | http://localhost:3012 (hers, the SECOND admin frontend, not 3001). Hits live lantern-app-dev Firebase. 3001 was avoided deliberately: it had been up 26 hours and had demonstrably missed changes on disk, so a pass there can confirm old code. |
| Build flags / config | none. VITE_MERCHANTS_API_URL=/api and MERCHANTS_API_ORIGIN must be set in apps/admin/.env.local |
| Build (commit) | the working tree at 2026-08-28, after the #981 proxy fix |
| Tester | Claude (headless Playwright, signed in as Agent Probe) |
| Date | 2026-08-28 |
Summary โ
| # | Scenario | Result | Notes |
|---|---|---|---|
| 1 | The section renders with three tabs, each reaching a real surface | [x] pass | Automated route pass |
| 2 | Delivery is the old dashboard, mounted body-only | [x] pass | One page header, not two |
| 3 | Review is the old queue, mounted body-only, and reads REAL data | [x] pass | The important one. See below |
| 4 | Placements shows real inventory and honest blanks | [x] pass | Three surfaces from the shared vocabulary |
| 5 | Both old paths redirect | [x] pass | Analytics and Merchants |
| 6 | Merchants no longer offers Offer review | [x] pass | Two sub-items remain |
| 7 | A non-JSON 200 raises instead of becoming an empty list | [x] pass | Measured before and after |
| 8 | CONTROL: the sections this work did not touch are unchanged | [x] pass | Shared with the Venues plan, 18 sections |
| 9 | Approving or rejecting an offer still works | [ ] NOT EXERCISED | Needs an offer in pending_review. See below |
| 10 | The Delivery chart renders with real rollup data | [ ] NOT EXERCISED | Needs the rollup to have run. See below |
Scenario 3 is the falsifying one for this plan. Its arms have opposite predictions, and the point is separating them: see its note.
Setup / preconditions โ
- Her stack: admin portal on 3012, APIs on 8080 to 8086. Do not restart or rebuild them.
- Sign in with the Agent Probe admin (
~/.lantern-agent-probe.env,AGENT_PROBE_PORTAL_PASSWORD, the PORTAL password, not the app passphrase: admin auth and app auth are deliberately separate systems). - Re-run the automated route pass with the harness in
docs/projects/admin-venues-section/harness/. - Budget your logins.
/auth/admin/signinrate-limits at 10 per 15 minutes per IP, and the admin and merchant portals SHARE one bucket. Worse, a 429 from the limiter is indistinguishable by status from the five-strike account lockout, so read the response'serrorcode (RATE_LIMITEDversusACCOUNT_LOCKED), never its status. Use a persistent browser profile so the run costs one login rather than one per route.
Scenarios โ
1. The section renders with three tabs, each reaching a real surface โ
- Steps: open
/admin/ad-network; click Delivery, Placements, Review; reload on a non-default tab. - Expected: index redirects to Delivery. Three tabs, each with its own URL. A reload lands on the same tab, because the tab is in the URL rather than in state.
Result: [x] pass [ ] fail [ ] blocked
Actual: /admin/ad-network redirects to /admin/ad-network/delivery. All three tabs settled on their own selector.
Evidence: docs/projects/admin-venues-section/screenshots/live/adnetwork-delivery.png, adnetwork-placements.png, adnetwork-review.png
Follow-up:2. Delivery is the old dashboard, mounted body-only โ
- Goal: the section owns the header. Mounted un-embedded, the page would draw a SECOND page header under the section's own.
- Expected: one page header (Ad Network), one tab strip, and the dashboard's refresh controls in the body rather than in a header of their own.
Result: [x] pass [ ] fail [ ] blocked
Actual: One header. Reporting window bar sized to content, refresh controls grouped at the end of the row.
Evidence: screenshots/live/adnetwork-delivery.png, plus an AdminShell test asserting data-embedded="true"
Follow-up:3. Review is the old queue, mounted body-only, and reads REAL data โ
- This is the falsifying scenario, and it exists because an empty queue has three possible causes that look identical. State them before running: (a) there genuinely are no offers awaiting review, (b)
#981, where vite proxied no/api/adminroute so the request was answered by the app shell, or (c) the harness gave up before the surface settled. An empty screenshot cannot tell you which. If the queue renders empty and you cannot separate the three, the correct report is "unknown", not "empty". - Steps:
- Open
/admin/ad-network/reviewand wait for a CONDITION, not a timeout. - Record the actual network response for
/api/admin/offers: status, content-type, and body.
- Open
- Expected: a
200 application/json. An empty list then means (a). A200 text/htmlmeans (b). No response at all means the wait was (c).
Result: [x] pass [ ] fail [ ] blocked
Actual: All three separated. GET /api/admin/offers returned 200 application/json with body {"offers":[],"count":0}.
So the queue is GENUINELY empty in dev, not silently broken. (b) was ruled out by fixing the proxy and
re-measuring: an unauthenticated curl went from 200 text/html to 401 application/json, which proves the
request now reaches merchants-api instead of the page. (c) was ruled out by waiting on a selector.
Evidence: screenshots/live/adnetwork-review.png; the recorded response above
Follow-up: The 2026-08-24 design survey recorded this queue as empty and reasoned about it as a product state. It
happens to be empty today, but that survey had no way to know, so its reasoning was unfounded at the time.4. Placements shows real inventory and honest blanks โ
- Expected: three surfaces (Hero rail, Inline venue card, Feed insertion) derived from
PLACEMENTSin@lantern/shared/ads, each with where it appears and how contended it is. The three per-surface figures render as a datedScaffoldpointing at#998, NOT as zeroes.Unknownis absent, with a note explaining it is a data-quality bucket rather than a fourth surface.
Result: [x] pass [ ] fail [ ] blocked
Actual: All three render with contention badges. Scaffold reads "Fill rate, eligible offers and CTR are not
flowing per surface yet", dated 2026-08-28, linking #998. No zeroes anywhere.
Evidence: screenshots/live/adnetwork-placements.png
Follow-up:5. Both old paths redirect โ
Result: [x] pass [ ] fail [ ] blocked
Actual: /admin/analytics/dashboards/ads -> /admin/ad-network/delivery.
/admin/merchants/offer-review -> /admin/ad-network/review.
Evidence: screenshots/live/redirect-analytics-ads.png, redirect-merchants-offer-review.png
Follow-up:6. Merchants no longer offers Offer review โ
- Goal: the section changed meaning, and no dead nav entry is left behind.
Result: [x] pass [ ] fail [ ] blocked
Actual: Merchants expands to All Merchants and Applications only. Both still render.
Evidence: the control walk covered both Merchants routes
Follow-up:7. A non-JSON 200 raises instead of becoming an empty list โ
- Goal: the silent-failure class, not just this instance.
parseResponseturned an HTML body into{ message: '<html>' }, soresult.offers || []rendered a confident empty queue with no error and no failed request. - Verify: curl the proxied path unauthenticated and read the content-type.
Result: [x] pass [ ] fail [ ] blocked
Actual: before: /api/admin/offers -> 200 text/html (the app shell answering)
after: /api/admin/offers -> 401 application/json (merchants-api saying no)
A 401 is the correct answer to an unauthenticated request and proves it reaches the service.
Evidence: measured against both 3001 and 3012 after the vite restart
Follow-up: The guard is in merchantsApi only. The same shape exists in every other API client on this portal.8. CONTROL: the sections this work did not touch are unchanged โ
Result: [x] pass [ ] fail [ ] blocked
Actual: 18 sections walked, all render, none swallowed by the catch-all.
Evidence: node docs/projects/admin-venues-section/harness/control-walk.e2e.mjs
Follow-up:9. Approving or rejecting an offer still works โ
- NOT EXERCISED. The queue is genuinely empty in dev, so there was nothing to approve or reject. This is the highest-value remaining gap: the move changed how the page is mounted, and approve/reject is the only behaviour on it.
- What would close it: publish an offer as the TEST MERCHANT (not as an admin: an admin publish bypasses the moderation queue by design, and the signature in the offer doc differs,
moderation: nullfor an admin publish versus{ autoOk: true, flags: [] }through the gate). Then approve it here and confirm the status write.
Result: [ ] pass [ ] fail [x] blocked
Actual: No offers in pending_review in dev.
Evidence: {"offers":[],"count":0}
Follow-up: Seed one via a merchant publish, then run approve and reject.10. The Delivery chart renders with real rollup data โ
- NOT EXERCISED. The dashboard renders and its empty state is honest ("the rollup lands daily via the aggregate-ad-delivery-daily job; a freshly provisioned table stays empty until its first run"), but no chart was drawn because there is no data.
- What would close it: run against an environment where the daily rollup has produced rows, then confirm the Hero, Inline, Feed and Unknown series all chart.
Result: [ ] pass [ ] fail [x] blocked
Actual: Page renders, chart area shows its empty state.
Evidence: screenshots/live/adnetwork-delivery.png
Follow-up:Notes โ
- This block did the MOVE, not the Offer review redesign. The operator named that separately. The queue is ported exactly as it stands, and whatever is wrong with it stays wrong until that work.
- What this run did not do: click anything. It loaded routes and recorded responses. No offer was approved, no tab was clicked by hand, no form was submitted.