diff --git a/BUILD-STATE.md b/BUILD-STATE.md index 7dd0707..6a87c33 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -1,7 +1,24 @@ # VYNDR — Build State ## Last Updated -2026-07-10 +2026-07-11 + +## Session S3 (a1 board, 2026-07-11) — Affiliate + Partnership Plumbing ✅ +Zero out-of-pocket; everything config-flip-ready but DISABLED/organic. +2398 → **2439 tests** (209 suites), web build exit 0. +- **BOOK IT deep links** — `web/src/lib/bookLinks.js` + `affiliateConfig.js` + (every book `enabled:false`; Impact/Partnerize param shapes documented). + StatStrip BOOK IT is a real anchor now (organic); scan hand-off links moved + to the builder. Every book anchor renders `rel="sponsored noopener noreferrer"`. +- **Best-price marker** — `slateAdapter.detectBestBook` (≥2 books, SAME line, + differing prices, else null) + green dot in StatStrip; `books[]` (the grouped + odds rows) now threads Slate → strips instead of being discarded by pickLine. +- **Partner refs** — `?ref=CODE` → 90d first-party `vyndr_ref` cookie + (first-touch, `PartnerRefCapture` in layout) → signup metadata `partner_ref` + → internal `GET /api/partners/report/:code` (zeros + note until the TODO + migration in docs/PARTNERS.md adds `user_profiles.partner_ref`; NOT run). +- **docs/PARTNERS.md** — ref link spec, Stripe promo-code mapping convention + (partner code == promotion code), TODO migration SQL. ## Current Phase SHIP BUILD v59.0 — Overnight session: ledger team/opponent addendum, diff --git a/CLAUDE.md b/CLAUDE.md index 0a4ee14..d64ffb7 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -801,6 +801,32 @@ phased plan in the Session-57 conversation / BUILD-STATE Next section). amber / VALUE green / red = STALE-or-miss only), mono data surfaces, and schedule-derived waiting copy. Keep them green. +## Affiliate + Partner Plumbing (A1 S3 — non-obvious) +- **Every sportsbook link goes through `web/src/lib/bookLinks.js`** + (`buildBookLink` → `{url, tracking}`; unknown book → null). The affiliate + layer is `web/src/lib/affiliateConfig.js` — ALL books `enabled:false` (no + program approved); flipping a book + filling params produces tracked URLs + with NO component changes. Empty param values are SKIPPED (a half-filled + config degrades to organic, never a fabricated id). Anchors MUST render + `rel={BOOK_LINK_REL}` ("sponsored noopener noreferrer") — tests assert it. +- **Best-price dot honesty rule:** `slateAdapter.detectBestBook(rows, side, + refLine)` returns a book ONLY when ≥2 books post the SAME line for the side + and prices differ — never compare odds across different lines, never mark a + lone price "best". The per-book rows reach the browser as `lines[]` on each + grouped prop; `Slate.groupByGame` now threads them as `books` on PropRowProp + (pickLine still picks the single displayed line). +- **Partner attribution:** `?ref=CODE` → `vyndr_ref` cookie (90d, FIRST-touch, + `lib/partnerRef.js` + `PartnerRefCapture` in layout) → `signUp` metadata + `partner_ref` → internal `GET /api/partners/report/:code`. The + `user_profiles.partner_ref` column does NOT exist yet — the endpoint returns + zeros + note until the TODO migration in `docs/PARTNERS.md` runs (metadata + lands in auth.users.raw_user_meta_data, which PostgREST can't query). OAuth + signups carry no metadata (known gap). Partner code == Stripe promotion code, + verbatim (docs/PARTNERS.md §3). +- **Worktree build gotcha:** `web/node_modules` isn't shared into git worktrees + and Turbopack REJECTS a symlink pointing outside the project root — use + `cp -al` (hardlink copy) from the main repo's web/node_modules. + ## Active Skills - vyndr-voice (all user-facing output) - prop-analysis (grading methodology) diff --git a/docs/PARTNERS.md b/docs/PARTNERS.md new file mode 100644 index 0000000..8e88df4 --- /dev/null +++ b/docs/PARTNERS.md @@ -0,0 +1,119 @@ +# VYNDR Partner + Affiliate Plumbing (A1 Session 3) + +Everything here ships DISABLED / organic by default. No affiliate program is +approved yet; nothing spends money and no link carries tracking params until an +operator flips config. Data semantics apply to attribution too — the report +endpoint returns zeros before it returns invented numbers. + +## 1. Partner ref links + +A partner shares: + +``` +https://vyndr.app/?ref=CODE +``` + +- `CODE` — A-Z 0-9 dash underscore, max 32 chars, case-insensitive + (canonicalized to UPPERCASE everywhere). +- On first visit `PartnerRefCapture` (mounted in the layout) sets the + first-party cookie `vyndr_ref` for 90 days (SameSite=Lax, Path=/). +- Attribution is FIRST TOUCH: an existing cookie is never overwritten. +- On email/password signup, `AuthContext.signUp` forwards the cookie value + into Supabase signup metadata as `partner_ref` + (`auth.users.raw_user_meta_data.partner_ref`). +- Known limitation: OAuth signups (Google, etc.) do not carry signup + metadata — those signups are not attributed yet. + +## 2. Partner report endpoint (internal) + +``` +GET /api/partners/report/:code +Header: x-internal-key: $VYNDR_INTERNAL_KEY +``` + +Response: + +```json +{ "ok": true, "code": "CODE", "signups": 0, "conversions": 0, "mrr_attributed": 0 } +``` + +- `signups` — user_profiles rows with `partner_ref = CODE` +- `conversions` — those currently `subscription_status = 'active'` on a paid tier +- `mrr_attributed` — sum of the real monthly price per active profile + (analyst 19.99 / 14.99 founder, desk 49.99 / 34.99 founder) + +### TODO migration (documented, NOT run) + +`user_profiles` has no `partner_ref` column today, and signup metadata lives in +`auth.users.raw_user_meta_data`, which PostgREST cannot query. Until the +migration below is applied, the endpoint returns zeros with a `note`. Once +applied, real numbers flow with no code change. + +```sql +-- TODO migration: 021_partner_ref.sql (do not run until reviewed) +alter table public.user_profiles + add column if not exists partner_ref text; + +create index if not exists idx_user_profiles_partner_ref + on public.user_profiles(partner_ref) + where partner_ref is not null; + +-- Copy the signup metadata field on profile creation +-- (extend the existing handle_new_user trigger): +create or replace function public.handle_new_user() +returns trigger as $$ +begin + insert into public.user_profiles (id, email, partner_ref) + values ( + new.id, + new.email, + upper(nullif(new.raw_user_meta_data->>'partner_ref', '')) + ) + on conflict (id) do nothing; + return new; +end; +$$ language plpgsql security definer; + +-- One-time backfill for signups that predate the column: +update public.user_profiles p +set partner_ref = upper(u.raw_user_meta_data->>'partner_ref') +from auth.users u +where u.id = p.id + and p.partner_ref is null + and nullif(u.raw_user_meta_data->>'partner_ref', '') is not null; +``` + +Note: migration 004 (`referral_codes` / `referral_conversions`) is the separate +USER-referral system (codes owned by auth users). Partner codes are external +strings and deliberately do not depend on those tables. + +## 3. Stripe promo-code mapping convention + +One code, three systems, zero lookup tables: + +- The partner's ref code IS their Stripe promotion code, verbatim. + Partner "HOOPSPOD" → `?ref=HOOPSPOD` link → Stripe promotion code + `HOOPSPOD` on checkout. +- Codes are created in Stripe as promotion codes on a shared partner coupon; + set promotion code metadata `partner: ` so Stripe reporting can be + cross-checked against `/api/partners/report/:code`. +- Cookie-attributed signups and promo-code redemptions are reconciled by the + shared code string — a signup that used the promo but arrived without the + cookie still attributes through Stripe's side. +- Founder codes (VYNDR, BETONBLK, `FOUNDER_CODE_EXPIRY` in stripeService) are + NOT partner codes — do not issue a partner code that collides with a founder + code. + +## 4. Sportsbook affiliate config (BOOK IT deep links) + +- `web/src/lib/bookLinks.js` builds every sportsbook deep link (StatStrip + BOOK IT + scan hand-off). Organic shape: `https://{host}/?search={player}`. +- `web/src/lib/affiliateConfig.js` is the single flip point: per book + `{ enabled: false, params: {} }`. Param shapes (Impact: irclickid / + sharedid / wpsrc; Partnerize-style: btag / afid / siteid) are documented in + that file. Empty param values are skipped — a half-filled config degrades to + organic, never a fabricated id. +- Every book anchor renders `rel="sponsored noopener noreferrer"` + (`BOOK_LINK_REL`) — required for affiliate compliance, harmless organic. +- BetRivers accepts an optional two-letter `state` for its state subdomain + (`mi.betrivers.com`); anything else falls back to `www`. diff --git a/specs/a1-s3-affiliate-partner-plumbing.md b/specs/a1-s3-affiliate-partner-plumbing.md new file mode 100644 index 0000000..90fd41c --- /dev/null +++ b/specs/a1-s3-affiliate-partner-plumbing.md @@ -0,0 +1,72 @@ +# A1 Session 3 — Affiliate + Partnership Plumbing + +## Operating constraints +- ZERO out-of-pocket. No affiliate program is approved yet — everything ships + config-flip-ready but DISABLED / organic by default. +- Data semantics: never fabricate market values. A "best price" claim renders + only when genuinely multiple books post the same line — absent beats wrong. +- No exclamation points in any copy (VOICE v1.1). + +## 1. BOOK IT deep-link builder +- `web/src/lib/bookLinks.js` (CommonJS, unit-testable) + - `SUPPORTED_BOOKS`: draftkings, fanduel, betmgm, caesars, betrivers + (hosts sourced from the existing SPORTSBOOKS list in scan/page.tsx + + lib/books.js brand map). + - `buildBookLink({ book, player, sport, state? }, config?) → { url, tracking } | null` + - Unknown book → null (absent beats wrong). + - Organic base: `https://{host}/?search={player}` (existing deep-link shape). + - Affiliate layer: reads `web/src/lib/affiliateConfig.js` — per book + `{ enabled: false, params: {} }` with Impact/Partnerize-style param + shapes documented in comments (irclickid / sharedid / afid / btag). + - `tracking: true` only when the book is enabled AND at least one + non-empty param was appended. Disabled → clean organic link. + - `BOOK_LINK_REL = 'sponsored noopener noreferrer'` — every book anchor + renders this rel. +- Wiring: + - `StatStrip.tsx` BookItTeaser → real `` deep link (best-price book when + known, else the prop's book, else DraftKings). Organic until config flips. + - `scan/page.tsx` sportsbook hand-off anchors → `buildBookLink` + + `BOOK_LINK_REL` (replaces the local SPORTSBOOKS/deepLink pair). + +## 2. Best-line highlight (cheap version) +- Finding (documented in the session report): the grouped odds proxy already + ships per-book rows to the browser as `lines[]` per player+stat; + `Slate.groupByGame`/`pickLine` collapse to ONE line and discard the rest. +- Ship: thread `books` (the raw `lines[]`) onto each PropRowProp; + `slateAdapter.detectBestBook(rows, side, refLine)` returns + `{ book, odds }` ONLY when ≥2 books post the SAME line for the side and + prices differ; `buildPlayerStripsFromProps` attaches `bestBook` + `book` + to each strip prop; StatStrip renders a subtle signal-green dot + (one meaning: best available price). + +## 3. Partner ref system +- (a) `web/src/lib/partnerRef.js` (CommonJS) — parse `?ref=CODE`, sanitize + (A–Z 0–9 - _, ≤32, uppercased), first-party cookie `vyndr_ref` (90d, + SameSite=Lax, Path=/), first-touch (never overwrites an existing cookie). + `components/vyndr/PartnerRefCapture.tsx` mounted in the layout + (GlobalHosts pattern). +- (b) `src/routes/partners.js` — `GET /api/partners/report/:code` behind + `requireInternalAuth` → `{ signups, conversions, mrr_attributed }` read + from `user_profiles` filtered by `partner_ref`. The column does NOT exist + yet: the endpoint degrades to zeros + a note, and the TODO migration is + documented in docs/PARTNERS.md (NOT run). +- (c) Signup: `AuthContext.signUp` forwards the `vyndr_ref` cookie into + Supabase signup metadata as `partner_ref`. +- (d) `docs/PARTNERS.md` — Stripe promo-code mapping convention + the + TODO migration. + +## Acceptance criteria +- All book links organic by default; flipping `enabled` + params in + affiliateConfig produces a tracked URL with the params appended. +- Best-price dot never renders on single-book or unequal-line data. +- `?ref=CODE` first visit sets `vyndr_ref` for 90 days; signup metadata + carries `partner_ref`; report endpoint 401s without the internal key. +- Full `npx jest` green; `cd web && npx next build` exit 0. + +## Test plan +- tests/unit/bookLinks.test.js — organic default, enabled param shapes, + aliases, betrivers state, unknown book, rel constant + source assertions. +- tests/unit/slateAdapterBestBook.test.js — detectBestBook honesty rules + + strip attachment. +- tests/unit/partnerRef.test.js — sanitize/parse/cookie/first-touch. +- tests/unit/partnersRoute.test.js — report math + internal-auth mount. diff --git a/src/app.js b/src/app.js index 5d0f905..eacb9e7 100644 --- a/src/app.js +++ b/src/app.js @@ -187,6 +187,9 @@ app.use('/api/content', contentRoutes); // the public surface; the Next.js admin route proxies through with // the key kept server-side. app.use('/api/internal', internalRoutes); +// A1 S3 — partner attribution report. Internal-key gated (router-level +// requireInternalAuth); no Next proxy on purpose — never browser-facing. +app.use('/api/partners', require('./routes/partners')); // Session 10 — Sentry's Express error handler catches uncaught // errors from every route mounted above. Must come AFTER routes but diff --git a/src/routes/partners.js b/src/routes/partners.js new file mode 100644 index 0000000..a08eef8 --- /dev/null +++ b/src/routes/partners.js @@ -0,0 +1,117 @@ +'use strict'; + +/** + * Partner attribution report (A1 Session 3). + * + * GET /api/partners/report/:code — internal-only (requireInternalAuth, + * same key as the snapshot pipeline; never browser-reachable, no Next + * proxy on purpose). Returns per-partner attribution: + * + * { ok, code, signups, conversions, mrr_attributed, note? } + * + * Data source: public.user_profiles filtered by `partner_ref` — the + * code a signup carried in its metadata (set by the web PartnerRefCapture + * → AuthContext.signUp flow). + * + * IMPORTANT — the `partner_ref` column does NOT exist on user_profiles + * yet. Signup metadata lands in auth.users.raw_user_meta_data, which + * PostgREST can't query. Until the TODO migration in docs/PARTNERS.md + * is applied (adds the column + copies it from the signup metadata in + * handle_new_user), the select errors and this endpoint HONESTLY + * degrades to zeros with a `note` — it never fabricates attribution. + * Once the migration runs, real numbers flow with no code change here. + * + * MRR attribution uses the REAL tier prices (founder_pricing-aware) on + * currently-active paid profiles. It is monthly recurring revenue at + * today's price book — not lifetime value, not a projection. + */ + +const express = require('express'); +const { requireInternalAuth } = require('../middleware/internalAuth'); +const { getSupabaseServiceClient } = require('../utils/supabase'); + +const router = express.Router(); + +router.use(requireInternalAuth({ loopbackOnly: false })); + +// Tier price book (matches stripeService / pricing page). +const TIER_MRR = { + analyst: { standard: 19.99, founder: 14.99 }, + desk: { standard: 49.99, founder: 34.99 }, +}; + +/** Same convention as web/src/lib/partnerRef.js — A-Z 0-9 - _, ≤32, uppercase. */ +function sanitizePartnerCode(raw) { + const s = String(raw == null ? '' : raw).trim().toUpperCase(); + if (!s || s.length > 32) return null; + return /^[A-Z0-9_-]+$/.test(s) ? s : null; +} + +/** + * Pure report math over user_profiles rows + * ({ tier, subscription_status, founder_pricing }). + * signups — every profile attributed to the code + * conversions — profiles currently on an active paid tier + * mrr_attributed — sum of those profiles' monthly price (founder-aware) + */ +function buildPartnerReport(rows) { + const list = Array.isArray(rows) ? rows : []; + let conversions = 0; + let mrr = 0; + for (const r of list) { + if (!r) continue; + const tier = String(r.tier || 'free').toLowerCase(); + const active = String(r.subscription_status || '') === 'active'; + const prices = TIER_MRR[tier]; + if (!prices || !active) continue; + conversions += 1; + mrr += r.founder_pricing === true ? prices.founder : prices.standard; + } + return { + signups: list.length, + conversions, + mrr_attributed: Math.round(mrr * 100) / 100, + }; +} + +// Injectable for tests (no network / no Supabase env needed). +let _getClient = getSupabaseServiceClient; +function _setClientForTests(fn) { + _getClient = typeof fn === 'function' ? fn : getSupabaseServiceClient; +} + +router.get('/report/:code', async (req, res) => { + const code = sanitizePartnerCode(req.params.code); + if (!code) { + return res.status(400).json({ ok: false, error: 'invalid partner code' }); + } + try { + const supabase = _getClient(); + const { data, error } = await supabase + .from('user_profiles') + .select('tier, subscription_status, founder_pricing') + .eq('partner_ref', code); + + if (error) { + // Column not migrated yet (docs/PARTNERS.md TODO) — zeros, never invented. + return res.json({ + ok: true, + code, + signups: 0, + conversions: 0, + mrr_attributed: 0, + note: 'partner_ref is not queryable yet — apply the TODO migration in docs/PARTNERS.md to activate attribution', + }); + } + return res.json({ ok: true, code, ...buildPartnerReport(data) }); + } catch (err) { + const message = err && err.message ? err.message : String(err); + console.error('[partners/report] failed:', message); + return res.status(500).json({ ok: false, error: message }); + } +}); + +module.exports = router; +module.exports.sanitizePartnerCode = sanitizePartnerCode; +module.exports.buildPartnerReport = buildPartnerReport; +module.exports._setClientForTests = _setClientForTests; diff --git a/tests/unit/bookItTeaser.test.js b/tests/unit/bookItTeaser.test.js index 666fd2a..fbeec23 100644 --- a/tests/unit/bookItTeaser.test.js +++ b/tests/unit/bookItTeaser.test.js @@ -1,20 +1,26 @@ -// Session 52 — Push-to-Book "Coming Soon" teaser (source-asserted). +// Session 52 shipped BOOK IT as a "coming soon" teaser. A1 Session 3 +// made it a REAL sportsbook deep link (organic until affiliateConfig +// flips a book on). GradeResultCard's PUSH-TO-BOOK (account linking / +// bet-slip push) remains a genuine teaser — that feature is unbuilt. const fs = require('fs'); const path = require('path'); const WEB = path.join(__dirname, '..', '..', 'web', 'src'); const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8'); -describe('BOOK IT teaser', () => { - it('StatStrip renders the "BOOK IT" teaser on graded props', () => { +describe('BOOK IT deep link (was the S52 teaser)', () => { + it('StatStrip renders BOOK IT as a real anchor built by bookLinks', () => { const src = read('components/vyndr/StatStrip.tsx'); expect(src).toContain('BookItTeaser'); expect(src).toContain('BOOK IT ⟶'); - expect(src).toContain('Push-to-Book coming soon'); expect(src).toContain(''); + expect(src).toContain('buildBookLink({ book: target, player, sport })'); + expect(src).toContain('rel={BOOK_LINK_REL}'); + // the dead-span teaser copy is gone + expect(src).not.toContain('Push-to-Book coming soon'); }); - it('GradeResultCard renders "PUSH-TO-BOOK · COMING SOON"', () => { + it('GradeResultCard still renders the honest "PUSH-TO-BOOK · COMING SOON" teaser', () => { const src = read('components/vyndr/GradeResultCard.tsx'); expect(src).toContain('PUSH-TO-BOOK · COMING SOON'); expect(src).toContain('Connect DraftKings, FanDuel, BetMGM'); diff --git a/tests/unit/bookLinks.test.js b/tests/unit/bookLinks.test.js new file mode 100644 index 0000000..da6387c --- /dev/null +++ b/tests/unit/bookLinks.test.js @@ -0,0 +1,114 @@ +// A1 Session 3 — BOOK IT deep-link builder. Organic by default; the +// affiliate layer only fires when a book is explicitly enabled with +// real params. Every anchor renders rel="sponsored noopener noreferrer". + +const fs = require('fs'); +const path = require('path'); +const { buildBookLink, resolveBookId, SUPPORTED_BOOKS, BOOK_LINK_REL } = require('../../web/src/lib/bookLinks'); +const { AFFILIATE_CONFIG } = require('../../web/src/lib/affiliateConfig'); + +const WEB = path.join(__dirname, '..', '..', 'web', 'src'); +const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8'); + +describe('buildBookLink — organic by default', () => { + it('builds a clean organic link for every supported book (default config)', () => { + for (const b of SUPPORTED_BOOKS) { + const out = buildBookLink({ book: b.id, player: 'Aaron Judge', sport: 'mlb' }); + expect(out).not.toBeNull(); + expect(out.tracking).toBe(false); + expect(out.url).toContain(`https://${b.host}/`); + expect(out.url).toContain('search=Aaron+Judge'); + // no affiliate params leak into an organic link + expect(out.url).not.toMatch(/irclickid|sharedid|btag|afid|siteid/); + } + }); + + it('ships EVERY book disabled in the checked-in config', () => { + for (const b of SUPPORTED_BOOKS) { + expect(AFFILIATE_CONFIG[b.id]).toBeDefined(); + expect(AFFILIATE_CONFIG[b.id].enabled).toBe(false); + } + }); + + it('resolves book aliases (DK / DraftKings / draftkings)', () => { + expect(resolveBookId('DK')).toBe('draftkings'); + expect(resolveBookId('DraftKings')).toBe('draftkings'); + expect(resolveBookId('fanduel')).toBe('fanduel'); + expect(resolveBookId('MGM')).toBe('betmgm'); + expect(resolveBookId('CZR')).toBe('caesars'); + expect(resolveBookId('BR')).toBe('betrivers'); + }); + + it('returns null for unknown books and missing player (absent beats wrong)', () => { + expect(buildBookLink({ book: 'bovada', player: 'Aaron Judge' })).toBeNull(); + expect(buildBookLink({ book: 'prizepicks', player: 'Aaron Judge' })).toBeNull(); + expect(buildBookLink({ book: 'draftkings', player: '' })).toBeNull(); + expect(buildBookLink({})).toBeNull(); + }); + + it('betrivers routes by two-letter state subdomain, else www', () => { + const mi = buildBookLink({ book: 'betrivers', player: 'Judge', state: 'MI' }); + expect(mi.url).toContain('https://mi.betrivers.com/'); + const bad = buildBookLink({ book: 'betrivers', player: 'Judge', state: 'Michigan' }); + expect(bad.url).toContain('https://www.betrivers.com/'); + const none = buildBookLink({ book: 'betrivers', player: 'Judge' }); + expect(none.url).toContain('https://www.betrivers.com/'); + }); +}); + +describe('buildBookLink — affiliate layer (config-flip)', () => { + const enabled = { + draftkings: { enabled: true, params: { irclickid: 'IRC123', sharedid: 'vyndr', wpsrc: 'vyndr-slate' } }, + betmgm: { enabled: true, params: { btag: 'BT-42', afid: '' } }, + caesars: { enabled: false, params: { btag: 'SHOULD-NOT-APPEAR' } }, + }; + + it('appends Impact-style params and reports tracking when enabled', () => { + const out = buildBookLink({ book: 'draftkings', player: 'Aaron Judge' }, enabled); + expect(out.tracking).toBe(true); + expect(out.url).toContain('irclickid=IRC123'); + expect(out.url).toContain('sharedid=vyndr'); + expect(out.url).toContain('wpsrc=vyndr-slate'); + expect(out.url).toContain('search=Aaron+Judge'); + }); + + it('skips empty param values (never emits a fabricated id)', () => { + const out = buildBookLink({ book: 'betmgm', player: 'Judge' }, enabled); + expect(out.tracking).toBe(true); + expect(out.url).toContain('btag=BT-42'); + expect(out.url).not.toContain('afid='); + }); + + it('enabled with ONLY empty params stays organic (tracking false)', () => { + const out = buildBookLink({ book: 'fanduel', player: 'Judge' }, { fanduel: { enabled: true, params: { irclickid: ' ' } } }); + expect(out.tracking).toBe(false); + expect(out.url).not.toContain('irclickid'); + }); + + it('disabled book with params configured stays organic', () => { + const out = buildBookLink({ book: 'caesars', player: 'Judge' }, enabled); + expect(out.tracking).toBe(false); + expect(out.url).not.toContain('SHOULD-NOT-APPEAR'); + }); +}); + +describe('rel="sponsored noopener noreferrer" on every book anchor', () => { + it('exports the exact rel contract', () => { + expect(BOOK_LINK_REL).toBe('sponsored noopener noreferrer'); + }); + + it('StatStrip BOOK IT anchor uses buildBookLink + BOOK_LINK_REL', () => { + const src = read('components/vyndr/StatStrip.tsx'); + expect(src).toContain('buildBookLink'); + expect(src).toContain('rel={BOOK_LINK_REL}'); + expect(src).toContain('BOOK IT ⟶'); + }); + + it('scan page hand-off anchors use buildBookLink + BOOK_LINK_REL', () => { + const src = read('app/scan/page.tsx'); + expect(src).toContain('SUPPORTED_BOOKS'); + expect(src).toContain('rel={BOOK_LINK_REL}'); + // the old hardcoded organic builder is gone + expect(src).not.toContain('const deepLink ='); + }); +}); diff --git a/tests/unit/partnerRef.test.js b/tests/unit/partnerRef.test.js new file mode 100644 index 0000000..5ffcdb7 --- /dev/null +++ b/tests/unit/partnerRef.test.js @@ -0,0 +1,94 @@ +// A1 Session 3 — partner ref capture: ?ref=CODE → first-party vyndr_ref +// cookie (90d, first-touch) → signup metadata partner_ref. + +const fs = require('fs'); +const path = require('path'); +const { + REF_COOKIE, + sanitizeRefCode, + parseRefFromSearch, + readRefCookie, + buildRefCookie, + captureRef, +} = require('../../web/src/lib/partnerRef'); + +const WEB = path.join(__dirname, '..', '..', 'web', 'src'); +const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8'); + +describe('sanitizeRefCode', () => { + it('uppercases and accepts A-Z 0-9 - _', () => { + expect(sanitizeRefCode('hoopspod')).toBe('HOOPSPOD'); + expect(sanitizeRefCode(' the-wire_22 ')).toBe('THE-WIRE_22'); + }); + it('rejects empty, too-long, and unsafe codes', () => { + expect(sanitizeRefCode('')).toBeNull(); + expect(sanitizeRefCode(null)).toBeNull(); + expect(sanitizeRefCode('a'.repeat(33))).toBeNull(); + expect(sanitizeRefCode('bad code')).toBeNull(); + expect(sanitizeRefCode('