Session 9: api-football + FootApi + Tank01 adapters, grace period middleware, cookie consent, /pricing page, OOM fix documented (1240 tests)

This commit is contained in:
Kev
2026-06-10 19:41:37 -04:00
parent 4db1c1c539
commit b55dcbd614
25 changed files with 2463 additions and 22 deletions
+147 -1
View File
@@ -4,7 +4,153 @@
2026-06-10
## Current Phase
SHIP BUILD v8.0 — Frontend Stripe Cutover + Soccer Pages (Session 8)
SHIP BUILD v9.0 — Critical site fixes, data-source upgrade, production readiness (Session 9)
## Session 9 (2026-06-10) — SHIPPED
World Cup opens tomorrow. This session closed three live-site
emergencies (404, OOM cycle, slow FCP), added three new soccer data
sources with a priority cascade, two new RapidAPI sports adapters, a
real grace-period downgrade middleware, and updated the legal pages.
### Phase 0 — critical fixes
- **`/pricing` 404 → fixed.** `web/src/app/pricing/page.tsx` created;
wraps the existing `Pricing` component on a standalone route so
email renewal CTAs (which link to `/pricing` via
`web/src/services/email.ts:204`) no longer land on 404. Metadata
block ships with OG + Twitter tags.
- **Web container OOM cycle → cause identified, fix documented.**
`docker logs` on the live host (z2zyki…-032334469519, 44 restarts
and climbing) returned `FATAL ERROR: Reached heap limit Allocation
failed - JavaScript heap out of memory`. Docker mem limit is
unlimited (0) — this is Node's own ~2 GB V8 default. Fix is a
Coolify env-var change: **`NODE_OPTIONS=--max-old-space-size=4096`**
on the web container. Cannot be applied from this session — listed
under the Coolify env requirements at the end of this entry.
- **7.5s FCP → root cause traced to the OOM cycle.** All page routes
are static-prerendered; root layout makes no blocking calls. The
FCP measurement is dominated by cold-start latency hit during each
restart. The NODE_OPTIONS fix is the primary FCP fix too — re-measure
after deploy.
### Phase 1 — soccer source upgrade
New adapter cascade for soccer (priority order):
1. **api-football.com (PRIMARY)**`src/services/adapters/apiFootballAdapter.js`.
100 req/day soft limit (90, with 10-req safety margin). 6 endpoints:
`getFixtures`, `getFixtureLineups`, `getFixturePlayerStats`,
`getFixtureEvents`, `getPlayerSeasonStats`, `getStandings`. Auth via
`x-apisports-key` header (NOT RapidAPI). Per-endpoint TTLs match
data volatility (fixtures 6h, lineups/playerstats 24h, events 12h).
2. **FootApi via RapidAPI (BACKUP)**`src/services/adapters/footApiAdapter.js`.
50 req/day (soft 45). 4 endpoints: `getMatchLineups` (28 stat keys),
`getMatchIncidents` (minute + addedTime), `getRefereeStatistics`
(yellow/red per game), `getWorldCupSchedule` (tournament ID 16).
3. **football-data.org (TERTIARY)** — existing Session 7j adapter unchanged.
The `soccerFeatureExtractor` now cascades through these via a new
`loadFromCascade()` helper. Each load returns a `_source` tag so
debugging is straightforward; `meta.sources` exposes the
attribution per lookup (`player`, `nextMatch`, `lastFixture`,
`referee`). Existing 17 soccer-extractor tests still pass; 7 new
cascade tests prove the priority order.
### Phase 1 — Tank01 RapidAPI adapters
- **`tank01NbaAdapter.js`** — live NBA box scores, schedule, betting
odds. Status-aware TTL: 5-min cache while a game is in-progress,
24-hour cache once it reports Final. Free tier 1,000 req/mo;
TTL-bound rather than counter-bound.
- **`tank01MlbAdapter.js`** — live MLB box scores, daily scoreboard,
and **batter-vs-pitcher** (the headline new MLB signal — a batter's
historical PA/AB/H/HR/SO line against a specific pitcher). Same
status-aware TTL pattern as NBA.
Both Tank01 adapters use the shared `RAPID_API_KEY` (also used by
FootApi). Host overridable via `TANK01_NBA_HOST` / `TANK01_MLB_HOST`.
### Phase 2 — production readiness
- **Grace-period downgrade middleware** — `src/middleware/gracePeriod.js`.
Fires at request time on tier-gated routes (`/api/scan/parlay`,
`/api/alerts`, `/api/props/joint-history`). Reads
`req.user.grace_period_until` (now selected by `requireAuth` in
`src/middleware/auth.js`), and on expiry atomically downgrades
`users.tier` and `user_profiles.tier` to `'free'`, clears the
timestamp, sets `subscription_status='expired'` on the profile
mirror, and rewrites `req.user` so the route immediately sees the
downgrade. Closes the long-standing "cancelled users keep paid
access forever" gap. **Ordering matters**: grace must run AFTER
requireAuth and BEFORE scanLimit, because scanLimit reads tier off
req.user — a just-expired Desk user would otherwise burn one final
unlimited-quota request.
- **TOS update** — `web/src/app/terms/page.tsx` Subscription Terms
switched from NexaPay to Stripe; Acceptable Use now explicitly
states "VYNDR does NOT offer API access at any tier" — closes the
Session 7h immutable.
- **Privacy update** — `web/src/app/privacy/page.tsx` Payment Data
section switched from NexaPay to Stripe with specifics on what
Stripe receives. New "Sub-processors" section explicitly lists
Stripe, Supabase, PostHog, Resend.
- **Cookie consent banner** — `web/src/components/CookieConsent.tsx`,
mounted in root layout. Thin bottom bar, SSR-safe (renders nothing
until client mount checks localStorage), single-button accept,
links to Privacy Policy.
- **Root layout metadata** — keywords + description extended to
include soccer and World Cup 2026 intelligence terms. OG + Twitter
cards already comprehensive from prior sessions. Per-page metadata
for /soccer + /scan deferred (those pages are `'use client'`; would
need server-component wrappers — cosmetic).
### Tests added
| Suite | Tests |
|------------------------------------------------|-------|
| `tests/unit/apiFootballAdapter.test.js` | 16 |
| `tests/unit/footApiAdapter.test.js` | 13 |
| `tests/unit/soccerFeatureExtractorCascade.test.js` | 7 |
| `tests/unit/tank01NbaAdapter.test.js` | 12 |
| `tests/unit/tank01MlbAdapter.test.js` | 12 |
| `tests/unit/gracePeriod.test.js` | 7 |
| **Session 9 total** | **67** |
### Quality gates
- `npm test`: **1240 / 1240 passing** (1173 baseline + 67 new), 97 suites, 0 regressions
- `web/npm run build`: clean — `/pricing` + everything else prerenders, no type errors
- License audit: only permissive licenses
### Coolify env vars (apply on the web container — keys not in repo)
```
NODE_OPTIONS=--max-old-space-size=4096 # fixes the OOM cycle
API_FOOTBALL_KEY=<from api-sports.io> # PRIMARY soccer source
FOOTBALL_DATA_API_KEY=<from football-data.org> # TERTIARY soccer source
RAPID_API_KEY=<from RapidAPI marketplace> # FootApi + Tank01 NBA + Tank01 MLB
FOOTAPI_HOST=footapi7.p.rapidapi.com # default — override only for mirrors
TANK01_NBA_HOST=tank01-fantasy-stats.p.rapidapi.com
TANK01_MLB_HOST=tank01-mlb-live-in-game-real-time-statistics.p.rapidapi.com
```
### Open items
- `NODE_OPTIONS` must be set in Coolify before the next deploy; until
then, the web container will keep OOM-looping. This is the single
most important production action item.
- The 2 GB+ heap usage that triggered the OOM suggests a memory leak
in the Next.js standalone server. Heap-snapshot investigation
deferred — the env-var bump buys headroom but doesn't fix the leak
root cause.
- Per-page OG metadata on `/soccer` and `/scan` requires those pages
to be refactored to a server-component wrapper pattern. Not blocking.
- The new adapter cascade improves data quality WHEN
`API_FOOTBALL_KEY` / `RAPID_API_KEY` are populated and a daily
prefetch has run against them. Until then, the cascade silently
falls through to football-data.org and static reference data.
Updating `scripts/soccer-data-prefetch.js` to write the new
`apifootball:*` / `footapi:*` cache keys is a follow-up.
---
## Session 8 (2026-06-10) — SHIPPED
+14
View File
@@ -453,3 +453,17 @@
{"ts":"2026-06-10T19:18:39.153Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-10T19:18:39.210Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T19:18:39.431Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T20:10:39.396Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T20:10:39.458Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-10T20:10:39.458Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-10T20:10:39.458Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T20:10:39.506Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-10T20:10:39.510Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T20:10:39.644Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T21:37:51.108Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
{"ts":"2026-06-10T21:37:51.115Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
{"ts":"2026-06-10T21:37:51.115Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
{"ts":"2026-06-10T21:37:51.188Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
{"ts":"2026-06-10T21:37:51.188Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
{"ts":"2026-06-10T21:37:51.300Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
{"ts":"2026-06-10T21:37:51.313Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
+18 -4
View File
@@ -198,13 +198,23 @@ back). Updated this session in Section 1 of Session 7c.
| `PINNACLE_API_BASE` | ✓ commented (legacy) |
| `ODDS_API_KEY` | ✓ commented (legacy) |
### Soccer / World Cup 2026 (Session 7j)
### Soccer / World Cup 2026 (Session 7j + 9)
| Var | Required | Default | Used By | Doc? |
| ------------------------- | -------- | ------------------------------------------------ | ------------------------------------------------------- | ---- |
| `FOOTBALL_DATA_API_KEY` | no | (none) | `footballDataAdapter`, `soccer-data-prefetch` | ✓ |
| `FOOTBALL_DATA_API_KEY` | no | (none) | `footballDataAdapter` (TERTIARY) | ✓ |
| `API_FOOTBALL_KEY` | no | (none) | `apiFootballAdapter` (PRIMARY, Session 9) | ✓ S9 |
| `SOCCER_LEAGUES` | no | `WC` | `poller/soccer.js`, `soccer-data-prefetch` | ✓ |
| `WORLDCUP_API_URL` | no | `https://worldcup2026-api.up.railway.app/api/...` | `poller/soccer.js` | ✓ |
| `RAPID_API_KEY` | no | (none) | reserved for `soccer-data-prefetch` referee enrichment | ✓ |
| `RAPID_API_KEY` | no | (none) | `footApiAdapter` (BACKUP), `tank01NbaAdapter`, `tank01MlbAdapter` | ✓ S9 |
| `FOOTAPI_HOST` | no | `footapi7.p.rapidapi.com` | `footApiAdapter` | ✓ S9 |
| `TANK01_NBA_HOST` | no | `tank01-fantasy-stats.p.rapidapi.com` | `tank01NbaAdapter` | ✓ S9 |
| `TANK01_MLB_HOST` | no | `tank01-mlb-live-in-game-real-time-statistics.p.rapidapi.com` | `tank01MlbAdapter` | ✓ S9 |
Container runtime (Session 9 finding):
- `NODE_OPTIONS=--max-old-space-size=4096` — set on the web container
in Coolify. Without it, Next.js's V8 default ceiling (~2 GB) is hit
in production and the container OOM-loops (44 restarts observed on
the live host before the fix was identified).
### Engine 2
| Var | Doc? |
@@ -368,7 +378,11 @@ Source: `grep -rn "cacheSet\|cacheGet\|redis\.set"`.
| Resend (email) | `web/src/services/email.ts` | `RESEND_API_KEY`, `RESEND_FROM_EMAIL` | n/a | transactional email |
| NexaPay | `web/src/services/nexapay.ts` | `NEXAPAY_*` | n/a | checkout fallback |
| PostHog | `web/src/lib/analytics.ts` | `NEXT_PUBLIC_POSTHOG_KEY/HOST` | n/a | browser analytics |
| football-data.org | `footballDataAdapter.js` | `FOOTBALL_DATA_API_KEY` | 10/min (8 enforced) | poller-soccer, prefetch |
| football-data.org | `footballDataAdapter.js` | `FOOTBALL_DATA_API_KEY` | 10/min (8 enforced) | poller-soccer, prefetch (TERTIARY) |
| api-football.com | `apiFootballAdapter.js` | `API_FOOTBALL_KEY` | 100/day (soft 90) | soccer cascade (PRIMARY, Session 9) |
| FootApi (RapidAPI) | `footApiAdapter.js` | `RAPID_API_KEY`, `FOOTAPI_HOST` | 50/day (soft 45) | soccer cascade (BACKUP, Session 9) |
| Tank01 NBA (RapidAPI) | `tank01NbaAdapter.js` | `RAPID_API_KEY`, `TANK01_NBA_HOST` | 1000/mo (TTL bound) | live NBA box scores (Session 9) |
| Tank01 MLB (RapidAPI) | `tank01MlbAdapter.js` | `RAPID_API_KEY`, `TANK01_MLB_HOST` | 1000/mo (TTL bound) | live MLB box + batter-vs-pitcher (Session 9) |
| Stripe | `services/stripeService.js` | `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_*` | n/a | checkout + webhook |
| The Odds API | `services/oddsService.js` | `ODDS_API_KEY` | quota tracked | per-sport odds endpoints |
| worldcup2026 OSS | `poller/soccer.js` | `WORLDCUP_API_URL` | none (free) | WC fixture poll |
+6 -2
View File
@@ -14,10 +14,14 @@ async function requireAuth(req, res, next) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
// Fetch user profile from our users table
// Fetch user profile from our users table. Session 9 added
// `grace_period_until` + `stripe_customer_id` to the select so the
// grace-period middleware can read them off `req.user` without a
// second round-trip. Both fields default to null when absent so
// pre-Stripe users behave identically to before.
const { data: profile, error: profileError } = await supabase
.from('users')
.select('id, email, tier, scan_count, scan_reset_date, founder_status')
.select('id, email, tier, scan_count, scan_reset_date, founder_status, grace_period_until, stripe_customer_id')
.eq('id', user.id)
.single();
+77
View File
@@ -0,0 +1,77 @@
/**
* Grace-period downgrade middleware (Session 9).
*
* Fires at request time on tier-gated routes. The Stripe webhook
* (`customer.subscription.deleted` and `invoice.payment_failed`) sets
* `users.grace_period_until` to now + 48h on cancellation / payment
* failure. Until Session 9, nothing actively checked whether the
* grace had expired — so cancelled users could keep paid access
* indefinitely. This middleware closes that gap.
*
* Behavior:
* - No `grace_period_until` on req.user → pass through
* - Grace still in the future → pass through
* - Grace expired → atomically
* downgrade `users.tier` AND `user_profiles.tier` to 'free',
* clear the grace timestamp, set subscription_status='expired'
* on the profile mirror, and rewrite req.user so the downstream
* route immediately sees the downgraded tier.
*
* Mount AFTER `requireAuth` on tier-gated routes. Routes that don't
* gate by tier (e.g. /api/bets read-only views) don't need it.
*
* Failure semantics: if either DB write fails, we still call next()
* — the user might briefly retain paid access until the next request,
* but at least the route keeps serving. The webhook's grace pointer
* stays set, so we'll try again on the next request.
*/
const { getSupabaseServiceClient } = require('../utils/supabase');
async function checkGracePeriod(req, res, next) {
const user = req.user;
// No user (unauth route slipping through?) — bail to next.
if (!user || !user.grace_period_until) return next();
const grace = new Date(user.grace_period_until);
// Invalid date → treat as no grace.
if (!Number.isFinite(grace.getTime())) return next();
// Still in grace window — let them keep paid access.
if (grace.getTime() > Date.now()) return next();
// Expired. Downgrade in both tables. We log on failure but DO NOT
// throw — the route still serves; we re-try on the next request.
try {
const supabase = getSupabaseServiceClient();
const { error: usersErr } = await supabase
.from('users')
.update({ tier: 'free', grace_period_until: null })
.eq('id', user.id);
if (usersErr) {
console.warn('[gracePeriod] users update failed:', usersErr.message);
}
const { error: profileErr } = await supabase
.from('user_profiles')
.update({
tier: 'free',
subscription_status: 'expired',
grace_period_until: null,
})
.eq('id', user.id);
if (profileErr) {
console.warn('[gracePeriod] user_profiles update failed:', profileErr.message);
}
// Reflect on req.user so the downstream route sees the downgrade
// immediately (no race against a stale closure).
req.user.tier = 'free';
req.user.grace_period_until = null;
} catch (err) {
console.warn('[gracePeriod] downgrade error (continuing):', err.message);
}
return next();
}
module.exports = { checkGracePeriod };
+3 -2
View File
@@ -1,12 +1,13 @@
const express = require('express');
const { requireAuth } = require('../middleware/auth');
const { checkGracePeriod } = require('../middleware/gracePeriod');
const { getAlertsForUser, markAlertRead } = require('../services/alertService');
const router = express.Router();
const PAID_TIERS = new Set(['analyst', 'desk']);
router.get('/', requireAuth, async (req, res) => {
router.get('/', requireAuth, checkGracePeriod, async (req, res) => {
if (!PAID_TIERS.has(req.user.tier)) {
return res.status(403).json({ error: 'Alerts are available on Analyst and Desk tiers' });
}
@@ -20,7 +21,7 @@ router.get('/', requireAuth, async (req, res) => {
}
});
router.patch('/:id/read', requireAuth, async (req, res) => {
router.patch('/:id/read', requireAuth, checkGracePeriod, async (req, res) => {
if (!PAID_TIERS.has(req.user.tier)) {
return res.status(403).json({ error: 'Alerts are available on Analyst and Desk tiers' });
}
+2 -1
View File
@@ -1,11 +1,12 @@
const express = require('express');
const { requireAuth } = require('../middleware/auth');
const { checkGracePeriod } = require('../middleware/gracePeriod');
const { getSupabaseServiceClient } = require('../utils/supabase');
const router = express.Router();
// GET /joint-history — joint outcome history and phi coefficient
router.get('/joint-history', requireAuth, async (req, res) => {
router.get('/joint-history', requireAuth, checkGracePeriod, async (req, res) => {
const { player_a, stat_a, player_b, stat_b } = req.query;
// Block free tier
+7 -1
View File
@@ -1,5 +1,6 @@
const express = require('express');
const { requireAuth } = require('../middleware/auth');
const { checkGracePeriod } = require('../middleware/gracePeriod');
const { scanLimit } = require('../middleware/scanLimit');
const { scanParlay } = require('../services/parlayScanService');
@@ -38,7 +39,12 @@ function validateLegs(legs) {
return null;
}
router.post('/parlay', requireAuth, scanLimit(), async (req, res) => {
// requireAuth → checkGracePeriod → scanLimit ordering matters:
// requireAuth populates req.user; checkGracePeriod may downgrade
// req.user.tier; scanLimit reads tier off req.user to pick the
// per-tier quota. If grace check ran AFTER scanLimit, a just-
// expired Desk user would get unlimited quota for one final scan.
router.post('/parlay', requireAuth, checkGracePeriod, scanLimit(), async (req, res) => {
const { legs } = req.body;
const validationError = validateLegs(legs);
+363
View File
@@ -0,0 +1,363 @@
/**
* api-football.com adapter — PRIMARY soccer data source (Session 9).
*
* The original API-Football by api-sports.io, now self-hosted (it
* left RapidAPI). Free tier: 100 requests/day. Richest soccer data
* we have access to: per-fixture player stats, lineups with grids,
* referee assignments, full event timeline.
*
* Auth: `x-apisports-key` header. This is NOT RapidAPI — DO NOT add
* x-rapidapi-host / x-rapidapi-key here (that's the FootApi backup
* adapter, separate file).
*
* Env:
* API_FOOTBALL_KEY — api-sports.io API key
*
* Rate limit:
* Hard 100 req/day on the upstream side. We track usage in Redis
* via the `apifootball:daily_count` key (24h TTL) and stop at 90 to
* leave a 10-req safety margin for the daily prefetch and any
* ad-hoc operator pulls.
*
* Graceful degradation:
* - Missing key → all functions return null
* - Daily counter exhausted → stale-while-revalidate from Redis
* if available, else null
* - Upstream 4xx/5xx → log + stale-while-revalidate
* fallback, else null
* Never throws.
*/
const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis');
const BASE_URL = 'https://v3.football.api-sports.io';
const HTTP_TIMEOUT_MS = 8_000;
// Per-endpoint cache TTLs (seconds). Match the upstream data
// volatility: fixtures drift fast, lineups freeze post-kickoff,
// season stats only matter day-over-day.
const TTL = Object.freeze({
fixtures: 6 * 3600, // 6h
lineups: 24 * 3600, // 24h — locked once posted
playerStats: 24 * 3600, // 24h — locked once final
events: 12 * 3600, // 12h
player: 24 * 3600, // 24h
standings: 12 * 3600, // 12h
});
const DAILY_LIMIT = 100;
const SAFETY_MARGIN = 10;
const SOFT_LIMIT = DAILY_LIMIT - SAFETY_MARGIN; // 90
const DAILY_COUNTER_KEY = 'apifootball:daily_count';
const DAILY_TTL_SEC = 24 * 3600;
function hasApiKey() {
return !!process.env.API_FOOTBALL_KEY;
}
async function readDailyCount() {
const v = await cacheGet(DAILY_COUNTER_KEY);
if (v == null) return 0;
if (typeof v === 'number') return v;
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
async function bumpDailyCount() {
const next = (await readDailyCount()) + 1;
// 24h sliding window — every bump refreshes the TTL so the counter
// doesn't expire mid-day after the first call of the morning.
await cacheSet(DAILY_COUNTER_KEY, next, DAILY_TTL_SEC);
return next;
}
// One central HTTP path — applies the api-sports auth, rate-limit
// check, caching, and stale-while-revalidate fallback. Returns
// parsed JSON or null. Never throws.
async function fetchWithCache(path, cacheKey, ttl) {
// 1. Fresh cache hit.
const fresh = await cacheGet(cacheKey);
if (fresh !== null) return fresh;
// 2. No API key → can't fetch. Callers degrade to null.
if (!hasApiKey()) return null;
// 3. Daily counter check — fall through to stale-while-revalidate.
const used = await readDailyCount();
if (used >= SOFT_LIMIT) {
const stale = await cacheGet(`${cacheKey}:stale`);
if (stale !== null) return stale;
return null;
}
// 4. Network.
try {
const res = await axios.get(`${BASE_URL}${path}`, {
headers: { 'x-apisports-key': process.env.API_FOOTBALL_KEY },
timeout: HTTP_TIMEOUT_MS,
});
await bumpDailyCount();
const body = res.data;
if (body && typeof body === 'object') {
await cacheSet(cacheKey, body, ttl);
await cacheSet(`${cacheKey}:stale`, body, ttl * 4);
}
return body;
} catch (err) {
console.warn('[apiFootball] fetch failed:', path, err.message);
const stale = await cacheGet(`${cacheKey}:stale`);
if (stale !== null) return stale;
return null;
}
}
// ---- Public surface ----
/**
* getFixtures — fixtures by league/season/date. World Cup is
* league=1, season=2026.
*
* @param {Object} params { league, season, date? }
* @returns {Promise<Array|null>}
*/
async function getFixtures({ league, season, date } = {}) {
if (!league || !season) return null;
const dateSegment = date ? `:${date}` : '';
const query = date
? `?league=${league}&season=${season}&date=${date}`
: `?league=${league}&season=${season}`;
const data = await fetchWithCache(
`/fixtures${query}`,
`apifootball:fixtures:${league}:${season}${dateSegment}`,
TTL.fixtures,
);
if (data === null) return null;
const list = data.response;
if (!Array.isArray(list)) return [];
return list.map((f) => ({
id: f.fixture?.id ?? null,
utcDate: f.fixture?.date ?? null,
status: f.fixture?.status?.short ?? null,
venue: f.fixture?.venue?.name ?? null,
referee: f.fixture?.referee ?? null,
league: f.league?.name ?? null,
season: f.league?.season ?? null,
round: f.league?.round ?? null,
homeTeam: f.teams?.home?.name ?? null,
awayTeam: f.teams?.away?.name ?? null,
homeTeamId: f.teams?.home?.id ?? null,
awayTeamId: f.teams?.away?.id ?? null,
score: f.score ?? null,
}));
}
/**
* getFixtureLineups — starting XI + bench, with grid positions.
*/
async function getFixtureLineups(fixtureId) {
if (!fixtureId) return null;
const data = await fetchWithCache(
`/fixtures/lineups?fixture=${fixtureId}`,
`apifootball:lineups:${fixtureId}`,
TTL.lineups,
);
if (data === null) return null;
const list = data.response;
if (!Array.isArray(list)) return [];
return list.map((side) => ({
team: side.team?.name ?? null,
teamId: side.team?.id ?? null,
coach: side.coach?.name ?? null,
formation: side.formation ?? null,
startXI: (side.startXI || []).map((p) => ({
id: p.player?.id ?? null,
name: p.player?.name ?? null,
number: p.player?.number ?? null,
pos: p.player?.pos ?? null,
grid: p.player?.grid ?? null,
})),
substitutes: (side.substitutes || []).map((p) => ({
id: p.player?.id ?? null,
name: p.player?.name ?? null,
number: p.player?.number ?? null,
pos: p.player?.pos ?? null,
})),
}));
}
/**
* getFixturePlayerStats — per-player stats for a finished match.
* THE money endpoint for grade calibration: shots, goals, passes,
* tackles, cards, minutes, official rating.
*/
async function getFixturePlayerStats(fixtureId) {
if (!fixtureId) return null;
const data = await fetchWithCache(
`/fixtures/players?fixture=${fixtureId}`,
`apifootball:playerstats:${fixtureId}`,
TTL.playerStats,
);
if (data === null) return null;
const list = data.response;
if (!Array.isArray(list)) return [];
const out = [];
for (const side of list) {
const team = side.team?.name ?? null;
for (const player of side.players || []) {
const stats = player.statistics?.[0] || {};
out.push({
team,
playerId: player.player?.id ?? null,
name: player.player?.name ?? null,
minutes: stats.games?.minutes ?? null,
position: stats.games?.position ?? null,
rating: stats.games?.rating ?? null,
substitute: !!stats.games?.substitute,
goals: stats.goals?.total ?? 0,
assists: stats.goals?.assists ?? 0,
shots_total: stats.shots?.total ?? 0,
shots_on: stats.shots?.on ?? 0,
passes_total: stats.passes?.total ?? 0,
passes_accuracy: stats.passes?.accuracy ?? null,
tackles_total: stats.tackles?.total ?? 0,
tackles_blocks: stats.tackles?.blocks ?? 0,
tackles_interceptions: stats.tackles?.interceptions ?? 0,
yellow: stats.cards?.yellow ?? 0,
red: stats.cards?.red ?? 0,
saves: stats.goals?.saves ?? null,
});
}
}
return out;
}
/**
* getFixtureEvents — minute-by-minute goals, cards, subs.
*/
async function getFixtureEvents(fixtureId) {
if (!fixtureId) return null;
const data = await fetchWithCache(
`/fixtures/events?fixture=${fixtureId}`,
`apifootball:events:${fixtureId}`,
TTL.events,
);
if (data === null) return null;
const list = data.response;
if (!Array.isArray(list)) return [];
return list.map((e) => ({
minute: e.time?.elapsed ?? null,
extra: e.time?.extra ?? null,
team: e.team?.name ?? null,
player: e.player?.name ?? null,
assist: e.assist?.name ?? null,
type: e.type ?? null,
detail: e.detail ?? null,
comments: e.comments ?? null,
}));
}
/**
* getPlayerSeasonStats — season aggregate for a single player.
* The feature extractor reads this for goals_per_90, xG (if exposed
* by the league response), minutes_per_game, start_rate.
*/
async function getPlayerSeasonStats(playerId, season) {
if (!playerId || !season) return null;
const data = await fetchWithCache(
`/players?id=${playerId}&season=${season}`,
`apifootball:player:${playerId}:season:${season}`,
TTL.player,
);
if (data === null) return null;
const list = data.response;
if (!Array.isArray(list) || list.length === 0) return [];
// The response is an array of { player, statistics: [perCompetition] }.
// We collapse into a flat per-competition list — the caller picks
// the league they care about (e.g. WC = league.id 1).
const player = list[0].player || {};
const stats = list[0].statistics || [];
return stats.map((s) => ({
playerId: player.id ?? null,
name: player.name ?? null,
nationality: player.nationality ?? null,
team: s.team?.name ?? null,
teamId: s.team?.id ?? null,
leagueId: s.league?.id ?? null,
leagueName: s.league?.name ?? null,
appearances: s.games?.appearences ?? 0, // sic — api-football typo
lineups: s.games?.lineups ?? 0,
minutes: s.games?.minutes ?? 0,
position: s.games?.position ?? null,
rating: s.games?.rating ?? null,
goals: s.goals?.total ?? 0,
assists: s.goals?.assists ?? 0,
shots_total: s.shots?.total ?? 0,
shots_on: s.shots?.on ?? 0,
passes_total: s.passes?.total ?? 0,
tackles_total: s.tackles?.total ?? 0,
yellow: s.cards?.yellow ?? 0,
red: s.cards?.red ?? 0,
penalty_scored: s.penalty?.scored ?? 0,
penalty_missed: s.penalty?.missed ?? 0,
}));
}
/**
* getStandings — league table with goals for/against per team.
*/
async function getStandings(league, season) {
if (!league || !season) return null;
const data = await fetchWithCache(
`/standings?league=${league}&season=${season}`,
`apifootball:standings:${league}:${season}`,
TTL.standings,
);
if (data === null) return null;
const list = data.response;
if (!Array.isArray(list) || list.length === 0) return [];
// World Cup standings are grouped (by group A, B, C…). Flatten so
// the prefetch can compute defensive rank across the whole field.
const out = [];
for (const entry of list) {
const groups = entry.league?.standings || [];
for (const group of groups) {
for (const row of group) {
out.push({
rank: row.rank ?? null,
team: row.team?.name ?? null,
teamId: row.team?.id ?? null,
played: row.all?.played ?? 0,
win: row.all?.win ?? 0,
draw: row.all?.draw ?? 0,
lose: row.all?.lose ?? 0,
goalsFor: row.all?.goals?.for ?? 0,
goalsAgainst: row.all?.goals?.against ?? 0,
points: row.points ?? 0,
group: row.group ?? null,
});
}
}
}
return out;
}
module.exports = {
getFixtures,
getFixtureLineups,
getFixturePlayerStats,
getFixtureEvents,
getPlayerSeasonStats,
getStandings,
hasApiKey,
__internals: {
BASE_URL,
TTL,
DAILY_LIMIT,
SOFT_LIMIT,
DAILY_COUNTER_KEY,
readDailyCount,
bumpDailyCount,
resetCounterForTests: async () => cacheSet(DAILY_COUNTER_KEY, 0, DAILY_TTL_SEC),
},
};
+256
View File
@@ -0,0 +1,256 @@
/**
* FootApi adapter — BACKUP soccer data source (Session 9).
*
* Wraps the RapidAPI-hosted FootApi service (Sofascore mirror). Used
* as the fallback when api-football.com is rate-limited or returns
* thin data. Free tier: 50 requests/day.
*
* Auth: RapidAPI headers (x-rapidapi-key + x-rapidapi-host). DO NOT
* use the x-apisports-key header here — that's the primary adapter.
*
* Env:
* RAPID_API_KEY — RapidAPI marketplace key (shared across Tank01 + FootApi)
* FOOTAPI_HOST — host header (defaults to footapi7.p.rapidapi.com)
*
* Rate limit:
* Hard 50 req/day. We track in `footapi:daily_count` (24h TTL) and
* stop at 45 — same 5-req safety margin as the primary adapter
* (smaller absolute margin because the daily budget is smaller).
*
* Tournament IDs used in the URL paths:
* 16 — FIFA World Cup
* (others discovered via the schedule endpoint as needed)
*/
const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis');
const HTTP_TIMEOUT_MS = 8_000;
const TTL = Object.freeze({
lineups: 24 * 3600,
incidents: 12 * 3600,
referee: 7 * 24 * 3600, // 7d — referee stats move slowly
schedule: 6 * 3600,
});
const DAILY_LIMIT = 50;
const SAFETY_MARGIN = 5;
const SOFT_LIMIT = DAILY_LIMIT - SAFETY_MARGIN; // 45
const DAILY_COUNTER_KEY = 'footapi:daily_count';
const DAILY_TTL_SEC = 24 * 3600;
const WC_TOURNAMENT_ID = 16;
function getHost() {
return process.env.FOOTAPI_HOST || 'footapi7.p.rapidapi.com';
}
function hasApiKey() {
return !!process.env.RAPID_API_KEY;
}
async function readDailyCount() {
const v = await cacheGet(DAILY_COUNTER_KEY);
if (v == null) return 0;
const n = typeof v === 'number' ? v : Number(v);
return Number.isFinite(n) ? n : 0;
}
async function bumpDailyCount() {
const next = (await readDailyCount()) + 1;
await cacheSet(DAILY_COUNTER_KEY, next, DAILY_TTL_SEC);
return next;
}
async function fetchWithCache(path, cacheKey, ttl) {
const fresh = await cacheGet(cacheKey);
if (fresh !== null) return fresh;
if (!hasApiKey()) return null;
const used = await readDailyCount();
if (used >= SOFT_LIMIT) {
const stale = await cacheGet(`${cacheKey}:stale`);
if (stale !== null) return stale;
return null;
}
try {
const host = getHost();
const res = await axios.get(`https://${host}${path}`, {
headers: {
'x-rapidapi-key': process.env.RAPID_API_KEY,
'x-rapidapi-host': host,
},
timeout: HTTP_TIMEOUT_MS,
});
await bumpDailyCount();
const body = res.data;
if (body && typeof body === 'object') {
await cacheSet(cacheKey, body, ttl);
await cacheSet(`${cacheKey}:stale`, body, ttl * 4);
}
return body;
} catch (err) {
console.warn('[footApi] fetch failed:', path, err.message);
const stale = await cacheGet(`${cacheKey}:stale`);
if (stale !== null) return stale;
return null;
}
}
// ---- Public surface ----
/**
* getMatchLineups — players with minutesPlayed and the 28-key stats
* block FootApi exposes per player (rating, shots, passes, tackles,
* goals, assists, cards, etc.).
*/
async function getMatchLineups(matchId) {
if (!matchId) return null;
const data = await fetchWithCache(
`/api/match/${matchId}/lineups`,
`footapi:match:${matchId}:lineups`,
TTL.lineups,
);
if (data === null) return null;
// The FootApi response carries `home` and `away` sides, each with
// `players` arrays. Flatten so callers don't need to reach into
// upstream-specific structure.
const out = [];
for (const side of ['home', 'away']) {
const team = data?.[side];
if (!team || !Array.isArray(team.players)) continue;
for (const entry of team.players) {
const p = entry?.player || {};
const stats = entry?.statistics || {};
out.push({
team: team.formation ? `${side}(${team.formation})` : side,
side,
playerId: p.id ?? null,
name: p.name ?? null,
position: entry.position ?? null,
shirtNumber: entry.shirtNumber ?? null,
substitute: !!entry.substitute,
captain: !!entry.captain,
minutesPlayed: stats.minutesPlayed ?? null,
rating: stats.rating ?? null,
goals: stats.goals ?? 0,
assists: stats.goalAssist ?? 0,
shots: stats.totalShots ?? 0,
shotsOnTarget: stats.shotOnTarget ?? 0,
passes: stats.totalPass ?? 0,
accuratePasses: stats.accuratePass ?? 0,
tackles: stats.totalTackle ?? 0,
yellow: stats.yellowCards ?? 0,
red: stats.redCards ?? 0,
saves: stats.saves ?? null,
keyPasses: stats.keyPass ?? 0,
});
}
}
return out;
}
/**
* getMatchIncidents — minute-by-minute goals, cards, subs. The
* minute + addedTime carry detail the events feed needs for trap
* detection (e.g. late-game cards inflate referee_card_bias signal).
*/
async function getMatchIncidents(matchId) {
if (!matchId) return null;
const data = await fetchWithCache(
`/api/match/${matchId}/incidents`,
`footapi:match:${matchId}:incidents`,
TTL.incidents,
);
if (data === null) return null;
const list = data?.incidents;
if (!Array.isArray(list)) return [];
return list.map((i) => ({
type: i.incidentType ?? null,
classType: i.incidentClass ?? null,
minute: i.time ?? null,
addedTime: i.addedTime ?? null,
isHome: i.isHome ?? null,
player: i.player?.name ?? null,
assist: i.assist1?.name ?? null,
text: i.text ?? null,
}));
}
/**
* getRefereeStatistics — referee card + appearance history per
* tournament. The shape `{ yellowCards, redCards, appearances }`
* feeds the soccer-trap `referee_card_bias` signal.
*/
async function getRefereeStatistics(refereeId) {
if (!refereeId) return null;
const data = await fetchWithCache(
`/api/referee/${refereeId}/statistics`,
`footapi:referee:${refereeId}:stats`,
TTL.referee,
);
if (data === null) return null;
const stats = data?.statistics;
if (!Array.isArray(stats)) return [];
return stats.map((s) => ({
tournamentId: s.tournament?.id ?? null,
tournamentName: s.tournament?.name ?? null,
season: s.season?.year ?? null,
appearances: s.appearances ?? 0,
yellowCards: s.yellowCards ?? 0,
redCards: s.redCards ?? 0,
yellowCardsPerGame: s.appearances > 0 ? Math.round((s.yellowCards / s.appearances) * 100) / 100 : null,
redCardsPerGame: s.appearances > 0 ? Math.round((s.redCards / s.appearances) * 1000) / 1000 : null,
}));
}
/**
* getWorldCupSchedule — fixtures for a date. Tournament ID 16 is
* the FIFA World Cup; the path is `/api/tournament/16/schedules/dd/mm/yyyy`.
*/
async function getWorldCupSchedule(day, month, year) {
if (!day || !month || !year) return null;
const dateKey = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const data = await fetchWithCache(
`/api/tournament/${WC_TOURNAMENT_ID}/schedules/${day}/${month}/${year}`,
`footapi:wc:schedule:${dateKey}`,
TTL.schedule,
);
if (data === null) return null;
const list = data?.events;
if (!Array.isArray(list)) return [];
return list.map((e) => ({
id: e.id ?? null,
startTimestamp: e.startTimestamp ?? null,
status: e.status?.type ?? null,
homeTeam: e.homeTeam?.name ?? null,
awayTeam: e.awayTeam?.name ?? null,
homeTeamId: e.homeTeam?.id ?? null,
awayTeamId: e.awayTeam?.id ?? null,
homeScore: e.homeScore?.current ?? null,
awayScore: e.awayScore?.current ?? null,
venue: e.venue?.name ?? null,
referee: e.referee?.name ?? null,
}));
}
module.exports = {
getMatchLineups,
getMatchIncidents,
getRefereeStatistics,
getWorldCupSchedule,
hasApiKey,
__internals: {
TTL,
DAILY_LIMIT,
SOFT_LIMIT,
DAILY_COUNTER_KEY,
WC_TOURNAMENT_ID,
readDailyCount,
bumpDailyCount,
getHost,
resetCounterForTests: async () => cacheSet(DAILY_COUNTER_KEY, 0, DAILY_TTL_SEC),
},
};
+185
View File
@@ -0,0 +1,185 @@
/**
* Tank01 MLB adapter (Session 9) — live in-game stats + batter-vs-pitcher.
*
* The BvP endpoint is the headline new signal: pre-game, we can show
* a batter's historical line against the starting pitcher (hits, K's,
* total bases). This was a documented Day-1 gap.
*
* Same RAPID_API_KEY as the NBA adapter; different host. Free tier
* is 1,000 req/month — we rely on cache TTLs to bound consumption.
*
* Env:
* RAPID_API_KEY — shared RapidAPI marketplace key
* TANK01_MLB_HOST — host (default `tank01-mlb-live-in-game-real-time-statistics.p.rapidapi.com`)
*/
const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis');
const HTTP_TIMEOUT_MS = 8_000;
const DEFAULT_HOST = 'tank01-mlb-live-in-game-real-time-statistics.p.rapidapi.com';
const TTL = Object.freeze({
boxScoreLive: 5 * 60,
boxScoreFinal: 24 * 3600,
scoreboard: 1 * 3600,
bvp: 24 * 3600, // BvP doesn't change mid-day — 24h cache is fine
});
function getHost() {
return process.env.TANK01_MLB_HOST || DEFAULT_HOST;
}
function hasApiKey() {
return !!process.env.RAPID_API_KEY;
}
async function fetchWithCache(path, cacheKey, ttl) {
const fresh = await cacheGet(cacheKey);
if (fresh !== null) return fresh;
if (!hasApiKey()) return null;
try {
const host = getHost();
const res = await axios.get(`https://${host}${path}`, {
headers: {
'x-rapidapi-key': process.env.RAPID_API_KEY,
'x-rapidapi-host': host,
},
timeout: HTTP_TIMEOUT_MS,
});
const body = res.data;
if (body && typeof body === 'object') {
await cacheSet(cacheKey, body, ttl);
await cacheSet(`${cacheKey}:stale`, body, ttl * 4);
}
return body;
} catch (err) {
console.warn('[tank01MLB] fetch failed:', path, err.message);
const stale = await cacheGet(`${cacheKey}:stale`);
if (stale !== null) return stale;
return null;
}
}
/**
* getMLBBoxScore — per-player batting + pitching lines.
* Status-aware TTL: same pattern as the NBA adapter (5 min live,
* 24 h once Final).
*/
async function getMLBBoxScore(gameId) {
if (!gameId) return null;
const cacheKey = `tank01:mlb:boxscore:${gameId}`;
const data = await fetchWithCache(
`/getMLBBoxScore?gameID=${encodeURIComponent(gameId)}`,
cacheKey,
TTL.boxScoreLive,
);
if (data === null) return null;
const body = data?.body || data;
const isFinal = (() => {
const s = body?.gameStatus || body?.status || '';
return typeof s === 'string' && /final/i.test(s);
})();
if (isFinal) await cacheSet(cacheKey, data, TTL.boxScoreFinal);
// Project batters + pitchers into a flat list. Tank01 splits these
// into `playerStats.{batting,pitching}` — we tag the role so the
// consumer can filter.
const stats = body?.playerStats || {};
const out = [];
for (const [id, entry] of Object.entries(stats.batting || stats.batters || {})) {
out.push({ role: 'batter', playerId: id, name: entry.longName || entry.name || null, team: entry.teamAbv || null, _raw: entry, _final: isFinal });
}
for (const [id, entry] of Object.entries(stats.pitching || stats.pitchers || {})) {
out.push({ role: 'pitcher', playerId: id, name: entry.longName || entry.name || null, team: entry.teamAbv || null, _raw: entry, _final: isFinal });
}
return out;
}
/**
* getMLBBatterVsPitcher — historical BvP matchup. The headline new
* MLB signal: a batter's plate appearances, hits, K's, HRs, total
* bases against a specific pitcher's career. Use ID, not name.
*/
async function getMLBBatterVsPitcher(batterId, pitcherId) {
if (!batterId || !pitcherId) return null;
const data = await fetchWithCache(
`/getMLBBatterVsPitcher?batterID=${encodeURIComponent(batterId)}&pitcherID=${encodeURIComponent(pitcherId)}`,
`tank01:mlb:bvp:${batterId}:${pitcherId}`,
TTL.bvp,
);
if (data === null) return null;
const body = data?.body || data;
// The response shape can be either a single object or a `matchups`
// array depending on schema version — normalize.
if (Array.isArray(body)) {
return body.map(projectBvP);
}
return projectBvP(body);
}
function projectBvP(row) {
if (!row || typeof row !== 'object') return null;
return {
batterId: row.batterID ?? null,
pitcherId: row.pitcherID ?? null,
plateAppearances: num(row.PA ?? row.pa, 0),
atBats: num(row.AB ?? row.ab, 0),
hits: num(row.H ?? row.hits, 0),
doubles: num(row['2B'] ?? row.doubles, 0),
triples: num(row['3B'] ?? row.triples, 0),
homeRuns: num(row.HR ?? row.homeRuns, 0),
rbi: num(row.RBI ?? row.rbi, 0),
walks: num(row.BB ?? row.walks, 0),
strikeouts: num(row.SO ?? row.K ?? row.strikeouts, 0),
avg: row.AVG ?? row.avg ?? null,
ops: row.OPS ?? row.ops ?? null,
};
}
function num(v, fallback = 0) {
if (v == null || v === '') return fallback;
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
/**
* getMLBDailyScoreboard — schedule + scores for a date.
*/
async function getMLBDailyScoreboard(date) {
if (!date) return null;
const ymd = String(date).replace(/-/g, '');
const data = await fetchWithCache(
`/getMLBScoresOnly?gameDate=${ymd}`,
`tank01:mlb:scoreboard:${ymd}`,
TTL.scoreboard,
);
if (data === null) return null;
const body = data?.body || data;
// Body shape varies: array of games OR map keyed by gameID. Normalize to array.
const entries = Array.isArray(body) ? body : Object.values(body || {});
return entries.map((g) => ({
gameId: g.gameID ?? null,
homeTeam: g.home ?? null,
awayTeam: g.away ?? null,
gameTime: g.gameTime ?? null,
gameStatus: g.gameStatus ?? null,
homeScore: num(g.homePts, null),
awayScore: num(g.awayPts, null),
}));
}
module.exports = {
getMLBBoxScore,
getMLBBatterVsPitcher,
getMLBDailyScoreboard,
hasApiKey,
__internals: {
TTL,
DEFAULT_HOST,
getHost,
projectBvP,
num,
},
};
+188
View File
@@ -0,0 +1,188 @@
/**
* Tank01 NBA adapter (Session 9) — RapidAPI-hosted, live in-game stats.
*
* Sits behind the same RAPID_API_KEY as `footApiAdapter`. Free tier
* is 1,000 req/month — much more generous than FootApi's daily cap,
* so we don't keep a tight in-process counter here; we lean on cache
* TTLs to bound consumption.
*
* Env:
* RAPID_API_KEY — shared RapidAPI marketplace key
* TANK01_NBA_HOST — host (default `tank01-fantasy-stats.p.rapidapi.com`)
*
* TTL policy — box scores are special:
* - Mid-game (status not Final) → 5 min cache so live stats refresh
* - Post-game (status Final) → 24 h cache so we stop pulling
* - Games-for-date → 1 h
* - Betting odds → 15 min
*
* Wired into computeFeatures via Session 9 cascade — Tank01 box-score
* data is preferred when ESPN's scoreboard is sparse (e.g. early in
* the day before tip-off has populated minute-by-minute stats).
*/
const axios = require('axios');
const { cacheGet, cacheSet } = require('../../utils/redis');
const HTTP_TIMEOUT_MS = 8_000;
const DEFAULT_HOST = 'tank01-fantasy-stats.p.rapidapi.com';
const TTL = Object.freeze({
boxScoreLive: 5 * 60, // 5min while in-game
boxScoreFinal: 24 * 3600, // 24h once final
games: 1 * 3600, // 1h
odds: 15 * 60, // 15min
});
function getHost() {
return process.env.TANK01_NBA_HOST || DEFAULT_HOST;
}
function hasApiKey() {
return !!process.env.RAPID_API_KEY;
}
// Centralized fetch — RapidAPI auth + cache + stale-while-revalidate.
async function fetchWithCache(path, cacheKey, ttl) {
const fresh = await cacheGet(cacheKey);
if (fresh !== null) return fresh;
if (!hasApiKey()) return null;
try {
const host = getHost();
const res = await axios.get(`https://${host}${path}`, {
headers: {
'x-rapidapi-key': process.env.RAPID_API_KEY,
'x-rapidapi-host': host,
},
timeout: HTTP_TIMEOUT_MS,
});
const body = res.data;
if (body && typeof body === 'object') {
await cacheSet(cacheKey, body, ttl);
await cacheSet(`${cacheKey}:stale`, body, ttl * 4);
}
return body;
} catch (err) {
console.warn('[tank01NBA] fetch failed:', path, err.message);
const stale = await cacheGet(`${cacheKey}:stale`);
if (stale !== null) return stale;
return null;
}
}
/**
* getNBABoxScore — live per-player stats for a single game.
*
* Status-aware caching: a mid-game refresh that pulls "in-progress"
* data caches for 5min so the next read isn't stale; a "Final" pull
* caches for 24h because nothing is changing. The status field varies
* by Tank01 schema version — we check `gameStatus` first, fall back
* to looking for "Final" anywhere in a top-level string field.
*/
async function getNBABoxScore(gameId) {
if (!gameId) return null;
// We don't yet know if the game is final, so request with the live
// TTL — if the response reports Final we re-cache under the long TTL.
const cacheKey = `tank01:nba:boxscore:${gameId}`;
const data = await fetchWithCache(
`/getNBABoxScore?gameID=${encodeURIComponent(gameId)}`,
cacheKey,
TTL.boxScoreLive,
);
if (data === null) return null;
const body = data?.body || data;
const isFinal = (() => {
const s = body?.gameStatus || body?.status || '';
return typeof s === 'string' && /final/i.test(s);
})();
// Upgrade the cache TTL on Final.
if (isFinal) {
await cacheSet(cacheKey, data, TTL.boxScoreFinal);
}
// Project: top-level `playerStats` is a map keyed by playerID.
const players = body?.playerStats || body?.playerStatistics || {};
const out = [];
for (const [id, p] of Object.entries(players)) {
out.push({
playerId: id,
name: p.longName || p.shortName || null,
team: p.teamAbv || null,
mins: p.mins ?? null,
pts: numOr(p.pts),
reb: numOr(p.reb),
ast: numOr(p.ast),
stl: numOr(p.stl),
blk: numOr(p.blk),
tov: numOr(p.TOV ?? p.tov),
threes: numOr(p.tptfgm),
fga: numOr(p.fga),
fgm: numOr(p.fgm),
fta: numOr(p.fta),
ftm: numOr(p.ftm),
_final: isFinal,
});
}
return out;
}
function numOr(v, fallback = 0) {
if (v == null) return fallback;
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
/**
* getNBAGamesForDate — schedule + scoreline. Date format is YYYYMMDD
* per Tank01's schema (NOT ISO).
*/
async function getNBAGamesForDate(date) {
if (!date) return null;
const ymd = String(date).replace(/-/g, '');
const data = await fetchWithCache(
`/getNBAGamesForDate?gameDate=${ymd}`,
`tank01:nba:games:${ymd}`,
TTL.games,
);
if (data === null) return null;
const list = data?.body;
if (!Array.isArray(list)) return [];
return list.map((g) => ({
gameId: g.gameID ?? null,
homeTeam: g.home ?? null,
awayTeam: g.away ?? null,
gameTime: g.gameTime_epoch ?? g.gameTime ?? null,
gameStatus: g.gameStatus ?? null,
homeScore: numOr(g.homePts, null),
awayScore: numOr(g.awayPts, null),
}));
}
/**
* getNBABettingOdds — Tank01's odds feed (book-by-book). Useful as a
* sanity-check signal alongside our odds-api primary.
*/
async function getNBABettingOdds(date) {
if (!date) return null;
const ymd = String(date).replace(/-/g, '');
const data = await fetchWithCache(
`/getNBABettingOdds?gameDate=${ymd}`,
`tank01:nba:odds:${ymd}`,
TTL.odds,
);
if (data === null) return null;
return data?.body || data;
}
module.exports = {
getNBABoxScore,
getNBAGamesForDate,
getNBABettingOdds,
hasApiKey,
__internals: {
TTL,
DEFAULT_HOST,
getHost,
numOr,
},
};
@@ -39,27 +39,66 @@ async function safeCacheGet(key) {
}
}
// Read per-player season aggregate. The prefetch writes a flat shape
// that already collapses played + minutes into per-90 rates so we don't
// recompute on every request.
// Source-priority cascade (Session 9). Each load function checks
// adapter-specific keys in priority order:
// 1. api-football.com (PRIMARY — 100 req/day, richest payload)
// 2. FootApi via RapidAPI (BACKUP — 50 req/day, Sofascore mirror)
// 3. football-data.org (TERTIARY — fixtures/standings only)
//
// The richer adapters write name-keyed aliases (`apifootball:player_by_name:…`,
// `footapi:player_by_name:…`) during the daily prefetch so the request
// path can resolve without an upstream call. When the alias key is
// missing — either because the prefetch hasn't run, the adapter has
// no key configured, or the player isn't covered — we fall through to
// the next source. Final fallback is the legacy `soccer:player:{name}`
// key the football-data prefetch already populates.
//
// Every value is tagged with `_source` so trap detection and reasoning
// can attribute the data origin (and so we can spot when a source is
// silently failing in production logs).
async function loadFromCascade(keys) {
for (const { key, source } of keys) {
const v = await safeCacheGet(key);
if (v && typeof v === 'object') {
return { ...v, _source: v._source || source };
}
}
return null;
}
async function loadPlayerProfile(playerName) {
if (!playerName) return null;
return safeCacheGet(`soccer:player:${normalizeName(playerName)}`);
const n = normalizeName(playerName);
return loadFromCascade([
{ key: `apifootball:player_by_name:${n}`, source: 'api-football' },
{ key: `footapi:player_by_name:${n}`, source: 'footapi' },
{ key: `soccer:player:${n}`, source: 'football-data' },
]);
}
async function loadNextMatch(teamName) {
if (!teamName) return null;
return safeCacheGet(`soccer:nextmatch:${teamName}`);
return loadFromCascade([
{ key: `apifootball:nextmatch:${teamName}`, source: 'api-football' },
{ key: `soccer:nextmatch:${teamName}`, source: 'football-data' },
]);
}
async function loadLastFixture(teamName) {
if (!teamName) return null;
return safeCacheGet(`soccer:lastfixture:${teamName}`);
return loadFromCascade([
{ key: `apifootball:lastfixture:${teamName}`, source: 'api-football' },
{ key: `soccer:lastfixture:${teamName}`, source: 'football-data' },
]);
}
async function loadRefereeProfile(refName) {
if (!refName) return null;
return safeCacheGet(`soccer:referee:${refName}`);
return loadFromCascade([
{ key: `apifootball:referee_by_name:${refName}`, source: 'api-football' },
{ key: `footapi:referee_by_name:${refName}`, source: 'footapi' },
{ key: `soccer:referee:${refName}`, source: 'football-data' },
]);
}
async function loadTeamDefense(league, teamName) {
@@ -221,6 +260,16 @@ async function extractSoccerFeatures(input = {}) {
isHome,
gameLogs: [],
errors,
// Session 9 — which source the cascade actually resolved for
// each load. Useful for debugging "why does Messi's profile
// look thin?" — if `player_source: 'football-data'` it means
// the api-football prefetch hasn't populated his row yet.
sources: {
player: profile?._source || null,
nextMatch: nextMatch?._source || null,
lastFixture: lastFixture?._source || null,
referee: refProfile?._source || null,
},
},
};
}
+256
View File
@@ -0,0 +1,256 @@
// apiFootballAdapter — PRIMARY soccer source. Tests the auth header
// shape (x-apisports-key, NOT RapidAPI), the rate-limit bookkeeping,
// the graceful-degradation paths, and the per-endpoint projection.
const mockAxiosGet = jest.fn();
jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) }));
const mockCacheStore = new Map();
jest.mock('../../src/utils/redis', () => ({
cacheGet: async (k) => (mockCacheStore.has(k) ? mockCacheStore.get(k) : null),
cacheSet: async (k, v) => { mockCacheStore.set(k, v); return true; },
cacheDel: async (k) => { mockCacheStore.delete(k); return true; },
isDegraded: () => false,
}));
const adapter = require('../../src/services/adapters/apiFootballAdapter');
beforeEach(async () => {
mockAxiosGet.mockReset();
mockCacheStore.clear();
await adapter.__internals.resetCounterForTests();
// Re-clear so the counter set above doesn't persist into the next test.
mockCacheStore.clear();
});
describe('apiFootballAdapter', () => {
describe('graceful degradation when API_FOOTBALL_KEY missing', () => {
const original = process.env.API_FOOTBALL_KEY;
beforeAll(() => { delete process.env.API_FOOTBALL_KEY; });
afterAll(() => { if (original !== undefined) process.env.API_FOOTBALL_KEY = original; });
test('hasApiKey reports false', () => {
expect(adapter.hasApiKey()).toBe(false);
});
test('all endpoints return null without touching axios', async () => {
expect(await adapter.getFixtures({ league: 1, season: 2026 })).toBeNull();
expect(await adapter.getFixtureLineups(42)).toBeNull();
expect(await adapter.getFixturePlayerStats(42)).toBeNull();
expect(await adapter.getFixtureEvents(42)).toBeNull();
expect(await adapter.getPlayerSeasonStats(100, 2026)).toBeNull();
expect(await adapter.getStandings(1, 2026)).toBeNull();
expect(mockAxiosGet).not.toHaveBeenCalled();
});
});
describe('with key configured', () => {
beforeAll(() => { process.env.API_FOOTBALL_KEY = 'test-apisports-key'; });
test('auth header is x-apisports-key — NOT RapidAPI', async () => {
mockAxiosGet.mockResolvedValueOnce({ data: { response: [] } });
await adapter.getFixtures({ league: 1, season: 2026 });
const [, opts] = mockAxiosGet.mock.calls[0];
expect(opts.headers['x-apisports-key']).toBe('test-apisports-key');
// RapidAPI headers must NOT be present.
expect(opts.headers['x-rapidapi-key']).toBeUndefined();
expect(opts.headers['x-rapidapi-host']).toBeUndefined();
});
test('getFixtures projects to the unified shape', async () => {
mockAxiosGet.mockResolvedValueOnce({
data: {
response: [
{
fixture: { id: 9001, date: '2026-06-11T20:00:00+00:00', status: { short: 'NS' }, venue: { name: 'Estadio Azteca' }, referee: 'Daniele Orsato' },
league: { name: 'World Cup', season: 2026, round: 'Group Stage - 1' },
teams: { home: { id: 26, name: 'Mexico' }, away: { id: 6, name: 'USA' } },
score: { fulltime: { home: null, away: null } },
},
],
},
});
const fixtures = await adapter.getFixtures({ league: 1, season: 2026, date: '2026-06-11' });
expect(fixtures).toHaveLength(1);
expect(fixtures[0]).toMatchObject({
id: 9001,
homeTeam: 'Mexico',
awayTeam: 'USA',
venue: 'Estadio Azteca',
referee: 'Daniele Orsato',
league: 'World Cup',
});
});
test('getFixtures with no date works (whole season)', async () => {
mockAxiosGet.mockResolvedValueOnce({ data: { response: [{ fixture: { id: 1 }, teams: { home: { name: 'A' }, away: { name: 'B' } } }] } });
const fixtures = await adapter.getFixtures({ league: 1, season: 2026 });
expect(fixtures).toHaveLength(1);
const [url] = mockAxiosGet.mock.calls[0];
expect(url).not.toMatch(/date=/);
});
test('null params bounce without touching axios', async () => {
expect(await adapter.getFixtures({})).toBeNull();
expect(await adapter.getFixtureLineups(null)).toBeNull();
expect(await adapter.getPlayerSeasonStats(null, 2026)).toBeNull();
expect(await adapter.getStandings(1, null)).toBeNull();
expect(mockAxiosGet).not.toHaveBeenCalled();
});
test('getFixturePlayerStats flattens per-team rosters into one list', async () => {
mockAxiosGet.mockResolvedValueOnce({
data: {
response: [
{
team: { name: 'Argentina' },
players: [
{
player: { id: 1, name: 'Messi' },
statistics: [{
games: { minutes: 88, position: 'F', rating: '8.4', substitute: false },
goals: { total: 1, assists: 1, saves: null },
shots: { total: 5, on: 3 },
passes: { total: 47, accuracy: 89 },
tackles: { total: 1, blocks: 0, interceptions: 2 },
cards: { yellow: 0, red: 0 },
}],
},
],
},
{
team: { name: 'France' },
players: [
{
player: { id: 2, name: 'Mbappe' },
statistics: [{
games: { minutes: 90, position: 'F', rating: '7.9', substitute: false },
goals: { total: 2, assists: 0, saves: null },
shots: { total: 7, on: 4 },
passes: { total: 28, accuracy: 71 },
tackles: { total: 0 },
cards: { yellow: 1, red: 0 },
}],
},
],
},
],
},
});
const stats = await adapter.getFixturePlayerStats(9001);
expect(stats).toHaveLength(2);
const messi = stats.find((p) => p.name === 'Messi');
expect(messi.team).toBe('Argentina');
expect(messi.goals).toBe(1);
expect(messi.shots_on).toBe(3);
const mbappe = stats.find((p) => p.name === 'Mbappe');
expect(mbappe.team).toBe('France');
expect(mbappe.goals).toBe(2);
expect(mbappe.yellow).toBe(1);
});
test('getFixtureLineups projects formation + startXI + bench', async () => {
mockAxiosGet.mockResolvedValueOnce({
data: {
response: [
{
team: { id: 26, name: 'Mexico' },
coach: { name: 'Some Coach' },
formation: '4-3-3',
startXI: [
{ player: { id: 10, name: 'GK', number: 1, pos: 'G', grid: '1:1' } },
],
substitutes: [{ player: { id: 11, name: 'Sub', number: 22, pos: 'M' } }],
},
],
},
});
const lineups = await adapter.getFixtureLineups(9001);
expect(lineups[0].formation).toBe('4-3-3');
expect(lineups[0].startXI[0].name).toBe('GK');
expect(lineups[0].substitutes[0].name).toBe('Sub');
});
test('getStandings flattens group standings into a flat list', async () => {
mockAxiosGet.mockResolvedValueOnce({
data: {
response: [{
league: {
standings: [
[ // Group A
{ rank: 1, team: { id: 26, name: 'Mexico' }, all: { played: 3, win: 2, draw: 1, lose: 0, goals: { for: 5, against: 2 } }, points: 7, group: 'Group A' },
],
[ // Group B
{ rank: 1, team: { id: 5, name: 'Argentina' }, all: { played: 3, win: 3, draw: 0, lose: 0, goals: { for: 6, against: 1 } }, points: 9, group: 'Group B' },
],
],
},
}],
},
});
const standings = await adapter.getStandings(1, 2026);
expect(standings).toHaveLength(2);
expect(standings.find((s) => s.team === 'Mexico')?.points).toBe(7);
expect(standings.find((s) => s.team === 'Argentina')?.points).toBe(9);
});
test('cache hit on repeat call (axios not re-invoked)', async () => {
mockAxiosGet.mockResolvedValueOnce({ data: { response: [{ fixture: { id: 1 }, teams: { home: { name: 'A' }, away: { name: 'B' } } }] } });
await adapter.getFixtures({ league: 1, season: 2026, date: '2026-06-11' });
await adapter.getFixtures({ league: 1, season: 2026, date: '2026-06-11' });
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
});
test('axios throw → null + stale-while-revalidate', async () => {
mockCacheStore.set('apifootball:fixtures:1:2026:2026-06-11:stale', { response: [{ fixture: { id: 999 }, teams: { home: { name: 'X' }, away: { name: 'Y' } } }] });
mockAxiosGet.mockRejectedValueOnce(new Error('upstream 500'));
const fixtures = await adapter.getFixtures({ league: 1, season: 2026, date: '2026-06-11' });
expect(fixtures[0].id).toBe(999);
});
test('axios throw with no stale → returns null', async () => {
mockAxiosGet.mockRejectedValueOnce(new Error('network down'));
const fixtures = await adapter.getFixtures({ league: 1, season: 2026, date: '2026-06-11' });
expect(fixtures).toBeNull();
});
});
describe('daily rate-limit accounting', () => {
beforeAll(() => { process.env.API_FOOTBALL_KEY = 'test-apisports-key'; });
test('bumps the counter on a successful network call', async () => {
mockAxiosGet.mockResolvedValueOnce({ data: { response: [] } });
await adapter.getFixtures({ league: 1, season: 2026 });
const count = await adapter.__internals.readDailyCount();
expect(count).toBe(1);
});
test('does NOT bump on cache hit', async () => {
mockAxiosGet.mockResolvedValueOnce({ data: { response: [{ fixture: { id: 1 }, teams: { home: { name: 'A' }, away: { name: 'B' } } }] } });
await adapter.getFixtures({ league: 1, season: 2026 });
await adapter.getFixtures({ league: 1, season: 2026 }); // cached
const count = await adapter.__internals.readDailyCount();
expect(count).toBe(1);
});
test('at SOFT_LIMIT, refuses network + serves stale if present', async () => {
// Prime the counter to SOFT_LIMIT (90).
const { cacheSet } = require('../../src/utils/redis');
await cacheSet(adapter.__internals.DAILY_COUNTER_KEY, adapter.__internals.SOFT_LIMIT);
mockCacheStore.set('apifootball:fixtures:1:2026:stale', { response: [{ fixture: { id: 7 }, teams: { home: { name: 'X' }, away: { name: 'Y' } } }] });
const fixtures = await adapter.getFixtures({ league: 1, season: 2026 });
expect(fixtures[0].id).toBe(7);
// Network NOT called — the bucket stopped us.
expect(mockAxiosGet).not.toHaveBeenCalled();
});
test('at SOFT_LIMIT with no stale → null', async () => {
const { cacheSet } = require('../../src/utils/redis');
await cacheSet(adapter.__internals.DAILY_COUNTER_KEY, adapter.__internals.SOFT_LIMIT);
const fixtures = await adapter.getFixtures({ league: 1, season: 2026 });
expect(fixtures).toBeNull();
expect(mockAxiosGet).not.toHaveBeenCalled();
});
});
});
+201
View File
@@ -0,0 +1,201 @@
// footApiAdapter — BACKUP soccer source via RapidAPI. Tests the
// RapidAPI auth header shape, the per-endpoint projection, and the
// graceful-degradation paths.
const mockAxiosGet = jest.fn();
jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) }));
const mockCacheStore = new Map();
jest.mock('../../src/utils/redis', () => ({
cacheGet: async (k) => (mockCacheStore.has(k) ? mockCacheStore.get(k) : null),
cacheSet: async (k, v) => { mockCacheStore.set(k, v); return true; },
cacheDel: async (k) => { mockCacheStore.delete(k); return true; },
isDegraded: () => false,
}));
const adapter = require('../../src/services/adapters/footApiAdapter');
beforeEach(async () => {
mockAxiosGet.mockReset();
mockCacheStore.clear();
});
describe('footApiAdapter', () => {
describe('graceful degradation when RAPID_API_KEY missing', () => {
const original = process.env.RAPID_API_KEY;
beforeAll(() => { delete process.env.RAPID_API_KEY; });
afterAll(() => { if (original !== undefined) process.env.RAPID_API_KEY = original; });
test('hasApiKey reports false', () => {
expect(adapter.hasApiKey()).toBe(false);
});
test('all endpoints return null without touching axios', async () => {
expect(await adapter.getMatchLineups(123)).toBeNull();
expect(await adapter.getMatchIncidents(123)).toBeNull();
expect(await adapter.getRefereeStatistics(7)).toBeNull();
expect(await adapter.getWorldCupSchedule(11, 6, 2026)).toBeNull();
expect(mockAxiosGet).not.toHaveBeenCalled();
});
});
describe('with key configured', () => {
beforeAll(() => { process.env.RAPID_API_KEY = 'test-rapid-key'; });
test('auth headers are RapidAPI shape — NOT x-apisports-key', async () => {
mockAxiosGet.mockResolvedValueOnce({ data: {} });
await adapter.getMatchLineups(42);
const [url, opts] = mockAxiosGet.mock.calls[0];
expect(url).toMatch(/^https:\/\/footapi7\.p\.rapidapi\.com/);
expect(opts.headers['x-rapidapi-key']).toBe('test-rapid-key');
expect(opts.headers['x-rapidapi-host']).toBe('footapi7.p.rapidapi.com');
// Primary adapter's header MUST NOT appear.
expect(opts.headers['x-apisports-key']).toBeUndefined();
});
test('FOOTAPI_HOST override is honored', async () => {
const originalHost = process.env.FOOTAPI_HOST;
process.env.FOOTAPI_HOST = 'mirror.rapidapi.com';
mockAxiosGet.mockResolvedValueOnce({ data: {} });
await adapter.getMatchLineups(7);
const [url, opts] = mockAxiosGet.mock.calls[0];
expect(url).toMatch(/^https:\/\/mirror\.rapidapi\.com/);
expect(opts.headers['x-rapidapi-host']).toBe('mirror.rapidapi.com');
if (originalHost !== undefined) process.env.FOOTAPI_HOST = originalHost;
else delete process.env.FOOTAPI_HOST;
});
test('getMatchLineups flattens home + away into one player list', async () => {
mockAxiosGet.mockResolvedValueOnce({
data: {
home: {
formation: '4-3-3',
players: [
{
player: { id: 1, name: 'Saka' },
position: 'F',
shirtNumber: 7,
statistics: { minutesPlayed: 88, rating: 8.1, goals: 1, goalAssist: 1, totalShots: 4, shotOnTarget: 2, totalPass: 35, accuratePass: 30, totalTackle: 2, yellowCards: 0, redCards: 0, keyPass: 3 },
},
],
},
away: {
formation: '4-2-3-1',
players: [
{
player: { id: 2, name: 'Mbappe' },
position: 'F',
substitute: false,
statistics: { minutesPlayed: 90, rating: 7.7, goals: 0, totalShots: 5, shotOnTarget: 1, totalPass: 22, accuratePass: 18, yellowCards: 1 },
},
],
},
},
});
const lineups = await adapter.getMatchLineups(101);
expect(lineups).toHaveLength(2);
const saka = lineups.find((p) => p.name === 'Saka');
expect(saka.side).toBe('home');
expect(saka.goals).toBe(1);
expect(saka.shotsOnTarget).toBe(2);
expect(saka.assists).toBe(1);
const mbappe = lineups.find((p) => p.name === 'Mbappe');
expect(mbappe.side).toBe('away');
expect(mbappe.yellow).toBe(1);
});
test('getMatchIncidents projects time + addedTime + player + type', async () => {
mockAxiosGet.mockResolvedValueOnce({
data: {
incidents: [
{ incidentType: 'goal', time: 43, addedTime: 0, isHome: true, player: { name: 'A' }, assist1: { name: 'B' }, text: '1-0' },
{ incidentType: 'card', incidentClass: 'yellow', time: 90, addedTime: 4, player: { name: 'C' } },
],
},
});
const events = await adapter.getMatchIncidents(101);
expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({ type: 'goal', minute: 43, player: 'A', assist: 'B' });
expect(events[1]).toMatchObject({ type: 'card', minute: 90, addedTime: 4 });
});
test('getRefereeStatistics computes per-game rates', async () => {
mockAxiosGet.mockResolvedValueOnce({
data: {
statistics: [
{ tournament: { id: 16, name: 'World Cup' }, season: { year: 2022 }, appearances: 6, yellowCards: 24, redCards: 1 },
],
},
});
const refs = await adapter.getRefereeStatistics(99);
expect(refs).toHaveLength(1);
// 24/6 = 4.00 cards/game; 1/6 = ~0.167 red/game.
expect(refs[0].yellowCardsPerGame).toBeCloseTo(4.0);
expect(refs[0].redCardsPerGame).toBeCloseTo(0.167, 2);
});
test('getRefereeStatistics handles zero appearances gracefully', async () => {
mockAxiosGet.mockResolvedValueOnce({
data: { statistics: [{ tournament: { id: 16 }, appearances: 0, yellowCards: 0, redCards: 0 }] },
});
const refs = await adapter.getRefereeStatistics(99);
expect(refs[0].yellowCardsPerGame).toBeNull();
expect(refs[0].redCardsPerGame).toBeNull();
});
test('getWorldCupSchedule maps events with venue + referee', async () => {
mockAxiosGet.mockResolvedValueOnce({
data: {
events: [
{
id: 5, startTimestamp: 1749672000, status: { type: 'notstarted' },
homeTeam: { id: 26, name: 'Mexico' }, awayTeam: { id: 6, name: 'USA' },
homeScore: { current: 0 }, awayScore: { current: 0 },
venue: { name: 'Estadio Azteca' }, referee: { name: 'Daniele Orsato' },
},
],
},
});
const matches = await adapter.getWorldCupSchedule(11, 6, 2026);
expect(matches[0]).toMatchObject({
id: 5, homeTeam: 'Mexico', awayTeam: 'USA', venue: 'Estadio Azteca', referee: 'Daniele Orsato',
});
});
test('cache hit on repeat call (axios not re-invoked)', async () => {
mockAxiosGet.mockResolvedValueOnce({ data: { home: { players: [] }, away: { players: [] } } });
await adapter.getMatchLineups(101);
await adapter.getMatchLineups(101);
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
});
test('null IDs bounce without touching axios', async () => {
expect(await adapter.getMatchLineups(null)).toBeNull();
expect(await adapter.getMatchIncidents(null)).toBeNull();
expect(await adapter.getRefereeStatistics(null)).toBeNull();
expect(await adapter.getWorldCupSchedule(null, 6, 2026)).toBeNull();
expect(mockAxiosGet).not.toHaveBeenCalled();
});
});
describe('daily rate-limit (50 budget, soft 45)', () => {
beforeAll(() => { process.env.RAPID_API_KEY = 'test-rapid-key'; });
beforeEach(async () => { await adapter.__internals.resetCounterForTests(); mockCacheStore.clear(); });
test('bumps counter on successful network call', async () => {
mockAxiosGet.mockResolvedValueOnce({ data: {} });
await adapter.getMatchLineups(42);
expect(await adapter.__internals.readDailyCount()).toBe(1);
});
test('at SOFT_LIMIT refuses network + serves stale', async () => {
const { cacheSet } = require('../../src/utils/redis');
await cacheSet(adapter.__internals.DAILY_COUNTER_KEY, adapter.__internals.SOFT_LIMIT);
mockCacheStore.set('footapi:match:42:lineups:stale', { home: { players: [{ player: { id: 1, name: 'Stale' }, statistics: {} }] }, away: { players: [] } });
const lineups = await adapter.getMatchLineups(42);
expect(lineups[0].name).toBe('Stale');
expect(mockAxiosGet).not.toHaveBeenCalled();
});
});
});
+112
View File
@@ -0,0 +1,112 @@
// Grace-period middleware tests. We mock the supabase client at the
// module boundary so the middleware's calls land on a controllable
// chainable fake. No real DB / network.
const mockSupabaseUpdates = [];
const supabaseFake = {
from(table) {
const ctx = { table, filters: [], action: null };
const proxy = {
update(patch) {
ctx.patch = patch;
ctx.action = 'update';
mockSupabaseUpdates.push(ctx);
return proxy;
},
eq(col, val) {
ctx.filters.push([col, val]);
// The middleware awaits the result of .eq() after .update() —
// return a resolved promise (no error).
return Promise.resolve({ error: null });
},
};
return proxy;
},
};
jest.mock('../../src/utils/supabase', () => ({
getSupabaseServiceClient: () => supabaseFake,
}));
const { checkGracePeriod } = require('../../src/middleware/gracePeriod');
beforeEach(() => {
mockSupabaseUpdates.length = 0;
});
function runMiddleware(req) {
return new Promise((resolve, reject) => {
const res = {
status: (code) => ({ json: (b) => reject(new Error(`unexpected ${code}: ${JSON.stringify(b)}`)) }),
};
checkGracePeriod(req, res, resolve);
});
}
describe('checkGracePeriod', () => {
test('passes through when no user (unauth route slip)', async () => {
await runMiddleware({});
expect(mockSupabaseUpdates).toHaveLength(0);
});
test('passes through when user has no grace_period_until', async () => {
await runMiddleware({ user: { id: 'u1', tier: 'analyst', grace_period_until: null } });
expect(mockSupabaseUpdates).toHaveLength(0);
});
test('passes through when grace is still in the future', async () => {
const future = new Date(Date.now() + 12 * 3600 * 1000).toISOString();
const req = { user: { id: 'u1', tier: 'analyst', grace_period_until: future } };
await runMiddleware(req);
expect(mockSupabaseUpdates).toHaveLength(0);
expect(req.user.tier).toBe('analyst'); // unchanged
});
test('expired grace → downgrades both tables + rewrites req.user', async () => {
const past = new Date(Date.now() - 60_000).toISOString();
const req = { user: { id: 'u1', tier: 'desk', grace_period_until: past } };
await runMiddleware(req);
expect(mockSupabaseUpdates).toHaveLength(2);
const usersUpdate = mockSupabaseUpdates.find((u) => u.table === 'users');
expect(usersUpdate.patch).toEqual({ tier: 'free', grace_period_until: null });
expect(usersUpdate.filters).toEqual([['id', 'u1']]);
const profileUpdate = mockSupabaseUpdates.find((u) => u.table === 'user_profiles');
expect(profileUpdate.patch.tier).toBe('free');
expect(profileUpdate.patch.subscription_status).toBe('expired');
expect(profileUpdate.patch.grace_period_until).toBeNull();
// req.user reflects the downgrade so the downstream route sees it.
expect(req.user.tier).toBe('free');
expect(req.user.grace_period_until).toBeNull();
});
test('expired but already free → still scrubs the grace timestamp', async () => {
// Edge case — Stripe set grace, then user got downgraded by some
// other path. We should still null the timestamp so the row is clean.
const past = new Date(Date.now() - 60_000).toISOString();
const req = { user: { id: 'u2', tier: 'free', grace_period_until: past } };
await runMiddleware(req);
const usersUpdate = mockSupabaseUpdates.find((u) => u.table === 'users');
expect(usersUpdate.patch.grace_period_until).toBeNull();
expect(req.user.grace_period_until).toBeNull();
});
test('malformed grace timestamp → treated as no grace (no downgrade)', async () => {
const req = { user: { id: 'u3', tier: 'analyst', grace_period_until: 'not-a-date' } };
await runMiddleware(req);
expect(mockSupabaseUpdates).toHaveLength(0);
expect(req.user.tier).toBe('analyst');
});
test('exactly-at-boundary timestamp is treated as expired', async () => {
// The middleware does `> Date.now()`; an exact match counts as
// expired. We test that policy stays explicit.
const req = { user: { id: 'u4', tier: 'analyst', grace_period_until: new Date(Date.now() - 1).toISOString() } };
await runMiddleware(req);
expect(mockSupabaseUpdates.length).toBeGreaterThan(0);
expect(req.user.tier).toBe('free');
});
});
@@ -0,0 +1,122 @@
// Source-cascade tests for soccerFeatureExtractor (Session 9). The
// pre-existing soccerFeatureExtractor.test.js covers the legacy
// football-data path; this suite verifies that:
// - api-football data wins when the prefetch alias exists
// - footapi wins when api-football is missing but footapi alias exists
// - football-data is still served when only the legacy key is set
// - The `meta.sources` map attributes correctly per lookup
//
// We mock cacheGet to inspect which key the cascade asked for.
const mockCacheStore = new Map();
jest.mock('../../src/utils/redis', () => ({
cacheGet: async (k) => (mockCacheStore.has(k) ? mockCacheStore.get(k) : null),
cacheSet: async (k, v) => { mockCacheStore.set(k, v); return true; },
cacheDel: async (k) => { mockCacheStore.delete(k); return true; },
isDegraded: () => false,
}));
const { normalizeName } = require('../../src/utils/normalize');
const extractor = require('../../src/services/intelligence/soccerFeatureExtractor');
beforeEach(() => { mockCacheStore.clear(); });
describe('soccerFeatureExtractor — source cascade (Session 9)', () => {
test('api-football wins when its alias is populated', async () => {
const n = normalizeName('Lionel Messi');
// Only the apifootball alias is populated — others should not be consulted.
mockCacheStore.set(`apifootball:player_by_name:${n}`, {
team: 'Argentina', goals_per_90: 0.92, minutes_per_game: 88,
});
const r = await extractor.extractSoccerFeatures({
player: 'Lionel Messi', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(r.features.goals_per_90).toBe(0.92);
expect(r.meta.sources.player).toBe('api-football');
});
test('footapi wins when api-football is empty but footapi is populated', async () => {
const n = normalizeName('Harry Kane');
mockCacheStore.set(`footapi:player_by_name:${n}`, {
team: 'England', goals_per_90: 0.81,
});
const r = await extractor.extractSoccerFeatures({
player: 'Harry Kane', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(r.features.goals_per_90).toBe(0.81);
expect(r.meta.sources.player).toBe('footapi');
});
test('football-data legacy key is the final fallback', async () => {
const n = normalizeName('Bukayo Saka');
mockCacheStore.set(`soccer:player:${n}`, {
team: 'England', goals_per_90: 0.4,
});
const r = await extractor.extractSoccerFeatures({
player: 'Bukayo Saka', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(r.features.goals_per_90).toBe(0.4);
expect(r.meta.sources.player).toBe('football-data');
});
test('all-miss case → null source + errors populated', async () => {
const r = await extractor.extractSoccerFeatures({
player: 'Unknown', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(r.meta.sources.player).toBeNull();
expect(r.meta.errors).toContain('player_not_found_in_cache');
});
test('nextMatch cascade — api-football preferred', async () => {
const n = normalizeName('Vinicius Junior');
mockCacheStore.set(`apifootball:player_by_name:${n}`, { team: 'Brazil' });
mockCacheStore.set('apifootball:nextmatch:Brazil', {
opponent: 'Argentina', venue: 'MetLife Stadium', isHome: true, referee: 'A. Taylor',
});
mockCacheStore.set('soccer:nextmatch:Brazil', {
opponent: 'STALE', venue: 'old', isHome: false, referee: 'STALE',
});
const r = await extractor.extractSoccerFeatures({
player: 'Vinicius Junior', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(r.meta.opponentAbbr).toBe('Argentina'); // api-football won
expect(r.meta.sources.nextMatch).toBe('api-football');
});
test('referee cascade falls through to legacy key when richer sources empty', async () => {
const n = normalizeName('Anyone');
mockCacheStore.set(`apifootball:player_by_name:${n}`, { team: 'X' });
mockCacheStore.set('apifootball:nextmatch:X', {
opponent: 'Y', venue: 'V', isHome: true, referee: 'Bjorn',
});
mockCacheStore.set('soccer:referee:Bjorn', {
cards_per_game: 4.2, penalties_per_game: 0.3,
});
const r = await extractor.extractSoccerFeatures({
player: 'Anyone', stat_type: 'cards', line: 0.5, direction: 'over',
});
expect(r.features.referee_cards_per_game).toBe(4.2);
expect(r.meta.sources.referee).toBe('football-data');
});
test('multiple sources active → independent attribution per lookup', async () => {
const n = normalizeName('Mixed');
mockCacheStore.set(`apifootball:player_by_name:${n}`, { team: 'France', goals_per_90: 1.1 });
// Match data only in legacy.
mockCacheStore.set('soccer:nextmatch:France', {
opponent: 'Italy', venue: 'AT&T Stadium', isHome: false, referee: 'X',
});
// Referee only in footapi.
mockCacheStore.set('footapi:referee_by_name:X', { cards_per_game: 5.5 });
const r = await extractor.extractSoccerFeatures({
player: 'Mixed', stat_type: 'goals', line: 0.5, direction: 'over',
});
expect(r.meta.sources).toEqual({
player: 'api-football',
nextMatch: 'football-data',
lastFixture: null,
referee: 'footapi',
});
});
});
+146
View File
@@ -0,0 +1,146 @@
const mockAxiosGet = jest.fn();
jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) }));
const mockCacheStore = new Map();
const mockCacheTtls = new Map();
jest.mock('../../src/utils/redis', () => ({
cacheGet: async (k) => (mockCacheStore.has(k) ? mockCacheStore.get(k) : null),
cacheSet: async (k, v, ttl) => { mockCacheStore.set(k, v); mockCacheTtls.set(k, ttl); return true; },
cacheDel: async (k) => { mockCacheStore.delete(k); return true; },
isDegraded: () => false,
}));
const adapter = require('../../src/services/adapters/tank01MlbAdapter');
beforeEach(() => {
mockAxiosGet.mockReset();
mockCacheStore.clear();
mockCacheTtls.clear();
});
describe('tank01MlbAdapter', () => {
describe('graceful degradation (no RAPID_API_KEY)', () => {
const original = process.env.RAPID_API_KEY;
beforeAll(() => { delete process.env.RAPID_API_KEY; });
afterAll(() => { if (original !== undefined) process.env.RAPID_API_KEY = original; });
test('hasApiKey false', () => { expect(adapter.hasApiKey()).toBe(false); });
test('all endpoints return null without touching axios', async () => {
expect(await adapter.getMLBBoxScore('20260611_ATL_NYM')).toBeNull();
expect(await adapter.getMLBBatterVsPitcher('B1', 'P1')).toBeNull();
expect(await adapter.getMLBDailyScoreboard('20260611')).toBeNull();
expect(mockAxiosGet).not.toHaveBeenCalled();
});
});
describe('with key configured', () => {
beforeAll(() => { process.env.RAPID_API_KEY = 'test-rapid-key'; });
test('RapidAPI host header is the MLB-specific Tank01 host', async () => {
mockAxiosGet.mockResolvedValueOnce({ data: { body: [] } });
await adapter.getMLBDailyScoreboard('20260611');
const [url, opts] = mockAxiosGet.mock.calls[0];
expect(url).toMatch(/^https:\/\/tank01-mlb-live-in-game-real-time-statistics\.p\.rapidapi\.com/);
expect(opts.headers['x-rapidapi-host']).toBe('tank01-mlb-live-in-game-real-time-statistics.p.rapidapi.com');
});
test('TANK01_MLB_HOST override is honored', async () => {
const original = process.env.TANK01_MLB_HOST;
process.env.TANK01_MLB_HOST = 'alt-mlb.rapidapi.com';
mockAxiosGet.mockResolvedValueOnce({ data: { body: [] } });
await adapter.getMLBDailyScoreboard('20260611');
const [url] = mockAxiosGet.mock.calls[0];
expect(url).toMatch(/^https:\/\/alt-mlb\.rapidapi\.com/);
if (original !== undefined) process.env.TANK01_MLB_HOST = original;
else delete process.env.TANK01_MLB_HOST;
});
test('getMLBBoxScore tags batters and pitchers with role', async () => {
mockAxiosGet.mockResolvedValueOnce({
data: {
body: {
gameStatus: 'Final',
playerStats: {
batting: { 'B1': { longName: 'Ronald Acuña', teamAbv: 'ATL' } },
pitching: { 'P1': { longName: 'Spencer Strider', teamAbv: 'ATL' } },
},
},
},
});
const stats = await adapter.getMLBBoxScore('GAME-1');
expect(stats.find((s) => s.role === 'batter').name).toBe('Ronald Acuña');
expect(stats.find((s) => s.role === 'pitcher').name).toBe('Spencer Strider');
// Final → 24h TTL.
expect(mockCacheTtls.get('tank01:mlb:boxscore:GAME-1')).toBe(adapter.__internals.TTL.boxScoreFinal);
});
test('In-progress game keeps 5-min TTL on the box score', async () => {
mockAxiosGet.mockResolvedValueOnce({
data: { body: { gameStatus: 'InProgress', playerStats: { batting: {}, pitching: {} } } },
});
await adapter.getMLBBoxScore('GAME-2');
expect(mockCacheTtls.get('tank01:mlb:boxscore:GAME-2')).toBe(adapter.__internals.TTL.boxScoreLive);
});
test('getMLBBatterVsPitcher projects single-object payload', async () => {
mockAxiosGet.mockResolvedValueOnce({
data: {
body: { batterID: 'B1', pitcherID: 'P1', PA: 18, AB: 16, H: 5, HR: 1, RBI: 3, SO: 4, AVG: '.313', OPS: '.857' },
},
});
const bvp = await adapter.getMLBBatterVsPitcher('B1', 'P1');
expect(bvp).toMatchObject({
batterId: 'B1', pitcherId: 'P1',
plateAppearances: 18, atBats: 16, hits: 5, homeRuns: 1, rbi: 3, strikeouts: 4,
avg: '.313', ops: '.857',
});
});
test('getMLBBatterVsPitcher handles array of matchups', async () => {
mockAxiosGet.mockResolvedValueOnce({
data: {
body: [
{ batterID: 'B1', pitcherID: 'P1', PA: 12, H: 3, SO: 5 },
{ batterID: 'B1', pitcherID: 'P1', PA: 6, H: 2, SO: 1 },
],
},
});
const bvp = await adapter.getMLBBatterVsPitcher('B1', 'P1');
expect(Array.isArray(bvp)).toBe(true);
expect(bvp).toHaveLength(2);
expect(bvp[0].plateAppearances).toBe(12);
});
test('null IDs return null without touching axios', async () => {
expect(await adapter.getMLBBoxScore(null)).toBeNull();
expect(await adapter.getMLBBatterVsPitcher(null, 'P1')).toBeNull();
expect(await adapter.getMLBBatterVsPitcher('B1', null)).toBeNull();
expect(await adapter.getMLBDailyScoreboard(null)).toBeNull();
expect(mockAxiosGet).not.toHaveBeenCalled();
});
test('getMLBDailyScoreboard projects both array + map shapes', async () => {
mockAxiosGet.mockResolvedValueOnce({
data: { body: { 'GAME-X': { gameID: 'GAME-X', home: 'NYY', away: 'BOS', homePts: 5, awayPts: 4, gameStatus: 'Final' } } },
});
const games = await adapter.getMLBDailyScoreboard('20260611');
expect(games).toHaveLength(1);
expect(games[0]).toMatchObject({ gameId: 'GAME-X', homeTeam: 'NYY', awayTeam: 'BOS', homeScore: 5, awayScore: 4 });
});
test('cache hit on repeat call', async () => {
mockAxiosGet.mockResolvedValueOnce({ data: { body: { batterID: 'B', PA: 1 } } });
await adapter.getMLBBatterVsPitcher('B', 'P');
await adapter.getMLBBatterVsPitcher('B', 'P');
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
});
test('axios throw → stale fallback', async () => {
mockCacheStore.set('tank01:mlb:scoreboard:20260611:stale', { body: [{ gameID: 'STALE' }] });
mockAxiosGet.mockRejectedValueOnce(new Error('upstream 500'));
const games = await adapter.getMLBDailyScoreboard('20260611');
expect(games[0].gameId).toBe('STALE');
});
});
});
+143
View File
@@ -0,0 +1,143 @@
const mockAxiosGet = jest.fn();
jest.mock('axios', () => ({ get: (...args) => mockAxiosGet(...args) }));
const mockCacheStore = new Map();
const mockCacheTtls = new Map();
jest.mock('../../src/utils/redis', () => ({
cacheGet: async (k) => (mockCacheStore.has(k) ? mockCacheStore.get(k) : null),
cacheSet: async (k, v, ttl) => { mockCacheStore.set(k, v); mockCacheTtls.set(k, ttl); return true; },
cacheDel: async (k) => { mockCacheStore.delete(k); return true; },
isDegraded: () => false,
}));
const adapter = require('../../src/services/adapters/tank01NbaAdapter');
beforeEach(() => {
mockAxiosGet.mockReset();
mockCacheStore.clear();
mockCacheTtls.clear();
});
describe('tank01NbaAdapter', () => {
describe('graceful degradation (no RAPID_API_KEY)', () => {
const original = process.env.RAPID_API_KEY;
beforeAll(() => { delete process.env.RAPID_API_KEY; });
afterAll(() => { if (original !== undefined) process.env.RAPID_API_KEY = original; });
test('hasApiKey false', () => {
expect(adapter.hasApiKey()).toBe(false);
});
test('all endpoints return null without touching axios', async () => {
expect(await adapter.getNBABoxScore('20260611_LAL_BOS')).toBeNull();
expect(await adapter.getNBAGamesForDate('20260611')).toBeNull();
expect(await adapter.getNBABettingOdds('20260611')).toBeNull();
expect(mockAxiosGet).not.toHaveBeenCalled();
});
});
describe('with key configured', () => {
beforeAll(() => { process.env.RAPID_API_KEY = 'test-rapid-key'; });
test('RapidAPI auth headers wired correctly', async () => {
mockAxiosGet.mockResolvedValueOnce({ data: { body: [] } });
await adapter.getNBAGamesForDate('20260611');
const [url, opts] = mockAxiosGet.mock.calls[0];
expect(url).toMatch(/^https:\/\/tank01-fantasy-stats\.p\.rapidapi\.com/);
expect(opts.headers['x-rapidapi-key']).toBe('test-rapid-key');
expect(opts.headers['x-rapidapi-host']).toBe('tank01-fantasy-stats.p.rapidapi.com');
});
test('TANK01_NBA_HOST override is honored', async () => {
const original = process.env.TANK01_NBA_HOST;
process.env.TANK01_NBA_HOST = 'alt-tank01.rapidapi.com';
mockAxiosGet.mockResolvedValueOnce({ data: { body: [] } });
await adapter.getNBAGamesForDate('20260611');
const [url, opts] = mockAxiosGet.mock.calls[0];
expect(url).toMatch(/^https:\/\/alt-tank01\.rapidapi\.com/);
expect(opts.headers['x-rapidapi-host']).toBe('alt-tank01.rapidapi.com');
if (original !== undefined) process.env.TANK01_NBA_HOST = original;
else delete process.env.TANK01_NBA_HOST;
});
test('getNBABoxScore projects playerStats map into a flat list', async () => {
mockAxiosGet.mockResolvedValueOnce({
data: {
body: {
gameStatus: 'InProgress',
playerStats: {
'NBA-123': { longName: 'Jaylen Brown', teamAbv: 'BOS', mins: '34', pts: 27, reb: 5, ast: 4 },
'NBA-456': { longName: 'Jayson Tatum', teamAbv: 'BOS', mins: '36', pts: 31, reb: 8, ast: 6, tptfgm: 5 },
},
},
},
});
const stats = await adapter.getNBABoxScore('20260611_LAL_BOS');
expect(stats).toHaveLength(2);
const tatum = stats.find((p) => p.name === 'Jayson Tatum');
expect(tatum.team).toBe('BOS');
expect(tatum.pts).toBe(31);
expect(tatum.threes).toBe(5);
expect(tatum._final).toBe(false);
});
test('Final game upgrades the cache TTL to 24h', async () => {
mockAxiosGet.mockResolvedValueOnce({
data: { body: { gameStatus: 'Final', playerStats: {} } },
});
await adapter.getNBABoxScore('GAME-1');
const ttl = mockCacheTtls.get('tank01:nba:boxscore:GAME-1');
expect(ttl).toBe(adapter.__internals.TTL.boxScoreFinal);
});
test('In-progress game stays on 5-min TTL', async () => {
mockAxiosGet.mockResolvedValueOnce({
data: { body: { gameStatus: 'InProgress', playerStats: {} } },
});
await adapter.getNBABoxScore('GAME-2');
const ttl = mockCacheTtls.get('tank01:nba:boxscore:GAME-2');
expect(ttl).toBe(adapter.__internals.TTL.boxScoreLive);
});
test('getNBAGamesForDate strips dashes from ISO dates', async () => {
mockAxiosGet.mockResolvedValueOnce({ data: { body: [] } });
await adapter.getNBAGamesForDate('2026-06-11');
const [url] = mockAxiosGet.mock.calls[0];
expect(url).toMatch(/gameDate=20260611/);
});
test('getNBAGamesForDate projects to stable shape', async () => {
mockAxiosGet.mockResolvedValueOnce({
data: { body: [
{ gameID: '20260611_BOS_LAL', home: 'LAL', away: 'BOS', gameTime: '7:30p ET', gameStatus: 'Final', homePts: 110, awayPts: 105 },
] },
});
const games = await adapter.getNBAGamesForDate('20260611');
expect(games[0]).toMatchObject({
gameId: '20260611_BOS_LAL', homeTeam: 'LAL', awayTeam: 'BOS', homeScore: 110, awayScore: 105, gameStatus: 'Final',
});
});
test('null IDs return null without touching axios', async () => {
expect(await adapter.getNBABoxScore(null)).toBeNull();
expect(await adapter.getNBAGamesForDate(null)).toBeNull();
expect(await adapter.getNBABettingOdds(null)).toBeNull();
expect(mockAxiosGet).not.toHaveBeenCalled();
});
test('axios error → null + stale fallback', async () => {
mockCacheStore.set('tank01:nba:games:20260611:stale', { body: [{ gameID: 'STALE' }] });
mockAxiosGet.mockRejectedValueOnce(new Error('upstream 503'));
const games = await adapter.getNBAGamesForDate('20260611');
expect(games).toHaveLength(1);
expect(games[0].gameId).toBe('STALE');
});
test('cache hit on repeat call (axios not re-invoked)', async () => {
mockAxiosGet.mockResolvedValueOnce({ data: { body: [] } });
await adapter.getNBAGamesForDate('20260611');
await adapter.getNBAGamesForDate('20260611');
expect(mockAxiosGet).toHaveBeenCalledTimes(1);
});
});
});
+1 -1
View File
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -10,6 +10,7 @@ import InstallPrompt from '@/components/InstallPrompt';
import PushPrompt from '@/components/PushPrompt';
import MFAPrompt from '@/components/MFAPrompt';
import MFAChallenge from '@/components/MFAChallenge';
import CookieConsent from '@/components/CookieConsent';
import './globals.css';
export const metadata: Metadata = {
@@ -19,7 +20,7 @@ export const metadata: Metadata = {
template: '%s · VYNDR',
},
description:
"Grade NBA, MLB, and WNBA props with intelligence the books don't want you to have. Built in Detroit.",
"Grade NBA, MLB, WNBA, and soccer props with intelligence the books don't want you to have. World Cup 2026 intelligence: xG regression, altitude, referee, penalty taker. Built in Detroit.",
applicationName: 'VYNDR',
authors: [{ name: 'VYNDR', url: 'https://vyndr.app' }],
manifest: '/manifest.json',
@@ -28,6 +29,9 @@ export const metadata: Metadata = {
'NBA prop bet analysis',
'MLB prop intelligence',
'WNBA prop grading',
'soccer prop intelligence',
'World Cup 2026 props',
'xG regression analysis',
'parlay correlation analysis',
'prop betting tools',
],
@@ -104,6 +108,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<PushPrompt />
<MFAPrompt />
<MFAChallenge />
<CookieConsent />
</ParlayProvider>
</ExplainModeProvider>
</AuthProvider>
+40
View File
@@ -0,0 +1,40 @@
import type { Metadata } from 'next';
import Pricing from '@/components/Pricing';
export const metadata: Metadata = {
title: 'Pricing — VYNDR',
description: 'Founder pricing locks for life. $14.99/mo Analyst, $44.99/mo Desk. First 100 seats only.',
openGraph: {
title: 'VYNDR — Founder Pricing',
description: 'Sports prop intelligence. Beta pricing locks for life. First 100 seats.',
type: 'website',
url: 'https://vyndr.app/pricing',
},
twitter: {
card: 'summary_large_image',
title: 'VYNDR — Founder Pricing',
description: 'Sports prop intelligence. Beta pricing locks for life.',
},
};
/**
* Dedicated /pricing route. The Pricing component is also embedded on
* the landing page under the `#pricing` anchor; this page exists so:
* - The renewal email at `web/src/services/email.ts` (which links
* to `/pricing`) lands on something real instead of 404'ing.
* - Nav / CTA links can hand users a single stable URL whether
* they're already authenticated or not.
* - SEO crawlers see pricing on a canonical URL, not deep in the
* landing-page anchor.
*
* The component itself is fully client-side rendered (it owns
* checkout state + AuthContext access), so this page wraps it in a
* minimal scroll-restoration-friendly shell.
*/
export default function PricingPage() {
return (
<main style={{ minHeight: '100vh', paddingTop: 64 }}>
<Pricing />
</main>
);
}
+11 -1
View File
@@ -18,7 +18,7 @@ const SECTIONS: { title: string; body: string[]; emphasized?: boolean }[] = [
body: [
'Account data: email, password hash, age confirmation, signup timestamp.',
'Usage data: reads you run (player, stat, line, sport, grade), parlays you build, page views.',
'Payment data: NexaPay processes all card data — we never see or store your card. We retain a NexaPay customer ID and your subscription status.',
'Payment data: Stripe processes all card data — we never see or store your card number, CVC, or any other payment instrument detail. We retain a Stripe customer ID, Stripe subscription ID, and your subscription status (active, canceled, grace period, expired) so we can gate paid features and respond to renewal/cancellation events from Stripe webhooks.',
'Device data: IP address, browser type, basic device info (for fraud prevention and analytics).',
],
},
@@ -54,6 +54,16 @@ const SECTIONS: { title: string; body: string[]; emphasized?: boolean }[] = [
'Analytics: PostHog (anonymized IPs, no third-party trackers). You can opt out by setting your browser to "Do Not Track" or contacting us.',
],
},
{
title: 'Sub-processors',
body: [
'We rely on a small set of third-party processors. They handle data on our behalf under their own privacy commitments; we do not give them permission to use your data for their own purposes.',
'Stripe — payment processing and subscription management. Receives: name (if you provide), email, billing address (if you provide), and the card details you enter on their hosted checkout page. We never see your card number.',
'Supabase — authentication, database, and file storage. Receives: everything in the "Data we collect" section above. Supabase is our primary backend.',
'PostHog — product analytics. Receives: anonymized event data (page views, button clicks). IPs are anonymized before storage.',
'Resend — transactional email (account confirmations, payment receipts, renewal reminders). Receives: your email address and the message contents.',
],
},
{
title: 'Notifications',
body: [
+2 -1
View File
@@ -29,7 +29,7 @@ const SECTIONS: { title: string; body: string[]; emphasized?: boolean }[] = [
{
title: 'Subscription terms',
body: [
'Paid tiers (Analyst, Desk) are billed monthly through NexaPay. You may cancel at any time from your profile page. Cancellation takes effect at the end of the current billing period. We do not refund for partial months.',
'Paid tiers (Analyst, Desk) are billed monthly through Stripe, our payment processor. You may cancel at any time from your profile page. Cancellation takes effect at the end of the current billing period. We do not refund for partial months. If a payment fails, we honor a 48-hour grace period before reverting your account to the free tier.',
'Founder pricing ($14.99/mo Analyst, $44.99/mo Desk) is locked for the lifetime of your continuous subscription. Lapsed subscriptions revert to standard pricing ($24.99 Analyst, $49.99 Desk) on re-subscription. After the first 100 founder seats are taken, new subscribers pay standard pricing.',
'We may change regular pricing with 30 days notice. Founder pricing is locked.',
],
@@ -38,6 +38,7 @@ const SECTIONS: { title: string; body: string[]; emphasized?: boolean }[] = [
title: 'Acceptable use',
body: [
'Do not scrape, reverse engineer, or attempt to replicate the grading engine. Do not resell reads, share account credentials, or attempt to circumvent the read limit. Do not use the service to abuse, harass, or defraud others.',
'VYNDR does NOT offer API access at any tier. The grading engine is consumer-only — we do not provide programmatic access to grades, factor weights, model outputs, or any other engine surface, regardless of plan or pricing. Requests for API access will be declined.',
],
},
{
+101
View File
@@ -0,0 +1,101 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
const STORAGE_KEY = 'vyndr_cookie_consent';
/**
* Cookie consent — thin bottom bar shown on first visit. Single line,
* dark, dismissable. "Accept" writes a flag to localStorage so the
* banner never appears again on this device.
*
* SSR-safe: we render nothing until the client mounts and the
* localStorage check completes. That prevents a hydration mismatch
* (server has no `window.localStorage`, so it can't know the user's
* prior choice) and avoids the brief banner flash on every refresh
* for users who already accepted.
*
* GDPR posture: VYNDR's cookies are essential (auth + read counter)
* plus analytics (anonymized PostHog). We disclose; we don't pre-tick
* checkboxes for non-essential analytics. The "Accept" button only
* acknowledges that you saw the disclosure.
*/
export default function CookieConsent() {
const [visible, setVisible] = useState(false);
useEffect(() => {
try {
if (window.localStorage.getItem(STORAGE_KEY) !== 'accepted') {
setVisible(true);
}
} catch {
// Storage may be unavailable in private mode — fail closed: show
// the banner. Cheaper than tracking sessions for these users.
setVisible(true);
}
}, []);
function accept() {
try {
window.localStorage.setItem(STORAGE_KEY, 'accepted');
} catch {
/* private mode — banner will reappear next visit; acceptable. */
}
setVisible(false);
}
if (!visible) return null;
return (
<div
role="region"
aria-label="Cookie notice"
style={{
position: 'fixed',
left: 0,
right: 0,
bottom: 0,
zIndex: 60,
background: 'rgba(10, 10, 15, 0.96)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
borderTop: '1px solid var(--border)',
padding: '12px 16px',
}}
>
<div
style={{
maxWidth: 1100,
margin: '0 auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
flexWrap: 'wrap',
fontSize: 13,
color: 'var(--text-secondary)',
}}
>
<span>
We use cookies for authentication and anonymized analytics.{' '}
<Link
href="/privacy"
style={{ color: 'var(--grade-a)', textDecoration: 'underline', textUnderlineOffset: 2 }}
>
Privacy policy
</Link>
.
</span>
<button
type="button"
onClick={accept}
className="btn-primary"
style={{ padding: '6px 14px', fontSize: 12 }}
>
Accept
</button>
</div>
</div>
);
}