b6787af191
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>
142 lines
5.8 KiB
JavaScript
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();
|
|
});
|
|
});
|