Session 51: Complete Team Hub (2234 tests)
Research-depth team view: /team/[abbr] with roster, archetypes, stats, props.
- Team API: mlbStatsAdapter.getTeams/resolveTeam/getTeamRoster (statsapi, abbr→id
+ active roster, cached). teamService.getTeamHub assembles roster → per-player
season stats (bounded concurrency) + archetype (snapshot grade or classify) +
tonight's graded props from grades:{sport}; whole hub cached 15min. MLB real;
NBA/WNBA graceful snapshot roster. GET /api/team/:abbr (404 unknown) + proxy.
- Team Hub page: server page.tsx (generateMetadata) + TeamHub client — header,
sort (archetype/graded/A-Z), archetype filter chips, roster rows (archetype +
player link + position + stats + graded props + parlay "+"), "No active props"
greyed state, loading/error.
- Game cards: team abbreviations are now TeamLinks → /team/:abbr?sport= (green
hover, stops propagation). Team Hub has "← Back to Slate".
Backend 2215 -> 2234 tests (+19), 190 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,34 @@
|
||||
// Session 51 — GET /api/team/:abbr (Team Hub endpoint).
|
||||
|
||||
const request = require('supertest');
|
||||
|
||||
jest.mock('../../src/services/teamService', () => ({
|
||||
getTeamHub: jest.fn(),
|
||||
}));
|
||||
const teamService = require('../../src/services/teamService');
|
||||
const app = require('../../src/app');
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('GET /api/team/:abbr', () => {
|
||||
it('returns the hub for a known team', async () => {
|
||||
teamService.getTeamHub.mockResolvedValue({ team: { name: 'New York Yankees', abbr: 'NYY', sport: 'mlb' }, roster: [{ player: 'Aaron Judge' }] });
|
||||
const res = await request(app).get('/api/team/NYY?sport=mlb');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.team.name).toBe('New York Yankees');
|
||||
expect(teamService.getTeamHub).toHaveBeenCalledWith('mlb', 'NYY');
|
||||
});
|
||||
|
||||
it('404s an unknown team with a helpful message', async () => {
|
||||
teamService.getTeamHub.mockResolvedValue(null);
|
||||
const res = await request(app).get('/api/team/ZZZ?sport=mlb');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toMatch(/abbreviation/i);
|
||||
});
|
||||
|
||||
it('defaults sport to mlb', async () => {
|
||||
teamService.getTeamHub.mockResolvedValue({ team: { abbr: 'BOS' }, roster: [] });
|
||||
await request(app).get('/api/team/BOS');
|
||||
expect(teamService.getTeamHub).toHaveBeenCalledWith('mlb', 'BOS');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
// Session 51 — Team Hub page + game-card team links (source-asserted, matching
|
||||
// the repo's frontend test pattern).
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const WEB = path.join(__dirname, '..', '..', 'web', 'src');
|
||||
const read = (rel) => fs.readFileSync(path.join(WEB, rel), 'utf8');
|
||||
|
||||
describe('Team Hub page', () => {
|
||||
const src = read('app/team/[abbr]/TeamHub.tsx');
|
||||
const page = read('app/team/[abbr]/page.tsx');
|
||||
|
||||
it('fetches /api/team/:abbr and renders team name + roster', () => {
|
||||
expect(src).toContain('/api/team/');
|
||||
expect(src).toContain('data.team.name');
|
||||
expect(src).toContain('roster.map');
|
||||
});
|
||||
it('player names link to the player profile', () => {
|
||||
expect(src).toContain('playerHref(p.player, data.team.sport)');
|
||||
});
|
||||
it('has sort (archetype/props/name) + archetype filter', () => {
|
||||
expect(src).toContain("k=\"archetype\"");
|
||||
expect(src).toContain("k=\"props\"");
|
||||
expect(src).toContain('archetypeFilter');
|
||||
expect(src).toContain('ArchetypeBadge');
|
||||
});
|
||||
it('shows "No active props" for players without props (greyed)', () => {
|
||||
expect(src).toContain('No active props');
|
||||
expect(src).toContain('opacity: noProps ? 0.6 : 1');
|
||||
});
|
||||
it('has back-to-slate navigation', () => {
|
||||
expect(src).toContain('← Back to Slate');
|
||||
expect(src).toContain('href="/dashboard"');
|
||||
});
|
||||
it('handles loading + error states', () => {
|
||||
expect(src).toContain("'loading'");
|
||||
expect(src).toContain("'error'");
|
||||
expect(src).toContain('Team not found');
|
||||
});
|
||||
it('wires the parlay "+" on graded props', () => {
|
||||
expect(src).toContain('useParlay');
|
||||
expect(src).toContain('onPropClick');
|
||||
expect(src).toContain('legKey');
|
||||
});
|
||||
it('server page exports generateMetadata with the team abbr', () => {
|
||||
expect(page).toContain('export async function generateMetadata');
|
||||
expect(page).toContain('Team Hub');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Game card team links', () => {
|
||||
const src = read('components/vyndr/GameCard.tsx');
|
||||
it('renders team abbreviations as links to /team/:abbr', () => {
|
||||
expect(src).toContain('function TeamLink');
|
||||
expect(src).toContain('/team/${encodeURIComponent(abbr)}?sport=');
|
||||
expect(src).toContain('<TeamLink abbr={g.away.abbr}');
|
||||
expect(src).toContain('<TeamLink abbr={g.home.abbr}');
|
||||
});
|
||||
it('stops propagation so the link does not trigger open-game', () => {
|
||||
expect(src).toContain('onClick={(e) => e.stopPropagation()}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Team API Next proxy', () => {
|
||||
it('forwards GET /api/team/:abbr to the backend', () => {
|
||||
const src = read('app/api/team/[abbr]/route.ts');
|
||||
expect(src).toContain('/api/team/');
|
||||
expect(src).toContain('BACKEND_URL');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
// Session 51 — Team Hub service. Adapter + cache injected (no network).
|
||||
|
||||
const svc = require('../../src/services/teamService');
|
||||
|
||||
function memCache(initial) {
|
||||
const store = { ...(initial || {}) };
|
||||
return { store, cacheGet: async (k) => (k in store ? store[k] : null), cacheSet: async (k, v) => { store[k] = v; } };
|
||||
}
|
||||
|
||||
const judgeSeason = { homeRuns: 34, atBats: 330, gamesPlayed: 92, avg: '.288', ops: '1.012', rbi: 87 };
|
||||
const acePitcherSeason = { era: '2.4', strikeOuts: 130, inningsPitched: '110.0', whip: '0.92', gamesStarted: 17, strikeoutsPer9Inn: '10.6' };
|
||||
|
||||
const mlbAdapter = {
|
||||
async resolveTeam(abbr) { return abbr === 'NYY' ? { id: 147, abbr: 'NYY', name: 'New York Yankees' } : null; },
|
||||
async getTeamRoster() {
|
||||
return [
|
||||
{ id: 592450, name: 'Aaron Judge', position: 'RF' },
|
||||
{ id: 1, name: 'Gerrit Cole', position: 'P' },
|
||||
{ id: 2, name: 'Bench Guy', position: '2B' },
|
||||
];
|
||||
},
|
||||
async getSeasonAverages(id, _s, group) {
|
||||
if (id === 592450) return judgeSeason;
|
||||
if (id === 1 && group === 'pitching') return acePitcherSeason;
|
||||
return null; // bench guy: no stats
|
||||
},
|
||||
};
|
||||
|
||||
const gradesEnv = {
|
||||
grades: [
|
||||
{ player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', grade: 'A', archetype: 'BOMBER', gradedAt: { line: 1.5, timestamp: '2026-06-19T18:00:00Z' } },
|
||||
{ player: 'Aaron Judge', stat_type: 'home_runs', line: 0.5, direction: 'over', grade: 'C', archetype: 'BOMBER' },
|
||||
],
|
||||
};
|
||||
|
||||
describe('getTeamHub (MLB, injected)', () => {
|
||||
it('returns team name + roster with archetype, stats, props', async () => {
|
||||
const cache = memCache({ 'grades:mlb': gradesEnv });
|
||||
const hub = await svc.getTeamHub('mlb', 'nyy', { mlbAdapter, cacheGet: cache.cacheGet, cacheSet: cache.cacheSet });
|
||||
expect(hub.team.name).toBe('New York Yankees');
|
||||
expect(hub.team.abbr).toBe('NYY');
|
||||
expect(hub.roster).toHaveLength(3);
|
||||
|
||||
const judge = hub.roster.find((p) => p.player === 'Aaron Judge');
|
||||
expect(judge.archetype.primary).toBe('BOMBER'); // from snapshot
|
||||
expect(judge.stats.length).toBeGreaterThan(0);
|
||||
expect(judge.propCount).toBe(2);
|
||||
|
||||
const cole = hub.roster.find((p) => p.player === 'Gerrit Cole');
|
||||
expect(cole.archetype.primary).toBe('ALPHA'); // classified from pitching stats
|
||||
});
|
||||
|
||||
it('shows a player with no stats + no props gracefully', async () => {
|
||||
const cache = memCache({ 'grades:mlb': gradesEnv });
|
||||
const hub = await svc.getTeamHub('mlb', 'NYY', { mlbAdapter, cacheGet: cache.cacheGet, cacheSet: cache.cacheSet });
|
||||
const bench = hub.roster.find((p) => p.player === 'Bench Guy');
|
||||
expect(bench.propCount).toBe(0);
|
||||
expect(bench.archetype).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an unknown team (→ 404)', async () => {
|
||||
const cache = memCache();
|
||||
expect(await svc.getTeamHub('mlb', 'ZZZ', { mlbAdapter, cacheGet: cache.cacheGet, cacheSet: cache.cacheSet })).toBeNull();
|
||||
});
|
||||
|
||||
it('caches the assembled hub (writes teamhub:{sport}:{abbr})', async () => {
|
||||
const cache = memCache({ 'grades:mlb': gradesEnv });
|
||||
await svc.getTeamHub('mlb', 'NYY', { mlbAdapter, cacheGet: cache.cacheGet, cacheSet: cache.cacheSet });
|
||||
expect(cache.store['teamhub:mlb:NYY']).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTeamHub (NBA fallback)', () => {
|
||||
it('builds a snapshot roster when no MLB feed', async () => {
|
||||
const cache = memCache({ 'grades:nba': { grades: [{ player: 'Victor Wembanyama', stat_type: 'points', line: 26.5, direction: 'over', grade: 'A', archetype: 'FORTRESS' }] } });
|
||||
const hub = await svc.getTeamHub('nba', 'SA', { cacheGet: cache.cacheGet, cacheSet: cache.cacheSet });
|
||||
expect(hub.rosterSource).toBe('snapshot');
|
||||
expect(hub.roster[0].player).toBe('Victor Wembanyama');
|
||||
expect(hub.roster[0].archetype.primary).toBe('FORTRESS');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user