S3 (a1): affiliate + partner plumbing
Zero out-of-pocket; everything config-flip-ready but DISABLED/organic. - BOOK IT deep links: web/src/lib/bookLinks.js + affiliateConfig.js (all books enabled:false, Impact/Partnerize param shapes documented, empty params skipped). Wired into StatStrip BookItTeaser (real anchor now) + scan hand-off links. Every book anchor renders rel="sponsored noopener noreferrer" (BOOK_LINK_REL). - Best-price marker: slateAdapter.detectBestBook (only when >=2 books post the SAME line and prices differ — absent beats wrong) + subtle signal-green dot in StatStrip. Slate.groupByGame threads the grouped per-book rows (books[]) onto PropRowProp instead of discarding them. - Partner refs: ?ref=CODE -> vyndr_ref cookie (90d, first-touch, PartnerRefCapture in layout) -> signup metadata partner_ref -> internal GET /api/partners/report/:code (requireInternalAuth; honest zeros + note until the TODO migration in docs/PARTNERS.md adds user_profiles.partner_ref — NOT run). Stripe promo-code convention: partner code == promotion code, verbatim. - Tests: +41 (2398 -> 2439, 209 suites); bookItTeaser + vyndrCoreScreens invariants updated to the new (stronger) rel contract. Web build exit 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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('<BookItTeaser p={p} />');
|
||||
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');
|
||||
|
||||
@@ -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 =');
|
||||
});
|
||||
});
|
||||
@@ -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('<script>')).toBeNull();
|
||||
expect(sanitizeRefCode('a;b')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRefFromSearch / readRefCookie / buildRefCookie', () => {
|
||||
it('parses ?ref= out of a search string', () => {
|
||||
expect(parseRefFromSearch('?ref=hoopspod&utm_source=x')).toBe('HOOPSPOD');
|
||||
expect(parseRefFromSearch('?utm_source=x')).toBeNull();
|
||||
expect(parseRefFromSearch('')).toBeNull();
|
||||
});
|
||||
it('builds a 90-day first-party cookie string', () => {
|
||||
const c = buildRefCookie('hoopspod');
|
||||
expect(c).toContain(`${REF_COOKIE}=HOOPSPOD`);
|
||||
expect(c).toContain(`Max-Age=${90 * 24 * 60 * 60}`);
|
||||
expect(c).toContain('Path=/');
|
||||
expect(c).toContain('SameSite=Lax');
|
||||
expect(c).not.toContain('Secure');
|
||||
expect(buildRefCookie('hoopspod', { secure: true })).toContain('; Secure');
|
||||
expect(buildRefCookie('bad code')).toBeNull();
|
||||
});
|
||||
it('reads the cookie back out of a document.cookie string', () => {
|
||||
expect(readRefCookie('foo=1; vyndr_ref=HOOPSPOD; bar=2')).toBe('HOOPSPOD');
|
||||
expect(readRefCookie('foo=1; bar=2')).toBeNull();
|
||||
expect(readRefCookie('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('captureRef — first touch wins', () => {
|
||||
const fakeWindow = (search, cookie = '') => {
|
||||
const doc = { cookie };
|
||||
return { location: { search, protocol: 'https:' }, document: doc };
|
||||
};
|
||||
|
||||
it('sets the cookie on a first visit with ?ref=', () => {
|
||||
const w = fakeWindow('?ref=hoopspod');
|
||||
expect(captureRef(w)).toBe('HOOPSPOD');
|
||||
expect(w.document.cookie).toContain('vyndr_ref=HOOPSPOD');
|
||||
expect(w.document.cookie).toContain('; Secure'); // https origin
|
||||
});
|
||||
|
||||
it('does NOT overwrite an existing ref cookie (first touch)', () => {
|
||||
const w = fakeWindow('?ref=newpartner', 'vyndr_ref=ORIGINAL');
|
||||
expect(captureRef(w)).toBeNull();
|
||||
expect(w.document.cookie).toBe('vyndr_ref=ORIGINAL');
|
||||
});
|
||||
|
||||
it('no-ops without a ref param or without a window', () => {
|
||||
const w = fakeWindow('?utm_source=x');
|
||||
expect(captureRef(w)).toBeNull();
|
||||
expect(w.document.cookie).toBe('');
|
||||
expect(captureRef(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('wiring (source-asserted)', () => {
|
||||
it('PartnerRefCapture is mounted in the layout (GlobalHosts pattern)', () => {
|
||||
const layout = read('app/layout.tsx');
|
||||
expect(layout).toContain('<PartnerRefCapture />');
|
||||
expect(layout).toContain("from '@/components/vyndr/PartnerRefCapture'");
|
||||
});
|
||||
it('signUp forwards the cookie as partner_ref signup metadata', () => {
|
||||
const auth = read('contexts/AuthContext.tsx');
|
||||
expect(auth).toContain('readRefCookie(document.cookie)');
|
||||
expect(auth).toContain('partner_ref: partnerRef');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// A1 Session 3 — internal partner attribution report.
|
||||
// Pure report math + route behavior with an injected Supabase client.
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const partnersRouter = require('../../src/routes/partners');
|
||||
const { sanitizePartnerCode, buildPartnerReport, _setClientForTests } = partnersRouter;
|
||||
|
||||
const KEY = 'test-internal-key';
|
||||
|
||||
function makeApp() {
|
||||
const app = express();
|
||||
app.use('/api/partners', partnersRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('sanitizePartnerCode', () => {
|
||||
it('uppercases and accepts the cookie convention (A-Z 0-9 - _, ≤32)', () => {
|
||||
expect(sanitizePartnerCode('hoopspod')).toBe('HOOPSPOD');
|
||||
expect(sanitizePartnerCode('THE-WIRE_22')).toBe('THE-WIRE_22');
|
||||
});
|
||||
it('rejects unsafe input', () => {
|
||||
expect(sanitizePartnerCode('a b')).toBeNull();
|
||||
expect(sanitizePartnerCode('a'.repeat(33))).toBeNull();
|
||||
expect(sanitizePartnerCode('')).toBeNull();
|
||||
expect(sanitizePartnerCode("x';drop")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPartnerReport', () => {
|
||||
it('counts signups, active paid conversions, and founder-aware MRR', () => {
|
||||
const rows = [
|
||||
{ tier: 'free', subscription_status: 'none', founder_pricing: false },
|
||||
{ tier: 'analyst', subscription_status: 'active', founder_pricing: false }, // 19.99
|
||||
{ tier: 'analyst', subscription_status: 'active', founder_pricing: true }, // 14.99
|
||||
{ tier: 'desk', subscription_status: 'active', founder_pricing: false }, // 49.99
|
||||
{ tier: 'desk', subscription_status: 'canceled', founder_pricing: false }, // not active
|
||||
];
|
||||
expect(buildPartnerReport(rows)).toEqual({ signups: 5, conversions: 3, mrr_attributed: 84.97 });
|
||||
});
|
||||
it('empty / bad input → zeros', () => {
|
||||
expect(buildPartnerReport([])).toEqual({ signups: 0, conversions: 0, mrr_attributed: 0 });
|
||||
expect(buildPartnerReport(null)).toEqual({ signups: 0, conversions: 0, mrr_attributed: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/partners/report/:code', () => {
|
||||
const OLD_KEY = process.env.VYNDR_INTERNAL_KEY;
|
||||
beforeAll(() => { process.env.VYNDR_INTERNAL_KEY = KEY; });
|
||||
afterAll(() => {
|
||||
if (OLD_KEY === undefined) delete process.env.VYNDR_INTERNAL_KEY;
|
||||
else process.env.VYNDR_INTERNAL_KEY = OLD_KEY;
|
||||
_setClientForTests(null);
|
||||
});
|
||||
|
||||
const fakeClient = (result) => () => ({
|
||||
from: () => ({ select: () => ({ eq: () => Promise.resolve(result) }) }),
|
||||
});
|
||||
|
||||
it('401s without the internal key', async () => {
|
||||
const res = await request(makeApp()).get('/api/partners/report/HOOPSPOD');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('400s an invalid code', async () => {
|
||||
const res = await request(makeApp())
|
||||
.get('/api/partners/report/bad%20code')
|
||||
.set('x-internal-key', KEY);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns real attribution when the partner_ref column exists', async () => {
|
||||
_setClientForTests(fakeClient({
|
||||
data: [
|
||||
{ tier: 'analyst', subscription_status: 'active', founder_pricing: true },
|
||||
{ tier: 'free', subscription_status: 'none', founder_pricing: false },
|
||||
],
|
||||
error: null,
|
||||
}));
|
||||
const res = await request(makeApp())
|
||||
.get('/api/partners/report/hoopspod')
|
||||
.set('x-internal-key', KEY);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ ok: true, code: 'HOOPSPOD', signups: 2, conversions: 1, mrr_attributed: 14.99 });
|
||||
});
|
||||
|
||||
it('degrades to zeros + note when the column is not migrated yet', async () => {
|
||||
_setClientForTests(fakeClient({ data: null, error: { message: 'column user_profiles.partner_ref does not exist' } }));
|
||||
const res = await request(makeApp())
|
||||
.get('/api/partners/report/HOOPSPOD')
|
||||
.set('x-internal-key', KEY);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.signups).toBe(0);
|
||||
expect(res.body.conversions).toBe(0);
|
||||
expect(res.body.mrr_attributed).toBe(0);
|
||||
expect(res.body.note).toContain('docs/PARTNERS.md');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
// A1 Session 3 — best available price detection. Honesty rules: a best
|
||||
// price only exists when ≥2 books post the SAME line for the side and
|
||||
// their prices differ. Absent beats wrong.
|
||||
|
||||
const { detectBestBook, buildPlayerStripsFromProps } = require('../../web/src/lib/slateAdapter');
|
||||
|
||||
describe('detectBestBook', () => {
|
||||
const rows = [
|
||||
{ book: 'draftkings', line: 1.5, over_odds: -115, under_odds: -105 },
|
||||
{ book: 'fanduel', line: 1.5, over_odds: -105, under_odds: -115 },
|
||||
{ book: 'betmgm', line: 1.5, over_odds: -120, under_odds: 100 },
|
||||
];
|
||||
|
||||
it('picks the best over price at the shared line', () => {
|
||||
const out = detectBestBook(rows, 'over', 1.5);
|
||||
expect(out).toEqual({ book: 'fanduel', odds: -105 });
|
||||
});
|
||||
|
||||
it('picks the best under price independently', () => {
|
||||
const out = detectBestBook(rows, 'under', 1.5);
|
||||
expect(out).toEqual({ book: 'betmgm', odds: 100 });
|
||||
});
|
||||
|
||||
it('returns null for a single book (a lone price is not "best")', () => {
|
||||
expect(detectBestBook([rows[0]], 'over', 1.5)).toBeNull();
|
||||
expect(detectBestBook([], 'over', 1.5)).toBeNull();
|
||||
expect(detectBestBook(null, 'over', 1.5)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when all books post the identical price', () => {
|
||||
const flat = [
|
||||
{ book: 'draftkings', line: 1.5, over_odds: -110 },
|
||||
{ book: 'fanduel', line: 1.5, over_odds: -110 },
|
||||
];
|
||||
expect(detectBestBook(flat, 'over', 1.5)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when books only post DIFFERENT lines (odds are not comparable)', () => {
|
||||
const split = [
|
||||
{ book: 'draftkings', line: 1.5, over_odds: -200 },
|
||||
{ book: 'fanduel', line: 2.5, over_odds: 120 },
|
||||
];
|
||||
expect(detectBestBook(split, 'over', 1.5)).toBeNull();
|
||||
expect(detectBestBook(split, 'over', 2.5)).toBeNull();
|
||||
});
|
||||
|
||||
it('without a reference line, compares at the modal line', () => {
|
||||
const mixed = [
|
||||
{ book: 'draftkings', line: 1.5, over_odds: -115 },
|
||||
{ book: 'fanduel', line: 1.5, over_odds: -105 },
|
||||
{ book: 'betmgm', line: 2.5, over_odds: 200 },
|
||||
];
|
||||
expect(detectBestBook(mixed, 'over')).toEqual({ book: 'fanduel', odds: -105 });
|
||||
});
|
||||
|
||||
it('ignores rows with missing book / line / side odds', () => {
|
||||
const dirty = [
|
||||
{ book: 'draftkings', line: 1.5, over_odds: -115 },
|
||||
{ book: null, line: 1.5, over_odds: -100 },
|
||||
{ book: 'fanduel', line: 1.5, over_odds: null },
|
||||
{ book: 'betmgm', over_odds: -105 },
|
||||
];
|
||||
expect(detectBestBook(dirty, 'over', 1.5)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPlayerStripsFromProps — bestBook attachment', () => {
|
||||
const books = [
|
||||
{ book: 'draftkings', line: 1.5, over_odds: -115, under_odds: -105 },
|
||||
{ book: 'fanduel', line: 1.5, over_odds: -105, under_odds: -115 },
|
||||
];
|
||||
|
||||
it('attaches bestBook + book to a graded prop (graded side)', () => {
|
||||
const props = [{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', book: 'draftkings', books }];
|
||||
const gradeIndex = {
|
||||
'aaron judge|total_bases': {
|
||||
player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5,
|
||||
direction: 'over', grade: 'A', gradedAt: { line: 1.5, odds: -115, timestamp: new Date().toISOString() },
|
||||
},
|
||||
};
|
||||
const strips = buildPlayerStripsFromProps(props, gradeIndex, {});
|
||||
expect(strips).toHaveLength(1);
|
||||
const p = strips[0].props[0];
|
||||
expect(p.book).toBe('draftkings');
|
||||
expect(p.bestBook).toEqual({ book: 'fanduel', odds: -105 });
|
||||
});
|
||||
|
||||
it('attaches bestBook on awaiting props too (still real market data)', () => {
|
||||
const props = [{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'under', book: 'draftkings', books }];
|
||||
const strips = buildPlayerStripsFromProps(props, {}, {});
|
||||
const p = strips[0].props[0];
|
||||
expect(p.awaiting).toBe(true);
|
||||
expect(p.bestBook).toEqual({ book: 'draftkings', odds: -105 });
|
||||
});
|
||||
|
||||
it('leaves bestBook null when only one book posted the prop', () => {
|
||||
const props = [{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', book: 'draftkings', books: [books[0]] }];
|
||||
const strips = buildPlayerStripsFromProps(props, {}, {});
|
||||
expect(strips[0].props[0].bestBook).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves bestBook null when props carry no books array (legacy shape)', () => {
|
||||
const props = [{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', book: 'draftkings' }];
|
||||
const strips = buildPlayerStripsFromProps(props, {}, {});
|
||||
expect(strips[0].props[0].bestBook).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -150,8 +150,12 @@ describe('Phase D — scan + landing wiring', () => {
|
||||
expect(src).toContain('mapScanToGradeResult');
|
||||
expect(src).not.toContain("from '@/components/GradeCard'");
|
||||
});
|
||||
it('scan keeps sportsbook deep-links safe (noopener noreferrer)', () => {
|
||||
expect(read('app/scan/page.tsx')).toContain('noopener noreferrer');
|
||||
it('scan keeps sportsbook deep-links safe (A1 S3: BOOK_LINK_REL = sponsored noopener noreferrer)', () => {
|
||||
const src = read('app/scan/page.tsx');
|
||||
expect(src).toContain('rel={BOOK_LINK_REL}');
|
||||
// the constant itself carries noopener noreferrer (+ sponsored)
|
||||
const { BOOK_LINK_REL } = require('../../web/src/lib/bookLinks');
|
||||
expect(BOOK_LINK_REL).toContain('noopener noreferrer');
|
||||
});
|
||||
it('landing mounts the ClaimMeter', () => {
|
||||
expect(read('app/page.tsx')).toContain('ClaimMeter');
|
||||
|
||||
Reference in New Issue
Block a user