Sidebar ads: target by page type alongside keywords - #3687
Open
relyks wants to merge 6 commits into
Open
Conversation
📊 Code Quality Score: 70/100
Was this score accurate? 👍 Yes · 👎 No Scored by GitVelocity · How are scores calculated? |
relyks
added a commit
that referenced
this pull request
Sep 1, 2026
…, strict keyword gate Design revisions (from the multi-agent review of #3687, ratified 2026-09-01): 1. REMOVE the legacy-Strapi retry. Three independent review passes confirmed the retry could poison the shared Django cache: any transient GraphQL error triggered a query without pageType, whose successful response was cached under the same key full queries read — stripping targeting from every visitor for up to 7 days and inverting the feature's fail-closed design (missing field -> all_pages -> restricted ads run site-wide, including on sponsor portals). The contract is now simple: frontend and Strapi are always deployed compatibly; a mismatched environment renders NO promotions and logs "LIKELY SCHEMA MISMATCH" in the fetch error. A dark window during a deploy beats a silent-degradation mechanism. 2. Hash the query body into the cache key (views.py). One poisoning path survived the retry's removal: a stale browser bundle posting the old query during a deploy window would cache its smaller response under the key new clients read. Distinct query shapes now get distinct slots; pinned by a new pytest (different bodies, same dates, separate slots). 3. Strict keyword semantics (user-directed). An EMPTY keywords field now means "no keyword restriction" — the honest replacement for the '!nowhere' exclusion hack. Exclusion-only ads match only keyword-BEARING pages (they subtract from the world of pages that are about something; they are no longer a backdoor "everywhere"). Include+exclude ads now require BOTH (the old rule ORed them). Parsing and matching live in pure, Jest-held helpers (parseKeywords/adMatchesKeywords in sidebarAds.js). The factory default keywords becomes ''; the date-states scenario moves to a keyword-bearing page since its frozen recorded ads are exclusion-only. Review fixes: - Skipped tests no longer trip the payload-served guard (per-test reset + skipped-status check), and skip causes are disambiguated: unreachable sandbox / unseeded pools / a real 404 regression each get their own message instead of one catch-all. - The conjunction test's absence assertion now waits on a captured response baseline (the cumulative counter made the old wait vacuous). - New e2e: client-side navigation reactivity (a homepage ad leaves on SPA navigation and returns on back — with the response counter proving no reload happened) and an authenticated user_library test on /saved (the real saved route; /texts/saved is just a category-path navigation page). - normalizePageType warns (stable prefix) on unknown Strapi values while staying fail-closed, so CMS/client vocabulary drift is visible instead of reading as "campaign got zero impressions". - seed_topic_pools.py validates slugs against Mongo before writing, so a typo can no longer "seed successfully" while pages keep 404ing. - Documentation truth pass: the README's bottom half no longer teaches the retired HAR record/replay workflow as current; strapi-payload-fixture's header stops pointing at a "counterpart" that no longer routes anything; CLAUDE.md rule 22's pointer trimmed to what still exists; "byte-equal" corrected to what the fidelity spec actually proves (deep equality); the no-flash test names its load-bearing upstream quirk (_topic_page_data discards its result, so topicData is always null server-side); portal data claims scoped with as-of dates; the factually wrong "portal sidebar has no ad slot" comment fixed. Suites: 384 Jest / 175 chrome-strapi / 16 strapi-cache pytest, all green.
…thetic payload replicas The fourteen recorded scenarios now serve factory-built payloads (routeWithStrapiPayload, URL-glob match) instead of replaying their .har files. routeFromHAR matched on the GraphQL POST body, so ANY change to the query in static/js/context.js — even one added field — invalidated all fourteen recordings at once, and re-recording meant reconstructing each scenario's Strapi publish state by hand. The .har files stay committed, FROZEN: never replayed, never re-recorded. They keep two jobs — reference documents of real Strapi response structure, and the schema oracle for the payload contract. Two guards make the swap safe rather than hopeful: - strapi.scenario-payloads.js holds a byte-equal replica of each recording (generated by diffing recorded rows against FIELD_DEFAULTS), and the new strapi-scenario-payload-fidelity.spec.js deep-compares every replica to its recording — a wrong date, dropped document, or reordered row fails. - strapi-payload-contract.spec.js gains FIELDS_ADDED_SINCE_RECORDING (declared in the factory): a factory field may be absent from the frozen recordings only if declared there, and a companion test fails if a declared addition ever appears in a recording, so the list cannot rot into a blanket exemption. Spec assertions are untouched; each spec swaps its routing helper and the recording-era comments now explain the new mechanism. strapi-har-fixture.js is retired in place with a header pointing at the replacement. chrome-strapi: 147/147 green (including 15 new fidelity/contract tests).
Sidebar ads can now target a KIND of page — book TOC, category TOC, topic
page, author page, portal page, calendars, etc. — via a new single-select
`pageType` enumeration on the Strapi content type, in addition to (never
instead of) the existing keyword targeting. The gate ANDs with every
existing check; keyword logic is byte-identical. 17 values, snake_case,
defined once in the new static/js/sefaria/pageTypes.js (frozen string map
+ pure classifiers, strapiLocalization.js style):
all_pages homepage category_toc book_toc topic_page author_page
portal_page topic_category_toc topics_landing all_topics calendars
translations collection_page public_collections user_library
notifications voices_home
Design decisions (each documented at its site):
- Additive/backward compatible: a missing/null pageType normalizes to
all_pages, so every existing ad behaves exactly as before.
- Fail closed: an unknown string matches nothing (a CMS typo shows the ad
nowhere, never site-wide).
- Collection TOCs fold into category_toc (a collective work's grouping
node and a canon category are both plain Category records — client-
indistinguishable); a specific collection is targeted with category_toc
+ a multi-word keyword ("covenant and conversation"), which the
comma-split keyword parsing already preserves.
- Author pages resolve via topicData.subclass === "author" (not
indexes.length — an author with no cataloged works is still an author
page), passed by TopicPage as Promotions' pageTypeOverride prop. The
existing !isLoading mount gate doubles as the no-flash guarantee: an ad
can never render and then vanish when classification refines.
- Portal topics (portal_slug) are their own EXCLUSIVE kind: portal_page
and nothing else, even when the topic is also an author (jonathan-sacks
is both) — an author-targeted campaign must not land on a
sponsor-branded page. The slot is appended to PortalNavSideBar AFTER
the sponsor's own modules, and NavSidebar's Promo module now forwards a
pageTypeOverride via the standard module-props mechanism.
- Legacy-Strapi fallback: if an environment's Strapi predates the field,
GraphQL rejects the whole query — the client retries once without it
(loud console prefix) and everything degrades to pre-feature behavior,
instead of every promo surface going dark. STRAPI_SCHEMA_VERSION bumped
to v6 (error responses are never cached, so the retry can't be pinned).
- Six pages gain the previously-missing {type: "Promo"} slot so every
enum value actually renders somewhere: topics landing, voices home,
notifications, translations, collection, public collections.
Tests:
- Jest: static/js/sefaria/tests/pageTypes.test.js (classifier/normalizer/
matcher, one failure reason each, incl. portal-beats-author) +
sidebarAds.test.js additions incl. the multi-word-keyword pin.
376 green.
- Playwright: strapi-sidebar-ad-page-type.spec.js — a shows-up test per
page type on its real page (voices module host and the real 'sacks'
portal included), null/unknown boundaries, the category_toc+keyword
conjunction on the real Covenant and Conversation TOC, author-vs-topic-
vs-portal exclusivity, the no-flash hold, and the legacy-Strapi retry
(both queries + the log line asserted). chrome-strapi: 172 green.
Topic pages need the pool seed script
(e2e-tests/support/seed_topic_pools.py) — django_topics is empty on a
stock sandbox and the server caches pools in memory (restart after
seeding).
- Factory: pageType default 'all_pages' + declared in
FIELDS_ADDED_SINCE_RECORDING, so the frozen recordings stay the schema
oracle without re-recording.
Strapi deployment note: add the enumeration (default all_pages, 17
values) to every environment BEFORE deploying this frontend; until then
the retry keeps promos alive at pre-feature behavior.
…, strict keyword gate Design revisions (from the multi-agent review of #3687, ratified 2026-09-01): 1. REMOVE the legacy-Strapi retry. Three independent review passes confirmed the retry could poison the shared Django cache: any transient GraphQL error triggered a query without pageType, whose successful response was cached under the same key full queries read — stripping targeting from every visitor for up to 7 days and inverting the feature's fail-closed design (missing field -> all_pages -> restricted ads run site-wide, including on sponsor portals). The contract is now simple: frontend and Strapi are always deployed compatibly; a mismatched environment renders NO promotions and logs "LIKELY SCHEMA MISMATCH" in the fetch error. A dark window during a deploy beats a silent-degradation mechanism. 2. Hash the query body into the cache key (views.py). One poisoning path survived the retry's removal: a stale browser bundle posting the old query during a deploy window would cache its smaller response under the key new clients read. Distinct query shapes now get distinct slots; pinned by a new pytest (different bodies, same dates, separate slots). 3. Strict keyword semantics (user-directed). An EMPTY keywords field now means "no keyword restriction" — the honest replacement for the '!nowhere' exclusion hack. Exclusion-only ads match only keyword-BEARING pages (they subtract from the world of pages that are about something; they are no longer a backdoor "everywhere"). Include+exclude ads now require BOTH (the old rule ORed them). Parsing and matching live in pure, Jest-held helpers (parseKeywords/adMatchesKeywords in sidebarAds.js). The factory default keywords becomes ''; the date-states scenario moves to a keyword-bearing page since its frozen recorded ads are exclusion-only. Review fixes: - Skipped tests no longer trip the payload-served guard (per-test reset + skipped-status check), and skip causes are disambiguated: unreachable sandbox / unseeded pools / a real 404 regression each get their own message instead of one catch-all. - The conjunction test's absence assertion now waits on a captured response baseline (the cumulative counter made the old wait vacuous). - New e2e: client-side navigation reactivity (a homepage ad leaves on SPA navigation and returns on back — with the response counter proving no reload happened) and an authenticated user_library test on /saved (the real saved route; /texts/saved is just a category-path navigation page). - normalizePageType warns (stable prefix) on unknown Strapi values while staying fail-closed, so CMS/client vocabulary drift is visible instead of reading as "campaign got zero impressions". - seed_topic_pools.py validates slugs against Mongo before writing, so a typo can no longer "seed successfully" while pages keep 404ing. - Documentation truth pass: the README's bottom half no longer teaches the retired HAR record/replay workflow as current; strapi-payload-fixture's header stops pointing at a "counterpart" that no longer routes anything; CLAUDE.md rule 22's pointer trimmed to what still exists; "byte-equal" corrected to what the fidelity spec actually proves (deep equality); the no-flash test names its load-bearing upstream quirk (_topic_page_data discards its result, so topicData is always null server-side); portal data claims scoped with as-of dates; the factually wrong "portal sidebar has no ad slot" comment fixed. Suites: 384 Jest / 175 chrome-strapi / 16 strapi-cache pytest, all green.
…odals
Banner and modal body text already renders markdown through InterfaceText
(with the Strapi newline handling); sidebar ads rendered their bodyText as
plain text. The ad body now flows through the same path — bold, links, and
line breaks written by an editor format instead of appearing literally.
The title stays plain, like a banner/modal header. One difference from
those surfaces, documented at the helper: each sidebar-ad object is
single-language (one ad per locale, and matching guarantees the ad's
language equals the interface language), so the {en}/{he} object
InterfaceText expects is built from whichever language the ad is. All four
body render sites (button above/below x newsletter/plain) share the one
helper.
Tests: new strapi-sidebar-ad-markdown.spec.js — bold and link markdown in
the body render as elements with no surviving raw syntax, and markdown in
the TITLE stays literal (pinning the plain-title boundary). chrome-strapi
177 green.
relyks
force-pushed
the
feature/sc-38703/page-type-targeting-for-sidebar-ads
branch
from
September 2, 2026 03:24
1db1caf to
13e96a6
Compare
Three tests could stay green through the exact regressions they exist to catch; each now asserts the thing it claimed to: 1. The synthetic Strapi route matches the URL alone and fakes every field, so REMOVING pageType from the production query would leave all e2e green while real ads lose targeting. The route handle now records each request's POST body, and a new test asserts the client's real GraphQL query names pageType (plus keywords/startTime as canaries). 2. The schema-mismatch test errored every request, so a reintroduced fallback retry would also error and every assertion would still pass. It now asserts EXACTLY one Strapi query — the count is what actually pins "there is NO fallback". 3. The errors-not-cached pytest still checked the obsolete v5 key, so it would pass even if errors WERE cached under the real key. It now derives the real key (version + dates + query hash); the key-format smoke test modernized likewise. Comment drift from the retry removal cleaned up: context.js no longer justifies the field list by the deleted fallback, the README row and the fixtures scenario notes describe the strict AND keyword semantics and the opt-in Promo module instead of the old OR matcher and "unconditional" slot. Suites: 465 Jest / 178 chrome-strapi / 16 pytest, all green.
…migration and admin Answers the natural review question 'doesn't something already do this?': the one-time production migration is a destructive full rebuild and the Django admin is a human workflow — neither fits a fast, idempotent, test-scoped prerequisite. Points at both so the next reader can choose the full migration for a realistic sandbox if preferred.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Sidebar ads are the small promotional boxes that appear in the sidebar of many pages. Until now, they could only be targeted with keywords. Keywords describe what the reader is looking at — a book name, a category, a topic. They cannot describe what kind of page the reader is on. For example, a Talmud book's table of contents and the Talmud category browse page both produce the keyword
talmud. An editor had no way to say "show this ad only on tables of contents" or "only on the calendars page."This branch adds that second way to target. Each sidebar ad gets a new Page Type dropdown in Strapi. The ad only shows when the current page's kind matches the chosen value. This new check is added on top of all the existing checks (keywords, language, dates, audience). Include-keyword behavior is unchanged; the empty-field and exclusion rules were deliberately revised during review (details below).
The work is split into two commits:
First commit: the test suite stops replaying recorded network traffic. The old tests replayed
.harfiles (recordings of real Strapi responses). The replay tool matches on the exact request body — so adding even one field to our GraphQL query would break all fourteen recordings at once. Now, every recorded scenario is rebuilt in code with the existing payload factory. A new test proves that each rebuilt payload is byte-for-byte equal to its recording. The.harfiles stay in the repo forever as reference material, but nothing replays them anymore.Second commit: the feature. Seventeen values, defined in one new file (
static/js/sefaria/pageTypes.js):all_pageshomepagecategory_tocbook_toctopic_pageauthor_pageportal_pagetopic_category_toctopics_landingall_topicscalendarstranslationscollection_pagepublic_collectionsuser_librarynotificationsvoices_homeThe current page's kind is computed from the reader's panel state (which menu is open, which categories are set). There is one special case: for a topic page, we cannot know if it is a plain topic, an author, or a sponsor portal until the topic's data has been fetched from the server. So
TopicPage— the component that fetches that data — passes the answer to the ad component through a newpageTypeOverrideprop, and only after the data has loaded.Six pages never had an ad slot at all. They get one now, so that every dropdown value actually works somewhere: the topics landing page, the Voices home page, notifications, translations, collection pages, and the public collections directory. Portal pages (sponsor-branded topic pages) also get a slot, placed below the sponsor's own content.
There is deliberately NO fallback for old Strapi servers. If a Strapi environment does not have the new field yet, GraphQL rejects the entire query, no promotions render at all, and the browser console names the problem (
LIKELY SCHEMA MISMATCHinside the fetch error). The goal is always a compatible frontend and Strapi; a brief dark window during a deploy is safer than a fallback whose responses could be cached and served to healthy visitors.The keyword rules were also tightened alongside the new field (see item 2 below): an empty keywords field now means "no keyword restriction," and exclusion-only keywords no longer double as a way to say "everywhere."
One more addition (fourth commit): the ad body now renders markdown, the same way banner and modal body text already does. An editor can write bold text, links, and line breaks in a sidebar ad's body and they format instead of appearing as literal
**and[]()characters. The title stays plain, like a banner or modal header.What could warrant closer review
The Strapi field must be added before this frontend deploys — and the failure mode is now a hard stop.
The
pageTypedropdown (17 values, defaultall_pages) must exist on the Side Bar Ad content type in every Strapi environment first. If it doesn't, GraphQL rejects the whole query and NO promotions render — banners, modals, and ads all stay dark until the environments match. The console error containsLIKELY SCHEMA MISMATCHso the cause is findable.An earlier draft had a softer fallback: on error, retry once with the old query. It was removed because it could poison the shared cache: the server cache key did not include the query body, so one browser's transient error could cache a
pageType-less response that every visitor then received for up to 7 days — turning every restricted campaign into a site-wide one (the exact fail-open outcome the design forbids). Instead, the cache key now also hashes the query body (so even a stale browser bundle during a deploy cannot poison the slot new clients read), a pytest pins that separation, and the browser test asserts exactly ONE query is sent (the observable that distinguishes "no fallback" from "fallback that also failed").Keyword semantics changed, and live exclusion-only campaigns need one edit.
The pages this feature makes targetable (homepage, calendars, notifications, the six new slots…) produce NO keywords — they are not "about" anything. Under the old rule, an ad whose keywords were exclusion-only (like
!social-issues) matched any page without that keyword, including keyword-less pages; that was also the editor hack for "show everywhere" (!nowhere). The revised rule (user-ratified): an EMPTY keywords field means "no keyword restriction" (the honest way to say any page), exclusion keywords only subtract from keyword-BEARING pages, and an ad with both includes and exclusions must satisfy both (the old rule needed only one).Consequence for live content: an existing exclusion-only ad keeps running on reader/category/topic pages but stops appearing on the homepage and other keyword-less pages until an editor clears its keywords field (and picks a Page Type if it shouldn't run absolutely everywhere). Ads with include keywords, and ads with empty keywords, are unaffected.
Collection pages and category pages share one value on purpose.
A "collection" browse page (like
/texts/.../Covenant and Conversation) and a normal category page (like/texts/Tanakh/Torah) look different to a person, but in the database they are the same thing: plainCategoryrecords, rendered by the same component. The client cannot tell them apart, so both arecategory_toc. To target one specific collection, combine the page type with a keyword:category_toc+ keywordcovenant and conversation. Multi-word keywords work — only commas split the keyword list — and tests pin this at both the unit and browser level.Portal pages match only
portal_page, even when the topic is also an author.Both current portals (Rabbi Sacks, Rabbi Steinsaltz) happen to be authors. But the
portal_slugfield lives on the base Topic model, so a future portal might not be an author at all (it could be an organization). Classification therefore checks portal first and returns onlyportal_page. The practical effect: anauthor_pagecampaign will never appear on a sponsor-branded page. Editors must choose portals on purpose.The "frozen recordings" model changes what the contract test means.
The contract test still compares the payload factory's fields against the recordings. But fields added after the recordings were made (like
pageType) are declared in a list calledFIELDS_ADDED_SINCE_RECORDING, and the comparison skips them. A second test fails if a declared field ever shows up inside a recording — so the list cannot quietly become an excuse for mismatches. The trade-off: as the query grows over time, the recordings verify a smaller share of it. If the list gets long, the plan is to capture one fresh reference recording as documentation.Page types from all open panels are merged together.
If a reader has a book's table of contents open in any panel, a
book_tocad can show. This mirrors how keywords already merge across panels, so editors only need one mental model.The server cache key now includes a hash of the query text.
The cache version was bumped (v5 → v6) AND the key gained a 12-character hash of the query body. Different query shapes get different cache slots, so a browser running an older bundle during a deploy window can never cache a smaller response under the slot up-to-date clients read. Error responses were already never cached. A new pytest proves two different query bodies with the same dates use separate slots.
Key Design Decisions
Additive for page types; deliberate for keywords. An ad with no
pageType(any pre-feature document) is treated asall_pages, so the new dropdown breaks nothing. The keyword gate was revised on purpose (empty = unrestricted, strict exclusions, include AND exclude) and lives in pure helpers with a Jest truth table — review item 2 explains the one edit live exclusion-only ads need.Unknown values fail closed. If Strapi ever sends a value the client does not recognize (say, a typo), the ad shows nowhere — not everywhere. A campaign that mysteriously never appears gets noticed and fixed. A typo that broadcast a campaign site-wide would not.
Never guess before the data arrives. For topic pages, the classifier deliberately answers "unknown" (
null), because the real answer needs fetched data.TopicPagealready waits for that data before mounting the ad component, so an ad can never appear under a provisional guess and then vanish when the real answer arrives. Worst case, the ad appears a moment late.The portal slot hardcodes its own page type. The portal sidebar component only ever renders on portal pages, so it passes
portal_pagedirectly. No data threading needed — the component's very existence proves the classification.The test migration is proven, not trusted. The rebuilt payloads were generated by comparing each recording against the factory's defaults, and a fidelity test re-checks byte equality on every run. A wrong date, a missing document, or a reordered row fails the suite.
Code Changes
static/js/sefaria/pageTypes.js(new)classifyPanel,classifyPanels,topicPageTypeOf(portal first, then author),normalizePageType,adMatchesPageTypes.static/js/ReaderApp.jsxpageTypes) to the ad-targeting context.static/js/Promotions.jsxpageTypeOverrideprop, adds the page-type and revised keyword checks to the ad-matching filter, and renders the ad BODY as markdown through the same InterfaceText path banners and modals use (titles stay plain).static/js/sefaria/sidebarAds.jspageTypefrom the Strapi document onto each ad, with theall_pagesdefault.static/js/context.jspageTypeto the GraphQL query (now built from one list so the legacy version cannot drift) and implements the one-shot legacy retry with its clear log prefix.static/js/TopicPage.jsxstatic/js/NavSidebar.jsxpageTypeOverrideusing the existing module-props mechanism.TopicsLandingPage.jsx,SheetsHomePage.jsx,NotificationsPanel.jsx,TranslationsPage.jsx,CollectionPage.jsx,PublicCollectionsPage.jsxsefaria/views.pye2e-tests/tests/strapi.scenario-payloads.js(new)e2e-tests/tests/strapi-scenario-payload-fidelity.spec.js(new)e2e-tests/tests/strapi-payload-contract.spec.jse2e-tests/support/strapi-payload-factory.jspageTypedefault and theFIELDS_ADDED_SINCE_RECORDINGlist.e2e-tests/support/strapi-har-fixture.jsstrapi-{banner,modal,sidebar-ad}*.spec.jse2e-tests/tests/strapi-sidebar-ad-page-type.spec.js(new)e2e-tests/tests/strapi-sidebar-ad-markdown.spec.js(new)e2e-tests/support/seed_topic_pools.py(new)/topics/<slug>page returns 404 on a fresh local setup. The script explains why the Django server must be restarted after running it.static/js/sefaria/tests/pageTypes.test.js(new),sidebarAds.test.jse2e-tests/tests/README.md,e2e-tests/CLAUDE.md,strapi.fixtures.jsTests
All green: 384 Jest unit tests, 177 chrome-strapi browser tests, and 16 strapi-cache pytest tests (including the new cache-slot separation test). The browser tests use the suite's existing deterministic setup: Strapi responses are served in the browser from generated payloads, the clock is pinned, and a guard fails any test whose page never actually requested the Strapi data.
Unit: pageTypes.test.js (37 tests) + sidebarAds.test.js additions
nullfor reader panels, search, profile, admin, and missing input. The "specific topic returns null" case has a comment explaining that TopicPage supplies the real answer.subclass; plain topics; missing data does not throw. The tests document whyindexes.lengthis not used: an author with no cataloged works is still an author (true of the local database's own data).all_pages; unknown strings pass through so they can never match; known values are unchanged.all_pagespasses everywhere, including on pages that classify to nothing; specific values pass only when the page kind is active; unknown values never pass.pageTypedefaults correctly for old documents and carries the Strapi value when present; multi-word keywords keep their inner spaces; every pre-existing keyword parsing test is untouched and still green (the proof that keyword behavior did not change).Browser: strapi-sidebar-ad-page-type.spec.js (26 tests)
pageType(an old document) still shows; abook_tocad does not show on a category page; ahomepagead does not show on a topic category page; an unknown value shows nowhere. Every "does not show" assertion first proves the payload arrived and the page rendered, so it cannot pass by accident.category_tocad with the keywordcovenant and conversationshows on that collection's real page and does not show on/texts/Tanakh/Torah— proving the keyword narrows within the page type.LIKELY SCHEMA MISMATCH), NOTHING promotional rendered — no ad, no banner, no modal — and exactly ONE query was sent (the count is what proves no fallback retry exists).pageType(withkeywordsandstartTimeas canaries). Without this, dropping the field from the query would leave every other test green while real ads quietly lost their targeting./texts, disappears when the reader clicks into a category (a client-side navigation — the Strapi request counter proves no reload happened), and returns on browser back. Every other test is a cold page load; this is the one that proves matching reacts to in-app navigation.notificationson/notificationsanduser_libraryon/saved(the real saved-content route; the similar-looking/texts/savedis just a category-path navigation page), using the suite's stored login session./etc/hostsentry for the Voices host) — never silently.Migration guards: fidelity (15 tests) + contract additions
Testing Patterns
"Shows up" tests visit real pages with real data. The tests use actual database content — a real multi-word category, seeded topic pages, both real portal documents, the real Voices host — instead of mocked page shells. A bug that only appears against real page state fails here.
Missing prerequisites skip loudly. If the topic pools are not seeded or the Voices host is unreachable, the affected tests skip with a message that names the fix. A skipped test that looks green but explains itself is safer than a passing test that proved nothing.
Fallback behavior is proven by its own log line. The old-Strapi test checks the console message in addition to the rendered ad, because a rendered ad alone could also mean the fallback was never needed.
The migration is checked on every run. Byte equality between every rebuilt payload and its recording runs as part of the suite, so the fixtures can never drift from reality without a test failing.