Session 41: P0 audit fixes — MLB stat_types, broken routes, tier mismatch, self-hosted fonts (1940 tests)
- Backend: whitelist MLB stat_types in analyze.js + scan.js gates (mirrors python validation.py); fixes MLB scans 400ing. - Routes: /settings -> /profile, /report -> /blog redirect pages. - Profile: read tier from useAuth().tier (nav's source) to kill the Free-vs-DESK mismatch. - Fonts: self-host Inter/JetBrains Mono/IBM Plex Mono via next/font, drop the 503ing fonts.googleapis.com <link>; rewire literal font-family refs to vars. - Kept /settings/security (real MFA page) intact — NOT clobbered to a redirect. - +33 tests (1907 -> 1940), 149 suites; web build clean (exit 0). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
// Session 41 — MLB stat_type acceptance. The Scan frontend sends MLB IDs
|
||||
// (hits, strikeouts, total_bases, rbi, home_runs, earned_runs, hits_allowed,
|
||||
// innings_pitched) but the /api/analyze validation gate only whitelisted the
|
||||
// NBA/soccer set, so every MLB scan 400'd. This file lives apart from
|
||||
// analyze.test.js because that suite sits exactly at the 10/min IP rate-limit
|
||||
// boundary; a separate file gets a fresh limiter (jest isolates module state
|
||||
// per file) so we can exercise all eight MLB stats under the cap.
|
||||
|
||||
const request = require('supertest');
|
||||
|
||||
const mockRedis = { get: jest.fn(), set: jest.fn(), hset: jest.fn(), hgetall: jest.fn(), expire: jest.fn() };
|
||||
jest.mock('../../src/utils/redis', () => ({
|
||||
getRedisClient: () => mockRedis,
|
||||
cacheGet: async () => null,
|
||||
cacheSet: async () => true,
|
||||
cacheDel: async () => true,
|
||||
isDegraded: () => false,
|
||||
}));
|
||||
|
||||
const mockAnalyzeViaEngine1 = jest.fn();
|
||||
jest.mock('../../src/services/intelligence/analyzeViaEngine1', () => ({
|
||||
analyzeViaEngine1: (...args) => mockAnalyzeViaEngine1(...args),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/tierGating', () => ({ applyTierGating: (result) => result }));
|
||||
|
||||
const { __internals: scanLimitInternals } = require('../../src/middleware/scanLimit');
|
||||
const app = require('../../src/app');
|
||||
|
||||
const MLB_STATS = ['hits', 'strikeouts', 'total_bases', 'rbi', 'home_runs', 'earned_runs', 'hits_allowed', 'innings_pitched'];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockRedis.get.mockResolvedValue(null);
|
||||
mockRedis.set.mockResolvedValue('OK');
|
||||
mockAnalyzeViaEngine1.mockReset();
|
||||
if (scanLimitInternals && scanLimitInternals.resetForTests) scanLimitInternals.resetForTests();
|
||||
});
|
||||
|
||||
describe('POST /api/analyze/prop — MLB stat_types', () => {
|
||||
it.each(MLB_STATS)('accepts %s (no longer 400s at the validation gate)', async (stat) => {
|
||||
mockAnalyzeViaEngine1.mockResolvedValueOnce({
|
||||
player: 'Aaron Judge',
|
||||
stat_type: stat,
|
||||
line: 1.5,
|
||||
direction: 'over',
|
||||
grade: 'B',
|
||||
confidence: 70,
|
||||
kill_conditions_triggered: [],
|
||||
reasoning: { summary: 'ok', steps: [] },
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/analyze/prop')
|
||||
.send({ player: 'Aaron Judge', sport: 'mlb', stat_type: stat, line: 1.5, direction: 'over' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.error).toBeUndefined();
|
||||
expect(mockAnalyzeViaEngine1).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still rejects a genuinely invalid stat_type', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/analyze/prop')
|
||||
.send({ player: 'Aaron Judge', sport: 'mlb', stat_type: 'vibes', line: 1.5, direction: 'over' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain('Invalid stat_type');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
// Session 41 — P0 audit fixes. Frontend assertions read page source as text
|
||||
// (same pattern as the Phase D–H suites); backend stat-gate acceptance is
|
||||
// covered in tests/integration/analyze.test.js.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const WEB = path.join(ROOT, 'web', 'src');
|
||||
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
|
||||
|
||||
describe('Session 41 — backend MLB stat_type whitelist', () => {
|
||||
const analyze = fs.readFileSync(path.join(ROOT, 'src', 'routes', 'analyze.js'), 'utf8');
|
||||
const scan = fs.readFileSync(path.join(ROOT, 'src', 'routes', 'scan.js'), 'utf8');
|
||||
const mlbStats = ['hits', 'strikeouts', 'total_bases', 'rbi', 'home_runs', 'earned_runs', 'hits_allowed', 'innings_pitched'];
|
||||
|
||||
it.each(mlbStats)('/api/analyze gate whitelists MLB stat %s', (stat) => {
|
||||
expect(analyze).toContain(`'${stat}'`);
|
||||
});
|
||||
|
||||
it.each(mlbStats)('/api/scan (parlay) gate whitelists MLB stat %s', (stat) => {
|
||||
expect(scan).toContain(`'${stat}'`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session 41 — broken-route redirects', () => {
|
||||
it('/settings redirects to /profile', () => {
|
||||
const src = read('app/settings/page.tsx');
|
||||
expect(src).toContain("from 'next/navigation'");
|
||||
expect(src).toContain("redirect('/profile')");
|
||||
});
|
||||
|
||||
it('/report redirects to /blog (THE REPORT link target)', () => {
|
||||
const src = read('app/report/page.tsx');
|
||||
expect(src).toContain("redirect('/blog')");
|
||||
});
|
||||
|
||||
it('/settings/security stays the real MFA page (NOT clobbered into a redirect)', () => {
|
||||
// The audit spec wanted this redirected too, but it is a working MFA
|
||||
// enrollment flow — overwriting it would be a security-feature regression.
|
||||
const src = read('app/settings/security/page.tsx');
|
||||
expect(src).toContain('mfa');
|
||||
expect(src).not.toContain("redirect('/profile')");
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session 41 — profile reads tier from useAuth', () => {
|
||||
const src = read('app/profile/page.tsx');
|
||||
|
||||
it('destructures tier from useAuth (same source as the nav)', () => {
|
||||
expect(src).toMatch(/tier:\s*authTier/);
|
||||
});
|
||||
|
||||
it('derives the displayed tier from the auth session', () => {
|
||||
expect(src).toContain('authTier || profile.tier');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session 41 — self-hosted fonts (no Google Fonts CDN)', () => {
|
||||
const layout = read('app/layout.tsx');
|
||||
const globals = read('app/globals.css');
|
||||
|
||||
it('layout uses next/font instead of a runtime <link>', () => {
|
||||
expect(layout).toContain("from 'next/font/google'");
|
||||
expect(layout).toContain('Inter(');
|
||||
expect(layout).toContain('JetBrains_Mono(');
|
||||
});
|
||||
|
||||
it('removed the runtime Google Fonts stylesheet <link>', () => {
|
||||
// The historical reference survives in a code comment; what must be gone
|
||||
// is the actual CDN stylesheet href that caused the 503.
|
||||
expect(layout).not.toMatch(/href=["'][^"']*fonts\.googleapis\.com/);
|
||||
expect(layout).not.toContain('rel="stylesheet"');
|
||||
});
|
||||
|
||||
it('globals.css :root maps --sans/--mono onto the next/font variables', () => {
|
||||
expect(globals).toContain('--sans: var(--font-sans)');
|
||||
expect(globals).toContain('--mono: var(--font-mono)');
|
||||
});
|
||||
});
|
||||
@@ -37,8 +37,11 @@ describe('Phase A — design tokens (§2)', () => {
|
||||
});
|
||||
|
||||
it('sets --sans to Inter and --mono to JetBrains Mono', () => {
|
||||
expect(css).toMatch(/--sans:\s*'Inter'/);
|
||||
expect(css).toMatch(/--mono:\s*'JetBrains Mono'/);
|
||||
// Session 41 — fonts are self-hosted via next/font, so the families now
|
||||
// come through the --font-* variables (with the literal names kept as
|
||||
// fallbacks for any non-next/font context).
|
||||
expect(css).toMatch(/--sans:\s*var\(--font-sans\),\s*'Inter'/);
|
||||
expect(css).toMatch(/--mono:\s*var\(--font-mono\),\s*'JetBrains Mono'/);
|
||||
});
|
||||
|
||||
it('sets glitch intensity to the §2 baseline of 1 and scan-op to 0.04', () => {
|
||||
@@ -48,9 +51,11 @@ describe('Phase A — design tokens (§2)', () => {
|
||||
});
|
||||
|
||||
describe('Phase A — fonts (§2)', () => {
|
||||
it('loads Inter (400–900) and JetBrains Mono (400–800) from Google Fonts', () => {
|
||||
expect(layout).toContain('Inter:wght@400;500;600;700;800;900');
|
||||
expect(layout).toContain('JetBrains+Mono:wght@400;500;600;700;800');
|
||||
it('self-hosts Inter + JetBrains Mono via next/font (Session 41 — no CDN <link>)', () => {
|
||||
expect(layout).toContain("from 'next/font/google'");
|
||||
expect(layout).toContain('Inter(');
|
||||
expect(layout).toContain('JetBrains_Mono(');
|
||||
expect(layout).not.toMatch(/href=["'][^"']*fonts\.googleapis\.com/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user