website refactor
This commit is contained in:
305
tests/integration/league/members-data-flow.integration.test.ts
Normal file
305
tests/integration/league/members-data-flow.integration.test.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Integration Test: League Members Data Flow
|
||||
*
|
||||
* Tests the complete data flow from database to API response for league members:
|
||||
* 1. Database query returns correct data
|
||||
* 2. Use case processes the data correctly
|
||||
* 3. Presenter transforms data to DTOs
|
||||
* 4. API returns correct response
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { IntegrationTestHarness, createTestHarness } from '../harness';
|
||||
|
||||
describe('League Members - Data Flow Integration', () => {
|
||||
let harness: IntegrationTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = createTestHarness();
|
||||
await harness.beforeAll();
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.afterAll();
|
||||
}, 30000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.beforeEach();
|
||||
});
|
||||
|
||||
describe('API to View Data Flow', () => {
|
||||
it('should return correct members DTO structure from API', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Members Test League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
// Create drivers
|
||||
const drivers = await Promise.all([
|
||||
factory.createDriver({ name: 'Owner Driver', country: 'US' }),
|
||||
factory.createDriver({ name: 'Admin Driver', country: 'UK' }),
|
||||
factory.createDriver({ name: 'Member Driver', country: 'CA' }),
|
||||
]);
|
||||
|
||||
// Create league memberships (simulated via database)
|
||||
// Note: In real implementation, memberships would be created through the domain
|
||||
// For this test, we'll verify the API response structure
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/memberships`);
|
||||
|
||||
// Verify: API response structure
|
||||
expect(response).toBeDefined();
|
||||
expect(response.memberships).toBeDefined();
|
||||
expect(Array.isArray(response.memberships)).toBe(true);
|
||||
|
||||
// Verify: Each membership has correct DTO structure
|
||||
for (const membership of response.memberships) {
|
||||
expect(membership).toHaveProperty('driverId');
|
||||
expect(membership).toHaveProperty('driver');
|
||||
expect(membership).toHaveProperty('role');
|
||||
expect(membership).toHaveProperty('status');
|
||||
expect(membership).toHaveProperty('joinedAt');
|
||||
|
||||
// Verify driver DTO structure
|
||||
expect(membership.driver).toHaveProperty('id');
|
||||
expect(membership.driver).toHaveProperty('iracingId');
|
||||
expect(membership.driver).toHaveProperty('name');
|
||||
expect(membership.driver).toHaveProperty('country');
|
||||
expect(membership.driver).toHaveProperty('joinedAt');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return empty members for league with no members', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Empty Members League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/memberships`);
|
||||
|
||||
expect(response.memberships).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle league with single member', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Single Member League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
const driver = await factory.createDriver({ name: 'Solo Member', country: 'US' });
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/memberships`);
|
||||
|
||||
// Should have at least the owner
|
||||
expect(response.memberships.length).toBeGreaterThan(0);
|
||||
|
||||
const soloMember = response.memberships.find(m => m.driver.name === 'Solo Member');
|
||||
expect(soloMember).toBeDefined();
|
||||
expect(soloMember?.role).toBeDefined();
|
||||
expect(soloMember?.status).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('End-to-End Data Flow', () => {
|
||||
it('should correctly transform member data to DTO', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Transformation Members League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
const drivers = await Promise.all([
|
||||
factory.createDriver({ name: 'Owner', country: 'US', iracingId: '1001' }),
|
||||
factory.createDriver({ name: 'Admin', country: 'UK', iracingId: '1002' }),
|
||||
factory.createDriver({ name: 'Member', country: 'CA', iracingId: '1003' }),
|
||||
]);
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/memberships`);
|
||||
|
||||
// Verify all drivers are in the response
|
||||
expect(response.memberships.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Verify each driver has correct data
|
||||
for (const driver of drivers) {
|
||||
const membership = response.memberships.find(m => m.driver.name === driver.name.toString());
|
||||
expect(membership).toBeDefined();
|
||||
expect(membership?.driver.id).toBe(driver.id.toString());
|
||||
expect(membership?.driver.iracingId).toBe(driver.iracingId);
|
||||
expect(membership?.driver.country).toBe(driver.country);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle league with many members', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Many Members League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
// Create 15 drivers
|
||||
const drivers = await Promise.all(
|
||||
Array.from({ length: 15 }, (_, i) =>
|
||||
factory.createDriver({ name: `Member ${i + 1}`, iracingId: `${2000 + i}` })
|
||||
)
|
||||
);
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/memberships`);
|
||||
|
||||
// Should have all drivers
|
||||
expect(response.memberships.length).toBeGreaterThanOrEqual(15);
|
||||
|
||||
// All memberships should have correct structure
|
||||
for (const membership of response.memberships) {
|
||||
expect(membership).toHaveProperty('driverId');
|
||||
expect(membership).toHaveProperty('driver');
|
||||
expect(membership).toHaveProperty('role');
|
||||
expect(membership).toHaveProperty('status');
|
||||
expect(membership).toHaveProperty('joinedAt');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle members with different roles', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Roles League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
const drivers = await Promise.all([
|
||||
factory.createDriver({ name: 'Owner', country: 'US' }),
|
||||
factory.createDriver({ name: 'Admin', country: 'UK' }),
|
||||
factory.createDriver({ name: 'Member', country: 'CA' }),
|
||||
]);
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/memberships`);
|
||||
|
||||
// Should have members with different roles
|
||||
const roles = response.memberships.map(m => m.role);
|
||||
expect(roles.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify roles are present
|
||||
const hasOwner = roles.some(r => r === 'owner' || r === 'OWNER');
|
||||
const hasAdmin = roles.some(r => r === 'admin' || r === 'ADMIN');
|
||||
const hasMember = roles.some(r => r === 'member' || r === 'MEMBER');
|
||||
|
||||
// At least owner should exist
|
||||
expect(hasOwner || hasAdmin || hasMember).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Consistency', () => {
|
||||
it('should maintain data consistency across multiple API calls', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Consistency Members League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
const driver = await factory.createDriver({ name: 'Consistent Member', country: 'DE' });
|
||||
|
||||
const api = harness.getApi();
|
||||
|
||||
// Make multiple calls
|
||||
const response1 = await api.get(`/leagues/${league.id}/memberships`);
|
||||
const response2 = await api.get(`/leagues/${league.id}/memberships`);
|
||||
const response3 = await api.get(`/leagues/${league.id}/memberships`);
|
||||
|
||||
// All responses should be identical
|
||||
expect(response1).toEqual(response2);
|
||||
expect(response2).toEqual(response3);
|
||||
|
||||
// Verify data integrity
|
||||
expect(response1.memberships.length).toBeGreaterThan(0);
|
||||
|
||||
const consistentMember = response1.memberships.find(m => m.driver.name === 'Consistent Member');
|
||||
expect(consistentMember).toBeDefined();
|
||||
expect(consistentMember?.driver.country).toBe('DE');
|
||||
});
|
||||
|
||||
it('should handle edge case: league with many members and complex data', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Complex Members League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
// Create 20 drivers
|
||||
const drivers = await Promise.all(
|
||||
Array.from({ length: 20 }, (_, i) =>
|
||||
factory.createDriver({
|
||||
name: `Complex Member ${i + 1}`,
|
||||
iracingId: `${3000 + i}`,
|
||||
country: ['US', 'UK', 'CA', 'DE', 'FR'][i % 5]
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/memberships`);
|
||||
|
||||
// Should have all drivers
|
||||
expect(response.memberships.length).toBeGreaterThanOrEqual(20);
|
||||
|
||||
// All memberships should have correct structure
|
||||
for (const membership of response.memberships) {
|
||||
expect(membership).toHaveProperty('driverId');
|
||||
expect(membership).toHaveProperty('driver');
|
||||
expect(membership).toHaveProperty('role');
|
||||
expect(membership).toHaveProperty('status');
|
||||
expect(membership).toHaveProperty('joinedAt');
|
||||
|
||||
// Verify driver has all required fields
|
||||
expect(membership.driver).toHaveProperty('id');
|
||||
expect(membership.driver).toHaveProperty('iracingId');
|
||||
expect(membership.driver).toHaveProperty('name');
|
||||
expect(membership.driver).toHaveProperty('country');
|
||||
expect(membership.driver).toHaveProperty('joinedAt');
|
||||
}
|
||||
|
||||
// Verify all drivers are present
|
||||
const driverNames = response.memberships.map(m => m.driver.name);
|
||||
for (const driver of drivers) {
|
||||
expect(driverNames).toContain(driver.name.toString());
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle edge case: members with optional fields', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Optional Fields League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
// Create driver without bio (should be optional)
|
||||
const driver = await factory.createDriver({ name: 'Test Member', country: 'US' });
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/memberships`);
|
||||
|
||||
expect(response.memberships.length).toBeGreaterThan(0);
|
||||
|
||||
const testMember = response.memberships.find(m => m.driver.name === 'Test Member');
|
||||
expect(testMember).toBeDefined();
|
||||
expect(testMember?.driver.bio).toBeUndefined(); // Optional field
|
||||
expect(testMember?.driver.name).toBe('Test Member');
|
||||
expect(testMember?.driver.country).toBe('US');
|
||||
});
|
||||
|
||||
it('should handle edge case: league with no completed races but has members', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'No Races Members League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
const driver = await factory.createDriver({ name: 'Waiting Member', country: 'US' });
|
||||
|
||||
// Create only scheduled races (no completed races)
|
||||
const race = await factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Future Track',
|
||||
car: 'Future Car',
|
||||
scheduledAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
status: 'scheduled'
|
||||
});
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/memberships`);
|
||||
|
||||
// Should still have members even with no completed races
|
||||
expect(response.memberships.length).toBeGreaterThan(0);
|
||||
|
||||
const waitingMember = response.memberships.find(m => m.driver.name === 'Waiting Member');
|
||||
expect(waitingMember).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
386
tests/integration/league/schedule-data-flow.integration.test.ts
Normal file
386
tests/integration/league/schedule-data-flow.integration.test.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* Integration Test: League Schedule Data Flow
|
||||
*
|
||||
* Tests the complete data flow from database to API response for league schedule:
|
||||
* 1. Database query returns correct data
|
||||
* 2. Use case processes the data correctly
|
||||
* 3. Presenter transforms data to DTOs
|
||||
* 4. API returns correct response
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { IntegrationTestHarness, createTestHarness } from '../harness';
|
||||
|
||||
describe('League Schedule - Data Flow Integration', () => {
|
||||
let harness: IntegrationTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = createTestHarness();
|
||||
await harness.beforeAll();
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.afterAll();
|
||||
}, 30000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.beforeEach();
|
||||
});
|
||||
|
||||
describe('API to View Data Flow', () => {
|
||||
it('should return correct schedule DTO structure from API', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Schedule Test League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
// Create races with different statuses
|
||||
const race1 = await factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Laguna Seca',
|
||||
car: 'Formula Ford',
|
||||
scheduledAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // Future race
|
||||
status: 'scheduled'
|
||||
});
|
||||
|
||||
const race2 = await factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Road Atlanta',
|
||||
car: 'Formula Ford',
|
||||
scheduledAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // Future race
|
||||
status: 'scheduled'
|
||||
});
|
||||
|
||||
const race3 = await factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Nürburgring',
|
||||
car: 'GT3',
|
||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // Past race
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/schedule`);
|
||||
|
||||
// Verify: API response structure
|
||||
expect(response).toBeDefined();
|
||||
expect(response.races).toBeDefined();
|
||||
expect(Array.isArray(response.races)).toBe(true);
|
||||
|
||||
// Verify: Each race has correct DTO structure
|
||||
for (const race of response.races) {
|
||||
expect(race).toHaveProperty('id');
|
||||
expect(race).toHaveProperty('track');
|
||||
expect(race).toHaveProperty('car');
|
||||
expect(race).toHaveProperty('scheduledAt');
|
||||
expect(race).toHaveProperty('status');
|
||||
expect(race).toHaveProperty('results');
|
||||
expect(Array.isArray(race.results)).toBe(true);
|
||||
}
|
||||
|
||||
// Verify: Race data matches what we created
|
||||
const scheduledRaces = response.races.filter(r => r.status === 'scheduled');
|
||||
const completedRaces = response.races.filter(r => r.status === 'completed');
|
||||
|
||||
expect(scheduledRaces).toHaveLength(2);
|
||||
expect(completedRaces).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should return empty schedule for league with no races', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Empty Schedule League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/schedule`);
|
||||
|
||||
expect(response.races).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle schedule with single race', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Single Race League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
const race = await factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Monza',
|
||||
car: 'GT3',
|
||||
scheduledAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
status: 'scheduled'
|
||||
});
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/schedule`);
|
||||
|
||||
expect(response.races).toHaveLength(1);
|
||||
expect(response.races[0].track).toBe('Monza');
|
||||
expect(response.races[0].car).toBe('GT3');
|
||||
expect(response.races[0].status).toBe('scheduled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('End-to-End Data Flow', () => {
|
||||
it('should correctly transform race data to schedule DTO', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Transformation Test League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
const driver = await factory.createDriver({ name: 'Test Driver', country: 'US' });
|
||||
|
||||
// Create a completed race with results
|
||||
const race = await factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Suzuka',
|
||||
car: 'Formula 1',
|
||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
await factory.createResult(race.id.toString(), driver.id.toString(), {
|
||||
position: 1,
|
||||
fastestLap: 92000,
|
||||
incidents: 0,
|
||||
startPosition: 2
|
||||
});
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/schedule`);
|
||||
|
||||
expect(response.races).toHaveLength(1);
|
||||
|
||||
const raceData = response.races[0];
|
||||
expect(raceData.track).toBe('Suzuka');
|
||||
expect(raceData.car).toBe('Formula 1');
|
||||
expect(raceData.status).toBe('completed');
|
||||
expect(raceData.results).toHaveLength(1);
|
||||
expect(raceData.results[0].position).toBe(1);
|
||||
expect(raceData.results[0].driverId).toBe(driver.id.toString());
|
||||
});
|
||||
|
||||
it('should handle schedule with multiple races and results', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Multi Race League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
const drivers = await Promise.all([
|
||||
factory.createDriver({ name: 'Driver 1', country: 'US' }),
|
||||
factory.createDriver({ name: 'Driver 2', country: 'UK' }),
|
||||
]);
|
||||
|
||||
// Create 3 races
|
||||
const races = await Promise.all([
|
||||
factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Track 1',
|
||||
car: 'Car 1',
|
||||
scheduledAt: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000),
|
||||
status: 'completed'
|
||||
}),
|
||||
factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Track 2',
|
||||
car: 'Car 1',
|
||||
scheduledAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000),
|
||||
status: 'completed'
|
||||
}),
|
||||
factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Track 3',
|
||||
car: 'Car 1',
|
||||
scheduledAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
status: 'scheduled'
|
||||
}),
|
||||
]);
|
||||
|
||||
// Add results to first two races
|
||||
await factory.createResult(races[0].id.toString(), drivers[0].id.toString(), { position: 1, fastestLap: 90000, incidents: 0, startPosition: 1 });
|
||||
await factory.createResult(races[0].id.toString(), drivers[1].id.toString(), { position: 2, fastestLap: 90500, incidents: 1, startPosition: 2 });
|
||||
|
||||
await factory.createResult(races[1].id.toString(), drivers[0].id.toString(), { position: 2, fastestLap: 89500, incidents: 1, startPosition: 2 });
|
||||
await factory.createResult(races[1].id.toString(), drivers[1].id.toString(), { position: 1, fastestLap: 89000, incidents: 0, startPosition: 1 });
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/schedule`);
|
||||
|
||||
expect(response.races).toHaveLength(3);
|
||||
|
||||
// Verify completed races have results
|
||||
const completedRaces = response.races.filter(r => r.status === 'completed');
|
||||
expect(completedRaces).toHaveLength(2);
|
||||
|
||||
for (const race of completedRaces) {
|
||||
expect(race.results).toHaveLength(2);
|
||||
expect(race.results[0].position).toBeDefined();
|
||||
expect(race.results[0].driverId).toBeDefined();
|
||||
}
|
||||
|
||||
// Verify scheduled race has no results
|
||||
const scheduledRace = response.races.find(r => r.status === 'scheduled');
|
||||
expect(scheduledRace).toBeDefined();
|
||||
expect(scheduledRace?.results).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle schedule with published/unpublished races', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Publish Test League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
// Create races with different publish states
|
||||
const race1 = await factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Track A',
|
||||
car: 'Car A',
|
||||
scheduledAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
status: 'scheduled'
|
||||
});
|
||||
|
||||
const race2 = await factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Track B',
|
||||
car: 'Car B',
|
||||
scheduledAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
|
||||
status: 'scheduled'
|
||||
});
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/schedule`);
|
||||
|
||||
expect(response.races).toHaveLength(2);
|
||||
|
||||
// Both races should be in the schedule
|
||||
const trackNames = response.races.map(r => r.track);
|
||||
expect(trackNames).toContain('Track A');
|
||||
expect(trackNames).toContain('Track B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Consistency', () => {
|
||||
it('should maintain data consistency across multiple API calls', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Consistency Schedule League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
const race = await factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Consistency Track',
|
||||
car: 'Consistency Car',
|
||||
scheduledAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
status: 'scheduled'
|
||||
});
|
||||
|
||||
const api = harness.getApi();
|
||||
|
||||
// Make multiple calls
|
||||
const response1 = await api.get(`/leagues/${league.id}/schedule`);
|
||||
const response2 = await api.get(`/leagues/${league.id}/schedule`);
|
||||
const response3 = await api.get(`/leagues/${league.id}/schedule`);
|
||||
|
||||
// All responses should be identical
|
||||
expect(response1).toEqual(response2);
|
||||
expect(response2).toEqual(response3);
|
||||
|
||||
// Verify data integrity
|
||||
expect(response1.races).toHaveLength(1);
|
||||
expect(response1.races[0].track).toBe('Consistency Track');
|
||||
expect(response1.races[0].car).toBe('Consistency Car');
|
||||
});
|
||||
|
||||
it('should handle edge case: league with many races', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Large Schedule League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
// Create 20 races
|
||||
const races = await Promise.all(
|
||||
Array.from({ length: 20 }, (_, i) =>
|
||||
factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: `Track ${i + 1}`,
|
||||
car: 'GT3',
|
||||
scheduledAt: new Date(Date.now() + (i + 1) * 24 * 60 * 60 * 1000),
|
||||
status: 'scheduled'
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/schedule`);
|
||||
|
||||
// Should have all 20 races
|
||||
expect(response.races).toHaveLength(20);
|
||||
|
||||
// All races should have correct structure
|
||||
for (const race of response.races) {
|
||||
expect(race).toHaveProperty('id');
|
||||
expect(race).toHaveProperty('track');
|
||||
expect(race).toHaveProperty('car');
|
||||
expect(race).toHaveProperty('scheduledAt');
|
||||
expect(race).toHaveProperty('status');
|
||||
expect(race).toHaveProperty('results');
|
||||
expect(Array.isArray(race.results)).toBe(true);
|
||||
}
|
||||
|
||||
// All races should be scheduled
|
||||
const allScheduled = response.races.every(r => r.status === 'scheduled');
|
||||
expect(allScheduled).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle edge case: league with races spanning multiple seasons', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Multi Season League' });
|
||||
|
||||
// Create two seasons
|
||||
const season1 = await factory.createSeason(league.id.toString(), { name: 'Season 1', year: 2024 });
|
||||
const season2 = await factory.createSeason(league.id.toString(), { name: 'Season 2', year: 2025 });
|
||||
|
||||
// Create races in both seasons
|
||||
const race1 = await factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Season 1 Track',
|
||||
car: 'Car 1',
|
||||
scheduledAt: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000), // Last year
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
const race2 = await factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Season 2 Track',
|
||||
car: 'Car 2',
|
||||
scheduledAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // This year
|
||||
status: 'scheduled'
|
||||
});
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/schedule`);
|
||||
|
||||
// Should have both races (schedule endpoint returns all races for league)
|
||||
expect(response.races).toHaveLength(2);
|
||||
|
||||
const trackNames = response.races.map(r => r.track);
|
||||
expect(trackNames).toContain('Season 1 Track');
|
||||
expect(trackNames).toContain('Season 2 Track');
|
||||
});
|
||||
|
||||
it('should handle edge case: race with no results', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'No Results League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
// Create a completed race with no results
|
||||
const race = await factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Empty Results Track',
|
||||
car: 'Empty Car',
|
||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/schedule`);
|
||||
|
||||
expect(response.races).toHaveLength(1);
|
||||
expect(response.races[0].results).toEqual([]);
|
||||
expect(response.races[0].status).toBe('completed');
|
||||
});
|
||||
});
|
||||
});
|
||||
395
tests/integration/league/standings-data-flow.integration.test.ts
Normal file
395
tests/integration/league/standings-data-flow.integration.test.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* Integration Test: League Standings Data Flow
|
||||
*
|
||||
* Tests the complete data flow from database to API response for league standings:
|
||||
* 1. Database query returns correct data
|
||||
* 2. Use case processes the data correctly
|
||||
* 3. Presenter transforms data to DTOs
|
||||
* 4. API returns correct response
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { IntegrationTestHarness, createTestHarness } from '../harness';
|
||||
|
||||
describe('League Standings - Data Flow Integration', () => {
|
||||
let harness: IntegrationTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = createTestHarness();
|
||||
await harness.beforeAll();
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.afterAll();
|
||||
}, 30000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.beforeEach();
|
||||
});
|
||||
|
||||
describe('API to View Data Flow', () => {
|
||||
it('should return correct standings DTO structure from API', async () => {
|
||||
// Setup: Create test data
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Test League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
// Create drivers
|
||||
const driver1 = await factory.createDriver({ name: 'John Doe', country: 'US' });
|
||||
const driver2 = await factory.createDriver({ name: 'Jane Smith', country: 'UK' });
|
||||
const driver3 = await factory.createDriver({ name: 'Bob Johnson', country: 'CA' });
|
||||
|
||||
// Create races with results
|
||||
const race1 = await factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Laguna Seca',
|
||||
car: 'Formula Ford',
|
||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // Past race
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
const race2 = await factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Road Atlanta',
|
||||
car: 'Formula Ford',
|
||||
scheduledAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), // Past race
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
// Create results for race 1
|
||||
await factory.createResult(race1.id.toString(), driver1.id.toString(), { position: 1, fastestLap: 95000, incidents: 0, startPosition: 2 });
|
||||
await factory.createResult(race1.id.toString(), driver2.id.toString(), { position: 2, fastestLap: 95500, incidents: 1, startPosition: 1 });
|
||||
await factory.createResult(race1.id.toString(), driver3.id.toString(), { position: 3, fastestLap: 96000, incidents: 2, startPosition: 3 });
|
||||
|
||||
// Create results for race 2
|
||||
await factory.createResult(race2.id.toString(), driver1.id.toString(), { position: 2, fastestLap: 94500, incidents: 1, startPosition: 1 });
|
||||
await factory.createResult(race2.id.toString(), driver2.id.toString(), { position: 1, fastestLap: 94000, incidents: 0, startPosition: 3 });
|
||||
await factory.createResult(race2.id.toString(), driver3.id.toString(), { position: 3, fastestLap: 95000, incidents: 1, startPosition: 2 });
|
||||
|
||||
// Execute: Call API endpoint
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/standings`);
|
||||
|
||||
// Verify: API response structure
|
||||
expect(response).toBeDefined();
|
||||
expect(response.standings).toBeDefined();
|
||||
expect(Array.isArray(response.standings)).toBe(true);
|
||||
|
||||
// Verify: Each standing has correct DTO structure
|
||||
for (const standing of response.standings) {
|
||||
expect(standing).toHaveProperty('driverId');
|
||||
expect(standing).toHaveProperty('driver');
|
||||
expect(standing).toHaveProperty('points');
|
||||
expect(standing).toHaveProperty('position');
|
||||
expect(standing).toHaveProperty('wins');
|
||||
expect(standing).toHaveProperty('podiums');
|
||||
expect(standing).toHaveProperty('races');
|
||||
expect(standing).toHaveProperty('positionChange');
|
||||
expect(standing).toHaveProperty('lastRacePoints');
|
||||
expect(standing).toHaveProperty('droppedRaceIds');
|
||||
|
||||
// Verify driver DTO structure
|
||||
expect(standing.driver).toHaveProperty('id');
|
||||
expect(standing.driver).toHaveProperty('iracingId');
|
||||
expect(standing.driver).toHaveProperty('name');
|
||||
expect(standing.driver).toHaveProperty('country');
|
||||
expect(standing.driver).toHaveProperty('joinedAt');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return empty standings for league with no results', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Empty League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/standings`);
|
||||
|
||||
expect(response.standings).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle standings with single driver', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Single Driver League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
const driver = await factory.createDriver({ name: 'Solo Driver', country: 'US' });
|
||||
const race = await factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Laguna Seca',
|
||||
car: 'Formula Ford',
|
||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
await factory.createResult(race.id.toString(), driver.id.toString(), { position: 1, fastestLap: 95000, incidents: 0, startPosition: 1 });
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/standings`);
|
||||
|
||||
expect(response.standings).toHaveLength(1);
|
||||
expect(response.standings[0].driver.name).toBe('Solo Driver');
|
||||
expect(response.standings[0].position).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('End-to-End Data Flow', () => {
|
||||
it('should correctly calculate standings from race results', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Calculation Test League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
// Create 3 drivers
|
||||
const drivers = await Promise.all([
|
||||
factory.createDriver({ name: 'Driver A', iracingId: '1001' }),
|
||||
factory.createDriver({ name: 'Driver B', iracingId: '1002' }),
|
||||
factory.createDriver({ name: 'Driver C', iracingId: '1003' }),
|
||||
]);
|
||||
|
||||
// Create 5 races
|
||||
const races = await Promise.all([
|
||||
factory.createRace({ leagueId: league.id.toString(), track: 'Track 1', car: 'Car 1', scheduledAt: new Date(Date.now() - 35 * 24 * 60 * 60 * 1000), status: 'completed' }),
|
||||
factory.createRace({ leagueId: league.id.toString(), track: 'Track 2', car: 'Car 1', scheduledAt: new Date(Date.now() - 28 * 24 * 60 * 60 * 1000), status: 'completed' }),
|
||||
factory.createRace({ leagueId: league.id.toString(), track: 'Track 3', car: 'Car 1', scheduledAt: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000), status: 'completed' }),
|
||||
factory.createRace({ leagueId: league.id.toString(), track: 'Track 4', car: 'Car 1', scheduledAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000), status: 'completed' }),
|
||||
factory.createRace({ leagueId: league.id.toString(), track: 'Track 5', car: 'Car 1', scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), status: 'completed' }),
|
||||
]);
|
||||
|
||||
// Create results with specific points to verify calculation
|
||||
// Standard scoring: 1st=25, 2nd=18, 3rd=15
|
||||
// Race 1: A=1st, B=2nd, C=3rd
|
||||
await factory.createResult(races[0].id.toString(), drivers[0].id.toString(), { position: 1, fastestLap: 90000, incidents: 0, startPosition: 1 });
|
||||
await factory.createResult(races[0].id.toString(), drivers[1].id.toString(), { position: 2, fastestLap: 90500, incidents: 1, startPosition: 2 });
|
||||
await factory.createResult(races[0].id.toString(), drivers[2].id.toString(), { position: 3, fastestLap: 91000, incidents: 2, startPosition: 3 });
|
||||
|
||||
// Race 2: B=1st, C=2nd, A=3rd
|
||||
await factory.createResult(races[1].id.toString(), drivers[1].id.toString(), { position: 1, fastestLap: 89500, incidents: 0, startPosition: 3 });
|
||||
await factory.createResult(races[1].id.toString(), drivers[2].id.toString(), { position: 2, fastestLap: 90000, incidents: 1, startPosition: 1 });
|
||||
await factory.createResult(races[1].id.toString(), drivers[0].id.toString(), { position: 3, fastestLap: 90500, incidents: 2, startPosition: 2 });
|
||||
|
||||
// Race 3: C=1st, A=2nd, B=3rd
|
||||
await factory.createResult(races[2].id.toString(), drivers[2].id.toString(), { position: 1, fastestLap: 89000, incidents: 0, startPosition: 2 });
|
||||
await factory.createResult(races[2].id.toString(), drivers[0].id.toString(), { position: 2, fastestLap: 89500, incidents: 1, startPosition: 3 });
|
||||
await factory.createResult(races[2].id.toString(), drivers[1].id.toString(), { position: 3, fastestLap: 90000, incidents: 2, startPosition: 1 });
|
||||
|
||||
// Race 4: A=1st, B=2nd, C=3rd
|
||||
await factory.createResult(races[3].id.toString(), drivers[0].id.toString(), { position: 1, fastestLap: 88500, incidents: 0, startPosition: 1 });
|
||||
await factory.createResult(races[3].id.toString(), drivers[1].id.toString(), { position: 2, fastestLap: 89000, incidents: 1, startPosition: 2 });
|
||||
await factory.createResult(races[3].id.toString(), drivers[2].id.toString(), { position: 3, fastestLap: 89500, incidents: 2, startPosition: 3 });
|
||||
|
||||
// Race 5: B=1st, C=2nd, A=3rd
|
||||
await factory.createResult(races[4].id.toString(), drivers[1].id.toString(), { position: 1, fastestLap: 88000, incidents: 0, startPosition: 3 });
|
||||
await factory.createResult(races[4].id.toString(), drivers[2].id.toString(), { position: 2, fastestLap: 88500, incidents: 1, startPosition: 1 });
|
||||
await factory.createResult(races[4].id.toString(), drivers[0].id.toString(), { position: 3, fastestLap: 89000, incidents: 2, startPosition: 2 });
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/standings`);
|
||||
|
||||
// Expected points:
|
||||
// Driver A: 25 + 15 + 18 + 25 + 15 = 98
|
||||
// Driver B: 18 + 25 + 15 + 18 + 25 = 101
|
||||
// Driver C: 15 + 18 + 25 + 15 + 18 = 91
|
||||
|
||||
expect(response.standings).toHaveLength(3);
|
||||
|
||||
// Find drivers in response
|
||||
const standingA = response.standings.find(s => s.driver.name === 'Driver A');
|
||||
const standingB = response.standings.find(s => s.driver.name === 'Driver B');
|
||||
const standingC = response.standings.find(s => s.driver.name === 'Driver C');
|
||||
|
||||
expect(standingA).toBeDefined();
|
||||
expect(standingB).toBeDefined();
|
||||
expect(standingC).toBeDefined();
|
||||
|
||||
// Verify positions (B should be 1st, A 2nd, C 3rd)
|
||||
expect(standingB?.position).toBe(1);
|
||||
expect(standingA?.position).toBe(2);
|
||||
expect(standingC?.position).toBe(3);
|
||||
|
||||
// Verify race counts
|
||||
expect(standingA?.races).toBe(5);
|
||||
expect(standingB?.races).toBe(5);
|
||||
expect(standingC?.races).toBe(5);
|
||||
|
||||
// Verify win counts
|
||||
expect(standingA?.wins).toBe(2); // Races 1 and 4
|
||||
expect(standingB?.wins).toBe(2); // Races 2 and 5
|
||||
expect(standingC?.wins).toBe(1); // Race 3
|
||||
|
||||
// Verify podium counts
|
||||
expect(standingA?.podiums).toBe(5); // All races
|
||||
expect(standingB?.podiums).toBe(5); // All races
|
||||
expect(standingC?.podiums).toBe(5); // All races
|
||||
});
|
||||
|
||||
it('should handle standings with tied points correctly', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Tie Test League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
const drivers = await Promise.all([
|
||||
factory.createDriver({ name: 'Driver X', iracingId: '2001' }),
|
||||
factory.createDriver({ name: 'Driver Y', iracingId: '2002' }),
|
||||
]);
|
||||
|
||||
const race1 = await factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Track A',
|
||||
car: 'Car A',
|
||||
scheduledAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000),
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
const race2 = await factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Track B',
|
||||
car: 'Car A',
|
||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
// Both drivers get same points: 25 + 18 = 43
|
||||
await factory.createResult(race1.id.toString(), drivers[0].id.toString(), { position: 1, fastestLap: 90000, incidents: 0, startPosition: 1 });
|
||||
await factory.createResult(race1.id.toString(), drivers[1].id.toString(), { position: 2, fastestLap: 90500, incidents: 1, startPosition: 2 });
|
||||
|
||||
await factory.createResult(race2.id.toString(), drivers[0].id.toString(), { position: 2, fastestLap: 89500, incidents: 1, startPosition: 2 });
|
||||
await factory.createResult(race2.id.toString(), drivers[1].id.toString(), { position: 1, fastestLap: 89000, incidents: 0, startPosition: 1 });
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/standings`);
|
||||
|
||||
expect(response.standings).toHaveLength(2);
|
||||
|
||||
// Both should have same points
|
||||
expect(response.standings[0].points).toBe(43);
|
||||
expect(response.standings[1].points).toBe(43);
|
||||
|
||||
// Positions should be 1 and 2 (tie-breaker logic may vary)
|
||||
const positions = response.standings.map(s => s.position).sort();
|
||||
expect(positions).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Consistency', () => {
|
||||
it('should maintain data consistency across multiple API calls', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Consistency Test League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
const driver = await factory.createDriver({ name: 'Consistent Driver', country: 'DE' });
|
||||
const race = await factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Nürburgring',
|
||||
car: 'GT3',
|
||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
await factory.createResult(race.id.toString(), driver.id.toString(), { position: 1, fastestLap: 85000, incidents: 0, startPosition: 1 });
|
||||
|
||||
const api = harness.getApi();
|
||||
|
||||
// Make multiple calls
|
||||
const response1 = await api.get(`/leagues/${league.id}/standings`);
|
||||
const response2 = await api.get(`/leagues/${league.id}/standings`);
|
||||
const response3 = await api.get(`/leagues/${league.id}/standings`);
|
||||
|
||||
// All responses should be identical
|
||||
expect(response1).toEqual(response2);
|
||||
expect(response2).toEqual(response3);
|
||||
|
||||
// Verify data integrity
|
||||
expect(response1.standings).toHaveLength(1);
|
||||
expect(response1.standings[0].driver.name).toBe('Consistent Driver');
|
||||
expect(response1.standings[0].points).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle edge case: league with many drivers and races', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Large League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
// Create 10 drivers
|
||||
const drivers = await Promise.all(
|
||||
Array.from({ length: 10 }, (_, i) =>
|
||||
factory.createDriver({ name: `Driver ${i + 1}`, iracingId: `${3000 + i}` })
|
||||
)
|
||||
);
|
||||
|
||||
// Create 10 races
|
||||
const races = await Promise.all(
|
||||
Array.from({ length: 10 }, (_, i) =>
|
||||
factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: `Track ${i + 1}`,
|
||||
car: 'GT3',
|
||||
scheduledAt: new Date(Date.now() - (10 - i) * 24 * 60 * 60 * 1000),
|
||||
status: 'completed'
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Create results for each race (random but consistent positions)
|
||||
for (let raceIndex = 0; raceIndex < 10; raceIndex++) {
|
||||
for (let driverIndex = 0; driverIndex < 10; driverIndex++) {
|
||||
const position = ((driverIndex + raceIndex) % 10) + 1;
|
||||
await factory.createResult(
|
||||
races[raceIndex].id.toString(),
|
||||
drivers[driverIndex].id.toString(),
|
||||
{
|
||||
position,
|
||||
fastestLap: 85000 + (position * 100),
|
||||
incidents: position % 3,
|
||||
startPosition: position
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/standings`);
|
||||
|
||||
// Should have all 10 drivers
|
||||
expect(response.standings).toHaveLength(10);
|
||||
|
||||
// All drivers should have 10 races
|
||||
for (const standing of response.standings) {
|
||||
expect(standing.races).toBe(10);
|
||||
expect(standing.driver).toBeDefined();
|
||||
expect(standing.driver.id).toBeDefined();
|
||||
expect(standing.driver.name).toBeDefined();
|
||||
}
|
||||
|
||||
// Positions should be unique 1-10
|
||||
const positions = response.standings.map(s => s.position).sort((a, b) => a - b);
|
||||
expect(positions).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
||||
});
|
||||
|
||||
it('should handle missing fields gracefully', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Edge Case League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
// Create driver without bio (should be optional)
|
||||
const driver = await factory.createDriver({ name: 'Test Driver', country: 'US' });
|
||||
|
||||
const race = await factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Test Track',
|
||||
car: 'Test Car',
|
||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
await factory.createResult(race.id.toString(), driver.id.toString(), { position: 1, fastestLap: 90000, incidents: 0, startPosition: 1 });
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/standings`);
|
||||
|
||||
expect(response.standings).toHaveLength(1);
|
||||
expect(response.standings[0].driver.bio).toBeUndefined(); // Optional field
|
||||
expect(response.standings[0].driver.name).toBe('Test Driver');
|
||||
expect(response.standings[0].driver.country).toBe('US');
|
||||
});
|
||||
});
|
||||
});
|
||||
493
tests/integration/league/stats-data-flow.integration.test.ts
Normal file
493
tests/integration/league/stats-data-flow.integration.test.ts
Normal file
@@ -0,0 +1,493 @@
|
||||
/**
|
||||
* Integration Test: League Stats Data Flow
|
||||
*
|
||||
* Tests the complete data flow from database to API response for league stats:
|
||||
* 1. Database query returns correct data
|
||||
* 2. Use case processes the data correctly
|
||||
* 3. Presenter transforms data to DTOs
|
||||
* 4. API returns correct response
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { IntegrationTestHarness, createTestHarness } from '../harness';
|
||||
|
||||
describe('League Stats - Data Flow Integration', () => {
|
||||
let harness: IntegrationTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = createTestHarness();
|
||||
await harness.beforeAll();
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.afterAll();
|
||||
}, 30000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.beforeEach();
|
||||
});
|
||||
|
||||
describe('API to View Data Flow', () => {
|
||||
it('should return correct stats DTO structure from API', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Stats Test League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
// Create drivers
|
||||
const drivers = await Promise.all([
|
||||
factory.createDriver({ name: 'Driver 1', country: 'US' }),
|
||||
factory.createDriver({ name: 'Driver 2', country: 'UK' }),
|
||||
factory.createDriver({ name: 'Driver 3', country: 'CA' }),
|
||||
]);
|
||||
|
||||
// Create races
|
||||
const races = await Promise.all([
|
||||
factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Track 1',
|
||||
car: 'Car 1',
|
||||
scheduledAt: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000),
|
||||
status: 'completed'
|
||||
}),
|
||||
factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Track 2',
|
||||
car: 'Car 1',
|
||||
scheduledAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000),
|
||||
status: 'completed'
|
||||
}),
|
||||
factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Track 3',
|
||||
car: 'Car 1',
|
||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
status: 'completed'
|
||||
}),
|
||||
]);
|
||||
|
||||
// Create results
|
||||
await factory.createResult(races[0].id.toString(), drivers[0].id.toString(), { position: 1, fastestLap: 90000, incidents: 0, startPosition: 1 });
|
||||
await factory.createResult(races[0].id.toString(), drivers[1].id.toString(), { position: 2, fastestLap: 90500, incidents: 1, startPosition: 2 });
|
||||
await factory.createResult(races[0].id.toString(), drivers[2].id.toString(), { position: 3, fastestLap: 91000, incidents: 2, startPosition: 3 });
|
||||
|
||||
await factory.createResult(races[1].id.toString(), drivers[0].id.toString(), { position: 2, fastestLap: 89500, incidents: 1, startPosition: 2 });
|
||||
await factory.createResult(races[1].id.toString(), drivers[1].id.toString(), { position: 1, fastestLap: 89000, incidents: 0, startPosition: 3 });
|
||||
await factory.createResult(races[1].id.toString(), drivers[2].id.toString(), { position: 3, fastestLap: 89500, incidents: 1, startPosition: 1 });
|
||||
|
||||
await factory.createResult(races[2].id.toString(), drivers[0].id.toString(), { position: 3, fastestLap: 88500, incidents: 2, startPosition: 3 });
|
||||
await factory.createResult(races[2].id.toString(), drivers[1].id.toString(), { position: 2, fastestLap: 88000, incidents: 1, startPosition: 1 });
|
||||
await factory.createResult(races[2].id.toString(), drivers[2].id.toString(), { position: 1, fastestLap: 87500, incidents: 0, startPosition: 2 });
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/stats`);
|
||||
|
||||
// Verify: API response structure
|
||||
expect(response).toBeDefined();
|
||||
expect(response).toHaveProperty('totalRaces');
|
||||
expect(response).toHaveProperty('totalDrivers');
|
||||
expect(response).toHaveProperty('totalResults');
|
||||
expect(response).toHaveProperty('averageIncidentsPerRace');
|
||||
expect(response).toHaveProperty('mostCommonTrack');
|
||||
expect(response).toHaveProperty('mostCommonCar');
|
||||
expect(response).toHaveProperty('topPerformers');
|
||||
expect(Array.isArray(response.topPerformers)).toBe(true);
|
||||
|
||||
// Verify: Top performers structure
|
||||
for (const performer of response.topPerformers) {
|
||||
expect(performer).toHaveProperty('driverId');
|
||||
expect(performer).toHaveProperty('driver');
|
||||
expect(performer).toHaveProperty('points');
|
||||
expect(performer).toHaveProperty('wins');
|
||||
expect(performer).toHaveProperty('podiums');
|
||||
expect(performer).toHaveProperty('races');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return empty stats for league with no data', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Empty Stats League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/stats`);
|
||||
|
||||
expect(response.totalRaces).toBe(0);
|
||||
expect(response.totalDrivers).toBe(0);
|
||||
expect(response.totalResults).toBe(0);
|
||||
expect(response.topPerformers).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle stats with single race', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Single Race Stats League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
const driver = await factory.createDriver({ name: 'Solo Driver', country: 'US' });
|
||||
const race = await factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Monza',
|
||||
car: 'GT3',
|
||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
await factory.createResult(race.id.toString(), driver.id.toString(), { position: 1, fastestLap: 85000, incidents: 0, startPosition: 1 });
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/stats`);
|
||||
|
||||
expect(response.totalRaces).toBe(1);
|
||||
expect(response.totalDrivers).toBe(1);
|
||||
expect(response.totalResults).toBe(1);
|
||||
expect(response.topPerformers).toHaveLength(1);
|
||||
expect(response.topPerformers[0].driver.name).toBe('Solo Driver');
|
||||
});
|
||||
});
|
||||
|
||||
describe('End-to-End Data Flow', () => {
|
||||
it('should correctly calculate stats from race results', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Calculation Stats League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
const drivers = await Promise.all([
|
||||
factory.createDriver({ name: 'Driver A', iracingId: '1001' }),
|
||||
factory.createDriver({ name: 'Driver B', iracingId: '1002' }),
|
||||
factory.createDriver({ name: 'Driver C', iracingId: '1003' }),
|
||||
]);
|
||||
|
||||
// Create 5 races with different tracks and cars
|
||||
const races = await Promise.all([
|
||||
factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Laguna Seca',
|
||||
car: 'Formula Ford',
|
||||
scheduledAt: new Date(Date.now() - 35 * 24 * 60 * 60 * 1000),
|
||||
status: 'completed'
|
||||
}),
|
||||
factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Road Atlanta',
|
||||
car: 'Formula Ford',
|
||||
scheduledAt: new Date(Date.now() - 28 * 24 * 60 * 60 * 1000),
|
||||
status: 'completed'
|
||||
}),
|
||||
factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Laguna Seca',
|
||||
car: 'Formula Ford',
|
||||
scheduledAt: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000),
|
||||
status: 'completed'
|
||||
}),
|
||||
factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Nürburgring',
|
||||
car: 'GT3',
|
||||
scheduledAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000),
|
||||
status: 'completed'
|
||||
}),
|
||||
factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Road Atlanta',
|
||||
car: 'GT3',
|
||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
status: 'completed'
|
||||
}),
|
||||
]);
|
||||
|
||||
// Create results with specific incidents
|
||||
// Race 1: Laguna Seca, Formula Ford
|
||||
await factory.createResult(races[0].id.toString(), drivers[0].id.toString(), { position: 1, fastestLap: 95000, incidents: 0, startPosition: 1 });
|
||||
await factory.createResult(races[0].id.toString(), drivers[1].id.toString(), { position: 2, fastestLap: 95500, incidents: 1, startPosition: 2 });
|
||||
await factory.createResult(races[0].id.toString(), drivers[2].id.toString(), { position: 3, fastestLap: 96000, incidents: 2, startPosition: 3 });
|
||||
|
||||
// Race 2: Road Atlanta, Formula Ford
|
||||
await factory.createResult(races[1].id.toString(), drivers[0].id.toString(), { position: 2, fastestLap: 94500, incidents: 1, startPosition: 2 });
|
||||
await factory.createResult(races[1].id.toString(), drivers[1].id.toString(), { position: 1, fastestLap: 94000, incidents: 0, startPosition: 3 });
|
||||
await factory.createResult(races[1].id.toString(), drivers[2].id.toString(), { position: 3, fastestLap: 95000, incidents: 1, startPosition: 1 });
|
||||
|
||||
// Race 3: Laguna Seca, Formula Ford
|
||||
await factory.createResult(races[2].id.toString(), drivers[0].id.toString(), { position: 1, fastestLap: 93500, incidents: 0, startPosition: 1 });
|
||||
await factory.createResult(races[2].id.toString(), drivers[1].id.toString(), { position: 2, fastestLap: 94000, incidents: 1, startPosition: 2 });
|
||||
await factory.createResult(races[2].id.toString(), drivers[2].id.toString(), { position: 3, fastestLap: 94500, incidents: 2, startPosition: 3 });
|
||||
|
||||
// Race 4: Nürburgring, GT3
|
||||
await factory.createResult(races[3].id.toString(), drivers[0].id.toString(), { position: 3, fastestLap: 92500, incidents: 2, startPosition: 3 });
|
||||
await factory.createResult(races[3].id.toString(), drivers[1].id.toString(), { position: 2, fastestLap: 92000, incidents: 1, startPosition: 1 });
|
||||
await factory.createResult(races[3].id.toString(), drivers[2].id.toString(), { position: 1, fastestLap: 91500, incidents: 0, startPosition: 2 });
|
||||
|
||||
// Race 5: Road Atlanta, GT3
|
||||
await factory.createResult(races[4].id.toString(), drivers[0].id.toString(), { position: 2, fastestLap: 91000, incidents: 1, startPosition: 2 });
|
||||
await factory.createResult(races[4].id.toString(), drivers[1].id.toString(), { position: 1, fastestLap: 90500, incidents: 0, startPosition: 3 });
|
||||
await factory.createResult(races[4].id.toString(), drivers[2].id.toString(), { position: 3, fastestLap: 91500, incidents: 1, startPosition: 1 });
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/stats`);
|
||||
|
||||
// Verify calculated stats
|
||||
expect(response.totalRaces).toBe(5);
|
||||
expect(response.totalDrivers).toBe(3);
|
||||
expect(response.totalResults).toBe(15);
|
||||
|
||||
// Verify average incidents per race
|
||||
// Total incidents: 0+1+2 + 1+0+1 + 0+1+2 + 2+1+0 + 1+0+1 = 15
|
||||
// Average: 15 / 5 = 3
|
||||
expect(response.averageIncidentsPerRace).toBe(3);
|
||||
|
||||
// Verify most common track (Laguna Seca appears 2 times, Road Atlanta 2 times, Nürburgring 1 time)
|
||||
// Should return one of the most common tracks
|
||||
expect(['Laguna Seca', 'Road Atlanta']).toContain(response.mostCommonTrack);
|
||||
|
||||
// Verify most common car (Formula Ford appears 3 times, GT3 appears 2 times)
|
||||
expect(response.mostCommonCar).toBe('Formula Ford');
|
||||
|
||||
// Verify top performers
|
||||
expect(response.topPerformers).toHaveLength(3);
|
||||
|
||||
// Find drivers in response
|
||||
const performerA = response.topPerformers.find(p => p.driver.name === 'Driver A');
|
||||
const performerB = response.topPerformers.find(p => p.driver.name === 'Driver B');
|
||||
const performerC = response.topPerformers.find(p => p.driver.name === 'Driver C');
|
||||
|
||||
expect(performerA).toBeDefined();
|
||||
expect(performerB).toBeDefined();
|
||||
expect(performerC).toBeDefined();
|
||||
|
||||
// Verify race counts
|
||||
expect(performerA?.races).toBe(5);
|
||||
expect(performerB?.races).toBe(5);
|
||||
expect(performerC?.races).toBe(5);
|
||||
|
||||
// Verify win counts
|
||||
expect(performerA?.wins).toBe(2); // Races 1 and 3
|
||||
expect(performerB?.wins).toBe(2); // Races 2 and 5
|
||||
expect(performerC?.wins).toBe(1); // Race 4
|
||||
|
||||
// Verify podium counts
|
||||
expect(performerA?.podiums).toBe(5); // All races
|
||||
expect(performerB?.podiums).toBe(5); // All races
|
||||
expect(performerC?.podiums).toBe(5); // All races
|
||||
});
|
||||
|
||||
it('should handle stats with varying race counts per driver', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Varying Races League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
const drivers = await Promise.all([
|
||||
factory.createDriver({ name: 'Full Timer', iracingId: '2001' }),
|
||||
factory.createDriver({ name: 'Part Timer', iracingId: '2002' }),
|
||||
factory.createDriver({ name: 'One Race', iracingId: '2003' }),
|
||||
]);
|
||||
|
||||
// Create 5 races
|
||||
const races = await Promise.all([
|
||||
factory.createRace({ leagueId: league.id.toString(), track: 'Track 1', car: 'Car 1', scheduledAt: new Date(Date.now() - 35 * 24 * 60 * 60 * 1000), status: 'completed' }),
|
||||
factory.createRace({ leagueId: league.id.toString(), track: 'Track 2', car: 'Car 1', scheduledAt: new Date(Date.now() - 28 * 24 * 60 * 60 * 1000), status: 'completed' }),
|
||||
factory.createRace({ leagueId: league.id.toString(), track: 'Track 3', car: 'Car 1', scheduledAt: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000), status: 'completed' }),
|
||||
factory.createRace({ leagueId: league.id.toString(), track: 'Track 4', car: 'Car 1', scheduledAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000), status: 'completed' }),
|
||||
factory.createRace({ leagueId: league.id.toString(), track: 'Track 5', car: 'Car 1', scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), status: 'completed' }),
|
||||
]);
|
||||
|
||||
// Full Timer: all 5 races
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await factory.createResult(races[i].id.toString(), drivers[0].id.toString(), { position: 1, fastestLap: 90000 + i * 100, incidents: i % 2, startPosition: 1 });
|
||||
}
|
||||
|
||||
// Part Timer: 3 races (1, 2, 4)
|
||||
await factory.createResult(races[0].id.toString(), drivers[1].id.toString(), { position: 2, fastestLap: 90500, incidents: 1, startPosition: 2 });
|
||||
await factory.createResult(races[1].id.toString(), drivers[1].id.toString(), { position: 2, fastestLap: 90600, incidents: 1, startPosition: 2 });
|
||||
await factory.createResult(races[3].id.toString(), drivers[1].id.toString(), { position: 2, fastestLap: 90800, incidents: 1, startPosition: 2 });
|
||||
|
||||
// One Race: only race 5
|
||||
await factory.createResult(races[4].id.toString(), drivers[2].id.toString(), { position: 2, fastestLap: 90900, incidents: 0, startPosition: 2 });
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/stats`);
|
||||
|
||||
expect(response.totalRaces).toBe(5);
|
||||
expect(response.totalDrivers).toBe(3);
|
||||
expect(response.totalResults).toBe(9); // 5 + 3 + 1
|
||||
|
||||
// Verify top performers have correct race counts
|
||||
const fullTimer = response.topPerformers.find(p => p.driver.name === 'Full Timer');
|
||||
const partTimer = response.topPerformers.find(p => p.driver.name === 'Part Timer');
|
||||
const oneRace = response.topPerformers.find(p => p.driver.name === 'One Race');
|
||||
|
||||
expect(fullTimer?.races).toBe(5);
|
||||
expect(partTimer?.races).toBe(3);
|
||||
expect(oneRace?.races).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Consistency', () => {
|
||||
it('should maintain data consistency across multiple API calls', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Consistency Stats League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
const driver = await factory.createDriver({ name: 'Consistent Driver', country: 'DE' });
|
||||
const race = await factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Nürburgring',
|
||||
car: 'GT3',
|
||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
await factory.createResult(race.id.toString(), driver.id.toString(), { position: 1, fastestLap: 85000, incidents: 0, startPosition: 1 });
|
||||
|
||||
const api = harness.getApi();
|
||||
|
||||
// Make multiple calls
|
||||
const response1 = await api.get(`/leagues/${league.id}/stats`);
|
||||
const response2 = await api.get(`/leagues/${league.id}/stats`);
|
||||
const response3 = await api.get(`/leagues/${league.id}/stats`);
|
||||
|
||||
// All responses should be identical
|
||||
expect(response1).toEqual(response2);
|
||||
expect(response2).toEqual(response3);
|
||||
|
||||
// Verify data integrity
|
||||
expect(response1.totalRaces).toBe(1);
|
||||
expect(response1.totalDrivers).toBe(1);
|
||||
expect(response1.totalResults).toBe(1);
|
||||
expect(response1.topPerformers).toHaveLength(1);
|
||||
expect(response1.topPerformers[0].driver.name).toBe('Consistent Driver');
|
||||
});
|
||||
|
||||
it('should handle edge case: league with many races and drivers', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Large Stats League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
// Create 10 drivers
|
||||
const drivers = await Promise.all(
|
||||
Array.from({ length: 10 }, (_, i) =>
|
||||
factory.createDriver({ name: `Driver ${i + 1}`, iracingId: `${3000 + i}` })
|
||||
)
|
||||
);
|
||||
|
||||
// Create 10 races
|
||||
const races = await Promise.all(
|
||||
Array.from({ length: 10 }, (_, i) =>
|
||||
factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: `Track ${i + 1}`,
|
||||
car: 'GT3',
|
||||
scheduledAt: new Date(Date.now() - (10 - i) * 24 * 60 * 60 * 1000),
|
||||
status: 'completed'
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Create results for each race (all drivers participate)
|
||||
for (let raceIndex = 0; raceIndex < 10; raceIndex++) {
|
||||
for (let driverIndex = 0; driverIndex < 10; driverIndex++) {
|
||||
const position = ((driverIndex + raceIndex) % 10) + 1;
|
||||
await factory.createResult(
|
||||
races[raceIndex].id.toString(),
|
||||
drivers[driverIndex].id.toString(),
|
||||
{
|
||||
position,
|
||||
fastestLap: 85000 + (position * 100),
|
||||
incidents: position % 3,
|
||||
startPosition: position
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/stats`);
|
||||
|
||||
// Should have correct totals
|
||||
expect(response.totalRaces).toBe(10);
|
||||
expect(response.totalDrivers).toBe(10);
|
||||
expect(response.totalResults).toBe(100); // 10 races * 10 drivers
|
||||
|
||||
// Should have 10 top performers (one per driver)
|
||||
expect(response.topPerformers).toHaveLength(10);
|
||||
|
||||
// All top performers should have 10 races
|
||||
for (const performer of response.topPerformers) {
|
||||
expect(performer.races).toBe(10);
|
||||
expect(performer.driver).toBeDefined();
|
||||
expect(performer.driver.id).toBeDefined();
|
||||
expect(performer.driver.name).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle edge case: league with no completed races', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'No Completed Races League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
const driver = await factory.createDriver({ name: 'Waiting Driver', country: 'US' });
|
||||
|
||||
// Create only scheduled races (no completed races)
|
||||
const race = await factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Future Track',
|
||||
car: 'Future Car',
|
||||
scheduledAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
status: 'scheduled'
|
||||
});
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/stats`);
|
||||
|
||||
// Should have 0 stats since no completed races
|
||||
expect(response.totalRaces).toBe(0);
|
||||
expect(response.totalDrivers).toBe(0);
|
||||
expect(response.totalResults).toBe(0);
|
||||
expect(response.topPerformers).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle edge case: league with mixed race statuses', async () => {
|
||||
const factory = harness.getFactory();
|
||||
const league = await factory.createLeague({ name: 'Mixed Status League' });
|
||||
const season = await factory.createSeason(league.id.toString());
|
||||
|
||||
const driver = await factory.createDriver({ name: 'Mixed Driver', country: 'US' });
|
||||
|
||||
// Create races with different statuses
|
||||
const completedRace = await factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Completed Track',
|
||||
car: 'Completed Car',
|
||||
scheduledAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
|
||||
status: 'completed'
|
||||
});
|
||||
|
||||
const scheduledRace = await factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Scheduled Track',
|
||||
car: 'Scheduled Car',
|
||||
scheduledAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
status: 'scheduled'
|
||||
});
|
||||
|
||||
const inProgressRace = await factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'In Progress Track',
|
||||
car: 'In Progress Car',
|
||||
scheduledAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000),
|
||||
status: 'in_progress'
|
||||
});
|
||||
|
||||
// Add result only to completed race
|
||||
await factory.createResult(completedRace.id.toString(), driver.id.toString(), { position: 1, fastestLap: 85000, incidents: 0, startPosition: 1 });
|
||||
|
||||
const api = harness.getApi();
|
||||
const response = await api.get(`/leagues/${league.id}/stats`);
|
||||
|
||||
// Should only count completed races
|
||||
expect(response.totalRaces).toBe(1);
|
||||
expect(response.totalDrivers).toBe(1);
|
||||
expect(response.totalResults).toBe(1);
|
||||
expect(response.topPerformers).toHaveLength(1);
|
||||
expect(response.topPerformers[0].driver.name).toBe('Mixed Driver');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,662 @@
|
||||
/**
|
||||
* Integration Tests for LeagueDetailPageQuery
|
||||
*
|
||||
* Tests the LeagueDetailPageQuery with mocked API clients to verify:
|
||||
* - Happy path: API returns valid league detail data
|
||||
* - Error handling: 404 when league not found
|
||||
* - Error handling: 500 when API server error
|
||||
* - Missing data: API returns partial data
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery';
|
||||
import { MockLeaguesApiClient } from './mocks/MockLeaguesApiClient';
|
||||
import { ApiError } from '../../../apps/website/lib/api/base/ApiError';
|
||||
|
||||
// Mock data factories
|
||||
const createMockLeagueDetailData = () => ({
|
||||
leagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'A test league',
|
||||
capacity: 10,
|
||||
currentMembers: 5,
|
||||
ownerId: 'driver-1',
|
||||
status: 'active' as const,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const createMockMembershipsData = () => ({
|
||||
members: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
iracingId: '12345',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
joinedAt: new Date().toISOString(),
|
||||
},
|
||||
role: 'owner' as const,
|
||||
status: 'active' as const,
|
||||
joinedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const createMockRacesPageData = () => ({
|
||||
races: [
|
||||
{
|
||||
id: 'race-1',
|
||||
track: 'Test Track',
|
||||
car: 'Test Car',
|
||||
scheduledAt: new Date().toISOString(),
|
||||
leagueName: 'Test League',
|
||||
status: 'scheduled' as const,
|
||||
strengthOfField: 50,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const createMockDriverData = () => ({
|
||||
id: 'driver-1',
|
||||
iracingId: '12345',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
joinedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const createMockLeagueConfigData = () => ({
|
||||
form: {
|
||||
scoring: {
|
||||
presetId: 'preset-1',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('LeagueDetailPageQuery Integration', () => {
|
||||
let mockLeaguesApiClient: MockLeaguesApiClient;
|
||||
|
||||
beforeEach(() => {
|
||||
mockLeaguesApiClient = new MockLeaguesApiClient();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockLeaguesApiClient.clearMocks();
|
||||
});
|
||||
|
||||
describe('Happy Path', () => {
|
||||
it('should return valid league detail data when API returns success', async () => {
|
||||
// Arrange
|
||||
const leagueId = 'league-1';
|
||||
const mockLeaguesData = createMockLeagueDetailData();
|
||||
const mockMembershipsData = createMockMembershipsData();
|
||||
const mockRacesPageData = createMockRacesPageData();
|
||||
const mockDriverData = createMockDriverData();
|
||||
const mockLeagueConfigData = createMockLeagueConfigData();
|
||||
|
||||
// Mock fetch to return different data based on the URL
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url.includes('/leagues/all-with-capacity-and-scoring')) {
|
||||
return Promise.resolve(createMockResponse(mockLeaguesData));
|
||||
}
|
||||
if (url.includes('/memberships')) {
|
||||
return Promise.resolve(createMockResponse(mockMembershipsData));
|
||||
}
|
||||
if (url.includes('/races/page-data')) {
|
||||
return Promise.resolve(createMockResponse(mockRacesPageData));
|
||||
}
|
||||
if (url.includes('/drivers/driver-1')) {
|
||||
return Promise.resolve(createMockResponse(mockDriverData));
|
||||
}
|
||||
if (url.includes('/config')) {
|
||||
return Promise.resolve(createMockResponse(mockLeagueConfigData));
|
||||
}
|
||||
return Promise.resolve(createMockErrorResponse(404, 'Not Found', 'Not Found'));
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data.league).toBeDefined();
|
||||
expect(data.league.id).toBe('league-1');
|
||||
expect(data.league.name).toBe('Test League');
|
||||
expect(data.league.capacity).toBe(10);
|
||||
expect(data.league.currentMembers).toBe(5);
|
||||
|
||||
expect(data.owner).toBeDefined();
|
||||
expect(data.owner?.id).toBe('driver-1');
|
||||
expect(data.owner?.name).toBe('Test Driver');
|
||||
|
||||
expect(data.memberships).toBeDefined();
|
||||
expect(data.memberships.members).toBeDefined();
|
||||
expect(data.memberships.members.length).toBe(1);
|
||||
|
||||
expect(data.races).toBeDefined();
|
||||
expect(data.races.length).toBe(1);
|
||||
expect(data.races[0].id).toBe('race-1');
|
||||
expect(data.races[0].name).toBe('Test Track - Test Car');
|
||||
|
||||
expect(data.scoringConfig).toBeDefined();
|
||||
expect(data.scoringConfig?.scoringPresetId).toBe('preset-1');
|
||||
});
|
||||
|
||||
it('should handle league without owner', async () => {
|
||||
// Arrange
|
||||
const leagueId = 'league-2';
|
||||
const mockLeaguesData = {
|
||||
leagues: [
|
||||
{
|
||||
id: 'league-2',
|
||||
name: 'League Without Owner',
|
||||
description: 'A league without an owner',
|
||||
capacity: 15,
|
||||
currentMembers: 8,
|
||||
// No ownerId
|
||||
status: 'active' as const,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
const mockMembershipsData = createMockMembershipsData();
|
||||
const mockRacesPageData = createMockRacesPageData();
|
||||
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url.includes('/leagues/all-with-capacity-and-scoring')) {
|
||||
return Promise.resolve(createMockResponse(mockLeaguesData));
|
||||
}
|
||||
if (url.includes('/memberships')) {
|
||||
return Promise.resolve(createMockResponse(mockMembershipsData));
|
||||
}
|
||||
if (url.includes('/races/page-data')) {
|
||||
return Promise.resolve(createMockResponse(mockRacesPageData));
|
||||
}
|
||||
return Promise.resolve(createMockErrorResponse(404, 'Not Found', 'Not Found'));
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
|
||||
expect(data.owner).toBeNull();
|
||||
expect(data.league.id).toBe('league-2');
|
||||
expect(data.league.name).toBe('League Without Owner');
|
||||
});
|
||||
|
||||
it('should handle league with no races', async () => {
|
||||
// Arrange
|
||||
const leagueId = 'league-3';
|
||||
const mockLeaguesData = createMockLeagueDetailData();
|
||||
const mockMembershipsData = createMockMembershipsData();
|
||||
const mockRacesPageData = { races: [] };
|
||||
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url.includes('/leagues/all-with-capacity-and-scoring')) {
|
||||
return Promise.resolve(createMockResponse(mockLeaguesData));
|
||||
}
|
||||
if (url.includes('/memberships')) {
|
||||
return Promise.resolve(createMockResponse(mockMembershipsData));
|
||||
}
|
||||
if (url.includes('/races/page-data')) {
|
||||
return Promise.resolve(createMockResponse(mockRacesPageData));
|
||||
}
|
||||
return Promise.resolve(createMockErrorResponse(404, 'Not Found', 'Not Found'));
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
|
||||
expect(data.races).toBeDefined();
|
||||
expect(data.races.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle 404 error when league not found', async () => {
|
||||
// Arrange
|
||||
const leagueId = 'non-existent-league';
|
||||
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url.includes('/leagues/all-with-capacity-and-scoring')) {
|
||||
return Promise.resolve(createMockResponse({ leagues: [] }));
|
||||
}
|
||||
return Promise.resolve(createMockErrorResponse(404, 'Not Found', 'League not found'));
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error).toBe('notFound');
|
||||
});
|
||||
|
||||
it('should handle 500 error when API server error', async () => {
|
||||
// Arrange
|
||||
const leagueId = 'league-1';
|
||||
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url.includes('/leagues/all-with-capacity-and-scoring')) {
|
||||
return Promise.resolve(createMockErrorResponse(500, 'Internal Server Error', 'Internal Server Error'));
|
||||
}
|
||||
return Promise.resolve(createMockErrorResponse(500, 'Internal Server Error', 'Internal Server Error'));
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error).toBe('serverError');
|
||||
});
|
||||
|
||||
it('should handle network error', async () => {
|
||||
// Arrange
|
||||
const leagueId = 'league-1';
|
||||
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error('Network error: Unable to reach the API server'));
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error).toBe('serverError');
|
||||
});
|
||||
|
||||
it('should handle timeout error', async () => {
|
||||
// Arrange
|
||||
const leagueId = 'league-1';
|
||||
const timeoutError = new Error('Request timed out after 30 seconds');
|
||||
timeoutError.name = 'AbortError';
|
||||
|
||||
global.fetch = vi.fn().mockRejectedValue(timeoutError);
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error).toBe('serverError');
|
||||
});
|
||||
|
||||
it('should handle unauthorized error', async () => {
|
||||
// Arrange
|
||||
const leagueId = 'league-1';
|
||||
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url.includes('/leagues/all-with-capacity-and-scoring')) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
text: async () => 'Unauthorized',
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
text: async () => 'Unauthorized',
|
||||
});
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error).toBe('unauthorized');
|
||||
});
|
||||
|
||||
it('should handle forbidden error', async () => {
|
||||
// Arrange
|
||||
const leagueId = 'league-1';
|
||||
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url.includes('/leagues/all-with-capacity-and-scoring')) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
text: async () => 'Forbidden',
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
text: async () => 'Forbidden',
|
||||
});
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error).toBe('unauthorized');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Missing Data', () => {
|
||||
it('should handle API returning partial data (missing memberships)', async () => {
|
||||
// Arrange
|
||||
const leagueId = 'league-1';
|
||||
const mockLeaguesData = createMockLeagueDetailData();
|
||||
const mockRacesPageData = createMockRacesPageData();
|
||||
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url.includes('/leagues/all-with-capacity-and-scoring')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => mockLeaguesData,
|
||||
});
|
||||
}
|
||||
if (url.includes('/memberships')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ members: [] }),
|
||||
});
|
||||
}
|
||||
if (url.includes('/races/page-data')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => mockRacesPageData,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
text: async () => 'Not Found',
|
||||
});
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
|
||||
expect(data.memberships).toBeDefined();
|
||||
expect(data.memberships.members).toBeDefined();
|
||||
expect(data.memberships.members.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle API returning partial data (missing races)', async () => {
|
||||
// Arrange
|
||||
const leagueId = 'league-1';
|
||||
const mockLeaguesData = createMockLeagueDetailData();
|
||||
const mockMembershipsData = createMockMembershipsData();
|
||||
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url.includes('/leagues/all-with-capacity-and-scoring')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => mockLeaguesData,
|
||||
});
|
||||
}
|
||||
if (url.includes('/memberships')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => mockMembershipsData,
|
||||
});
|
||||
}
|
||||
if (url.includes('/races/page-data')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ races: [] }),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
text: async () => 'Not Found',
|
||||
});
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
|
||||
expect(data.races).toBeDefined();
|
||||
expect(data.races.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle API returning partial data (missing scoring config)', async () => {
|
||||
// Arrange
|
||||
const leagueId = 'league-1';
|
||||
const mockLeaguesData = createMockLeagueDetailData();
|
||||
const mockMembershipsData = createMockMembershipsData();
|
||||
const mockRacesPageData = createMockRacesPageData();
|
||||
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url.includes('/leagues/all-with-capacity-and-scoring')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => mockLeaguesData,
|
||||
});
|
||||
}
|
||||
if (url.includes('/memberships')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => mockMembershipsData,
|
||||
});
|
||||
}
|
||||
if (url.includes('/races/page-data')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => mockRacesPageData,
|
||||
});
|
||||
}
|
||||
if (url.includes('/config')) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
text: async () => 'Config not found',
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
text: async () => 'Not Found',
|
||||
});
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
|
||||
expect(data.scoringConfig).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle API returning partial data (missing owner)', async () => {
|
||||
// Arrange
|
||||
const leagueId = 'league-1';
|
||||
const mockLeaguesData = {
|
||||
leagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'A test league',
|
||||
capacity: 10,
|
||||
currentMembers: 5,
|
||||
ownerId: 'driver-1',
|
||||
status: 'active' as const,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
const mockMembershipsData = createMockMembershipsData();
|
||||
const mockRacesPageData = createMockRacesPageData();
|
||||
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url.includes('/leagues/all-with-capacity-and-scoring')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => mockLeaguesData,
|
||||
});
|
||||
}
|
||||
if (url.includes('/memberships')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => mockMembershipsData,
|
||||
});
|
||||
}
|
||||
if (url.includes('/races/page-data')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => mockRacesPageData,
|
||||
});
|
||||
}
|
||||
if (url.includes('/drivers/driver-1')) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
text: async () => 'Driver not found',
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
text: async () => 'Not Found',
|
||||
});
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
|
||||
expect(data.owner).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle API returning empty leagues array', async () => {
|
||||
// Arrange
|
||||
const leagueId = 'league-1';
|
||||
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url.includes('/leagues/all-with-capacity-and-scoring')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ leagues: [] }),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
text: async () => 'Not Found',
|
||||
});
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error.type).toBe('notFound');
|
||||
expect(error.message).toContain('Leagues not found');
|
||||
});
|
||||
|
||||
it('should handle API returning null data', async () => {
|
||||
// Arrange
|
||||
const leagueId = 'league-1';
|
||||
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url.includes('/leagues/all-with-capacity-and-scoring')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => null,
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
text: async () => 'Not Found',
|
||||
});
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error.type).toBe('notFound');
|
||||
expect(error.message).toContain('Leagues not found');
|
||||
});
|
||||
|
||||
it('should handle API returning malformed data', async () => {
|
||||
// Arrange
|
||||
const leagueId = 'league-1';
|
||||
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url.includes('/leagues/all-with-capacity-and-scoring')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ someOtherProperty: 'value' }),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
text: async () => 'Not Found',
|
||||
});
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error.type).toBe('notFound');
|
||||
expect(error.message).toContain('Leagues not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
364
tests/integration/website/LeaguesPageQuery.integration.test.ts
Normal file
364
tests/integration/website/LeaguesPageQuery.integration.test.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* Integration Tests for LeaguesPageQuery
|
||||
*
|
||||
* Tests the LeaguesPageQuery with mocked API clients to verify:
|
||||
* - Happy path: API returns valid leagues data
|
||||
* - Error handling: 404 when leagues endpoint not found
|
||||
* - Error handling: 500 when API server error
|
||||
* - Empty results: API returns empty leagues list
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { LeaguesPageQuery } from '@/lib/page-queries/LeaguesPageQuery';
|
||||
|
||||
// Mock data factories
|
||||
const createMockLeaguesData = () => ({
|
||||
leagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Test League 1',
|
||||
description: 'A test league',
|
||||
ownerId: 'driver-1',
|
||||
createdAt: new Date().toISOString(),
|
||||
usedSlots: 5,
|
||||
settings: {
|
||||
maxDrivers: 10,
|
||||
},
|
||||
scoring: {
|
||||
gameId: 'game-1',
|
||||
gameName: 'Test Game',
|
||||
primaryChampionshipType: 'driver' as const,
|
||||
scoringPresetId: 'preset-1',
|
||||
scoringPresetName: 'Test Preset',
|
||||
dropPolicySummary: 'No drops',
|
||||
scoringPatternSummary: 'Standard scoring',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'league-2',
|
||||
name: 'Test League 2',
|
||||
description: 'Another test league',
|
||||
ownerId: 'driver-2',
|
||||
createdAt: new Date().toISOString(),
|
||||
usedSlots: 15,
|
||||
settings: {
|
||||
maxDrivers: 20,
|
||||
},
|
||||
scoring: {
|
||||
gameId: 'game-1',
|
||||
gameName: 'Test Game',
|
||||
primaryChampionshipType: 'driver' as const,
|
||||
scoringPresetId: 'preset-1',
|
||||
scoringPresetName: 'Test Preset',
|
||||
dropPolicySummary: 'No drops',
|
||||
scoringPatternSummary: 'Standard scoring',
|
||||
},
|
||||
},
|
||||
],
|
||||
totalCount: 2,
|
||||
});
|
||||
|
||||
const createMockEmptyLeaguesData = () => ({
|
||||
leagues: [],
|
||||
});
|
||||
|
||||
describe('LeaguesPageQuery Integration', () => {
|
||||
let originalFetch: typeof global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
// Store original fetch to restore later
|
||||
originalFetch = global.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original fetch
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe('Happy Path', () => {
|
||||
it('should return valid leagues data when API returns success', async () => {
|
||||
// Arrange
|
||||
const mockData = createMockLeaguesData();
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockData,
|
||||
text: async () => JSON.stringify(mockData),
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
const viewData = result.unwrap();
|
||||
|
||||
expect(viewData).toBeDefined();
|
||||
expect(viewData.leagues).toBeDefined();
|
||||
expect(viewData.leagues.length).toBe(2);
|
||||
|
||||
// Verify first league
|
||||
expect(viewData.leagues[0].id).toBe('league-1');
|
||||
expect(viewData.leagues[0].name).toBe('Test League 1');
|
||||
expect(viewData.leagues[0].settings.maxDrivers).toBe(10);
|
||||
expect(viewData.leagues[0].usedSlots).toBe(5);
|
||||
|
||||
// Verify second league
|
||||
expect(viewData.leagues[1].id).toBe('league-2');
|
||||
expect(viewData.leagues[1].name).toBe('Test League 2');
|
||||
expect(viewData.leagues[1].settings.maxDrivers).toBe(20);
|
||||
expect(viewData.leagues[1].usedSlots).toBe(15);
|
||||
});
|
||||
|
||||
it('should handle single league correctly', async () => {
|
||||
// Arrange
|
||||
const mockData = {
|
||||
leagues: [
|
||||
{
|
||||
id: 'single-league',
|
||||
name: 'Single League',
|
||||
description: 'Only one league',
|
||||
ownerId: 'driver-1',
|
||||
createdAt: new Date().toISOString(),
|
||||
usedSlots: 3,
|
||||
settings: {
|
||||
maxDrivers: 5,
|
||||
},
|
||||
scoring: {
|
||||
gameId: 'game-1',
|
||||
gameName: 'Test Game',
|
||||
primaryChampionshipType: 'driver' as const,
|
||||
scoringPresetId: 'preset-1',
|
||||
scoringPresetName: 'Test Preset',
|
||||
dropPolicySummary: 'No drops',
|
||||
scoringPatternSummary: 'Standard scoring',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockData,
|
||||
text: async () => JSON.stringify(mockData),
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
const viewData = result.unwrap();
|
||||
|
||||
expect(viewData.leagues.length).toBe(1);
|
||||
expect(viewData.leagues[0].id).toBe('single-league');
|
||||
expect(viewData.leagues[0].name).toBe('Single League');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty Results', () => {
|
||||
it('should handle empty leagues list from API', async () => {
|
||||
// Arrange
|
||||
const mockData = createMockEmptyLeaguesData();
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockData,
|
||||
text: async () => JSON.stringify(mockData),
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
const viewData = result.unwrap();
|
||||
|
||||
expect(viewData).toBeDefined();
|
||||
expect(viewData.leagues).toBeDefined();
|
||||
expect(viewData.leagues.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle 404 error when leagues endpoint not found', async () => {
|
||||
// Arrange
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
text: async () => 'Leagues not found',
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error).toBe('LEAGUES_FETCH_FAILED');
|
||||
});
|
||||
|
||||
it('should handle 500 error when API server error', async () => {
|
||||
// Arrange
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
text: async () => 'Internal Server Error',
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error).toBe('LEAGUES_FETCH_FAILED');
|
||||
});
|
||||
|
||||
it('should handle network error', async () => {
|
||||
// Arrange
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error('Network error: Unable to reach the API server'));
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error).toBe('LEAGUES_FETCH_FAILED');
|
||||
});
|
||||
|
||||
it('should handle timeout error', async () => {
|
||||
// Arrange
|
||||
const timeoutError = new Error('Request timed out after 30 seconds');
|
||||
timeoutError.name = 'AbortError';
|
||||
global.fetch = vi.fn().mockRejectedValue(timeoutError);
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error).toBe('LEAGUES_FETCH_FAILED');
|
||||
});
|
||||
|
||||
it('should handle unauthorized error (redirect)', async () => {
|
||||
// Arrange
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
text: async () => 'Unauthorized',
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error).toBe('redirect');
|
||||
});
|
||||
|
||||
it('should handle forbidden error (redirect)', async () => {
|
||||
// Arrange
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
text: async () => 'Forbidden',
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error).toBe('redirect');
|
||||
});
|
||||
|
||||
it('should handle unknown error type', async () => {
|
||||
// Arrange
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 999,
|
||||
statusText: 'Unknown Error',
|
||||
text: async () => 'Unknown error',
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error).toBe('UNKNOWN_ERROR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle API returning null or undefined data', async () => {
|
||||
// Arrange
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => null,
|
||||
text: async () => 'null',
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error).toBe('LEAGUES_FETCH_FAILED');
|
||||
});
|
||||
|
||||
it('should handle API returning malformed data', async () => {
|
||||
// Arrange
|
||||
const mockData = {
|
||||
// Missing 'leagues' property
|
||||
someOtherProperty: 'value',
|
||||
};
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockData,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.getError();
|
||||
expect(error).toBe('LEAGUES_FETCH_FAILED');
|
||||
});
|
||||
|
||||
it('should handle API returning leagues with missing required fields', async () => {
|
||||
// Arrange
|
||||
const mockData = {
|
||||
leagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
// Missing other required fields
|
||||
},
|
||||
],
|
||||
};
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockData,
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await LeaguesPageQuery.execute();
|
||||
|
||||
// Assert
|
||||
// Should still succeed - the builder should handle partial data
|
||||
expect(result.isOk()).toBe(true);
|
||||
const viewData = result.unwrap();
|
||||
expect(viewData.leagues.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
149
tests/integration/website/mocks/MockLeaguesApiClient.ts
Normal file
149
tests/integration/website/mocks/MockLeaguesApiClient.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { LeaguesApiClient } from '../../../../apps/website/lib/api/leagues/LeaguesApiClient';
|
||||
import { ApiError } from '../../../../apps/website/lib/api/base/ApiError';
|
||||
import type { Logger } from '../../../../apps/website/lib/interfaces/Logger';
|
||||
import type { ErrorReporter } from '../../../../apps/website/lib/interfaces/ErrorReporter';
|
||||
|
||||
/**
|
||||
* Mock LeaguesApiClient for testing
|
||||
* Allows controlled responses without making actual HTTP calls
|
||||
*/
|
||||
export class MockLeaguesApiClient extends LeaguesApiClient {
|
||||
private mockResponses: Map<string, any> = new Map();
|
||||
private mockErrors: Map<string, ApiError> = new Map();
|
||||
|
||||
constructor(
|
||||
baseUrl: string = 'http://localhost:3001',
|
||||
errorReporter: ErrorReporter = {
|
||||
report: () => {},
|
||||
} as any,
|
||||
logger: Logger = {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
} as any
|
||||
) {
|
||||
super(baseUrl, errorReporter, logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a mock response for a specific endpoint
|
||||
*/
|
||||
setMockResponse(endpoint: string, response: any): void {
|
||||
this.mockResponses.set(endpoint, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a mock error for a specific endpoint
|
||||
*/
|
||||
setMockError(endpoint: string, error: ApiError): void {
|
||||
this.mockErrors.set(endpoint, error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all mock responses and errors
|
||||
*/
|
||||
clearMocks(): void {
|
||||
this.mockResponses.clear();
|
||||
this.mockErrors.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Override getAllWithCapacityAndScoring to return mock data
|
||||
*/
|
||||
async getAllWithCapacityAndScoring(): Promise<any> {
|
||||
const endpoint = '/leagues/all-with-capacity-and-scoring';
|
||||
|
||||
if (this.mockErrors.has(endpoint)) {
|
||||
throw this.mockErrors.get(endpoint);
|
||||
}
|
||||
|
||||
if (this.mockResponses.has(endpoint)) {
|
||||
return this.mockResponses.get(endpoint);
|
||||
}
|
||||
|
||||
// Default mock response
|
||||
return {
|
||||
leagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'A test league',
|
||||
ownerId: 'driver-1',
|
||||
createdAt: new Date().toISOString(),
|
||||
usedSlots: 5,
|
||||
settings: {
|
||||
maxDrivers: 10,
|
||||
},
|
||||
scoring: {
|
||||
gameId: 'game-1',
|
||||
gameName: 'Test Game',
|
||||
primaryChampionshipType: 'driver',
|
||||
scoringPresetId: 'preset-1',
|
||||
scoringPresetName: 'Test Preset',
|
||||
dropPolicySummary: 'No drops',
|
||||
scoringPatternSummary: 'Standard scoring',
|
||||
},
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Override getMemberships to return mock data
|
||||
*/
|
||||
async getMemberships(leagueId: string): Promise<any> {
|
||||
const endpoint = `/leagues/${leagueId}/memberships`;
|
||||
|
||||
if (this.mockErrors.has(endpoint)) {
|
||||
throw this.mockErrors.get(endpoint);
|
||||
}
|
||||
|
||||
if (this.mockResponses.has(endpoint)) {
|
||||
return this.mockResponses.get(endpoint);
|
||||
}
|
||||
|
||||
// Default mock response
|
||||
return {
|
||||
members: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
iracingId: '12345',
|
||||
name: 'Test Driver',
|
||||
country: 'US',
|
||||
joinedAt: new Date().toISOString(),
|
||||
},
|
||||
role: 'owner',
|
||||
status: 'active',
|
||||
joinedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Override getLeagueConfig to return mock data
|
||||
*/
|
||||
async getLeagueConfig(leagueId: string): Promise<any> {
|
||||
const endpoint = `/leagues/${leagueId}/config`;
|
||||
|
||||
if (this.mockErrors.has(endpoint)) {
|
||||
throw this.mockErrors.get(endpoint);
|
||||
}
|
||||
|
||||
if (this.mockResponses.has(endpoint)) {
|
||||
return this.mockResponses.get(endpoint);
|
||||
}
|
||||
|
||||
// Default mock response
|
||||
return {
|
||||
form: {
|
||||
scoring: {
|
||||
presetId: 'preset-1',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user