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>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
// Wave 1 trust bug — honest renewal render. classifyRenewal must never let a
|
||||
// far-future / comped `subscription_end` (the "RENEWS 6/9/2036" lie) render as
|
||||
// a real monthly renewal, and must fail closed to `unknown` on bad input.
|
||||
|
||||
const { classifyRenewal, MONTHLY_RENEWAL_MAX_DAYS } = require('../../web/src/lib/billingDisplay');
|
||||
|
||||
const NOW = Date.parse('2026-07-13T00:00:00.000Z');
|
||||
const DAY = 86_400_000;
|
||||
|
||||
describe('classifyRenewal — honest billing render', () => {
|
||||
test('MONTHLY_RENEWAL_MAX_DAYS is 60', () => {
|
||||
expect(MONTHLY_RENEWAL_MAX_DAYS).toBe(60);
|
||||
});
|
||||
|
||||
test('the 2036 comped/seed row is NOT a renewal → none', () => {
|
||||
const r = classifyRenewal('2036-06-09T00:00:00.000Z', NOW);
|
||||
expect(r.kind).toBe('none');
|
||||
expect(r.iso).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a plausible monthly next-bill (now + 30d) → date, carries iso', () => {
|
||||
const r = classifyRenewal(new Date(NOW + 30 * DAY).toISOString(), NOW);
|
||||
expect(r.kind).toBe('date');
|
||||
expect(typeof r.iso).toBe('string');
|
||||
expect(Date.parse(r.iso)).toBe(NOW + 30 * DAY);
|
||||
});
|
||||
|
||||
test('absent value (null / undefined / empty) → unknown', () => {
|
||||
expect(classifyRenewal(null, NOW).kind).toBe('unknown');
|
||||
expect(classifyRenewal(undefined, NOW).kind).toBe('unknown');
|
||||
expect(classifyRenewal('', NOW).kind).toBe('unknown');
|
||||
});
|
||||
|
||||
test('a renewal well in the past → lapsed', () => {
|
||||
const r = classifyRenewal(new Date(NOW - 10 * DAY).toISOString(), NOW);
|
||||
expect(r.kind).toBe('lapsed');
|
||||
});
|
||||
|
||||
test('malformed date string → unknown (never coerced to an epoch date)', () => {
|
||||
expect(classifyRenewal('not a date', NOW).kind).toBe('unknown');
|
||||
expect(classifyRenewal('N/A', NOW).kind).toBe('unknown');
|
||||
});
|
||||
|
||||
test('exactly at the 60-day boundary still reads as a date; just beyond → none', () => {
|
||||
expect(classifyRenewal(new Date(NOW + 60 * DAY).toISOString(), NOW).kind).toBe('date');
|
||||
expect(classifyRenewal(new Date(NOW + 61 * DAY).toISOString(), NOW).kind).toBe('none');
|
||||
});
|
||||
});
|
||||
@@ -47,10 +47,14 @@ describe('Pricing — Desk is the hero, real prices, single primary CTA', () =>
|
||||
expect(pricing).not.toContain("originalPrice: '$49.99'");
|
||||
});
|
||||
|
||||
test('the "$1M terminal · $44.99" story leads, above the grid, with a real feature ladder', () => {
|
||||
test('the Desk story leads with deadpan value-showing copy (no "$1M" brag), above the grid, with a real feature ladder', () => {
|
||||
expect(page).toContain('import DeskShowcase');
|
||||
expect(page).toContain('<DeskShowcase');
|
||||
expect(showcase).toContain('$1M terminal');
|
||||
// Wave 1 — the headline SHOWS what Desk does instead of claiming a dollar
|
||||
// figure (VYNDR voice: understated, no hype, no "$1M"/"terminal"-as-brag).
|
||||
expect(showcase).toContain('Every grade, every alt line, live.');
|
||||
expect(showcase).not.toContain('$1M');
|
||||
expect(showcase).not.toContain('1M terminal');
|
||||
expect(showcase).toContain('$44.99'); // the conversion hook figure
|
||||
expect(showcase).toContain('$34.99'); // the founder price
|
||||
// real feature visuals fill the right half (kills the dead half, #8)
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user