feat: complete Phase 3 - Domain Modeling & Data Layer

Implements comprehensive type system, runtime validation, API client,
and testing infrastructure for CS2.WTF.

TypeScript Interfaces (src/lib/types/):
- Match.ts: Match, MatchPlayer, MatchListItem types
- Player.ts: Player, PlayerMeta, PlayerProfile types
- RoundStats.ts: Round economy and performance data
- Weapon.ts: Weapon statistics with hit groups (HitGroup, WeaponType enums)
- Message.ts: Chat messages with filtering support
- api.ts: API responses, errors, and APIException class
- Complete type safety with strict null checks

Zod Schemas (src/lib/schemas/):
- Runtime validation for all data models
- Schema parsers with safe/unsafe variants
- Special handling for backend typo (looses → losses)
- Share code validation regex
- CS2-specific validations (rank 0-30000, MR12 rounds)

API Client (src/lib/api/):
- client.ts: Axios-based HTTP client with error handling
  - Request cancellation support (AbortController)
  - Automatic retry logic for transient failures
  - Timeout handling (10s default)
  - Typed APIException errors
- players.ts: Player endpoints (profile, meta, track/untrack, search)
- matches.ts: Match endpoints (parse, details, weapons, rounds, chat, search)
- Zod validation on all API responses

MSW Mock Handlers (src/mocks/):
- fixtures.ts: Comprehensive mock data for testing
- handlers/players.ts: Mock player API endpoints
- handlers/matches.ts: Mock match API endpoints
- browser.ts: Browser MSW worker for development
- server.ts: Node MSW server for Vitest tests
- Realistic responses with delays and pagination
- Safe integer IDs to avoid precision loss

Configuration:
- .env.example: Complete environment variable documentation
- src/vite-env.d.ts: Vite environment type definitions
- All strict TypeScript checks passing (0 errors, 0 warnings)

Features:
- Cancellable requests for search (prevent race conditions)
- Data normalization (backend typo handling)
- Comprehensive error types (NetworkError, Timeout, etc.)
- Share code parsing and validation
- Pagination support for players and matches
- Mock data for offline development and testing

Build Status: ✓ Production build successful
Type Check: ✓ 0 errors, 0 warnings
Lint: ✓ All checks passed
Phase 3 Completion: 100%

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-04 20:31:20 +01:00
parent 66aea51c39
commit d811efc394
26 changed files with 2444 additions and 6 deletions

44
src/mocks/browser.ts Normal file
View File

@@ -0,0 +1,44 @@
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';
/**
* MSW Browser Worker
* Used for mocking API requests in the browser (development mode)
*/
/**
* Create MSW service worker
*/
export const worker = setupWorker(...handlers);
/**
* Start MSW worker with console logging
*/
export const startMocking = async () => {
const isDev = import.meta.env?.DEV ?? false;
const isMockingEnabled = import.meta.env?.VITE_ENABLE_MSW_MOCKING === 'true';
if (isDev && isMockingEnabled) {
await worker.start({
onUnhandledRequest: 'bypass',
serviceWorker: {
url: '/mockServiceWorker.js'
}
});
console.log('[MSW] API mocking enabled for development');
}
};
/**
* Stop MSW worker
*/
export const stopMocking = () => {
worker.stop();
console.log('[MSW] API mocking stopped');
};
/**
* Default export
*/
export default worker;

194
src/mocks/fixtures.ts Normal file
View File

@@ -0,0 +1,194 @@
import type { Player, Match, MatchPlayer, MatchListItem, PlayerMeta } from '$lib/types';
/**
* Mock data fixtures for testing and development
*/
/** Mock players */
export const mockPlayers: Player[] = [
{
id: 765611980123456, // Smaller mock Steam ID (safe integer)
name: 'TestPlayer1',
avatar:
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg',
vanity_url: 'testplayer1',
steam_updated: '2024-11-04T10:30:00Z',
profile_created: '2015-03-12T00:00:00Z',
wins: 1250,
losses: 980,
ties: 45,
vac_count: 0,
game_ban_count: 0
},
{
id: 765611980876543, // Smaller mock Steam ID (safe integer)
name: 'TestPlayer2',
avatar: 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/ab/abc123.jpg',
steam_updated: '2024-11-04T11:15:00Z',
wins: 850,
losses: 720,
ties: 30,
vac_count: 0,
game_ban_count: 0
}
];
/** Mock player metadata */
export const mockPlayerMeta: PlayerMeta = {
id: 765611980123456,
name: 'TestPlayer1',
avatar:
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg',
recent_matches: 25,
last_match_date: '2024-11-01T18:45:00Z',
avg_kills: 21.3,
avg_deaths: 17.8,
avg_kast: 75.2,
win_rate: 56.5
};
/** Mock match players */
export const mockMatchPlayers: MatchPlayer[] = [
{
id: 765611980123456,
name: 'Player1',
avatar:
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg',
team_id: 2,
kills: 24,
deaths: 18,
assists: 6,
headshot: 12,
mvp: 3,
score: 56,
kast: 78,
rank_old: 18500,
rank_new: 18650,
dmg_enemy: 2450,
dmg_team: 120,
flash_assists: 4,
flash_duration_enemy: 15.6,
flash_total_enemy: 8,
ud_he: 450,
ud_flames: 230,
ud_flash: 5,
ud_smoke: 3,
avg_ping: 25.5,
color: 'yellow'
},
{
id: 765611980876543,
name: 'Player2',
avatar: 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/ab/abc123.jpg',
team_id: 2,
kills: 19,
deaths: 20,
assists: 8,
headshot: 9,
mvp: 2,
score: 48,
kast: 72,
rank_old: 17200,
rank_new: 17180,
dmg_enemy: 2180,
dmg_team: 85,
avg_ping: 32.1,
color: 'blue'
},
{
id: 765611980111111,
name: 'Player3',
avatar: 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/cd/cde456.jpg',
team_id: 3,
kills: 22,
deaths: 19,
assists: 5,
headshot: 14,
mvp: 4,
score: 60,
kast: 80,
rank_old: 19800,
rank_new: 19920,
dmg_enemy: 2680,
dmg_team: 45,
avg_ping: 18.3,
color: 'green'
}
];
/** Mock matches */
export const mockMatches: Match[] = [
{
match_id: 358948771684207, // Smaller mock match ID (safe integer)
share_code: 'CSGO-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX',
map: 'de_inferno',
date: '2024-11-01T18:45:00Z',
score_team_a: 13,
score_team_b: 10,
duration: 2456,
match_result: 1,
max_rounds: 24,
demo_parsed: true,
vac_present: false,
gameban_present: false,
tick_rate: 64.0,
players: mockMatchPlayers
},
{
match_id: 358948771684208,
share_code: 'CSGO-YYYYY-YYYYY-YYYYY-YYYYY-YYYYY',
map: 'de_mirage',
date: '2024-11-02T20:15:00Z',
score_team_a: 16,
score_team_b: 14,
duration: 2845,
match_result: 1,
max_rounds: 24,
demo_parsed: true,
vac_present: false,
gameban_present: false,
tick_rate: 64.0
},
{
match_id: 358948771684209,
share_code: 'CSGO-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ',
map: 'de_dust2',
date: '2024-11-03T15:30:00Z',
score_team_a: 9,
score_team_b: 13,
duration: 1980,
match_result: 2,
max_rounds: 24,
demo_parsed: true,
vac_present: false,
gameban_present: false,
tick_rate: 64.0
}
];
/** Mock match list items */
export const mockMatchListItems: MatchListItem[] = mockMatches.map((match) => ({
match_id: match.match_id,
map: match.map,
date: match.date,
score_team_a: match.score_team_a,
score_team_b: match.score_team_b,
duration: match.duration,
demo_parsed: match.demo_parsed,
player_count: 10
}));
/** Helper: Generate random Steam ID (safe integer) */
export const generateSteamId = (): number => {
return 765611980000000 + Math.floor(Math.random() * 999999);
};
/** Helper: Get mock player by ID */
export const getMockPlayer = (id: number): Player | undefined => {
return mockPlayers.find((p) => p.id === id);
};
/** Helper: Get mock match by ID */
export const getMockMatch = (id: number): Match | undefined => {
return mockMatches.find((m) => m.match_id === id);
};

View File

@@ -0,0 +1,17 @@
/**
* MSW Request Handlers
* Mocks all CS2.WTF API endpoints for testing and development
*/
import { playersHandlers } from './players';
import { matchesHandlers } from './matches';
/**
* Combined handlers for all API endpoints
*/
export const handlers = [...playersHandlers, ...matchesHandlers];
/**
* Default export
*/
export default handlers;

View File

@@ -0,0 +1,192 @@
import { http, HttpResponse, delay } from 'msw';
import { mockMatches, mockMatchListItems, getMockMatch } from '../fixtures';
import type {
MatchParseResponse,
MatchesListResponse,
MatchRoundsResponse,
MatchWeaponsResponse,
MatchChatResponse
} from '$lib/types';
/**
* MSW handlers for Match API endpoints
*/
const API_BASE_URL = 'http://localhost:8000';
export const matchesHandlers = [
// GET /match/parse/:sharecode
http.get(`${API_BASE_URL}/match/parse/:sharecode`, async () => {
// Simulate parsing delay
await delay(500);
const response: MatchParseResponse = {
match_id: 358948771684207,
status: 'parsing',
message: 'Demo download and parsing initiated',
estimated_time: 120
};
return HttpResponse.json(response);
}),
// GET /match/:id
http.get(`${API_BASE_URL}/match/:id`, ({ params }) => {
const { id } = params;
const matchId = Number(id);
const match = getMockMatch(matchId) || mockMatches[0];
return HttpResponse.json(match);
}),
// GET /match/:id/weapons
http.get(`${API_BASE_URL}/match/:id/weapons`, ({ params }) => {
const { id } = params;
const matchId = Number(id);
const response: MatchWeaponsResponse = {
match_id: matchId,
weapons: [
{
player_id: 765611980123456,
weapon_stats: [
{
eq_type: 17,
weapon_name: 'AK-47',
kills: 12,
damage: 1450,
hits: 48,
hit_groups: {
head: 8,
chest: 25,
stomach: 8,
left_arm: 3,
right_arm: 2,
left_leg: 1,
right_leg: 1
},
headshot_pct: 16.7
}
]
}
]
};
return HttpResponse.json(response);
}),
// GET /match/:id/rounds
http.get(`${API_BASE_URL}/match/:id/rounds`, ({ params }) => {
const { id } = params;
const matchId = Number(id);
const winReasons = ['elimination', 'bomb_defused', 'bomb_exploded'];
const response: MatchRoundsResponse = {
match_id: matchId,
rounds: Array.from({ length: 23 }, (_, i) => ({
round: i + 1,
winner: i % 2 === 0 ? 2 : 3,
win_reason: winReasons[i % 3] || 'elimination',
players: [
{
round: i + 1,
player_id: 765611980123456,
bank: 800 + i * 1000,
equipment: 650 + i * 500,
spent: 650 + i * 500,
kills_in_round: i % 3,
damage_in_round: 100 + i * 20
}
]
}))
};
return HttpResponse.json(response);
}),
// GET /match/:id/chat
http.get(`${API_BASE_URL}/match/:id/chat`, ({ params }) => {
const { id } = params;
const matchId = Number(id);
const response: MatchChatResponse = {
match_id: matchId,
messages: [
{
player_id: 765611980123456,
player_name: 'Player1',
message: 'nice shot!',
tick: 15840,
round: 8,
all_chat: true,
timestamp: '2024-11-01T19:12:34Z'
},
{
player_id: 765611980876543,
player_name: 'Player2',
message: 'thanks',
tick: 15920,
round: 8,
all_chat: true,
timestamp: '2024-11-01T19:12:38Z'
},
{
player_id: 765611980111111,
player_name: 'Player3',
message: 'rush b no stop',
tick: 18400,
round: 9,
all_chat: false,
timestamp: '2024-11-01T19:14:12Z'
}
]
};
return HttpResponse.json(response);
}),
// GET /matches
http.get(`${API_BASE_URL}/matches`, ({ request }) => {
const url = new URL(request.url);
const limit = Number(url.searchParams.get('limit')) || 50;
const map = url.searchParams.get('map');
const playerId = url.searchParams.get('player_id');
let matches = [...mockMatchListItems];
// Apply filters
if (map) {
matches = matches.filter((m) => m.map === map);
}
if (playerId) {
// In a real scenario, filter by player participation
matches = matches.slice(0, Math.ceil(matches.length / 2));
}
const response: MatchesListResponse = {
matches: matches.slice(0, limit),
next_page_time: Date.now() / 1000 - 86400,
has_more: matches.length > limit,
total_count: matches.length
};
return HttpResponse.json(response);
}),
// GET /matches/next/:time
http.get(`${API_BASE_URL}/matches/next/:time`, ({ request }) => {
const url = new URL(request.url);
const limit = Number(url.searchParams.get('limit')) || 50;
// Return older matches for pagination
const response: MatchesListResponse = {
matches: mockMatchListItems.slice(limit, limit * 2),
next_page_time: Date.now() / 1000 - 172800,
has_more: mockMatchListItems.length > limit * 2
};
return HttpResponse.json(response);
})
];

View File

@@ -0,0 +1,99 @@
import { http, HttpResponse } from 'msw';
import { mockPlayers, mockPlayerMeta, getMockPlayer } from '../fixtures';
import type { TrackPlayerResponse } from '$lib/types';
/**
* MSW handlers for Player API endpoints
*/
const API_BASE_URL = 'http://localhost:8000';
export const playersHandlers = [
// GET /player/:id
http.get(`${API_BASE_URL}/player/:id`, ({ params }) => {
const { id } = params;
const playerId = Number(id);
const player = getMockPlayer(playerId);
if (!player) {
return HttpResponse.json(mockPlayers[0]);
}
return HttpResponse.json(player);
}),
// GET /player/:id/next/:time
http.get(`${API_BASE_URL}/player/:id/next/:time`, ({ params }) => {
const { id } = params;
const playerId = Number(id);
const player = getMockPlayer(playerId) ?? mockPlayers[0];
// Return player with paginated matches (simulate older matches)
return HttpResponse.json({
...player,
matches: player?.matches?.slice(5, 10) || []
});
}),
// GET /player/:id/meta
http.get(`${API_BASE_URL}/player/:id/meta`, () => {
return HttpResponse.json(mockPlayerMeta);
}),
// GET /player/:id/meta/:limit
http.get(`${API_BASE_URL}/player/:id/meta/:limit`, ({ params }) => {
const { limit } = params;
const limitNum = Number(limit);
return HttpResponse.json({
...mockPlayerMeta,
recent_matches: limitNum
});
}),
// POST /player/:id/track
http.post(`${API_BASE_URL}/player/:id/track`, async () => {
const response: TrackPlayerResponse = {
success: true,
message: 'Player added to tracking queue'
};
return HttpResponse.json(response);
}),
// DELETE /player/:id/track
http.delete(`${API_BASE_URL}/player/:id/track`, () => {
const response: TrackPlayerResponse = {
success: true,
message: 'Player removed from tracking'
};
return HttpResponse.json(response);
}),
// GET /players/search (custom endpoint for search)
http.get(`${API_BASE_URL}/players/search`, ({ request }) => {
const url = new URL(request.url);
const query = url.searchParams.get('q');
const limit = Number(url.searchParams.get('limit')) || 10;
// Filter players by name
const filtered = mockPlayers
.filter((p) => p.name.toLowerCase().includes(query?.toLowerCase() || ''))
.slice(0, limit)
.map((p) => ({
id: p.id,
name: p.name,
avatar: p.avatar,
recent_matches: 25,
last_match_date: '2024-11-01T18:45:00Z',
avg_kills: 20.5,
avg_deaths: 18.2,
avg_kast: 74.0,
win_rate: 55.0
}));
return HttpResponse.json(filtered);
})
];

39
src/mocks/server.ts Normal file
View File

@@ -0,0 +1,39 @@
import { setupServer } from 'msw/node';
import { beforeAll, afterEach, afterAll } from 'vitest';
import { handlers } from './handlers';
/**
* MSW Server
* Used for mocking API requests in Node.js (tests)
*/
/**
* Create MSW server for testing
*/
export const server = setupServer(...handlers);
/**
* Setup server for tests
* Call this in test setup files (e.g., vitest.setup.ts)
*/
export const setupMockServer = () => {
// Start server before all tests
beforeAll(() => {
server.listen({ onUnhandledRequest: 'bypass' });
});
// Reset handlers after each test
afterEach(() => {
server.resetHandlers();
});
// Close server after all tests
afterAll(() => {
server.close();
});
};
/**
* Default export
*/
export default server;