Files
vyndr/tests/unit/mlbNamesakeResolve.test.js
T
builtbykev b6787af191 Wave 1: kill three trust bugs (billing renewal + namesake collision + Desk copy)
FIX 1 — Honest billing renewal render. VYNDR tiers are monthly, so a
`subscription_end` far in the future (the manually-seeded "RENEWS 6/9/2036"
founder row) is a comped/lifetime/seed value, not a renewal. New
web/src/lib/billingDisplay.js `classifyRenewal()` → date | none | lapsed |
unknown (strict Date.parse guard, MONTHLY_RENEWAL_MAX_DAYS=60). Profile page
renders the classified label for both the "Renews" stat and the
cancel-scheduled "Access ends" line — no raw far-future date. No DB row mutated.

FIX 2 — MLB namesake collision (James Wood → "Chicago Cubs"). searchPlayer now
collects ALL exact-nameKey matches instead of first-`.find`; a ≥2 collision
resolves ONLY via a confident teamHint (the prop's game participants, matched
against the cached /teams list with ESPN↔statsapi abbr reconciliation), else
refuses (null) — never guesses. The hint threads getPlayerStats →
resolvePlayerStats → snapshotService (built from each prop's home/away team).
Join invariant: a single-exact player whose team isn't in the hinted game has
its team DROPPED (null), so streaks/rosterlogs never tag a foreign team. Full
teamHint recovery shipped (not just the refuse fallback).

FIX 3 — DeskShowcase headline "A $1M terminal." → deadpan value-showing copy
"Every grade, every alt line, live." Prices ($44.99 / $34.99) unchanged.

Tests: billingDisplay.test.js (7), mlbNamesakeResolve.test.js (12,
disambiguation + join invariant + pure helpers), ds5PricingStates updated to
assert the new headline and no "$1M". Full suite green (237 suites / 2863
tests); web `next build` exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 03:48:58 -04:00

142 lines
5.8 KiB
JavaScript

// Wave 1 trust bug — MLB namesake collision. Two different players can share an
// EXACT nameKey ("James Wood": the Nationals star + a Cubs-affiliate namesake).
// The old `.find` took the first → wrong currentTeam → wrong opponents → "built
// vs AL East" fabrication in streaks/rosterlogs. Doctrine: never guess among
// namesakes — resolve only with a confident team hint, else refuse (null).
//
// Hermetic: `searchPlayer` accepts injected `people`/`teams` fixtures — no
// network, no redis.
const mlb = require('../../src/services/adapters/mlbStatsAdapter');
const { teamRecordMatchesHint, disambiguateByHint, canonAbbr } = mlb.__internals;
// statsapi-shaped fixtures.
const JUDGE = {
id: 592450, fullName: 'Aaron Judge',
currentTeam: { id: 147, name: 'New York Yankees' },
primaryPosition: { abbreviation: 'RF' },
};
const WOOD_NATIONALS = {
id: 691026, fullName: 'James Wood',
currentTeam: { id: 120, name: 'Washington Nationals' },
primaryPosition: { abbreviation: 'LF' },
};
const WOOD_CUBS = {
id: 999999, fullName: 'James Wood',
currentTeam: { id: 112, name: 'Chicago Cubs' },
primaryPosition: { abbreviation: 'P' },
};
const SOTO_METS = {
id: 665742, fullName: 'Juan Soto',
currentTeam: { id: 121, name: 'New York Mets' },
primaryPosition: { abbreviation: 'RF' },
};
const TEAMS = [
{ id: 147, abbr: 'NYY', name: 'New York Yankees' },
{ id: 120, abbr: 'WSH', name: 'Washington Nationals' },
{ id: 112, abbr: 'CHC', name: 'Chicago Cubs' },
{ id: 146, abbr: 'MIA', name: 'Miami Marlins' },
{ id: 121, abbr: 'NYM', name: 'New York Mets' },
{ id: 119, abbr: 'LAD', name: 'Los Angeles Dodgers' },
{ id: 135, abbr: 'SD', name: 'San Diego Padres' },
];
describe('searchPlayer — namesake disambiguation (never guess)', () => {
test('(a) a single unambiguous name resolves (Aaron Judge → Yankees)', async () => {
const r = await mlb.searchPlayer('Aaron Judge', 2026, { people: [JUDGE], teams: TEAMS });
expect(r).not.toBeNull();
expect(r.id).toBe(592450);
expect(r.team).toBe('New York Yankees');
});
test('(b) two "James Wood" with NO hint → null (NEVER the Cubs one)', async () => {
const r = await mlb.searchPlayer('James Wood', 2026, { people: [WOOD_NATIONALS, WOOD_CUBS], teams: TEAMS });
expect(r).toBeNull(); // refuse — honest absent beats wrong
});
test('(c) two "James Wood" + a Nationals-game hint → the Nationals Wood', async () => {
const r = await mlb.searchPlayer('James Wood', 2026, {
people: [WOOD_CUBS, WOOD_NATIONALS], // Cubs first — the old bug picked this
teams: TEAMS,
teamHint: ['WSH', 'MIA'], // Nationals @ Marlins
});
expect(r).not.toBeNull();
expect(r.id).toBe(691026);
expect(r.team).toBe('Washington Nationals');
expect(r.team).not.toBe('Chicago Cubs');
});
test('(c2) a full-team-name hint disambiguates too', async () => {
const r = await mlb.searchPlayer('James Wood', 2026, {
people: [WOOD_CUBS, WOOD_NATIONALS],
teams: TEAMS,
teamHint: ['Washington Nationals', 'Miami Marlins'],
});
expect(r.id).toBe(691026);
});
test('(c3) two namesakes + a hint matching NEITHER → null (still refuse)', async () => {
const r = await mlb.searchPlayer('James Wood', 2026, {
people: [WOOD_NATIONALS, WOOD_CUBS],
teams: TEAMS,
teamHint: ['LAD', 'SD'],
});
expect(r).toBeNull();
});
});
// JOIN INVARIANT (streaks/rosterlogs) — a resolved player's team must be a
// participant of the prop's game; on mismatch the team is DROPPED, never a
// foreign tag. This is the mechanism snapshotService relies on before it writes
// teamByPlayer + rosterlogs opponents.
describe('searchPlayer — team join invariant (drop, never tag foreign)', () => {
test('a single player whose team is NOT in the hinted game → team dropped to null', async () => {
const r = await mlb.searchPlayer('Juan Soto', 2026, {
people: [SOTO_METS],
teams: TEAMS,
teamHint: ['LAD', 'SD'], // Soto (Mets) is in neither → cannot confirm join
});
expect(r).not.toBeNull(); // still the right player by name
expect(r.id).toBe(665742);
expect(r.team).toBeNull(); // but his team is not tagged onto a foreign game
expect(r.teamId).toBeNull();
});
test('a single player whose team IS in the hinted game keeps its team', async () => {
const r = await mlb.searchPlayer('Juan Soto', 2026, {
people: [SOTO_METS],
teams: TEAMS,
teamHint: ['NYM', 'LAD'], // Mets @ Dodgers → confirmed
});
expect(r.team).toBe('New York Mets');
});
test('with NO hint, a single player keeps its authoritative team', async () => {
const r = await mlb.searchPlayer('Juan Soto', 2026, { people: [SOTO_METS], teams: TEAMS });
expect(r.team).toBe('New York Mets');
});
});
describe('pure helpers — abbr reconciliation + hint matching', () => {
test('ESPN↔statsapi abbr aliases canonicalize (AZ↔ARI, CHW↔CWS)', () => {
expect(canonAbbr('AZ')).toBe(canonAbbr('ARI'));
expect(canonAbbr('CHW')).toBe(canonAbbr('CWS'));
expect(canonAbbr('nyy')).toBe('NYY');
});
test('teamRecordMatchesHint matches by abbr via the teams list', () => {
expect(teamRecordMatchesHint({ id: 120, name: 'Washington Nationals' }, ['WSH'], TEAMS)).toBe(true);
expect(teamRecordMatchesHint({ id: 120, name: 'Washington Nationals' }, ['CHC'], TEAMS)).toBe(false);
});
test('teamRecordMatchesHint matches by partial name', () => {
expect(teamRecordMatchesHint({ id: 120, name: 'Washington Nationals' }, ['Nationals'], TEAMS)).toBe(true);
});
test('disambiguateByHint refuses when 2 candidates share the hinted team', () => {
const dupe = [WOOD_NATIONALS, { ...WOOD_CUBS, currentTeam: { id: 120, name: 'Washington Nationals' } }];
expect(disambiguateByHint(dupe, ['WSH'], TEAMS)).toBeNull();
});
});