Build /api/props/top-graded server selector: rank with p_win, serve without it
New READ endpoint. No grade, ledger row, lock_line, or scoring write. Push
scoring untouched.
REVIEW ZERO CORRECTED THE PREMISE: the handler NEVER EXISTED in any commit
(searched git rev-list --all for a /top-graded definition in src/ — zero hits).
Not "removed" — the three axios callers (cheatsheetGenerator, gradeOfTheDay,
widget) and the Next proxy were written against a phantom endpoint, so those
three content generators have silently received [] for their entire life.
Contract recovered from the four consumers, not guessed: {props:[...]},
?sport=UPPERCASE (absent = all sports, which gradeOfTheDay relies on) + ?limit,
rows carrying player/stat/line/direction/sport/grade/confidence? plus the
player_name/stat_type aliases and game_id.
POPULATED-PATH RISK FOUND: the board's populated branch had never run in prod,
and dashboard/page.tsx:463 calls g.stat.replace(/_/g,' ') UNGUARDED (g.player
also feeds the row key, /scan URL and heading; sport must be UPPERCASE for
SportPill). toRow requires non-empty string player+stat and a finite line,
uppercases sport, and DROPS unrenderable rows — a shorter board beats a broken
one.
THE LEAK BOUNDARY (why this is server-side): the browser cannot rank on p_win
for all tiers because stripModelPrice deliberately withholds it from unentitled
tiers. Order of operations is
read cache -> RANK with p_win (every tier) -> map rows incl. model fields
-> stripModelPrice(rows, tier) -> serialize
so a free caller receives the paid RANKING without the paid VALUES. Tier comes
from resolveTierFromRequest, which FAILS CLOSED to 'free'. Cache-Control is
private under a bearer token, public otherwise (the /api/snapshot precedent).
ONE SHARED DEFINITION, no drift: new src/utils/gradeRanking.js
(takeablePWin/descNullsLast/rankGrades). heroPropService now imports
takeablePWin instead of its inline copy (behaviour unchanged — it was that
logic verbatim); the selector imports rankGrades; web/src/lib/slateAdapter
keeps its mirror (the browser cannot import src/, S25) and a test cross-checks
the two on identical fixtures (playerName.js precedent). Board is grade-first
("top GRADES"), hero is p_win-first ("top read") — they differ BY DESIGN and
agree within the leading tier.
HONEST LIMIT: the Next proxy (cachedBackendJson) sends no Authorization header
and caches under a shared key, so via the dashboard every viewer gets the
free-tier payload — correct order, no paid values. That is the SAFE behaviour;
forwarding auth into a shared cache is exactly how a paid payload leaks to
anonymous viewers. Per-tier delivery through the proxy needs a tier-keyed cache
and is not done here.
Verified on real prod snapshot data (anonymous path): MLB 8 props, WNBA 10,
0 paid-field leaks, render-contract safe on every row, sport uppercase.
Floor: 311 suites / 3882 tests green (18 new — leak test uses POPULATED p_win,
not today's nulls: entitled gets p_win and it drove the order, unentitled gets
a byte-identical order with all five MODEL_FIELDS absent and no trace in
JSON.stringify, while book/fair market facts survive). Web build exit 0.
Dashboard visual is auth-gated -> tagged for the Chrome audit, not faked.
Held: edge_pct rescale/retirement (Order B); board columns/contract unchanged;
tier-keyed proxy caching.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
This commit is contained in:
@@ -359,3 +359,36 @@ OVER @0.5 → `0.5•(C) 1(C) 1.5(F)` (line-ASC PASS), UNDER @1.5 → `2.5(C) 2(
|
||||
(line-DESC PASS). Only the *rendered* Desk card remains unverified anonymously → tagged for the Chrome
|
||||
audit, no visual faked. **Held:** edge_pct rescale/display retirement, building
|
||||
the missing `/api/props/top-graded` selector, exposing p_win to unentitled tiers.
|
||||
|
||||
---
|
||||
|
||||
# /api/props/top-graded SERVER SELECTOR — 2026-07-29 (spec `specs/top-graded-selector.md`, shipped)
|
||||
|
||||
The dashboard TOP GRADES board had no feed. **The handler NEVER existed in any commit** (searched
|
||||
`git rev-list --all`) — so the three axios callers (cheatsheetGenerator, gradeOfTheDay, widget) plus
|
||||
the Next proxy had always received `[]`. Contract recovered from those consumers, not guessed.
|
||||
|
||||
**The leak boundary — the whole point of doing it server-side.** The browser cannot rank on `p_win`
|
||||
for all tiers because `stripModelPrice` deliberately withholds it from unentitled tiers ("shipping
|
||||
p_win is shipping the price in a different base", S67). Order of operations:
|
||||
`read cache → RANK with p_win (every tier) → map rows incl. model fields → stripModelPrice(rows, tier)
|
||||
→ serialize`. A free caller gets the paid RANKING without the paid VALUES. Tier resolution FAILS
|
||||
CLOSED to `free`; `Cache-Control` is `private` under a bearer token, `public` otherwise.
|
||||
|
||||
**Populated-path risk found and handled:** the board's populated branch had never run in prod, and
|
||||
`dashboard/page.tsx:463` calls `g.stat.replace(/_/g,' ')` **unguarded** — `toRow` requires string
|
||||
`player`+`stat`, a finite `line`, uppercases `sport` for `SportPill`, and drops unrenderable rows.
|
||||
|
||||
**One shared ranking definition:** new `src/utils/gradeRanking.js`; `heroPropService` now imports
|
||||
`takeablePWin` (was an inline copy, behaviour unchanged), the selector imports `rankGrades`, and the
|
||||
web mirror is cross-checked by test. Board is grade-first ("top GRADES"), hero is p_win-first ("top
|
||||
read") — they differ by design and agree within the leading tier.
|
||||
|
||||
**Honest limit:** the Next proxy forwards no Authorization and caches under a shared key, so via the
|
||||
dashboard every viewer receives the free-tier payload — correct order, no paid values. That is the safe
|
||||
default (forwarding auth into a shared cache is how paid payloads leak); per-tier delivery through the
|
||||
proxy needs a tier-keyed cache and is NOT done here.
|
||||
|
||||
**Verified:** 311 suites / 3882 tests green (18 new, leak test on POPULATED p_win), web build exit 0,
|
||||
and induced on real prod snapshot data — MLB 8 / WNBA 10 props, 0 paid-field leaks, render-contract
|
||||
safe. Dashboard visual is auth-gated → tagged for the Chrome audit, not faked.
|
||||
|
||||
@@ -264,6 +264,56 @@
|
||||
> exposing p_win to unentitled tiers. **Dashboard + Desk visuals are auth/feed-gated → TAGGED FOR
|
||||
> THE CHROME AUDIT, no visual faked.**
|
||||
|
||||
> ## 🟢 `/api/props/top-graded` SERVER SELECTOR BUILT 2026-07-29 (spec `specs/top-graded-selector.md`)
|
||||
> The dashboard TOP GRADES board finally has a feed. New READ endpoint; no grade/ledger/
|
||||
> lock_line/scoring write. **0.1 CORRECTS the premise: the handler NEVER EXISTED** — searched
|
||||
> every commit (`git rev-list --all`) for a `/top-graded` definition in `src/`, **zero hits**. Not
|
||||
> "removed": the three axios callers (`cheatsheetGenerator`, `gradeOfTheDay`, `widget`) and the Next
|
||||
> proxy were written against a phantom endpoint, so **those three content generators have silently
|
||||
> received `[]` for their entire life** — a second, previously-unnoticed casualty now fixed.
|
||||
> **CONTRACT recovered from consumers, not guessed:** envelope `{props:[...]}`; params `sport`
|
||||
> (UPPERCASE NBA|MLB|WNBA, absent = all sports, which `gradeOfTheDay` relies on) + `limit`; rows carry
|
||||
> `player/stat/line/direction/sport/grade/confidence?` plus the `player_name`/`stat_type` aliases and
|
||||
> `game_id` the other callers read.
|
||||
> **🔴 0.5 POPULATED-PATH RISK FOUND (the board's populated branch had never run in prod):**
|
||||
> `dashboard/page.tsx:463` calls **`g.stat.replace(/_/g,' ')` UNGUARDED**, and `g.player` feeds the row
|
||||
> key + `/scan` URL + heading, and `sport` must be UPPERCASE for `SportPill` (`type Sport =
|
||||
> 'NBA'|'MLB'|'WNBA'`). A row missing any of those would have CRASHED the board on first populated
|
||||
> render. `toRow` therefore requires non-empty string `player`+`stat` and a finite `line`, uppercases
|
||||
> `sport`, and **DROPS** an unrenderable row — a shorter board beats a broken one.
|
||||
> **THE ORDER OF OPERATIONS (the leak surface):** read cache → **RANK with `p_win` for EVERY tier
|
||||
> server-side** → map rows *including* model fields → **`stripModelPrice(rows, tier)`** → serialize. So
|
||||
> a free caller receives the paid RANKING without the paid VALUES. Tier via
|
||||
> `resolveTierFromRequest` which **FAILS CLOSED to 'free'** (a resolution failure can only withhold,
|
||||
> never leak). `Cache-Control` = `private` with a bearer token, `public` otherwise (the `/api/snapshot`
|
||||
> precedent — a CDN must never hand a paid payload to an anonymous viewer).
|
||||
> **ONE SHARED DEFINITION, no drift:** extracted `src/utils/gradeRanking.js`
|
||||
> (`takeablePWin`/`descNullsLast`/`rankGrades`). **`heroPropService` now imports `takeablePWin`
|
||||
> instead of its inline copy** (behaviour unchanged — it was that logic verbatim); the selector imports
|
||||
> `rankGrades`; `web/src/lib/slateAdapter` keeps its mirror (browser can't import `src/`, S25) and a
|
||||
> test **cross-checks the two on identical fixtures** — the `playerName.js` precedent.
|
||||
> **0.3 VERIFIED LIVE that the server HAS p_win:** `/api/hero-prop` returns `available:true` (Brionna
|
||||
> Jones, B, wnba) and the hero rule REQUIRES non-null p_win + a takeable price. Public
|
||||
> `/api/snapshot` shows 0/8 MLB + 0/25 WNBA only because it is stripped on the way out.
|
||||
> **⚠️ HONEST LIMIT — via the dashboard, EVERY viewer gets the free-tier payload.** The Next proxy
|
||||
> (`cachedBackendJson`) sends only `{Accept}` — **no Authorization header** — and caches under a
|
||||
> SHARED key (`todayKey(sport,'top_graded')`). That is the SAFE behaviour: forwarding auth into a shared
|
||||
> cache is exactly how a paid payload leaks to anonymous viewers. So the board shows the correct ORDER
|
||||
> with no paid values for everyone; entitled payloads are served on a direct authenticated API call
|
||||
> (proven by route test). Wiring per-tier delivery through the proxy would need a tier-keyed cache — NOT
|
||||
> this order.
|
||||
> **VERIFIED ON REAL PROD SNAPSHOT DATA** (anonymous path, what the board will actually render):
|
||||
> MLB 8 props (B c57 Chandler Simpson edge 140 → …), WNBA 10 (B c69 Rhyne Howard u17.5 points → …),
|
||||
> **0 paid-field leaks, render-contract safe on every row, sport uppercase**.
|
||||
> **FLOOR: 311 suites / 3882 tests green (18 new), web build exit 0.** The leak test uses POPULATED
|
||||
> p_win fixtures (not today's nulls): entitled → p_win present and it drove the order; unentitled →
|
||||
> **byte-identical order, all five MODEL_FIELDS absent, `JSON.stringify` carries no trace**, while
|
||||
> market facts (book/fair) SURVIVE — the fair leg is never the paywall. Also locked: chalk (p_win .95
|
||||
> @−300) never tops the board, nulls last but PRESENT, refusals excluded, unrenderable rows dropped,
|
||||
> thin slate → `200 {props:[]}` never a 404, and board-vs-hero differ by design yet agree within tier.
|
||||
> **HELD:** edge_pct rescale/retirement (Order B) · board columns/contract unchanged · tier-keyed proxy
|
||||
> caching. Dashboard visual is auth-gated → TAGGED FOR THE CHROME AUDIT, not faked.
|
||||
|
||||
- **Redirect EXISTS + WIRED:** `closingCapture.buildCaptureRows`→`closing_captures` (append-only,
|
||||
provenance: captured_at/book/line_type/both-prices/missed_reason) via `intradayRefreshService:221`
|
||||
+ internal endpoint; `ledgerService.attachClosingProb`→`closing_prob` (de-vigs both raw sides,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# SPEC — `/api/props/top-graded` server selector (rank with p_win, serve without it)
|
||||
|
||||
**Status:** built 2026-07-29. New READ endpoint. No grade/ledger/lock_line/scoring write.
|
||||
Push scoring untouched. Spec companion: `specs/grade-board-sort.md`.
|
||||
|
||||
## 1. Review Zero findings
|
||||
|
||||
- **0.1 CORRECTED — the handler NEVER EXISTED.** Not "removed": searched every commit
|
||||
(`git rev-list --all`) for a `/top-graded` definition in `src/` — **zero hits**. The
|
||||
three axios callers (`content/cheatsheetGenerator:21`, `content/gradeOfTheDay:17`,
|
||||
`routes/widget:76`) and the Next proxy were written against a phantom endpoint, so those
|
||||
three content generators have silently received `[]` for their entire life. The contract
|
||||
is therefore recoverable ONLY from consumers, which is what this spec builds to.
|
||||
- **CONTRACT (VERIFIED, union of four consumers).** Envelope `{ props: [...] }` (all three
|
||||
callers do `Array.isArray(res.data?.props)`; the Next proxy returns the same). Query:
|
||||
`sport` (UPPERCASE `NBA|MLB|WNBA` — the proxy validates that set; absent = all sports,
|
||||
which `gradeOfTheDay` relies on) and `limit`. Row fields REQUIRED by
|
||||
`dashboard/page.tsx` `TopGrade`: `player`, `stat`, `line`, `direction` (`'over'|'under'`),
|
||||
`sport`, `grade`, `confidence?`. Callers additionally read `player_name || player`,
|
||||
`stat_type || stat`, and `game_id` (cheatsheet's `gameCount`).
|
||||
- **0.5 POPULATED-PATH RISK — FLAGGED.** The board's populated branch has effectively never
|
||||
run in prod. `dashboard/page.tsx:463` calls **`g.stat.replace(/_/g,' ')` UNGUARDED** — a
|
||||
row without a string `stat` THROWS and takes out the board. `g.player` is used in the key,
|
||||
the `/scan` URL and the `<h3>`; `sport` must be UPPERCASE (`type Sport = 'NBA'|'MLB'|'WNBA'`,
|
||||
fed to `SportPill`). `confidence` is the only null-guarded field. The selector therefore
|
||||
emits `player`/`stat` as non-empty strings and uppercases `sport`, and DROPS any row that
|
||||
cannot satisfy that (a crashed board is worse than a shorter board).
|
||||
- **0.2 VERIFIED — the strip can run server-side, after ranking.**
|
||||
`utils/requestTier.resolveTierFromRequest(req)` is purpose-built for a PUBLIC endpoint
|
||||
(bearer token when present, else `'free'`) and **FAILS CLOSED** — any error returns the
|
||||
LEAST entitled tier, so a resolution failure can only withhold, never leak.
|
||||
`utils/snapshotGating.stripModelPrice(rows, tier)` deletes
|
||||
`model_odds|p_win|ev_pct|value|takeable` for unentitled tiers and stamps
|
||||
`model_price_locked` where a book+fair pair survives.
|
||||
- **0.3 VERIFIED LIVE — the server HAS p_win right now.** `GET /api/hero-prop` returns
|
||||
`available:true` (Brionna Jones, B, wnba), and the hero rule REQUIRES a non-null `p_win`
|
||||
AND a takeable price, so the snapshot cache carries both server-side. The public
|
||||
`/api/snapshot` shows `p_win` on 0/8 MLB + 0/25 WNBA rows only because it is stripped on
|
||||
the way out.
|
||||
- **0.4 ONE SHARED DEFINITION.** Extracted to `src/utils/gradeRanking.js`
|
||||
(`takeablePWin`, `descNullsLast`, `rankGrades`). `heroPropService` now imports
|
||||
`takeablePWin` instead of its inline copy, and the new selector imports `rankGrades`.
|
||||
The browser cannot import `src/` (S25 rule), so `web/src/lib/slateAdapter` keeps its
|
||||
mirror — a test cross-checks the two on identical fixtures, the `playerName.js` precedent.
|
||||
Takeable band = `config/valueEngine.isTakeable` (−160..+200), strict-null.
|
||||
|
||||
## 2. The order of operations (the leak surface)
|
||||
|
||||
read snapshot cache → RANK with p_win (all tiers, server-side)
|
||||
→ map to contract rows INCLUDING model fields
|
||||
→ stripModelPrice(rows, tier) ← the boundary
|
||||
→ serialize
|
||||
|
||||
Ranking happens BEFORE the strip, so a free caller gets the SAME order an entitled caller
|
||||
gets, without the paid values. `Cache-Control` follows the `/api/snapshot` precedent:
|
||||
`private` when a bearer token is present, `public` otherwise — a CDN must never hand a paid
|
||||
payload to an anonymous viewer.
|
||||
|
||||
## 3. Rank order
|
||||
|
||||
`grade → confidence → takeable-gated p_win (nulls LAST) → SIGNED edge (nulls LAST) →
|
||||
stable input order`. Grade-first because this is "top **GRADES**", not "top read" — it is
|
||||
INTENDED that this board and the hero can lead with different picks (the hero is p_win-first).
|
||||
They agree within the leading grade tier.
|
||||
|
||||
## 4. Acceptance criteria
|
||||
|
||||
1. Entitled request: ranked by takeable-gated p_win; `p_win` PRESENT in the payload.
|
||||
2. Unentitled request: **byte-identical ORDER**; `p_win`/`ev_pct`/`model_odds`/`value`/
|
||||
`takeable` ABSENT. Proven with POPULATED p_win, not today's nulls.
|
||||
3. Nulls sort LAST server-side; untakeable chalk never tops the board.
|
||||
4. Grade tier dominates every signal.
|
||||
5. Thin/empty slate → `200 { props: [] }`, never 404, never filler.
|
||||
6. Every emitted row satisfies the populated-render contract (string `player`+`stat`,
|
||||
uppercase `sport`).
|
||||
7. Hero / client / server use ONE definition — cross-checked by test.
|
||||
8. No grade/ledger/lock_line/scoring write. Suite green, web build exit 0.
|
||||
|
||||
## 5. Held
|
||||
|
||||
edge_pct rescale or display retirement (Order B) · changing the board's columns or contract ·
|
||||
making the board and hero lead with the same pick.
|
||||
@@ -2,9 +2,50 @@ const express = require('express');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { checkGracePeriod } = require('../middleware/gracePeriod');
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
const { createRateLimit } = require('../middleware/rateLimit');
|
||||
const { resolveTierFromRequest } = require('../utils/requestTier');
|
||||
const { getTopGraded } = require('../services/topGradedService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* GET /top-graded — the dashboard TOP GRADES board (specs/top-graded-selector.md).
|
||||
*
|
||||
* PUBLIC + tier-aware, NOT `requireAuth`: this board must keep serving anonymous
|
||||
* callers (it is the free funnel), so entitlement is resolved best-effort and
|
||||
* FAILS CLOSED to 'free'. Ranking happens server-side WITH `p_win` for every
|
||||
* tier; `stripModelPrice` then removes the paid signal for unentitled callers —
|
||||
* so a free caller gets the CORRECT ORDER without the paid VALUES.
|
||||
*
|
||||
* A thin slate is `200 { props: [] }`, never a 404 and never filler: the board
|
||||
* renders its own honest empty state, and a 404 is what left it blank for months.
|
||||
*
|
||||
* Cache-Control follows the /api/snapshot precedent — `private` once a bearer
|
||||
* token is present, so a CDN can never hand a paid payload to an anonymous viewer.
|
||||
*/
|
||||
router.get('/top-graded', createRateLimit({ windowMs: 60_000, max: 60 }), async (req, res) => {
|
||||
const deps = router.__deps || {};
|
||||
const sportParam = req.query.sport ? String(req.query.sport) : null;
|
||||
try {
|
||||
const tier = await (deps.resolveTier || resolveTierFromRequest)(req);
|
||||
const out = await (deps.getTopGraded || getTopGraded)({
|
||||
sport: sportParam,
|
||||
limit: req.query.limit,
|
||||
tier,
|
||||
...(deps.cacheGet ? { cacheGet: deps.cacheGet } : {}),
|
||||
});
|
||||
res.set('Cache-Control', req.headers.authorization ? 'private, max-age=60' : 'public, max-age=60');
|
||||
return res.json(out);
|
||||
} catch (err) {
|
||||
console.error('[props/top-graded]', err.message);
|
||||
// Honest empty — the board's empty state, never a 404.
|
||||
return res.status(200).json({ props: [], sport: sportParam ? sportParam.toUpperCase() : null, updated_at: null });
|
||||
}
|
||||
});
|
||||
|
||||
// Test seam — lets route tests inject without Redis/Supabase (slips.js precedent).
|
||||
router.__internals = { setDeps: (d) => { router.__deps = d; } };
|
||||
|
||||
// GET /joint-history — joint outcome history and phi coefficient
|
||||
router.get('/joint-history', requireAuth, checkGracePeriod, async (req, res) => {
|
||||
const { player_a, stat_a, player_b, stat_b } = req.query;
|
||||
|
||||
@@ -88,16 +88,18 @@ async function pickHeroProp(deps = {}) {
|
||||
// product. Floor = the project's `isTakeable` band (-160..+200), same definition
|
||||
// the takeable-edge proof/audit used.
|
||||
// - NO BACKFILL: nothing qualifies → honest empty state, never a weak recent read.
|
||||
const { isTakeable } = require('../config/valueEngine');
|
||||
// Strict null: `Number(null) === 0` is the exact fabrication this hero fell to.
|
||||
const num = (v) => (v == null || v === '' ? null : (Number.isFinite(Number(v)) ? Number(v) : null));
|
||||
// ONE SHARED DEFINITION (2026-07-29, specs/top-graded-selector.md): the
|
||||
// takeable-gated p_win primitive now lives in `utils/gradeRanking` so the hero
|
||||
// (p_win-FIRST, "top read"), the server board (`topGradedService`, grade-FIRST,
|
||||
// "top GRADES") and the client board cannot drift apart. Behaviour here is
|
||||
// UNCHANGED — `takeablePWin` is this hero's former inline logic verbatim:
|
||||
// strict-null p_win, price = book_odds ?? gradedAt.odds, the `isTakeable` band.
|
||||
const { takeablePWin } = require('../utils/gradeRanking');
|
||||
let hero = null; let heroP = -Infinity;
|
||||
for (const { g, sport } of all) {
|
||||
if (!isAB(g.grade) || !candidate(g)) continue;
|
||||
const p = num(g.p_win);
|
||||
if (p == null) continue; // no champion p_win → cannot rank
|
||||
const price = num(g.book_odds) ?? num(g.gradedAt && g.gradedAt.odds);
|
||||
if (price == null || !isTakeable(price)) continue; // takeable price only — excludes chalk
|
||||
const p = takeablePWin(g); // null → no champion p_win, or an untakeable (chalk) price
|
||||
if (p == null) continue;
|
||||
if (p > heroP) { heroP = p; hero = { g, sport }; }
|
||||
}
|
||||
if (hero) {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* topGradedService (2026-07-29, specs/top-graded-selector.md) — the SERVER
|
||||
* selector behind `GET /api/props/top-graded`.
|
||||
*
|
||||
* WHY THIS EXISTS: the endpoint the dashboard TOP GRADES board fetches was
|
||||
* NEVER IMPLEMENTED in any commit (searched all of `git rev-list --all`) — the
|
||||
* three axios callers (cheatsheetGenerator, gradeOfTheDay, widget) and the Next
|
||||
* proxy have always received `[]`, so the board rendered its empty fallback.
|
||||
*
|
||||
* THE POINT OF DOING IT SERVER-SIDE: the browser CANNOT rank on `p_win` for
|
||||
* every tier, because `utils/snapshotGating.stripModelPrice` deliberately
|
||||
* withholds it from unentitled tiers ("shipping p_win is shipping the price in a
|
||||
* different base", Session 67). Here the server ranks WITH p_win and then strips
|
||||
* it, so a free caller receives the CORRECT ORDER without the paid SIGNAL.
|
||||
*
|
||||
* read cache → RANK with p_win → map rows (model fields included)
|
||||
* → stripModelPrice(rows, tier) ← the boundary
|
||||
* → serialize
|
||||
*
|
||||
* Everything is injectable so route tests never touch Redis.
|
||||
*/
|
||||
|
||||
const { rankGrades } = require('../utils/gradeRanking');
|
||||
const { stripModelPrice } = require('../utils/snapshotGating');
|
||||
|
||||
const DEFAULT_SPORTS = ['nba', 'mlb', 'wnba'];
|
||||
const DEFAULT_LIMIT = 10;
|
||||
const MAX_LIMIT = 50;
|
||||
|
||||
/**
|
||||
* The populated-render contract, recovered from the four consumers (0.1/0.5).
|
||||
*
|
||||
* `dashboard/page.tsx:463` calls `g.stat.replace(/_/g,' ')` UNGUARDED and uses
|
||||
* `g.player` in the row key, the `/scan` link and the heading — so a row missing
|
||||
* either would CRASH the board. We therefore require both as non-empty strings
|
||||
* and DROP any row that cannot satisfy it: a shorter board beats a broken one.
|
||||
* `sport` is UPPERCASED because the dashboard types it `'NBA'|'MLB'|'WNBA'` and
|
||||
* feeds it to `SportPill`.
|
||||
*/
|
||||
function toRow(g, sport) {
|
||||
const player = g.player_name || g.player;
|
||||
const stat = g.stat_type || g.stat;
|
||||
if (typeof player !== 'string' || !player.trim()) return null;
|
||||
if (typeof stat !== 'string' || !stat.trim()) return null;
|
||||
const line = Number(g.line != null ? g.line : (g.gradedAt && g.gradedAt.line));
|
||||
if (!Number.isFinite(line)) return null;
|
||||
const direction = String(g.direction || 'over').toLowerCase() === 'under' ? 'under' : 'over';
|
||||
|
||||
const row = {
|
||||
// ── the dashboard TopGrade contract ──
|
||||
player,
|
||||
stat,
|
||||
line,
|
||||
direction,
|
||||
sport: String(sport || g.sport || '').toUpperCase(),
|
||||
grade: g.grade,
|
||||
confidence: g.confidence != null ? g.confidence : null,
|
||||
// ── what the other three callers read ──
|
||||
player_name: player,
|
||||
stat_type: stat,
|
||||
game_id: g.game_id != null ? g.game_id : null,
|
||||
// ── market facts (never gated — the fair leg is never the paywall) ──
|
||||
book: g.book != null ? g.book : null,
|
||||
book_odds: g.book_odds != null ? g.book_odds : null,
|
||||
fair_odds: g.fair_odds != null ? g.fair_odds : null,
|
||||
projection: g.projection != null ? g.projection : null,
|
||||
edge: (g.edge != null ? g.edge : (g.edge_pct != null ? g.edge_pct : null)),
|
||||
graded_at: (g.gradedAt && g.gradedAt.timestamp) || null,
|
||||
};
|
||||
// ── MODEL fields: included here ON PURPOSE so stripModelPrice can remove
|
||||
// them per entitlement AFTER ranking. Never serialize this row un-stripped.
|
||||
if (g.p_win != null) row.p_win = g.p_win;
|
||||
if (g.ev_pct != null) row.ev_pct = g.ev_pct;
|
||||
if (g.model_odds != null) row.model_odds = g.model_odds;
|
||||
if (g.value != null) row.value = g.value;
|
||||
if (g.takeable != null) row.takeable = g.takeable;
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Load one sport's graded rows: the locked snapshot first, else the envelope. */
|
||||
async function loadGrades(sport, cacheGet) {
|
||||
const snap = await cacheGet(`snapshot:${sport}:latest`);
|
||||
if (snap && Array.isArray(snap.grades)) return snap.grades;
|
||||
const env = await cacheGet(`grades:${sport}`);
|
||||
if (env && Array.isArray(env.grades)) return env.grades;
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* getTopGraded({ sport, limit, tier, cacheGet })
|
||||
* → { props, sport, updated_at } — ALWAYS 200-shaped; a thin slate is
|
||||
* `props: []`, never an error and never filler.
|
||||
*
|
||||
* `sport` omitted → every sport merged (gradeOfTheDay calls it that way).
|
||||
*/
|
||||
async function getTopGraded(opts = {}) {
|
||||
const cacheGet = opts.cacheGet || require('../utils/redis').cacheGet;
|
||||
const tier = opts.tier || 'free';
|
||||
const requested = opts.sport ? String(opts.sport).toLowerCase() : null;
|
||||
const sports = requested ? [requested] : DEFAULT_SPORTS;
|
||||
const n = Number(opts.limit);
|
||||
const limit = Number.isFinite(n) && n > 0 ? Math.min(Math.floor(n), MAX_LIMIT) : DEFAULT_LIMIT;
|
||||
|
||||
const collected = [];
|
||||
let updatedAt = null;
|
||||
for (const sp of sports) {
|
||||
let grades = [];
|
||||
try { grades = await loadGrades(sp, cacheGet); } catch { grades = []; }
|
||||
for (const g of grades) {
|
||||
// A refusal is not a read — `insufficient_data` rows never reach a board.
|
||||
if (!g || !g.grade || g.insufficient_data) continue;
|
||||
collected.push({ g, sport: sp });
|
||||
}
|
||||
}
|
||||
|
||||
// RANK FIRST, with p_win, for EVERY tier — the whole reason this is server-side.
|
||||
const ranked = rankGrades(collected.map((c) => c.g), null);
|
||||
const sportOf = new Map(collected.map((c) => [c.g, c.sport]));
|
||||
|
||||
const rows = [];
|
||||
for (const g of ranked) {
|
||||
if (rows.length >= limit) break;
|
||||
const row = toRow(g, requested ? requested : sportOf.get(g));
|
||||
if (row) rows.push(row); // unrenderable rows are dropped, not faked
|
||||
if (!updatedAt && row && row.graded_at) updatedAt = row.graded_at;
|
||||
}
|
||||
|
||||
// THE BOUNDARY — strip AFTER ranking, BEFORE serialization.
|
||||
const props = stripModelPrice(rows, tier);
|
||||
return { props, sport: requested ? requested.toUpperCase() : null, updated_at: updatedAt };
|
||||
}
|
||||
|
||||
module.exports = { getTopGraded, __internals: { toRow, loadGrades, DEFAULT_SPORTS, DEFAULT_LIMIT, MAX_LIMIT } };
|
||||
@@ -0,0 +1,99 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* gradeRanking (2026-07-29, specs/top-graded-selector.md) — THE ONE definition of
|
||||
* how a board of graded props is ordered. Extracted so the hero, the server
|
||||
* selector, and the client board cannot drift apart.
|
||||
*
|
||||
* WHO USES THIS:
|
||||
* - `services/heroPropService` — imports `takeablePWin` (its "top READ" rule
|
||||
* is p_win-FIRST, so it uses the primitive, not `rankGrades`).
|
||||
* - `services/topGradedService` — imports `rankGrades` (its "top GRADES" board
|
||||
* is grade-FIRST). Those two leading picks may legitimately differ; they
|
||||
* agree WITHIN the leading grade tier.
|
||||
* - `web/src/lib/slateAdapter.selectTopGrades` — a MIRROR, because the browser
|
||||
* cannot import `src/` (the Session-25 rule). `tests/unit/gradeBoardSort`
|
||||
* cross-checks the two on identical fixtures — the `playerName.js` precedent.
|
||||
* If you change the order here, change it there IN THE SAME COMMIT.
|
||||
*
|
||||
* WHY p_win AND NOT ev_pct/edge_pct: ev_pct is NULL on served grades and
|
||||
* `Number(null) === 0` made every prop tie at 0 (the hero bug); edge_pct is a
|
||||
* price-free (proj−line)/line artifact whose scale is a function of line size.
|
||||
* p_win is the only signal whose takeable-MLB-over CLV survived the skew audit.
|
||||
*/
|
||||
|
||||
const { isTakeable } = require('../config/valueEngine');
|
||||
|
||||
const GRADE_RANK = Object.freeze({
|
||||
'A+': 0, A: 1, 'A-': 2, 'B+': 3, B: 4, 'B-': 5, 'C+': 6, C: 7, 'C-': 8, D: 9, F: 10,
|
||||
});
|
||||
|
||||
/** Grade letter → sortable tier rank (lower = better). Unknown → 99. */
|
||||
function gradeRankOf(g) {
|
||||
const k = String(g == null ? '' : g).trim().toUpperCase();
|
||||
return GRADE_RANK[k] !== undefined ? GRADE_RANK[k] : 99;
|
||||
}
|
||||
|
||||
/** Strict numeric read — `Number(null) === 0` is the recurring fabrication bug. */
|
||||
function strictNum(v) {
|
||||
if (v == null || v === '') return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The takeable-gated champion probability for one grade row, or null.
|
||||
*
|
||||
* The takeable filter is MANDATORY: raw p_win crowns −300 chalk, which is not
|
||||
* the product. Band = `config/valueEngine.isTakeable` (−160..+200), the same
|
||||
* definition the takeable-edge proof and the over-side skew audit used.
|
||||
* Price falls back to the LOCKED odds (`gradedAt.odds`) when `book_odds` is absent.
|
||||
*/
|
||||
function takeablePWin(g) {
|
||||
const p = strictNum(g && g.p_win);
|
||||
if (p == null) return null;
|
||||
const price = strictNum(g && g.book_odds) ?? strictNum(g && g.gradedAt && g.gradedAt.odds);
|
||||
if (price == null || !isTakeable(price)) return null;
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Descending comparator that always sorts a null signal LAST (never first). */
|
||||
function descNullsLast(a, b) {
|
||||
if (a == null && b == null) return 0;
|
||||
if (a == null) return 1;
|
||||
if (b == null) return -1;
|
||||
return b - a;
|
||||
}
|
||||
|
||||
/**
|
||||
* rankGrades — "top GRADES" order: grade tier → confidence → takeable-gated
|
||||
* p_win → SIGNED edge → stable input order. Nulls sort LAST on both signals.
|
||||
*
|
||||
* SCALES ARE NEVER MIXED: p_win (0..1) is only ever compared against p_win and
|
||||
* edge (%) only against edge. Comparing 0.62 against 62 is not a comparison.
|
||||
*
|
||||
* Rows without a grade are dropped (a board of ungraded rows is not a board).
|
||||
* `limit` omitted → the whole ranked list (callers slice).
|
||||
*/
|
||||
function rankGrades(grades, limit) {
|
||||
const arr = (Array.isArray(grades) ? grades : []).filter((g) => g && g.grade);
|
||||
const scored = arr.map((g, idx) => ({
|
||||
g,
|
||||
idx,
|
||||
rank: gradeRankOf(g.grade),
|
||||
conf: strictNum(g.confidence) == null ? -1 : strictNum(g.confidence),
|
||||
pWin: takeablePWin(g),
|
||||
edge: strictNum(g.edge != null ? g.edge : g.edge_pct),
|
||||
}));
|
||||
scored.sort((a, b) => a.rank - b.rank
|
||||
|| b.conf - a.conf
|
||||
|| descNullsLast(a.pWin, b.pWin)
|
||||
|| descNullsLast(a.edge, b.edge)
|
||||
|| a.idx - b.idx);
|
||||
const out = scored.map((s) => s.g);
|
||||
return limit == null ? out : out.slice(0, Math.max(0, limit));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
GRADE_RANK, gradeRankOf, strictNum, takeablePWin, descNullsLast, rankGrades,
|
||||
};
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* /api/props/top-graded server selector (specs/top-graded-selector.md).
|
||||
*
|
||||
* THE TEST THAT MATTERS is the LEAK BOUNDARY: rank WITH p_win server-side, serve
|
||||
* WITHOUT it to unentitled tiers. Proven on POPULATED p_win (fixtures below carry
|
||||
* real values) — not on today's stripped nulls, which would prove nothing.
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const svc = require('../../src/services/topGradedService');
|
||||
const { rankGrades, takeablePWin } = require('../../src/utils/gradeRanking');
|
||||
const webAdapter = require('../../web/src/lib/slateAdapter');
|
||||
const { MODEL_FIELDS } = require('../../src/utils/snapshotGating');
|
||||
|
||||
// POPULATED p_win + takeable prices. `chalk` is untakeable (-300) with the HIGHEST
|
||||
// p_win, so it must NOT top the board.
|
||||
const GRADES = [
|
||||
{ player: 'chalk', stat_type: 'hits', line: 0.5, direction: 'over', grade: 'B', confidence: 57, p_win: 0.95, book_odds: -300, ev_pct: 9, model_odds: -1900, value: true, takeable: false, fair_odds: -280 },
|
||||
{ player: 'mid', stat_type: 'points', line: 8.5, direction: 'over', grade: 'B', confidence: 57, p_win: 0.61, book_odds: -120, ev_pct: 3, model_odds: -156, value: true, takeable: true, fair_odds: -115 },
|
||||
{ player: 'best', stat_type: 'points', line: 6.5, direction: 'over', grade: 'B', confidence: 57, p_win: 0.78, book_odds: -110, ev_pct: 5, model_odds: -354, value: true, takeable: true, fair_odds: -108 },
|
||||
{ player: 'nosignal', stat_type: 'assists',line: 3.5, direction: 'over', grade: 'B', confidence: 57 },
|
||||
{ player: 'topgrade', stat_type: 'rebounds',line: 4.5, direction: 'under', grade: 'A', confidence: 75, p_win: 0.52, book_odds: -105, fair_odds: -102 },
|
||||
];
|
||||
const cacheGet = (key) => Promise.resolve(
|
||||
key === 'snapshot:wnba:latest' ? { grades: GRADES, updated_at: 'T' } : null,
|
||||
);
|
||||
const names = (props) => props.map((p) => p.player);
|
||||
|
||||
describe('topGradedService — rank order', () => {
|
||||
test('grade tier dominates; takeable p_win orders within a tier; nulls LAST', async () => {
|
||||
const { props } = await svc.getTopGraded({ sport: 'WNBA', tier: 'desk', cacheGet });
|
||||
// A first (grade), then B rows by takeable p_win desc, missing signal last.
|
||||
expect(names(props)).toEqual(['topgrade', 'best', 'mid', 'chalk', 'nosignal']);
|
||||
});
|
||||
|
||||
test('UNTAKEABLE chalk does not top the board despite the highest p_win', async () => {
|
||||
const { props } = await svc.getTopGraded({ sport: 'WNBA', tier: 'desk', cacheGet });
|
||||
const b = props.filter((p) => p.grade === 'B').map((p) => p.player);
|
||||
expect(b[0]).toBe('best'); // 0.78 takeable
|
||||
expect(b.indexOf('chalk')).toBeGreaterThan(b.indexOf('mid')); // 0.95 but -300
|
||||
});
|
||||
|
||||
test('a missing-signal row is PRESENT, just last', async () => {
|
||||
const { props } = await svc.getTopGraded({ sport: 'WNBA', tier: 'desk', cacheGet });
|
||||
expect(names(props)).toContain('nosignal');
|
||||
expect(names(props)[props.length - 1]).toBe('nosignal');
|
||||
});
|
||||
});
|
||||
|
||||
describe('topGradedService — THE LEAK BOUNDARY (populated p_win)', () => {
|
||||
test('entitled tier: p_win PRESENT and it drove the ranking', async () => {
|
||||
const { props } = await svc.getTopGraded({ sport: 'WNBA', tier: 'desk', cacheGet });
|
||||
const best = props.find((p) => p.player === 'best');
|
||||
expect(best.p_win).toBe(0.78);
|
||||
expect(best.ev_pct).toBe(5);
|
||||
expect(best.model_odds).toBe(-354);
|
||||
});
|
||||
|
||||
test('UNENTITLED tier: identical ORDER, paid signal ABSENT', async () => {
|
||||
const entitled = await svc.getTopGraded({ sport: 'WNBA', tier: 'desk', cacheGet });
|
||||
const free = await svc.getTopGraded({ sport: 'WNBA', tier: 'free', cacheGet });
|
||||
|
||||
// the ORDER is byte-identical — the free caller gets the paid RANKING
|
||||
expect(names(free.props)).toEqual(names(entitled.props));
|
||||
|
||||
// ...and none of the paid VALUES
|
||||
for (const row of free.props) {
|
||||
for (const f of MODEL_FIELDS) {
|
||||
expect(Object.prototype.hasOwnProperty.call(row, f)).toBe(false);
|
||||
}
|
||||
}
|
||||
// serialized form carries no trace either
|
||||
const json = JSON.stringify(free.props);
|
||||
expect(json).not.toMatch(/p_win|ev_pct|model_odds/);
|
||||
// market facts SURVIVE — the fair leg is never the paywall
|
||||
expect(free.props.find((p) => p.player === 'best').fair_odds).toBe(-108);
|
||||
expect(free.props.find((p) => p.player === 'best').book_odds).toBe(-110);
|
||||
});
|
||||
|
||||
test('anonymous (no tier given) defaults to the LEAST entitled — fails closed', async () => {
|
||||
const { props } = await svc.getTopGraded({ sport: 'WNBA', cacheGet });
|
||||
for (const row of props) {
|
||||
for (const f of MODEL_FIELDS) expect(row[f]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('topGradedService — contract + honest empty', () => {
|
||||
test('emits the populated-render contract (unguarded g.stat.replace must not crash)', async () => {
|
||||
const { props } = await svc.getTopGraded({ sport: 'WNBA', tier: 'free', cacheGet });
|
||||
for (const p of props) {
|
||||
expect(typeof p.player).toBe('string');
|
||||
expect(p.player.length).toBeGreaterThan(0);
|
||||
expect(typeof p.stat).toBe('string');
|
||||
expect(() => p.stat.replace(/_/g, ' ')).not.toThrow();
|
||||
expect(p.sport).toBe('WNBA'); // UPPERCASE for SportPill
|
||||
expect(['over', 'under']).toContain(p.direction);
|
||||
expect(Number.isFinite(p.line)).toBe(true);
|
||||
expect(p.grade).toBeTruthy();
|
||||
expect(p.player_name).toBe(p.player); // the other callers' alias
|
||||
expect(p.stat_type).toBe(p.stat);
|
||||
}
|
||||
});
|
||||
|
||||
test('drops unrenderable rows rather than crashing the board', async () => {
|
||||
const bad = (key) => Promise.resolve(key === 'snapshot:mlb:latest' ? { grades: [
|
||||
{ player: null, stat_type: 'hits', line: 0.5, grade: 'A' },
|
||||
{ player: 'ok', stat_type: null, line: 0.5, grade: 'A' },
|
||||
{ player: 'ok2', stat_type: 'hits', line: null, grade: 'A' },
|
||||
{ player: 'good', stat_type: 'hits', line: 0.5, grade: 'A' },
|
||||
] } : null);
|
||||
const { props } = await svc.getTopGraded({ sport: 'MLB', tier: 'free', cacheGet: bad });
|
||||
expect(names(props)).toEqual(['good']);
|
||||
});
|
||||
|
||||
test('thin/empty slate → props: [] (never a 404, never filler)', async () => {
|
||||
const empty = () => Promise.resolve(null);
|
||||
const out = await svc.getTopGraded({ sport: 'MLB', tier: 'free', cacheGet: empty });
|
||||
expect(out.props).toEqual([]);
|
||||
});
|
||||
|
||||
test('refusals (insufficient_data) never reach the board', async () => {
|
||||
const g = (key) => Promise.resolve(key === 'snapshot:mlb:latest' ? { grades: [
|
||||
{ player: 'refused', stat_type: 'hits', line: 0.5, grade: 'C', insufficient_data: true },
|
||||
{ player: 'real', stat_type: 'hits', line: 0.5, grade: 'C' },
|
||||
] } : null);
|
||||
const { props } = await svc.getTopGraded({ sport: 'MLB', tier: 'free', cacheGet: g });
|
||||
expect(names(props)).toEqual(['real']);
|
||||
});
|
||||
|
||||
test('limit is honored and capped; no sport = all sports merged', async () => {
|
||||
const one = await svc.getTopGraded({ sport: 'WNBA', tier: 'free', limit: 2, cacheGet });
|
||||
expect(one.props).toHaveLength(2);
|
||||
const all = await svc.getTopGraded({ tier: 'free', cacheGet }); // gradeOfTheDay's call
|
||||
expect(all.props.length).toBeGreaterThan(0);
|
||||
expect(all.sport).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ONE shared ranking definition — hero / server / client cannot drift', () => {
|
||||
test('server rankGrades and the web mirror agree on identical fixtures', () => {
|
||||
const fixture = GRADES.map((g) => ({ ...g, player: g.player, edge: g.p_win ? null : 1 }));
|
||||
const server = rankGrades(fixture, 10).map((g) => g.player);
|
||||
const client = webAdapter.selectTopGrades(fixture, 10).map((g) => g.player);
|
||||
expect(server).toEqual(client);
|
||||
});
|
||||
|
||||
test('the hero uses the SAME takeable-gated primitive (no inline copy)', () => {
|
||||
const src = require('fs').readFileSync(
|
||||
require('path').join(__dirname, '../../src/services/heroPropService.js'), 'utf8',
|
||||
);
|
||||
expect(src).toMatch(/require\('\.\.\/utils\/gradeRanking'\)/);
|
||||
expect(src).toMatch(/takeablePWin\(g\)/);
|
||||
// the former inline duplicate is gone
|
||||
expect(src).not.toMatch(/const num = \(v\) =>/);
|
||||
});
|
||||
|
||||
test('takeablePWin gates chalk and honors the locked-odds fallback', () => {
|
||||
expect(takeablePWin({ p_win: 0.9, book_odds: -300 })).toBeNull();
|
||||
expect(takeablePWin({ p_win: 0.9, book_odds: -120 })).toBe(0.9);
|
||||
expect(takeablePWin({ p_win: 0.9, gradedAt: { odds: -115 } })).toBe(0.9);
|
||||
expect(takeablePWin({ p_win: null, book_odds: -110 })).toBeNull();
|
||||
expect(takeablePWin({ p_win: 0.9 })).toBeNull(); // no price → cannot gate
|
||||
});
|
||||
|
||||
test('board (grade-first) and hero (p_win-first) may differ, but agree WITHIN a tier', async () => {
|
||||
const { props } = await svc.getTopGraded({ sport: 'WNBA', tier: 'desk', cacheGet });
|
||||
expect(props[0].player).toBe('topgrade'); // grade A leads the board
|
||||
// the hero's rule = max takeable p_win regardless of tier → 'best'
|
||||
const heroPick = GRADES.filter((g) => takeablePWin(g) != null)
|
||||
.sort((a, b) => takeablePWin(b) - takeablePWin(a))[0];
|
||||
expect(heroPick.player).toBe('best');
|
||||
expect(props[0].player).not.toBe(heroPick.player); // INTENDED difference
|
||||
// ...and within the leading tier they agree
|
||||
const tier = props.filter((p) => p.grade === 'A');
|
||||
expect(tier[0].player).toBe('topgrade');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/props/top-graded — route', () => {
|
||||
let app;
|
||||
beforeAll(() => { app = require('../../src/app'); });
|
||||
afterEach(() => { require('../../src/routes/props').__deps = undefined; });
|
||||
|
||||
test('200 with ranked props; anonymous caller gets NO paid fields + public cache', async () => {
|
||||
require('../../src/routes/props').__internals.setDeps({
|
||||
resolveTier: async () => 'free',
|
||||
getTopGraded: (o) => svc.getTopGraded({ ...o, cacheGet }),
|
||||
});
|
||||
const res = await request(app).get('/api/props/top-graded?sport=WNBA');
|
||||
expect(res.status).toBe(200);
|
||||
expect(names(res.body.props)).toEqual(['topgrade', 'best', 'mid', 'chalk', 'nosignal']);
|
||||
expect(res.headers['cache-control']).toMatch(/public/);
|
||||
for (const row of res.body.props) {
|
||||
for (const f of MODEL_FIELDS) expect(row[f]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test('an entitled bearer caller gets p_win AND a private cache directive', async () => {
|
||||
require('../../src/routes/props').__internals.setDeps({
|
||||
resolveTier: async () => 'desk',
|
||||
getTopGraded: (o) => svc.getTopGraded({ ...o, cacheGet }),
|
||||
});
|
||||
const res = await request(app)
|
||||
.get('/api/props/top-graded?sport=WNBA')
|
||||
.set('Authorization', 'Bearer x');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.props.find((p) => p.player === 'best').p_win).toBe(0.78);
|
||||
expect(res.headers['cache-control']).toMatch(/private/);
|
||||
});
|
||||
|
||||
test('a thrown selector still returns 200 + empty props, never a 404', async () => {
|
||||
require('../../src/routes/props').__internals.setDeps({
|
||||
resolveTier: async () => 'free',
|
||||
getTopGraded: () => { throw new Error('boom'); },
|
||||
});
|
||||
const res = await request(app).get('/api/props/top-graded?sport=MLB');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.props).toEqual([]);
|
||||
});
|
||||
});
|
||||
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user