6d36e05bfe
Serving/gating change only. src/services/ untouched (git diff empty): no grade,
model or settlement-logic change. Pricing and migration are Builds 2 and 3.
Push scoring untouched.
THE RULE: a grade is PAID while its outcome is unknown and becomes FREE the
moment it resolves.
Resolution is read ONLY from a written outcome — never from time, game status or
gradedAt. A game can be final long before the settle pass runs, so treating
"probably over" as settled is exactly how a live edge would leak; a test asserts
an hours-old gradedAt with no outcome is still LIVE. void and unrecoverable ARE
resolutions (terminal results, no live edge left). isResolved FAILS CLOSED:
null outcome, {} with no result, and empty-string result all read as LIVE, so a
settlement failure withholds content rather than exposing it — the same
direction resolveTierFromRequest fails.
FREE/ANON: settled grades pass through IN FULL, reasoning and kill conditions
included — settled reads are the proof product and cost nothing once the outcome
is known. That also converts the previously-unenforced board reasoning leak into
a deliberate rule rather than an oversight.
LIVE grades for unentitled tiers are reduced to a shell: every piece of model
JUDGMENT is dropped (grade, confidence, confidence_basis, reasoning,
kill_conditions_triggered, projection, edge_pct, matchup_grade, form, alt_lines,
kelly) and `locked: true` is stamped so the card renders the unlock prompt. The
free-side DATA stays so the tease is real rather than empty: player, market,
line, book_odds, fair_odds, season/last10 stats, archetype, gradedAt, history.
fair_odds deliberately survives — the de-vigged fair number is the free hook and
is never the paywall. A test asserts the serialized free row carries no trace of
the withheld judgment.
THE TEASE IS AGGREGATE ONLY: live_locked = {count, tiers} computed from the
ungated rows and never joined back to one, and no gated row carries a grade — so
a free viewer learns that N reads exist and their tier shape without being able
to work out WHICH prop is the A.
Gate order in the route: stripModelPrice (S67) first, then gateLiveGrades.
Entitled tiers get the array back by reference — zero cost, zero change.
Floor: 319 suites / 3971 tests green (10 new), web build exit 0.
One test note: the route-level supertest case was removed deliberately — it
needs a live Redis and hangs on ioredis' reconnect timer in a single-suite local
run (known behaviour, CLAUDE.md). The gate contract is fully covered by pure
tests; the wire is verified against prod anonymously in the fingerprint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
102 lines
4.8 KiB
JavaScript
102 lines
4.8 KiB
JavaScript
/**
|
|
* BUILD 1 — the settled/live gate (specs/tier-redesign-spec.md).
|
|
* The load-bearing monetization boundary: unresolved = paid, resolved = free.
|
|
*/
|
|
const request = require('supertest');
|
|
const g = require('../../src/utils/snapshotGating');
|
|
|
|
const settled = { player: 'Settled Sam', stat_type: 'points', line: 12.5, book_odds: -110, fair_odds: -104,
|
|
season_avg: 14, grade: 'A', confidence: 75, reasoning: { summary: 'real why' },
|
|
kill_conditions_triggered: [{ code: 'X', reason: 'r' }], projection: 15, edge_pct: 20,
|
|
outcome: { result: 'hit', actual: 14 } };
|
|
const live = { player: 'Live Larry', stat_type: 'assists', line: 5.5, book_odds: -120, fair_odds: -112,
|
|
season_avg: 6, archetype: { primary: { name: 'CONDUCTOR' } }, gradedAt: { line: 5.5, odds: -120 },
|
|
grade: 'A', confidence: 75, confidence_basis: 'grade_band', reasoning: { summary: 'secret why' },
|
|
kill_conditions_triggered: [{ code: 'Y', reason: 'secret' }], projection: 7.1, edge_pct: 29,
|
|
matchup_grade: 'B', form: 'hot', alt_lines: [{}], kelly: { units: 1 } };
|
|
|
|
describe('resolution is the flip point — and only a WRITTEN outcome counts', () => {
|
|
test('a real outcome resolves; void/unrecoverable are terminal ⇒ resolved', () => {
|
|
expect(g.isResolved(settled)).toBe(true);
|
|
expect(g.isResolved({ outcome: { result: 'void' } })).toBe(true);
|
|
expect(g.isResolved({ outcome: 'unrecoverable' })).toBe(true);
|
|
});
|
|
|
|
test('FAILS CLOSED — anything unproven is LIVE (paid), never assumed settled', () => {
|
|
expect(g.isResolved(live)).toBe(false);
|
|
expect(g.isResolved({ outcome: null })).toBe(false);
|
|
expect(g.isResolved({ outcome: {} })).toBe(false); // no result field
|
|
expect(g.isResolved({ outcome: { result: '' } })).toBe(false);
|
|
expect(g.isResolved({})).toBe(false);
|
|
expect(g.isResolved(null)).toBe(false);
|
|
});
|
|
|
|
test('resolution is NEVER inferred from time or game status', () => {
|
|
// a grade locked hours ago with no outcome is still LIVE
|
|
expect(g.isResolved({ gradedAt: { timestamp: '2020-01-01T00:00:00Z' } })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('free tier — settled is FULL, live is a shell', () => {
|
|
const out = g.gateLiveGrades([settled, live], 'free');
|
|
|
|
test('a SETTLED grade passes through in full, reasoning included (the proof product)', () => {
|
|
expect(out[0].grade).toBe('A');
|
|
expect(out[0].reasoning.summary).toBe('real why');
|
|
expect(out[0].kill_conditions_triggered).toHaveLength(1);
|
|
expect(out[0].edge_pct).toBe(20);
|
|
expect(out[0].projection).toBe(15);
|
|
});
|
|
|
|
test('a LIVE grade loses every piece of model JUDGMENT', () => {
|
|
for (const f of ['grade', 'confidence', 'confidence_basis', 'reasoning', 'kill_conditions_triggered',
|
|
'projection', 'edge_pct', 'matchup_grade', 'form', 'alt_lines', 'kelly']) {
|
|
expect(out[1][f]).toBeUndefined();
|
|
}
|
|
expect(out[1].locked).toBe(true);
|
|
});
|
|
|
|
test('a LIVE grade KEEPS the free-side data (the tease is real, not empty)', () => {
|
|
expect(out[1].player).toBe('Live Larry');
|
|
expect(out[1].stat_type).toBe('assists');
|
|
expect(out[1].line).toBe(5.5);
|
|
expect(out[1].book_odds).toBe(-120);
|
|
expect(out[1].fair_odds).toBe(-112); // the fair leg is NEVER the paywall
|
|
expect(out[1].season_avg).toBe(6);
|
|
expect(out[1].archetype).toBeTruthy();
|
|
});
|
|
|
|
test('the serialized free payload contains no trace of live judgment', () => {
|
|
const json = JSON.stringify(out[1]);
|
|
expect(json).not.toMatch(/secret why|secret|grade_band|hot/);
|
|
});
|
|
});
|
|
|
|
describe('the tease is AGGREGATE ONLY — never which prop is which tier', () => {
|
|
test('reports count + tier distribution', () => {
|
|
const s = g.liveLockedSummary([settled, live, { grade: 'B' }, { grade: 'C' }]);
|
|
expect(s.count).toBe(3); // settled excluded from the live count
|
|
expect(s.tiers).toEqual({ A: 1, B: 1, C: 1 });
|
|
});
|
|
|
|
test('no gated ROW carries a tier, so the aggregate cannot be joined back', () => {
|
|
const out = g.gateLiveGrades([live, { player: 'P2', grade: 'B' }], 'free');
|
|
for (const row of out) expect(row.grade).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('entitled tiers are untouched', () => {
|
|
test('analyst and desk get the array back by reference — zero cost, zero change', () => {
|
|
const rows = [settled, live];
|
|
expect(g.gateLiveGrades(rows, 'analyst')).toBe(rows);
|
|
expect(g.gateLiveGrades(rows, 'desk')).toBe(rows);
|
|
expect(g.entitledToLiveGrades('free')).toBe(false);
|
|
expect(g.entitledToLiveGrades(undefined)).toBe(false); // anonymous
|
|
});
|
|
});
|
|
|
|
// NOTE: the route-level assertion is deliberately NOT here. It needs a live Redis,
|
|
// and a single-suite run with redis down hangs on ioredis' reconnect timer (a known
|
|
// local behaviour, see CLAUDE.md). The gate's CONTRACT is fully covered by the pure
|
|
// tests above; the wire is verified against PROD anonymously in the fingerprint.
|