Compare commits
4 Commits
6df38a462a
...
9bb6b228f1
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bb6b228f1 | |||
| 95276df5af | |||
| 34eae53184 | |||
| a00ca4edfd |
@@ -5,52 +5,86 @@
|
||||
import type { Transaction, Wallet } from '@core/payments/domain/entities/Wallet';
|
||||
import type { WalletRepository, TransactionRepository } from '@core/payments/domain/repositories/WalletRepository';
|
||||
import type { Logger } from '@core/shared/domain/Logger';
|
||||
import type { LeagueWalletRepository } from '@core/racing/domain/repositories/LeagueWalletRepository';
|
||||
|
||||
const wallets: Map<string, Wallet> = new Map();
|
||||
const transactions: Map<string, Transaction> = new Map();
|
||||
const wallets: Map<string, any> = new Map();
|
||||
const transactions: Map<string, any> = new Map();
|
||||
|
||||
export class InMemoryWalletRepository implements WalletRepository {
|
||||
export class InMemoryWalletRepository implements WalletRepository, LeagueWalletRepository {
|
||||
constructor(private readonly logger: Logger) {}
|
||||
|
||||
async findById(id: string): Promise<Wallet | null> {
|
||||
async findById(id: string): Promise<any | null> {
|
||||
this.logger.debug('[InMemoryWalletRepository] findById', { id });
|
||||
return wallets.get(id) || null;
|
||||
}
|
||||
|
||||
async findByLeagueId(leagueId: string): Promise<Wallet | null> {
|
||||
async findByLeagueId(leagueId: string): Promise<any | null> {
|
||||
this.logger.debug('[InMemoryWalletRepository] findByLeagueId', { leagueId });
|
||||
return Array.from(wallets.values()).find(w => w.leagueId === leagueId) || null;
|
||||
return Array.from(wallets.values()).find(w => w.leagueId.toString() === leagueId) || null;
|
||||
}
|
||||
|
||||
async create(wallet: Wallet): Promise<Wallet> {
|
||||
async create(wallet: any): Promise<any> {
|
||||
this.logger.debug('[InMemoryWalletRepository] create', { wallet });
|
||||
wallets.set(wallet.id, wallet);
|
||||
wallets.set(wallet.id.toString(), wallet);
|
||||
return wallet;
|
||||
}
|
||||
|
||||
async update(wallet: Wallet): Promise<Wallet> {
|
||||
async update(wallet: any): Promise<any> {
|
||||
this.logger.debug('[InMemoryWalletRepository] update', { wallet });
|
||||
wallets.set(wallet.id, wallet);
|
||||
wallets.set(wallet.id.toString(), wallet);
|
||||
return wallet;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
wallets.delete(id);
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
return wallets.has(id);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
wallets.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export class InMemoryTransactionRepository implements TransactionRepository {
|
||||
constructor(private readonly logger: Logger) {}
|
||||
|
||||
async findById(id: string): Promise<Transaction | null> {
|
||||
async findById(id: string): Promise<any | null> {
|
||||
this.logger.debug('[InMemoryTransactionRepository] findById', { id });
|
||||
return transactions.get(id) || null;
|
||||
}
|
||||
|
||||
async findByWalletId(walletId: string): Promise<Transaction[]> {
|
||||
async findByWalletId(walletId: string): Promise<any[]> {
|
||||
this.logger.debug('[InMemoryTransactionRepository] findByWalletId', { walletId });
|
||||
return Array.from(transactions.values()).filter(t => t.walletId === walletId);
|
||||
return Array.from(transactions.values()).filter(t => t.walletId.toString() === walletId);
|
||||
}
|
||||
|
||||
async create(transaction: Transaction): Promise<Transaction> {
|
||||
async create(transaction: any): Promise<any> {
|
||||
this.logger.debug('[InMemoryTransactionRepository] create', { transaction });
|
||||
transactions.set(transaction.id, transaction);
|
||||
transactions.set(transaction.id.toString(), transaction);
|
||||
return transaction;
|
||||
}
|
||||
}
|
||||
|
||||
async update(transaction: any): Promise<any> {
|
||||
transactions.set(transaction.id.toString(), transaction);
|
||||
return transaction;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
transactions.delete(id);
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
return transactions.has(id);
|
||||
}
|
||||
|
||||
findByType(type: any): Promise<any[]> {
|
||||
return Promise.resolve(Array.from(transactions.values()).filter(t => t.type === type));
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
transactions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
52
plans/test_gap_analysis.md
Normal file
52
plans/test_gap_analysis.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Test Coverage Analysis & Gap Report
|
||||
|
||||
## 1. Executive Summary
|
||||
We have compared the existing E2E and Integration tests against the core concepts defined in [`docs/concept/`](docs/concept) and the testing principles in [`docs/TESTING_LAYERS.md`](docs/TESTING_LAYERS.md).
|
||||
|
||||
While the functional coverage is high, there are critical gaps in **Integration Testing** specifically regarding external boundaries (iRacing API) and specific infrastructure-heavy business logic (Rating Engine).
|
||||
|
||||
## 2. Concept vs. Test Mapping
|
||||
|
||||
| Concept Area | E2E Coverage | Integration Coverage | Status |
|
||||
|--------------|--------------|----------------------|--------|
|
||||
| **League Management** | [`leagues/`](tests/e2e/leagues) | [`leagues/`](tests/integration/leagues) | ✅ Covered |
|
||||
| **Season/Schedule** | [`leagues/league-schedule.spec.ts`](tests/e2e/leagues/league-schedule.spec.ts) | [`leagues/schedule/`](tests/integration/leagues/schedule) | ✅ Covered |
|
||||
| **Results Import** | [`races/race-results.spec.ts`](tests/e2e/races/race-results.spec.ts) | [`races/results/`](tests/integration/races/results) | ⚠️ Missing iRacing API Integration |
|
||||
| **Complaints/Penalties** | [`leagues/league-stewarding.spec.ts`](tests/e2e/leagues/league-stewarding.spec.ts) | [`races/stewarding/`](tests/integration/races/stewarding) | ✅ Covered |
|
||||
| **Team Competition** | [`teams/`](tests/e2e/teams) | [`teams/`](tests/integration/teams) | ✅ Covered |
|
||||
| **Driver Profile/Stats** | [`drivers/`](tests/e2e/drivers) | [`drivers/profile/`](tests/integration/drivers/profile) | ✅ Covered |
|
||||
| **Rating System** | None | None | ❌ Missing |
|
||||
| **Social/Messaging** | None | None | ❌ Missing |
|
||||
|
||||
## 3. Identified Gaps in Integration Tests
|
||||
|
||||
According to [`docs/TESTING_LAYERS.md`](docs/TESTING_LAYERS.md), integration tests should protect **environmental correctness** (DB, external APIs, Auth).
|
||||
|
||||
### 🚨 Critical Gaps (Infrastructure/Boundaries)
|
||||
1. **iRacing API Integration**:
|
||||
- *Concept*: [`docs/concept/ADMINS.md`](docs/concept/ADMINS.md:83) (Automatic Results Import).
|
||||
- *Gap*: We have tests for *displaying* results, but no integration tests verifying the actual handshake and parsing logic with the iRacing API boundary.
|
||||
2. **Rating Engine Persistence**:
|
||||
- *Concept*: [`docs/concept/RATING.md`](docs/concept/RATING.md) (GridPilot Rating).
|
||||
- *Gap*: The rating system involves complex calculations that must be persisted correctly. We lack integration tests for the `RatingService` interacting with the DB.
|
||||
3. **Auth/Identity Provider**:
|
||||
- *Concept*: [`docs/concept/CONCEPT.md`](docs/concept/CONCEPT.md:172) (Safety, Security & Trust).
|
||||
- *Gap*: No integration tests for the Auth boundary (e.g., JWT validation, session persistence).
|
||||
|
||||
### 🛠 Functional Gaps (Business Logic Integration)
|
||||
1. **Social/Messaging**:
|
||||
- *Concept*: [`docs/concept/SOCIAL.md`](docs/concept/SOCIAL.md) (Messaging, Notifications).
|
||||
- *Gap*: No integration tests for message persistence or notification delivery (queues).
|
||||
2. **Constructors-Style Scoring**:
|
||||
- *Concept*: [`docs/concept/RACING.md`](docs/concept/RACING.md:47) (Constructors-Style Points).
|
||||
- *Gap*: While we have `StandingsCalculation.test.ts`, we need specific integration tests for complex multi-driver team scoring scenarios against the DB.
|
||||
|
||||
## 4. Proposed Action Plan
|
||||
|
||||
1. **Implement iRacing API Contract/Integration Tests**: Verify the parsing of iRacing result payloads.
|
||||
2. **Add Rating Persistence Tests**: Ensure `GridPilot Rating` updates correctly in the DB after race results are processed.
|
||||
3. **Add Social/Notification Integration**: Test the persistence of messages and the triggering of notifications.
|
||||
4. **Auth Integration**: Verify the system-level Auth flow as per the "Trust" requirement.
|
||||
|
||||
---
|
||||
*Uncle Bob's Note: Remember, the closer a test is to the code, the more of them you should have. But for the system to be robust, the boundaries must be ironclad.*
|
||||
@@ -1,408 +0,0 @@
|
||||
/**
|
||||
* Contract Validation Tests for API
|
||||
*
|
||||
* These tests validate that the API DTOs and OpenAPI spec are consistent
|
||||
* and that the generated types will be compatible with the website.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
interface OpenAPISchema {
|
||||
type?: string;
|
||||
format?: string;
|
||||
$ref?: string;
|
||||
items?: OpenAPISchema;
|
||||
properties?: Record<string, OpenAPISchema>;
|
||||
required?: string[];
|
||||
enum?: string[];
|
||||
nullable?: boolean;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
}
|
||||
|
||||
interface OpenAPISpec {
|
||||
openapi: string;
|
||||
info: {
|
||||
title: string;
|
||||
description: string;
|
||||
version: string;
|
||||
};
|
||||
paths: Record<string, any>;
|
||||
components: {
|
||||
schemas: Record<string, OpenAPISchema>;
|
||||
};
|
||||
}
|
||||
|
||||
describe('API Contract Validation', () => {
|
||||
const apiRoot = path.join(__dirname, '../..'); // /Users/marcmintel/Projects/gridpilot
|
||||
const openapiPath = path.join(apiRoot, 'apps/api/openapi.json');
|
||||
const generatedTypesDir = path.join(apiRoot, 'apps/website/lib/types/generated');
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
describe('OpenAPI Spec Integrity', () => {
|
||||
it('should have a valid OpenAPI spec file', async () => {
|
||||
const specExists = await fs.access(openapiPath).then(() => true).catch(() => false);
|
||||
expect(specExists).toBe(true);
|
||||
});
|
||||
|
||||
it('should have a valid JSON structure', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
expect(() => JSON.parse(content)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should have required OpenAPI fields', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
expect(spec.openapi).toMatch(/^3\.\d+\.\d+$/);
|
||||
expect(spec.info).toBeDefined();
|
||||
expect(spec.info.title).toBeDefined();
|
||||
expect(spec.info.version).toBeDefined();
|
||||
expect(spec.components).toBeDefined();
|
||||
expect(spec.components.schemas).toBeDefined();
|
||||
});
|
||||
|
||||
it('committed openapi.json should match generator output', async () => {
|
||||
const repoRoot = apiRoot; // Already at the repo root
|
||||
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gridpilot-openapi-'));
|
||||
const generatedOpenapiPath = path.join(tmpDir, 'openapi.json');
|
||||
|
||||
await execFileAsync(
|
||||
'npx',
|
||||
['--no-install', 'tsx', 'scripts/generate-openapi-spec.ts', '--output', generatedOpenapiPath],
|
||||
{ cwd: repoRoot, maxBuffer: 20 * 1024 * 1024 },
|
||||
);
|
||||
|
||||
const committed: OpenAPISpec = JSON.parse(await fs.readFile(openapiPath, 'utf-8'));
|
||||
const generated: OpenAPISpec = JSON.parse(await fs.readFile(generatedOpenapiPath, 'utf-8'));
|
||||
|
||||
expect(generated).toEqual(committed);
|
||||
});
|
||||
|
||||
it('should include real HTTP paths for known routes', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const pathKeys = Object.keys(spec.paths ?? {});
|
||||
expect(pathKeys.length).toBeGreaterThan(0);
|
||||
|
||||
// A couple of stable routes to detect "empty/stale" specs.
|
||||
expect(spec.paths['/drivers/leaderboard']).toBeDefined();
|
||||
expect(spec.paths['/dashboard/overview']).toBeDefined();
|
||||
|
||||
// Sanity-check the operation objects exist (method keys are lowercase in OpenAPI).
|
||||
expect(spec.paths['/drivers/leaderboard'].get).toBeDefined();
|
||||
expect(spec.paths['/dashboard/overview'].get).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include league schedule publish/unpublish endpoints and published state', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/publish']).toBeDefined();
|
||||
expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/publish'].post).toBeDefined();
|
||||
|
||||
expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/unpublish']).toBeDefined();
|
||||
expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/unpublish'].post).toBeDefined();
|
||||
|
||||
const scheduleSchema = spec.components.schemas['LeagueScheduleDTO'];
|
||||
if (!scheduleSchema) {
|
||||
throw new Error('Expected LeagueScheduleDTO schema to be present in OpenAPI spec');
|
||||
}
|
||||
|
||||
expect(scheduleSchema.properties?.published).toBeDefined();
|
||||
expect(scheduleSchema.properties?.published?.type).toBe('boolean');
|
||||
expect(scheduleSchema.required ?? []).toContain('published');
|
||||
});
|
||||
|
||||
it('should include league roster admin read endpoints and schemas', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
expect(spec.paths['/leagues/{leagueId}/admin/roster/members']).toBeDefined();
|
||||
expect(spec.paths['/leagues/{leagueId}/admin/roster/members'].get).toBeDefined();
|
||||
|
||||
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests']).toBeDefined();
|
||||
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests'].get).toBeDefined();
|
||||
|
||||
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/approve']).toBeDefined();
|
||||
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/approve'].post).toBeDefined();
|
||||
|
||||
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/reject']).toBeDefined();
|
||||
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/reject'].post).toBeDefined();
|
||||
|
||||
const memberSchema = spec.components.schemas['LeagueRosterMemberDTO'];
|
||||
if (!memberSchema) {
|
||||
throw new Error('Expected LeagueRosterMemberDTO schema to be present in OpenAPI spec');
|
||||
}
|
||||
|
||||
expect(memberSchema.properties?.driverId).toBeDefined();
|
||||
expect(memberSchema.properties?.role).toBeDefined();
|
||||
expect(memberSchema.properties?.joinedAt).toBeDefined();
|
||||
expect(memberSchema.required ?? []).toContain('driverId');
|
||||
expect(memberSchema.required ?? []).toContain('role');
|
||||
expect(memberSchema.required ?? []).toContain('joinedAt');
|
||||
expect(memberSchema.required ?? []).toContain('driver');
|
||||
|
||||
const joinRequestSchema = spec.components.schemas['LeagueRosterJoinRequestDTO'];
|
||||
if (!joinRequestSchema) {
|
||||
throw new Error('Expected LeagueRosterJoinRequestDTO schema to be present in OpenAPI spec');
|
||||
}
|
||||
|
||||
expect(joinRequestSchema.properties?.id).toBeDefined();
|
||||
expect(joinRequestSchema.properties?.leagueId).toBeDefined();
|
||||
expect(joinRequestSchema.properties?.driverId).toBeDefined();
|
||||
expect(joinRequestSchema.properties?.requestedAt).toBeDefined();
|
||||
expect(joinRequestSchema.required ?? []).toContain('id');
|
||||
expect(joinRequestSchema.required ?? []).toContain('leagueId');
|
||||
expect(joinRequestSchema.required ?? []).toContain('driverId');
|
||||
expect(joinRequestSchema.required ?? []).toContain('requestedAt');
|
||||
expect(joinRequestSchema.required ?? []).toContain('driver');
|
||||
});
|
||||
|
||||
it('should have no circular references in schemas', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
const schemas = spec.components.schemas;
|
||||
|
||||
const visited = new Set<string>();
|
||||
const visiting = new Set<string>();
|
||||
|
||||
function detectCircular(schemaName: string): boolean {
|
||||
if (visiting.has(schemaName)) return true;
|
||||
if (visited.has(schemaName)) return false;
|
||||
|
||||
visiting.add(schemaName);
|
||||
const schema = schemas[schemaName];
|
||||
|
||||
if (!schema) {
|
||||
visiting.delete(schemaName);
|
||||
visited.add(schemaName);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check properties for references
|
||||
if (schema.properties) {
|
||||
for (const prop of Object.values(schema.properties)) {
|
||||
if (prop.$ref) {
|
||||
const refName = prop.$ref.split('/').pop();
|
||||
if (refName && detectCircular(refName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (prop.items?.$ref) {
|
||||
const refName = prop.items.$ref.split('/').pop();
|
||||
if (refName && detectCircular(refName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visiting.delete(schemaName);
|
||||
visited.add(schemaName);
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const schemaName of Object.keys(schemas)) {
|
||||
expect(detectCircular(schemaName)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('DTO Consistency', () => {
|
||||
it('should have generated DTO files for critical schemas', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const generatedFiles = await fs.readdir(generatedTypesDir);
|
||||
const generatedDTOs = generatedFiles
|
||||
.filter(f => f.endsWith('.ts'))
|
||||
.map(f => f.replace('.ts', ''));
|
||||
|
||||
// We intentionally do NOT require a 1:1 mapping for *all* schemas here.
|
||||
// OpenAPI generation and type generation can be run as separate steps,
|
||||
// and new schemas should not break API contract validation by themselves.
|
||||
const criticalDTOs = [
|
||||
'RequestAvatarGenerationInputDTO',
|
||||
'RequestAvatarGenerationOutputDTO',
|
||||
'UploadMediaInputDTO',
|
||||
'UploadMediaOutputDTO',
|
||||
'RaceDTO',
|
||||
'DriverDTO',
|
||||
];
|
||||
|
||||
for (const dtoName of criticalDTOs) {
|
||||
expect(spec.components.schemas[dtoName]).toBeDefined();
|
||||
expect(generatedDTOs).toContain(dtoName);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have consistent property types between DTOs and schemas', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
const schemas = spec.components.schemas;
|
||||
|
||||
for (const [schemaName, schema] of Object.entries(schemas)) {
|
||||
const dtoPath = path.join(generatedTypesDir, `${schemaName}.ts`);
|
||||
const dtoExists = await fs.access(dtoPath).then(() => true).catch(() => false);
|
||||
|
||||
if (!dtoExists) continue;
|
||||
|
||||
const dtoContent = await fs.readFile(dtoPath, 'utf-8');
|
||||
|
||||
// Check that all required properties are present
|
||||
if (schema.required) {
|
||||
for (const requiredProp of schema.required) {
|
||||
expect(dtoContent).toContain(requiredProp);
|
||||
}
|
||||
}
|
||||
|
||||
// Check that all properties are present
|
||||
if (schema.properties) {
|
||||
for (const propName of Object.keys(schema.properties)) {
|
||||
expect(dtoContent).toContain(propName);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type Generation Integrity', () => {
|
||||
it('should have valid TypeScript syntax in generated files', async () => {
|
||||
const files = await fs.readdir(generatedTypesDir);
|
||||
const dtos = files.filter(f => f.endsWith('.ts') && !f.endsWith('.test.ts'));
|
||||
|
||||
for (const file of dtos) {
|
||||
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
|
||||
|
||||
// `index.ts` is a generated barrel file (no interfaces).
|
||||
if (file === 'index.ts') {
|
||||
expect(content).toContain('export type {');
|
||||
expect(content).toContain("from './");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Basic TypeScript syntax checks (DTO interfaces)
|
||||
expect(content).toContain('export interface');
|
||||
expect(content).toContain('{');
|
||||
expect(content).toContain('}');
|
||||
|
||||
// Should not have syntax errors (basic check)
|
||||
expect(content).not.toContain('undefined;');
|
||||
expect(content).not.toContain('any;');
|
||||
}
|
||||
});
|
||||
|
||||
it('should have proper imports for dependencies', async () => {
|
||||
const files = await fs.readdir(generatedTypesDir);
|
||||
const dtos = files.filter(f => f.endsWith('.ts') && !f.endsWith('.test.ts'));
|
||||
|
||||
for (const file of dtos) {
|
||||
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
|
||||
const importMatches = content.match(/import type \{ (\w+) \} from '\.\/(\w+)';/g) || [];
|
||||
|
||||
for (const importLine of importMatches) {
|
||||
const match = importLine.match(/import type \{ (\w+) \} from '\.\/(\w+)';/);
|
||||
if (match) {
|
||||
const [, importedType, fromFile] = match;
|
||||
expect(importedType).toBe(fromFile);
|
||||
|
||||
// Check that the imported file exists
|
||||
const importedPath = path.join(generatedTypesDir, `${fromFile}.ts`);
|
||||
const exists = await fs.access(importedPath).then(() => true).catch(() => false);
|
||||
expect(exists).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Contract Compatibility', () => {
|
||||
it('should maintain backward compatibility for existing DTOs', async () => {
|
||||
// This test ensures that when regenerating types, existing properties aren't removed
|
||||
// unless explicitly intended
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
// Check critical DTOs that are likely used in production
|
||||
const criticalDTOs = [
|
||||
'RequestAvatarGenerationInputDTO',
|
||||
'RequestAvatarGenerationOutputDTO',
|
||||
'UploadMediaInputDTO',
|
||||
'UploadMediaOutputDTO',
|
||||
'RaceDTO',
|
||||
'DriverDTO'
|
||||
];
|
||||
|
||||
for (const dtoName of criticalDTOs) {
|
||||
if (spec.components.schemas[dtoName]) {
|
||||
const dtoPath = path.join(generatedTypesDir, `${dtoName}.ts`);
|
||||
const exists = await fs.access(dtoPath).then(() => true).catch(() => false);
|
||||
expect(exists).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle nullable fields correctly', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
const schemas = spec.components.schemas;
|
||||
|
||||
for (const [, schema] of Object.entries(schemas)) {
|
||||
const required = new Set(schema.required ?? []);
|
||||
if (!schema.properties) continue;
|
||||
|
||||
for (const [propName, propSchema] of Object.entries(schema.properties)) {
|
||||
if (!propSchema.nullable) continue;
|
||||
|
||||
// In OpenAPI 3.0, a `nullable: true` property should not be listed as required,
|
||||
// otherwise downstream generators can't represent it safely.
|
||||
expect(required.has(propName)).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should have no empty string defaults for avatar/logo URLs', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
const schemas = spec.components.schemas;
|
||||
|
||||
// Check DTOs that should use URL|null pattern
|
||||
const mediaRelatedDTOs = [
|
||||
'GetAvatarOutputDTO',
|
||||
'UpdateAvatarInputDTO',
|
||||
'DashboardDriverSummaryDTO',
|
||||
'DriverProfileDriverSummaryDTO',
|
||||
'DriverLeaderboardItemDTO',
|
||||
'TeamListItemDTO',
|
||||
'LeagueSummaryDTO',
|
||||
'SponsorDTO',
|
||||
];
|
||||
|
||||
for (const dtoName of mediaRelatedDTOs) {
|
||||
const schema = schemas[dtoName];
|
||||
if (!schema || !schema.properties) continue;
|
||||
|
||||
// Check for avatarUrl, logoUrl properties
|
||||
for (const [propName, propSchema] of Object.entries(schema.properties)) {
|
||||
if (propName === 'avatarUrl' || propName === 'logoUrl') {
|
||||
// Should be string type, nullable (no empty string defaults)
|
||||
expect(propSchema.type).toBe('string');
|
||||
expect(propSchema.nullable).toBe(true);
|
||||
// Should not have default value of empty string
|
||||
if (propSchema.default !== undefined) {
|
||||
expect(propSchema.default).not.toBe('');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "gp_session",
|
||||
"value": "gp_9f9c4115-2a02-4be7-9aec-72ddb3c7cdbf",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": -1,
|
||||
"httpOnly": true,
|
||||
"secure": false,
|
||||
"sameSite": "Lax"
|
||||
}
|
||||
],
|
||||
"userId": "68fd953d-4f4a-47b6-83b9-ec361238e4f1",
|
||||
"email": "smoke-test-1767897520573@example.com",
|
||||
"password": "Password123"
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
# API Smoke Tests
|
||||
|
||||
This directory contains true end-to-end API smoke tests that make direct HTTP requests to the running API server to validate endpoint functionality and detect issues like "presenter not presented" errors.
|
||||
|
||||
## Overview
|
||||
|
||||
The API smoke tests are designed to:
|
||||
|
||||
1. **Test all public API endpoints** - Make requests to discover and validate endpoints
|
||||
2. **Detect presenter errors** - Identify use cases that return errors without calling `this.output.present()`
|
||||
3. **Validate response formats** - Ensure endpoints return proper data structures
|
||||
4. **Test error handling** - Verify graceful handling of invalid inputs
|
||||
5. **Generate detailed reports** - Create JSON and Markdown reports of findings
|
||||
|
||||
## Files
|
||||
|
||||
- `api-smoke.test.ts` - Main Playwright test file
|
||||
- `README.md` - This documentation
|
||||
|
||||
## Usage
|
||||
|
||||
### Local Testing
|
||||
|
||||
Run the API smoke tests against a locally running API:
|
||||
|
||||
```bash
|
||||
# Start the API server (in one terminal)
|
||||
npm run docker:dev:up
|
||||
|
||||
# Run smoke tests (in another terminal)
|
||||
npm run test:api:smoke
|
||||
```
|
||||
|
||||
### Docker Testing (Recommended)
|
||||
|
||||
Run the tests in the full Docker e2e environment:
|
||||
|
||||
```bash
|
||||
# Start the complete e2e environment
|
||||
npm run docker:e2e:up
|
||||
|
||||
# Run smoke tests in Docker
|
||||
npm run test:api:smoke:docker
|
||||
|
||||
# Or use the unified command
|
||||
npm run test:e2e:website # This runs all e2e tests including API smoke
|
||||
```
|
||||
|
||||
### CI/CD Integration
|
||||
|
||||
Add to your CI pipeline:
|
||||
|
||||
```yaml
|
||||
# GitHub Actions example
|
||||
- name: Start E2E Environment
|
||||
run: npm run docker:e2e:up
|
||||
|
||||
- name: Run API Smoke Tests
|
||||
run: npm run test:api:smoke:docker
|
||||
|
||||
- name: Upload Test Reports
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: api-smoke-reports
|
||||
path: |
|
||||
api-smoke-report.json
|
||||
api-smoke-report.md
|
||||
playwright-report/
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
The smoke tests cover:
|
||||
|
||||
### Race Endpoints
|
||||
- `/races/all` - Get all races
|
||||
- `/races/total-races` - Get total count
|
||||
- `/races/page-data` - Get paginated data
|
||||
- `/races/reference/penalty-types` - Reference data
|
||||
- `/races/{id}` - Race details (with invalid IDs)
|
||||
- `/races/{id}/results` - Race results
|
||||
- `/races/{id}/sof` - Strength of field
|
||||
- `/races/{id}/protests` - Protests
|
||||
- `/races/{id}/penalties` - Penalties
|
||||
|
||||
### League Endpoints
|
||||
- `/leagues/all` - All leagues
|
||||
- `/leagues/available` - Available leagues
|
||||
- `/leagues/{id}` - League details
|
||||
- `/leagues/{id}/standings` - Standings
|
||||
- `/leagues/{id}/schedule` - Schedule
|
||||
|
||||
### Team Endpoints
|
||||
- `/teams/all` - All teams
|
||||
- `/teams/{id}` - Team details
|
||||
- `/teams/{id}/members` - Team members
|
||||
|
||||
### Driver Endpoints
|
||||
- `/drivers/leaderboard` - Leaderboard
|
||||
- `/drivers/total-drivers` - Total count
|
||||
- `/drivers/{id}` - Driver details
|
||||
|
||||
### Media Endpoints
|
||||
- `/media/avatar/{id}` - Avatar retrieval
|
||||
- `/media/{id}` - Media retrieval
|
||||
|
||||
### Sponsor Endpoints
|
||||
- `/sponsors/pricing` - Sponsorship pricing
|
||||
- `/sponsors/dashboard` - Sponsor dashboard
|
||||
- `/sponsors/{id}` - Sponsor details
|
||||
|
||||
### Auth Endpoints
|
||||
- `/auth/login` - Login
|
||||
- `/auth/signup` - Signup
|
||||
- `/auth/session` - Session info
|
||||
|
||||
### Dashboard Endpoints
|
||||
- `/dashboard/overview` - Overview
|
||||
- `/dashboard/feed` - Activity feed
|
||||
|
||||
### Analytics Endpoints
|
||||
- `/analytics/metrics` - Metrics
|
||||
- `/analytics/dashboard` - Dashboard data
|
||||
|
||||
### Admin Endpoints
|
||||
- `/admin/users` - User management
|
||||
|
||||
### Protest Endpoints
|
||||
- `/protests/race/{id}` - Race protests
|
||||
|
||||
### Payment Endpoints
|
||||
- `/payments/wallet` - Wallet info
|
||||
|
||||
### Notification Endpoints
|
||||
- `/notifications/unread` - Unread notifications
|
||||
|
||||
### Feature Flags
|
||||
- `/features` - Feature flag configuration
|
||||
|
||||
## Reports
|
||||
|
||||
After running tests, three reports are generated:
|
||||
|
||||
1. **`api-smoke-report.json`** - Detailed JSON report with all test results
|
||||
2. **`api-smoke-report.md`** - Human-readable Markdown report
|
||||
3. **Playwright HTML report** - Interactive test report (in `playwright-report/`)
|
||||
|
||||
### Report Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-07T22:00:00Z",
|
||||
"summary": {
|
||||
"total": 50,
|
||||
"success": 45,
|
||||
"failed": 5,
|
||||
"presenterErrors": 3,
|
||||
"avgResponseTime": 45.2
|
||||
},
|
||||
"results": [...],
|
||||
"failures": [...]
|
||||
}
|
||||
```
|
||||
|
||||
## Detecting Presenter Errors
|
||||
|
||||
The test specifically looks for the "Presenter not presented" error pattern:
|
||||
|
||||
```typescript
|
||||
// Detects these patterns:
|
||||
- "Presenter not presented"
|
||||
- "presenter not presented"
|
||||
- Error messages containing these phrases
|
||||
```
|
||||
|
||||
When found, these are flagged as **presenter errors** and require immediate attention.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### API Not Ready
|
||||
If tests fail because API isn't ready:
|
||||
```bash
|
||||
# Check API health
|
||||
curl http://localhost:3101/health
|
||||
|
||||
# Wait longer in test setup (increase timeout in test file)
|
||||
```
|
||||
|
||||
### Port Conflicts
|
||||
```bash
|
||||
# Stop conflicting services
|
||||
npm run docker:e2e:down
|
||||
|
||||
# Check what's running
|
||||
docker-compose -f docker-compose.e2e.yml ps
|
||||
```
|
||||
|
||||
### Missing Data
|
||||
The tests expect seeded data. If you see 404s:
|
||||
```bash
|
||||
# Ensure bootstrap is enabled
|
||||
export GRIDPILOT_API_BOOTSTRAP=1
|
||||
|
||||
# Restart services
|
||||
npm run docker:e2e:clean && npm run docker:e2e:up
|
||||
```
|
||||
|
||||
## Integration with Existing Tests
|
||||
|
||||
This smoke test complements the existing test suite:
|
||||
|
||||
- **Unit tests** (`apps/api/src/**/*Service.test.ts`) - Test individual services
|
||||
- **Integration tests** (`tests/integration/`) - Test component interactions
|
||||
- **E2E website tests** (`tests/e2e/website/`) - Test website functionality
|
||||
- **API smoke tests** (this) - Test API endpoints directly
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Run before deployments** - Catch presenter errors before they reach production
|
||||
2. **Run in CI/CD** - Automated regression testing
|
||||
3. **Review reports** - Always check the generated reports
|
||||
4. **Fix presenter errors immediately** - They indicate missing `.present()` calls
|
||||
5. **Keep tests updated** - Add new endpoints as they're created
|
||||
|
||||
## Performance
|
||||
|
||||
- Typical runtime: 30-60 seconds
|
||||
- Parallel execution: Playwright runs tests in parallel by default
|
||||
- Response time tracking: All requests are timed
|
||||
- Average response time tracked in reports
|
||||
|
||||
## Maintenance
|
||||
|
||||
When adding new endpoints:
|
||||
1. Add them to the test arrays in `api-smoke.test.ts`
|
||||
2. Test locally first: `npm run test:api:smoke`
|
||||
3. Verify reports show expected results
|
||||
4. Commit updated test file
|
||||
|
||||
When fixing presenter errors:
|
||||
1. Run smoke test to identify failing endpoints
|
||||
2. Check the specific error messages
|
||||
3. Fix the use case to call `this.output.present()` before returning
|
||||
4. Re-run smoke test to verify fix
|
||||
@@ -1,122 +0,0 @@
|
||||
/**
|
||||
* API Authentication Setup for E2E Tests
|
||||
*
|
||||
* This setup creates authentication sessions for both regular and admin users
|
||||
* that are persisted across all tests in the suite.
|
||||
*/
|
||||
|
||||
import { test as setup } from '@playwright/test';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL ?? process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3101';
|
||||
|
||||
// Define auth file paths
|
||||
const USER_AUTH_FILE = path.join(__dirname, '.auth/user-session.json');
|
||||
const ADMIN_AUTH_FILE = path.join(__dirname, '.auth/admin-session.json');
|
||||
|
||||
setup('Authenticate regular user', async ({ request }) => {
|
||||
console.log(`[AUTH SETUP] Creating regular user session at: ${API_BASE_URL}`);
|
||||
|
||||
// Wait for API to be ready
|
||||
const maxAttempts = 30;
|
||||
let apiReady = false;
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
const response = await request.get(`${API_BASE_URL}/health`);
|
||||
if (response.ok()) {
|
||||
apiReady = true;
|
||||
console.log(`[AUTH SETUP] API is ready after ${i + 1} attempts`);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue trying
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
if (!apiReady) {
|
||||
throw new Error('API failed to become ready');
|
||||
}
|
||||
|
||||
// Create test user and establish cookie-based session
|
||||
const testEmail = `smoke-test-${Date.now()}@example.com`;
|
||||
const testPassword = 'Password123';
|
||||
|
||||
// Signup
|
||||
const signupResponse = await request.post(`${API_BASE_URL}/auth/signup`, {
|
||||
data: {
|
||||
email: testEmail,
|
||||
password: testPassword,
|
||||
displayName: 'Smoke Tester',
|
||||
username: `smokeuser${Date.now()}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!signupResponse.ok()) {
|
||||
throw new Error(`Signup failed: ${signupResponse.status()}`);
|
||||
}
|
||||
|
||||
const signupData = await signupResponse.json();
|
||||
const testUserId = signupData?.user?.userId ?? null;
|
||||
console.log('[AUTH SETUP] Test user created:', testUserId);
|
||||
|
||||
// Login to establish cookie session
|
||||
const loginResponse = await request.post(`${API_BASE_URL}/auth/login`, {
|
||||
data: {
|
||||
email: testEmail,
|
||||
password: testPassword
|
||||
}
|
||||
});
|
||||
|
||||
if (!loginResponse.ok()) {
|
||||
throw new Error(`Login failed: ${loginResponse.status()}`);
|
||||
}
|
||||
|
||||
console.log('[AUTH SETUP] Regular user session established');
|
||||
|
||||
// Get cookies and save to auth file
|
||||
const context = request.context();
|
||||
const cookies = context.cookies();
|
||||
|
||||
// Ensure auth directory exists
|
||||
await fs.mkdir(path.dirname(USER_AUTH_FILE), { recursive: true });
|
||||
|
||||
// Save cookies to file
|
||||
await fs.writeFile(USER_AUTH_FILE, JSON.stringify({ cookies }, null, 2));
|
||||
console.log(`[AUTH SETUP] Saved user session to: ${USER_AUTH_FILE}`);
|
||||
});
|
||||
|
||||
setup('Authenticate admin user', async ({ request }) => {
|
||||
console.log(`[AUTH SETUP] Creating admin user session at: ${API_BASE_URL}`);
|
||||
|
||||
// Use seeded admin credentials
|
||||
const adminEmail = 'demo.admin@example.com';
|
||||
const adminPassword = 'Demo1234!';
|
||||
|
||||
// Login as admin
|
||||
const loginResponse = await request.post(`${API_BASE_URL}/auth/login`, {
|
||||
data: {
|
||||
email: adminEmail,
|
||||
password: adminPassword
|
||||
}
|
||||
});
|
||||
|
||||
if (!loginResponse.ok()) {
|
||||
throw new Error(`Admin login failed: ${loginResponse.status()}`);
|
||||
}
|
||||
|
||||
console.log('[AUTH SETUP] Admin user session established');
|
||||
|
||||
// Get cookies and save to auth file
|
||||
const context = request.context();
|
||||
const cookies = context.cookies();
|
||||
|
||||
// Ensure auth directory exists
|
||||
await fs.mkdir(path.dirname(ADMIN_AUTH_FILE), { recursive: true });
|
||||
|
||||
// Save cookies to file
|
||||
await fs.writeFile(ADMIN_AUTH_FILE, JSON.stringify({ cookies }, null, 2));
|
||||
console.log(`[AUTH SETUP] Saved admin session to: ${ADMIN_AUTH_FILE}`);
|
||||
});
|
||||
@@ -1,412 +0,0 @@
|
||||
/**
|
||||
* API Smoke Test
|
||||
*
|
||||
* This test performs true e2e testing of all API endpoints by making direct HTTP requests
|
||||
* to the running API server. It tests for:
|
||||
* - Basic connectivity and response codes
|
||||
* - Presenter errors ("Presenter not presented")
|
||||
* - Response format validation
|
||||
* - Error handling
|
||||
*
|
||||
* This test is designed to run in the Docker e2e environment and can be executed with:
|
||||
* npm run test:e2e:website (which runs everything in Docker)
|
||||
*/
|
||||
|
||||
import { test, expect, request } from '@playwright/test';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
interface EndpointTestResult {
|
||||
endpoint: string;
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
status: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
response?: unknown;
|
||||
hasPresenterError: boolean;
|
||||
responseTime: number;
|
||||
}
|
||||
|
||||
interface TestReport {
|
||||
timestamp: string;
|
||||
summary: {
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
presenterErrors: number;
|
||||
avgResponseTime: number;
|
||||
};
|
||||
results: EndpointTestResult[];
|
||||
failures: EndpointTestResult[];
|
||||
}
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL ?? process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3101';
|
||||
|
||||
// Auth file paths
|
||||
const USER_AUTH_FILE = path.join(__dirname, '.auth/user-session.json');
|
||||
const ADMIN_AUTH_FILE = path.join(__dirname, '.auth/admin-session.json');
|
||||
|
||||
test.describe('API Smoke Tests', () => {
|
||||
// Aggregate across the whole suite (used for final report).
|
||||
const allResults: EndpointTestResult[] = [];
|
||||
|
||||
let testResults: EndpointTestResult[] = [];
|
||||
|
||||
test.beforeAll(async () => {
|
||||
console.log(`[API SMOKE] Testing API at: ${API_BASE_URL}`);
|
||||
|
||||
// Verify auth files exist
|
||||
const userAuthExists = await fs.access(USER_AUTH_FILE).then(() => true).catch(() => false);
|
||||
const adminAuthExists = await fs.access(ADMIN_AUTH_FILE).then(() => true).catch(() => false);
|
||||
|
||||
if (!userAuthExists || !adminAuthExists) {
|
||||
throw new Error('Auth files not found. Run global setup first.');
|
||||
}
|
||||
|
||||
console.log('[API SMOKE] Auth files verified');
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await generateReport();
|
||||
});
|
||||
|
||||
test('all public GET endpoints respond correctly', async ({ request }) => {
|
||||
testResults = [];
|
||||
const endpoints = [
|
||||
// Race endpoints
|
||||
{ method: 'GET' as const, path: '/races/all', name: 'Get all races' },
|
||||
{ method: 'GET' as const, path: '/races/total-races', name: 'Get total races count' },
|
||||
{ method: 'GET' as const, path: '/races/page-data', name: 'Get races page data' },
|
||||
{ method: 'GET' as const, path: '/races/all/page-data', name: 'Get all races page data' },
|
||||
{ method: 'GET' as const, path: '/races/reference/penalty-types', name: 'Get penalty types reference' },
|
||||
|
||||
// League endpoints
|
||||
{ method: 'GET' as const, path: '/leagues/all-with-capacity', name: 'Get all leagues' },
|
||||
{ method: 'GET' as const, path: '/leagues/available', name: 'Get available leagues' },
|
||||
|
||||
// Team endpoints
|
||||
{ method: 'GET' as const, path: '/teams/all', name: 'Get all teams' },
|
||||
|
||||
// Driver endpoints
|
||||
{ method: 'GET' as const, path: '/drivers/leaderboard', name: 'Get driver leaderboard' },
|
||||
{ method: 'GET' as const, path: '/drivers/total-drivers', name: 'Get total drivers count' },
|
||||
|
||||
// Sponsor endpoints
|
||||
{ method: 'GET' as const, path: '/sponsors/pricing', name: 'Get sponsorship pricing' },
|
||||
|
||||
// Features endpoint
|
||||
{ method: 'GET' as const, path: '/features', name: 'Get feature flags' },
|
||||
|
||||
// Hello endpoint
|
||||
{ method: 'GET' as const, path: '/hello', name: 'Hello World' },
|
||||
|
||||
// Media endpoints
|
||||
{ method: 'GET' as const, path: '/media/avatar/non-existent-id', name: 'Get non-existent avatar' },
|
||||
|
||||
// Driver by ID
|
||||
{ method: 'GET' as const, path: '/drivers/non-existent-id', name: 'Get non-existent driver' },
|
||||
];
|
||||
|
||||
console.log(`\n[API SMOKE] Testing ${endpoints.length} public GET endpoints...`);
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
await testEndpoint(request, endpoint);
|
||||
}
|
||||
|
||||
// Check for failures
|
||||
const failures = testResults.filter(r => !r.success);
|
||||
if (failures.length > 0) {
|
||||
console.log('\n❌ FAILURES FOUND:');
|
||||
failures.forEach(r => {
|
||||
console.log(` ${r.method} ${r.endpoint} - ${r.status} - ${r.error || r.response}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Assert all endpoints succeeded
|
||||
expect(failures.length).toBe(0);
|
||||
});
|
||||
|
||||
test('POST endpoints handle requests gracefully', async ({ request }) => {
|
||||
testResults = [];
|
||||
const endpoints = [
|
||||
// Auth endpoints (no auth required)
|
||||
{ method: 'POST' as const, path: '/auth/signup', name: 'Signup', requiresAuth: false, body: { email: `test-smoke-${Date.now()}@example.com`, password: 'Password123', displayName: 'Smoke Test', username: 'smoketest' } },
|
||||
{ method: 'POST' as const, path: '/auth/login', name: 'Login', requiresAuth: false, body: { email: 'demo.driver@example.com', password: 'Demo1234!' } },
|
||||
|
||||
// Protected endpoints (require auth)
|
||||
{ method: 'POST' as const, path: '/races/123/register', name: 'Register for race', requiresAuth: true, body: { driverId: 'test-driver' } },
|
||||
{ method: 'POST' as const, path: '/races/protests/file', name: 'File protest', requiresAuth: true, body: { raceId: '123', protestingDriverId: 'driver-1', accusedDriverId: 'driver-2', incident: { lap: 1, description: 'Test protest' } } },
|
||||
{ method: 'POST' as const, path: '/leagues/league-1/join', name: 'Join league', requiresAuth: true, body: {} },
|
||||
{ method: 'POST' as const, path: '/teams/123/join', name: 'Join team', requiresAuth: true, body: { teamId: '123' } },
|
||||
];
|
||||
|
||||
console.log(`\n[API SMOKE] Testing ${endpoints.length} POST endpoints...`);
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
await testEndpoint(request, endpoint);
|
||||
}
|
||||
|
||||
// Check for presenter errors
|
||||
const presenterErrors = testResults.filter(r => r.hasPresenterError);
|
||||
expect(presenterErrors.length).toBe(0);
|
||||
});
|
||||
|
||||
test('parameterized endpoints handle missing IDs gracefully', async ({ request }) => {
|
||||
testResults = [];
|
||||
const endpoints = [
|
||||
{ method: 'GET' as const, path: '/races/non-existent-id', name: 'Get non-existent race', requiresAuth: false },
|
||||
{ method: 'GET' as const, path: '/races/non-existent-id/results', name: 'Get non-existent race results', requiresAuth: false },
|
||||
{ method: 'GET' as const, path: '/leagues/non-existent-id', name: 'Get non-existent league', requiresAuth: false },
|
||||
{ method: 'GET' as const, path: '/teams/non-existent-id', name: 'Get non-existent team', requiresAuth: false },
|
||||
{ method: 'GET' as const, path: '/drivers/non-existent-id', name: 'Get non-existent driver', requiresAuth: false },
|
||||
{ method: 'GET' as const, path: '/media/avatar/non-existent-id', name: 'Get non-existent avatar', requiresAuth: false },
|
||||
];
|
||||
|
||||
console.log(`\n[API SMOKE] Testing ${endpoints.length} parameterized endpoints with invalid IDs...`);
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
await testEndpoint(request, endpoint);
|
||||
}
|
||||
|
||||
// Check for failures
|
||||
const failures = testResults.filter(r => !r.success);
|
||||
expect(failures.length).toBe(0);
|
||||
});
|
||||
|
||||
test('authenticated endpoints respond correctly', async () => {
|
||||
testResults = [];
|
||||
|
||||
// Load user auth cookies
|
||||
const userAuthData = await fs.readFile(USER_AUTH_FILE, 'utf-8');
|
||||
const userCookies = JSON.parse(userAuthData).cookies;
|
||||
|
||||
// Create new API request context with user auth
|
||||
const userContext = await request.newContext({
|
||||
storageState: {
|
||||
cookies: userCookies,
|
||||
origins: [{ origin: API_BASE_URL, localStorage: [] }]
|
||||
}
|
||||
});
|
||||
|
||||
const endpoints = [
|
||||
// Dashboard
|
||||
{ method: 'GET' as const, path: '/dashboard/overview', name: 'Dashboard Overview' },
|
||||
|
||||
// Analytics
|
||||
{ method: 'GET' as const, path: '/analytics/metrics', name: 'Analytics Metrics' },
|
||||
|
||||
// Notifications
|
||||
{ method: 'GET' as const, path: '/notifications/unread', name: 'Unread Notifications' },
|
||||
];
|
||||
|
||||
console.log(`\n[API SMOKE] Testing ${endpoints.length} authenticated endpoints...`);
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
await testEndpoint(userContext, endpoint);
|
||||
}
|
||||
|
||||
// Check for presenter errors
|
||||
const presenterErrors = testResults.filter(r => r.hasPresenterError);
|
||||
expect(presenterErrors.length).toBe(0);
|
||||
|
||||
// Clean up
|
||||
await userContext.dispose();
|
||||
});
|
||||
|
||||
test('admin endpoints respond correctly', async () => {
|
||||
testResults = [];
|
||||
|
||||
// Load admin auth cookies
|
||||
const adminAuthData = await fs.readFile(ADMIN_AUTH_FILE, 'utf-8');
|
||||
const adminCookies = JSON.parse(adminAuthData).cookies;
|
||||
|
||||
// Create new API request context with admin auth
|
||||
const adminContext = await request.newContext({
|
||||
storageState: {
|
||||
cookies: adminCookies,
|
||||
origins: [{ origin: API_BASE_URL, localStorage: [] }]
|
||||
}
|
||||
});
|
||||
|
||||
const endpoints = [
|
||||
// Payments (requires admin capability)
|
||||
{ method: 'GET' as const, path: '/payments/wallets?leagueId=league-1', name: 'Wallets' },
|
||||
];
|
||||
|
||||
console.log(`\n[API SMOKE] Testing ${endpoints.length} admin endpoints...`);
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
await testEndpoint(adminContext, endpoint);
|
||||
}
|
||||
|
||||
// Check for presenter errors
|
||||
const presenterErrors = testResults.filter(r => r.hasPresenterError);
|
||||
expect(presenterErrors.length).toBe(0);
|
||||
|
||||
// Clean up
|
||||
await adminContext.dispose();
|
||||
});
|
||||
|
||||
async function testEndpoint(
|
||||
request: import('@playwright/test').APIRequestContext,
|
||||
endpoint: { method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; path: string; name?: string; body?: unknown; requiresAuth?: boolean }
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
const fullUrl = `${API_BASE_URL}${endpoint.path}`;
|
||||
|
||||
console.log(`\n[TEST] ${endpoint.method} ${endpoint.path} (${endpoint.name || 'Unknown'})`);
|
||||
|
||||
try {
|
||||
let response;
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
// Playwright's request context handles cookies automatically
|
||||
// No need to set Authorization header for cookie-based auth
|
||||
|
||||
switch (endpoint.method) {
|
||||
case 'GET':
|
||||
response = await request.get(fullUrl, { headers });
|
||||
break;
|
||||
case 'POST':
|
||||
response = await request.post(fullUrl, { data: endpoint.body || {}, headers });
|
||||
break;
|
||||
case 'PUT':
|
||||
response = await request.put(fullUrl, { data: endpoint.body || {}, headers });
|
||||
break;
|
||||
case 'DELETE':
|
||||
response = await request.delete(fullUrl, { headers });
|
||||
break;
|
||||
case 'PATCH':
|
||||
response = await request.patch(fullUrl, { data: endpoint.body || {}, headers });
|
||||
break;
|
||||
}
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
const status = response.status();
|
||||
const body = await response.json().catch(() => null);
|
||||
const bodyText = await response.text().catch(() => '');
|
||||
|
||||
// Check for presenter errors
|
||||
const hasPresenterError =
|
||||
bodyText.includes('Presenter not presented') ||
|
||||
bodyText.includes('presenter not presented') ||
|
||||
(body && body.message && body.message.includes('Presenter not presented')) ||
|
||||
(body && body.error && body.error.includes('Presenter not presented'));
|
||||
|
||||
// Success is 200-299 status, or 404 for non-existent resources, and no presenter error
|
||||
const isNotFound = status === 404;
|
||||
const success = (status >= 200 && status < 300 || isNotFound) && !hasPresenterError;
|
||||
|
||||
const result: EndpointTestResult = {
|
||||
endpoint: endpoint.path,
|
||||
method: endpoint.method,
|
||||
status,
|
||||
success,
|
||||
hasPresenterError,
|
||||
responseTime,
|
||||
response: body || bodyText.substring(0, 200),
|
||||
};
|
||||
|
||||
if (!success) {
|
||||
result.error = body?.message || bodyText.substring(0, 200);
|
||||
}
|
||||
|
||||
testResults.push(result);
|
||||
allResults.push(result);
|
||||
|
||||
if (hasPresenterError) {
|
||||
console.log(` ❌ PRESENTER ERROR: ${status} - ${body?.message || bodyText.substring(0, 100)}`);
|
||||
} else if (success) {
|
||||
console.log(` ✅ ${status} (${responseTime}ms)`);
|
||||
} else {
|
||||
console.log(` ⚠️ ${status} (${responseTime}ms) - ${body?.message || 'Error'}`);
|
||||
}
|
||||
|
||||
} catch (error: unknown) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
const errorString = error instanceof Error ? error.message : String(error);
|
||||
|
||||
const result: EndpointTestResult = {
|
||||
endpoint: endpoint.path,
|
||||
method: endpoint.method,
|
||||
status: 0,
|
||||
success: false,
|
||||
hasPresenterError: false,
|
||||
responseTime,
|
||||
error: errorString,
|
||||
};
|
||||
|
||||
// Check if it's a presenter error
|
||||
if (errorString.includes('Presenter not presented')) {
|
||||
result.hasPresenterError = true;
|
||||
console.log(` ❌ PRESENTER ERROR (exception): ${errorString}`);
|
||||
} else {
|
||||
console.log(` ❌ EXCEPTION: ${errorString}`);
|
||||
}
|
||||
|
||||
testResults.push(result);
|
||||
allResults.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateReport(): Promise<void> {
|
||||
const summary = {
|
||||
total: allResults.length,
|
||||
success: allResults.filter(r => r.success).length,
|
||||
failed: allResults.filter(r => !r.success).length,
|
||||
presenterErrors: allResults.filter(r => r.hasPresenterError).length,
|
||||
avgResponseTime: allResults.reduce((sum, r) => sum + r.responseTime, 0) / allResults.length || 0,
|
||||
};
|
||||
|
||||
const report: TestReport = {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary,
|
||||
results: allResults,
|
||||
failures: allResults.filter(r => !r.success),
|
||||
};
|
||||
|
||||
// Write JSON report
|
||||
const jsonPath = path.join(__dirname, '../../../api-smoke-report.json');
|
||||
await fs.writeFile(jsonPath, JSON.stringify(report, null, 2));
|
||||
|
||||
// Write Markdown report
|
||||
const mdPath = path.join(__dirname, '../../../api-smoke-report.md');
|
||||
let md = `# API Smoke Test Report\n\n`;
|
||||
md += `**Generated:** ${new Date().toISOString()}\n`;
|
||||
md += `**API Base URL:** ${API_BASE_URL}\n\n`;
|
||||
|
||||
md += `## Summary\n\n`;
|
||||
md += `- **Total Endpoints:** ${summary.total}\n`;
|
||||
md += `- **✅ Success:** ${summary.success}\n`;
|
||||
md += `- **❌ Failed:** ${summary.failed}\n`;
|
||||
md += `- **⚠️ Presenter Errors:** ${summary.presenterErrors}\n`;
|
||||
md += `- **Avg Response Time:** ${summary.avgResponseTime.toFixed(2)}ms\n\n`;
|
||||
|
||||
if (summary.presenterErrors > 0) {
|
||||
md += `## Presenter Errors\n\n`;
|
||||
const presenterFailures = allResults.filter(r => r.hasPresenterError);
|
||||
presenterFailures.forEach((r, i) => {
|
||||
md += `${i + 1}. **${r.method} ${r.endpoint}**\n`;
|
||||
md += ` - Status: ${r.status}\n`;
|
||||
md += ` - Error: ${r.error || 'No error message'}\n\n`;
|
||||
});
|
||||
}
|
||||
|
||||
if (summary.failed > 0 && summary.presenterErrors < summary.failed) {
|
||||
md += `## Other Failures\n\n`;
|
||||
const otherFailures = allResults.filter(r => !r.success && !r.hasPresenterError);
|
||||
otherFailures.forEach((r, i) => {
|
||||
md += `${i + 1}. **${r.method} ${r.endpoint}**\n`;
|
||||
md += ` - Status: ${r.status}\n`;
|
||||
md += ` - Error: ${r.error || 'No error message'}\n\n`;
|
||||
});
|
||||
}
|
||||
|
||||
await fs.writeFile(mdPath, md);
|
||||
|
||||
console.log(`\n📊 Reports generated:`);
|
||||
console.log(` JSON: ${jsonPath}`);
|
||||
console.log(` Markdown: ${mdPath}`);
|
||||
console.log(`\nSummary: ${summary.success}/${summary.total} passed, ${summary.presenterErrors} presenter errors`);
|
||||
}
|
||||
});
|
||||
@@ -1,782 +0,0 @@
|
||||
/**
|
||||
* League API Tests
|
||||
*
|
||||
* This test suite performs comprehensive API testing for league-related endpoints.
|
||||
* It validates:
|
||||
* - Response structure matches expected DTO
|
||||
* - Required fields are present
|
||||
* - Data types are correct
|
||||
* - Edge cases (empty results, missing data)
|
||||
* - Business logic (sorting, filtering, calculations)
|
||||
*
|
||||
* This test is designed to run in the Docker e2e environment and can be executed with:
|
||||
* npm run test:e2e:website (which runs everything in Docker)
|
||||
*/
|
||||
|
||||
import { test, expect, request } from '@playwright/test';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
interface TestResult {
|
||||
endpoint: string;
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
status: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
response?: unknown;
|
||||
hasPresenterError: boolean;
|
||||
responseTime: number;
|
||||
}
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL ?? process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3101';
|
||||
|
||||
// Auth file paths
|
||||
const USER_AUTH_FILE = path.join(__dirname, '.auth/user-session.json');
|
||||
const ADMIN_AUTH_FILE = path.join(__dirname, '.auth/admin-session.json');
|
||||
|
||||
test.describe('League API Tests', () => {
|
||||
const allResults: TestResult[] = [];
|
||||
let testResults: TestResult[] = [];
|
||||
|
||||
test.beforeAll(async () => {
|
||||
console.log(`[LEAGUE API] Testing API at: ${API_BASE_URL}`);
|
||||
|
||||
// Verify auth files exist
|
||||
const userAuthExists = await fs.access(USER_AUTH_FILE).then(() => true).catch(() => false);
|
||||
const adminAuthExists = await fs.access(ADMIN_AUTH_FILE).then(() => true).catch(() => false);
|
||||
|
||||
if (!userAuthExists || !adminAuthExists) {
|
||||
throw new Error('Auth files not found. Run global setup first.');
|
||||
}
|
||||
|
||||
console.log('[LEAGUE API] Auth files verified');
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await generateReport();
|
||||
});
|
||||
|
||||
test('League Discovery Endpoints - Public endpoints', async ({ request }) => {
|
||||
testResults = [];
|
||||
const endpoints = [
|
||||
{ method: 'GET' as const, path: '/leagues/all-with-capacity', name: 'Get all leagues with capacity' },
|
||||
{ method: 'GET' as const, path: '/leagues/all-with-capacity-and-scoring', name: 'Get all leagues with capacity and scoring' },
|
||||
{ method: 'GET' as const, path: '/leagues/total-leagues', name: 'Get total leagues count' },
|
||||
{ method: 'GET' as const, path: '/leagues/all', name: 'Get all leagues (alias)' },
|
||||
{ method: 'GET' as const, path: '/leagues/available', name: 'Get available leagues (alias)' },
|
||||
];
|
||||
|
||||
console.log(`\n[LEAGUE API] Testing ${endpoints.length} league discovery endpoints...`);
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
await testEndpoint(request, endpoint);
|
||||
}
|
||||
|
||||
// Check for failures
|
||||
const failures = testResults.filter(r => !r.success);
|
||||
if (failures.length > 0) {
|
||||
console.log('\n❌ FAILURES FOUND:');
|
||||
failures.forEach(r => {
|
||||
console.log(` ${r.method} ${r.endpoint} - ${r.status} - ${r.error || r.response}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Assert all endpoints succeeded
|
||||
expect(failures.length).toBe(0);
|
||||
});
|
||||
|
||||
test('League Discovery - Response structure validation', async ({ request }) => {
|
||||
testResults = [];
|
||||
|
||||
// Test /leagues/all-with-capacity
|
||||
const allLeaguesResponse = await request.get(`${API_BASE_URL}/leagues/all-with-capacity`);
|
||||
expect(allLeaguesResponse.ok()).toBe(true);
|
||||
|
||||
const allLeaguesData = await allLeaguesResponse.json();
|
||||
expect(allLeaguesData).toHaveProperty('leagues');
|
||||
expect(allLeaguesData).toHaveProperty('totalCount');
|
||||
expect(Array.isArray(allLeaguesData.leagues)).toBe(true);
|
||||
expect(typeof allLeaguesData.totalCount).toBe('number');
|
||||
|
||||
// Validate league structure if leagues exist
|
||||
if (allLeaguesData.leagues.length > 0) {
|
||||
const league = allLeaguesData.leagues[0];
|
||||
expect(league).toHaveProperty('id');
|
||||
expect(league).toHaveProperty('name');
|
||||
expect(league).toHaveProperty('description');
|
||||
expect(league).toHaveProperty('ownerId');
|
||||
expect(league).toHaveProperty('createdAt');
|
||||
expect(league).toHaveProperty('settings');
|
||||
expect(league.settings).toHaveProperty('maxDrivers');
|
||||
expect(league).toHaveProperty('usedSlots');
|
||||
|
||||
// Validate data types
|
||||
expect(typeof league.id).toBe('string');
|
||||
expect(typeof league.name).toBe('string');
|
||||
expect(typeof league.description).toBe('string');
|
||||
expect(typeof league.ownerId).toBe('string');
|
||||
expect(typeof league.createdAt).toBe('string');
|
||||
expect(typeof league.settings.maxDrivers).toBe('number');
|
||||
expect(typeof league.usedSlots).toBe('number');
|
||||
|
||||
// Validate business logic: usedSlots <= maxDrivers
|
||||
expect(league.usedSlots).toBeLessThanOrEqual(league.settings.maxDrivers);
|
||||
}
|
||||
|
||||
// Test /leagues/all-with-capacity-and-scoring
|
||||
const scoredLeaguesResponse = await request.get(`${API_BASE_URL}/leagues/all-with-capacity-and-scoring`);
|
||||
expect(scoredLeaguesResponse.ok()).toBe(true);
|
||||
|
||||
const scoredLeaguesData = await scoredLeaguesResponse.json();
|
||||
expect(scoredLeaguesData).toHaveProperty('leagues');
|
||||
expect(scoredLeaguesData).toHaveProperty('totalCount');
|
||||
expect(Array.isArray(scoredLeaguesData.leagues)).toBe(true);
|
||||
|
||||
// Validate scoring structure if leagues exist
|
||||
if (scoredLeaguesData.leagues.length > 0) {
|
||||
const league = scoredLeaguesData.leagues[0];
|
||||
expect(league).toHaveProperty('scoring');
|
||||
expect(league.scoring).toHaveProperty('gameId');
|
||||
expect(league.scoring).toHaveProperty('scoringPresetId');
|
||||
|
||||
// Validate data types
|
||||
expect(typeof league.scoring.gameId).toBe('string');
|
||||
expect(typeof league.scoring.scoringPresetId).toBe('string');
|
||||
}
|
||||
|
||||
// Test /leagues/total-leagues
|
||||
const totalResponse = await request.get(`${API_BASE_URL}/leagues/total-leagues`);
|
||||
expect(totalResponse.ok()).toBe(true);
|
||||
|
||||
const totalData = await totalResponse.json();
|
||||
expect(totalData).toHaveProperty('totalLeagues');
|
||||
expect(typeof totalData.totalLeagues).toBe('number');
|
||||
expect(totalData.totalLeagues).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// Validate consistency: totalCount from all-with-capacity should match totalLeagues
|
||||
expect(allLeaguesData.totalCount).toBe(totalData.totalLeagues);
|
||||
|
||||
testResults.push({
|
||||
endpoint: '/leagues/all-with-capacity',
|
||||
method: 'GET',
|
||||
status: allLeaguesResponse.status(),
|
||||
success: true,
|
||||
hasPresenterError: false,
|
||||
responseTime: 0,
|
||||
});
|
||||
|
||||
testResults.push({
|
||||
endpoint: '/leagues/all-with-capacity-and-scoring',
|
||||
method: 'GET',
|
||||
status: scoredLeaguesResponse.status(),
|
||||
success: true,
|
||||
hasPresenterError: false,
|
||||
responseTime: 0,
|
||||
});
|
||||
|
||||
testResults.push({
|
||||
endpoint: '/leagues/total-leagues',
|
||||
method: 'GET',
|
||||
status: totalResponse.status(),
|
||||
success: true,
|
||||
hasPresenterError: false,
|
||||
responseTime: 0,
|
||||
});
|
||||
|
||||
allResults.push(...testResults);
|
||||
});
|
||||
|
||||
test('League Detail Endpoints - Public endpoints', async ({ request }) => {
|
||||
testResults = [];
|
||||
|
||||
// First, get a valid league ID from the discovery endpoint
|
||||
const discoveryResponse = await request.get(`${API_BASE_URL}/leagues/all-with-capacity`);
|
||||
const discoveryData = await discoveryResponse.json();
|
||||
|
||||
if (discoveryData.leagues.length === 0) {
|
||||
console.log('[LEAGUE API] No leagues found, skipping detail endpoint tests');
|
||||
return;
|
||||
}
|
||||
|
||||
const leagueId = discoveryData.leagues[0].id;
|
||||
|
||||
const endpoints = [
|
||||
{ method: 'GET' as const, path: `/leagues/${leagueId}`, name: 'Get league details' },
|
||||
{ method: 'GET' as const, path: `/leagues/${leagueId}/seasons`, name: 'Get league seasons' },
|
||||
{ method: 'GET' as const, path: `/leagues/${leagueId}/stats`, name: 'Get league stats' },
|
||||
{ method: 'GET' as const, path: `/leagues/${leagueId}/memberships`, name: 'Get league memberships' },
|
||||
];
|
||||
|
||||
console.log(`\n[LEAGUE API] Testing ${endpoints.length} league detail endpoints for league ${leagueId}...`);
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
await testEndpoint(request, endpoint);
|
||||
}
|
||||
|
||||
// Check for failures
|
||||
const failures = testResults.filter(r => !r.success);
|
||||
if (failures.length > 0) {
|
||||
console.log('\n❌ FAILURES FOUND:');
|
||||
failures.forEach(r => {
|
||||
console.log(` ${r.method} ${r.endpoint} - ${r.status} - ${r.error || r.response}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Assert all endpoints succeeded
|
||||
expect(failures.length).toBe(0);
|
||||
});
|
||||
|
||||
test('League Detail - Response structure validation', async ({ request }) => {
|
||||
testResults = [];
|
||||
|
||||
// First, get a valid league ID from the discovery endpoint
|
||||
const discoveryResponse = await request.get(`${API_BASE_URL}/leagues/all-with-capacity`);
|
||||
const discoveryData = await discoveryResponse.json();
|
||||
|
||||
if (discoveryData.leagues.length === 0) {
|
||||
console.log('[LEAGUE API] No leagues found, skipping detail validation tests');
|
||||
return;
|
||||
}
|
||||
|
||||
const leagueId = discoveryData.leagues[0].id;
|
||||
|
||||
// Test /leagues/{id}
|
||||
const leagueResponse = await request.get(`${API_BASE_URL}/leagues/${leagueId}`);
|
||||
expect(leagueResponse.ok()).toBe(true);
|
||||
|
||||
const leagueData = await leagueResponse.json();
|
||||
expect(leagueData).toHaveProperty('id');
|
||||
expect(leagueData).toHaveProperty('name');
|
||||
expect(leagueData).toHaveProperty('description');
|
||||
expect(leagueData).toHaveProperty('ownerId');
|
||||
expect(leagueData).toHaveProperty('createdAt');
|
||||
|
||||
// Validate data types
|
||||
expect(typeof leagueData.id).toBe('string');
|
||||
expect(typeof leagueData.name).toBe('string');
|
||||
expect(typeof leagueData.description).toBe('string');
|
||||
expect(typeof leagueData.ownerId).toBe('string');
|
||||
expect(typeof leagueData.createdAt).toBe('string');
|
||||
|
||||
// Validate ID matches requested ID
|
||||
expect(leagueData.id).toBe(leagueId);
|
||||
|
||||
// Test /leagues/{id}/seasons
|
||||
const seasonsResponse = await request.get(`${API_BASE_URL}/leagues/${leagueId}/seasons`);
|
||||
expect(seasonsResponse.ok()).toBe(true);
|
||||
|
||||
const seasonsData = await seasonsResponse.json();
|
||||
expect(Array.isArray(seasonsData)).toBe(true);
|
||||
|
||||
// Validate season structure if seasons exist
|
||||
if (seasonsData.length > 0) {
|
||||
const season = seasonsData[0];
|
||||
expect(season).toHaveProperty('id');
|
||||
expect(season).toHaveProperty('name');
|
||||
expect(season).toHaveProperty('status');
|
||||
|
||||
// Validate data types
|
||||
expect(typeof season.id).toBe('string');
|
||||
expect(typeof season.name).toBe('string');
|
||||
expect(typeof season.status).toBe('string');
|
||||
}
|
||||
|
||||
// Test /leagues/{id}/stats
|
||||
const statsResponse = await request.get(`${API_BASE_URL}/leagues/${leagueId}/stats`);
|
||||
expect(statsResponse.ok()).toBe(true);
|
||||
|
||||
const statsData = await statsResponse.json();
|
||||
expect(statsData).toHaveProperty('memberCount');
|
||||
expect(statsData).toHaveProperty('raceCount');
|
||||
expect(statsData).toHaveProperty('avgSOF');
|
||||
|
||||
// Validate data types
|
||||
expect(typeof statsData.memberCount).toBe('number');
|
||||
expect(typeof statsData.raceCount).toBe('number');
|
||||
expect(typeof statsData.avgSOF).toBe('number');
|
||||
|
||||
// Validate business logic: counts should be non-negative
|
||||
expect(statsData.memberCount).toBeGreaterThanOrEqual(0);
|
||||
expect(statsData.raceCount).toBeGreaterThanOrEqual(0);
|
||||
expect(statsData.avgSOF).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// Test /leagues/{id}/memberships
|
||||
const membershipsResponse = await request.get(`${API_BASE_URL}/leagues/${leagueId}/memberships`);
|
||||
expect(membershipsResponse.ok()).toBe(true);
|
||||
|
||||
const membershipsData = await membershipsResponse.json();
|
||||
expect(membershipsData).toHaveProperty('members');
|
||||
expect(Array.isArray(membershipsData.members)).toBe(true);
|
||||
|
||||
// Validate membership structure if members exist
|
||||
if (membershipsData.members.length > 0) {
|
||||
const member = membershipsData.members[0];
|
||||
expect(member).toHaveProperty('driverId');
|
||||
expect(member).toHaveProperty('role');
|
||||
expect(member).toHaveProperty('joinedAt');
|
||||
|
||||
// Validate data types
|
||||
expect(typeof member.driverId).toBe('string');
|
||||
expect(typeof member.role).toBe('string');
|
||||
expect(typeof member.joinedAt).toBe('string');
|
||||
|
||||
// Validate business logic: at least one owner must exist
|
||||
const hasOwner = membershipsData.members.some((m: any) => m.role === 'owner');
|
||||
expect(hasOwner).toBe(true);
|
||||
}
|
||||
|
||||
testResults.push({
|
||||
endpoint: `/leagues/${leagueId}`,
|
||||
method: 'GET',
|
||||
status: leagueResponse.status(),
|
||||
success: true,
|
||||
hasPresenterError: false,
|
||||
responseTime: 0,
|
||||
});
|
||||
|
||||
testResults.push({
|
||||
endpoint: `/leagues/${leagueId}/seasons`,
|
||||
method: 'GET',
|
||||
status: seasonsResponse.status(),
|
||||
success: true,
|
||||
hasPresenterError: false,
|
||||
responseTime: 0,
|
||||
});
|
||||
|
||||
testResults.push({
|
||||
endpoint: `/leagues/${leagueId}/stats`,
|
||||
method: 'GET',
|
||||
status: statsResponse.status(),
|
||||
success: true,
|
||||
hasPresenterError: false,
|
||||
responseTime: 0,
|
||||
});
|
||||
|
||||
testResults.push({
|
||||
endpoint: `/leagues/${leagueId}/memberships`,
|
||||
method: 'GET',
|
||||
status: membershipsResponse.status(),
|
||||
success: true,
|
||||
hasPresenterError: false,
|
||||
responseTime: 0,
|
||||
});
|
||||
|
||||
allResults.push(...testResults);
|
||||
});
|
||||
|
||||
test('League Schedule Endpoints - Public endpoints', async ({ request }) => {
|
||||
testResults = [];
|
||||
|
||||
// First, get a valid league ID from the discovery endpoint
|
||||
const discoveryResponse = await request.get(`${API_BASE_URL}/leagues/all-with-capacity`);
|
||||
const discoveryData = await discoveryResponse.json();
|
||||
|
||||
if (discoveryData.leagues.length === 0) {
|
||||
console.log('[LEAGUE API] No leagues found, skipping schedule endpoint tests');
|
||||
return;
|
||||
}
|
||||
|
||||
const leagueId = discoveryData.leagues[0].id;
|
||||
|
||||
const endpoints = [
|
||||
{ method: 'GET' as const, path: `/leagues/${leagueId}/schedule`, name: 'Get league schedule' },
|
||||
];
|
||||
|
||||
console.log(`\n[LEAGUE API] Testing ${endpoints.length} league schedule endpoints for league ${leagueId}...`);
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
await testEndpoint(request, endpoint);
|
||||
}
|
||||
|
||||
// Check for failures
|
||||
const failures = testResults.filter(r => !r.success);
|
||||
if (failures.length > 0) {
|
||||
console.log('\n❌ FAILURES FOUND:');
|
||||
failures.forEach(r => {
|
||||
console.log(` ${r.method} ${r.endpoint} - ${r.status} - ${r.error || r.response}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Assert all endpoints succeeded
|
||||
expect(failures.length).toBe(0);
|
||||
});
|
||||
|
||||
test('League Schedule - Response structure validation', async ({ request }) => {
|
||||
testResults = [];
|
||||
|
||||
// First, get a valid league ID from the discovery endpoint
|
||||
const discoveryResponse = await request.get(`${API_BASE_URL}/leagues/all-with-capacity`);
|
||||
const discoveryData = await discoveryResponse.json();
|
||||
|
||||
if (discoveryData.leagues.length === 0) {
|
||||
console.log('[LEAGUE API] No leagues found, skipping schedule validation tests');
|
||||
return;
|
||||
}
|
||||
|
||||
const leagueId = discoveryData.leagues[0].id;
|
||||
|
||||
// Test /leagues/{id}/schedule
|
||||
const scheduleResponse = await request.get(`${API_BASE_URL}/leagues/${leagueId}/schedule`);
|
||||
expect(scheduleResponse.ok()).toBe(true);
|
||||
|
||||
const scheduleData = await scheduleResponse.json();
|
||||
expect(scheduleData).toHaveProperty('seasonId');
|
||||
expect(scheduleData).toHaveProperty('races');
|
||||
expect(Array.isArray(scheduleData.races)).toBe(true);
|
||||
|
||||
// Validate data types
|
||||
expect(typeof scheduleData.seasonId).toBe('string');
|
||||
|
||||
// Validate race structure if races exist
|
||||
if (scheduleData.races.length > 0) {
|
||||
const race = scheduleData.races[0];
|
||||
expect(race).toHaveProperty('id');
|
||||
expect(race).toHaveProperty('track');
|
||||
expect(race).toHaveProperty('car');
|
||||
expect(race).toHaveProperty('scheduledAt');
|
||||
|
||||
// Validate data types
|
||||
expect(typeof race.id).toBe('string');
|
||||
expect(typeof race.track).toBe('string');
|
||||
expect(typeof race.car).toBe('string');
|
||||
expect(typeof race.scheduledAt).toBe('string');
|
||||
|
||||
// Validate business logic: races should be sorted by scheduledAt
|
||||
const scheduledTimes = scheduleData.races.map((r: any) => new Date(r.scheduledAt).getTime());
|
||||
const sortedTimes = [...scheduledTimes].sort((a, b) => a - b);
|
||||
expect(scheduledTimes).toEqual(sortedTimes);
|
||||
}
|
||||
|
||||
testResults.push({
|
||||
endpoint: `/leagues/${leagueId}/schedule`,
|
||||
method: 'GET',
|
||||
status: scheduleResponse.status(),
|
||||
success: true,
|
||||
hasPresenterError: false,
|
||||
responseTime: 0,
|
||||
});
|
||||
|
||||
allResults.push(...testResults);
|
||||
});
|
||||
|
||||
test('League Standings Endpoints - Public endpoints', async ({ request }) => {
|
||||
testResults = [];
|
||||
|
||||
// First, get a valid league ID from the discovery endpoint
|
||||
const discoveryResponse = await request.get(`${API_BASE_URL}/leagues/all-with-capacity`);
|
||||
const discoveryData = await discoveryResponse.json();
|
||||
|
||||
if (discoveryData.leagues.length === 0) {
|
||||
console.log('[LEAGUE API] No leagues found, skipping standings endpoint tests');
|
||||
return;
|
||||
}
|
||||
|
||||
const leagueId = discoveryData.leagues[0].id;
|
||||
|
||||
const endpoints = [
|
||||
{ method: 'GET' as const, path: `/leagues/${leagueId}/standings`, name: 'Get league standings' },
|
||||
];
|
||||
|
||||
console.log(`\n[LEAGUE API] Testing ${endpoints.length} league standings endpoints for league ${leagueId}...`);
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
await testEndpoint(request, endpoint);
|
||||
}
|
||||
|
||||
// Check for failures
|
||||
const failures = testResults.filter(r => !r.success);
|
||||
if (failures.length > 0) {
|
||||
console.log('\n❌ FAILURES FOUND:');
|
||||
failures.forEach(r => {
|
||||
console.log(` ${r.method} ${r.endpoint} - ${r.status} - ${r.error || r.response}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Assert all endpoints succeeded
|
||||
expect(failures.length).toBe(0);
|
||||
});
|
||||
|
||||
test('League Standings - Response structure validation', async ({ request }) => {
|
||||
testResults = [];
|
||||
|
||||
// First, get a valid league ID from the discovery endpoint
|
||||
const discoveryResponse = await request.get(`${API_BASE_URL}/leagues/all-with-capacity`);
|
||||
const discoveryData = await discoveryResponse.json();
|
||||
|
||||
if (discoveryData.leagues.length === 0) {
|
||||
console.log('[LEAGUE API] No leagues found, skipping standings validation tests');
|
||||
return;
|
||||
}
|
||||
|
||||
const leagueId = discoveryData.leagues[0].id;
|
||||
|
||||
// Test /leagues/{id}/standings
|
||||
const standingsResponse = await request.get(`${API_BASE_URL}/leagues/${leagueId}/standings`);
|
||||
expect(standingsResponse.ok()).toBe(true);
|
||||
|
||||
const standingsData = await standingsResponse.json();
|
||||
expect(standingsData).toHaveProperty('standings');
|
||||
expect(Array.isArray(standingsData.standings)).toBe(true);
|
||||
|
||||
// Validate standing structure if standings exist
|
||||
if (standingsData.standings.length > 0) {
|
||||
const standing = standingsData.standings[0];
|
||||
expect(standing).toHaveProperty('position');
|
||||
expect(standing).toHaveProperty('driverId');
|
||||
expect(standing).toHaveProperty('points');
|
||||
expect(standing).toHaveProperty('races');
|
||||
|
||||
// Validate data types
|
||||
expect(typeof standing.position).toBe('number');
|
||||
expect(typeof standing.driverId).toBe('string');
|
||||
expect(typeof standing.points).toBe('number');
|
||||
expect(typeof standing.races).toBe('number');
|
||||
|
||||
// Validate business logic: position must be sequential starting from 1
|
||||
const positions = standingsData.standings.map((s: any) => s.position);
|
||||
const expectedPositions = Array.from({ length: positions.length }, (_, i) => i + 1);
|
||||
expect(positions).toEqual(expectedPositions);
|
||||
|
||||
// Validate business logic: points must be non-negative
|
||||
expect(standing.points).toBeGreaterThanOrEqual(0);
|
||||
|
||||
// Validate business logic: races count must be non-negative
|
||||
expect(standing.races).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
|
||||
testResults.push({
|
||||
endpoint: `/leagues/${leagueId}/standings`,
|
||||
method: 'GET',
|
||||
status: standingsResponse.status(),
|
||||
success: true,
|
||||
hasPresenterError: false,
|
||||
responseTime: 0,
|
||||
});
|
||||
|
||||
allResults.push(...testResults);
|
||||
});
|
||||
|
||||
test('Edge Cases - Invalid league IDs', async ({ request }) => {
|
||||
testResults = [];
|
||||
|
||||
const endpoints = [
|
||||
{ method: 'GET' as const, path: '/leagues/non-existent-league-id', name: 'Get non-existent league' },
|
||||
{ method: 'GET' as const, path: '/leagues/non-existent-league-id/seasons', name: 'Get seasons for non-existent league' },
|
||||
{ method: 'GET' as const, path: '/leagues/non-existent-league-id/stats', name: 'Get stats for non-existent league' },
|
||||
{ method: 'GET' as const, path: '/leagues/non-existent-league-id/schedule', name: 'Get schedule for non-existent league' },
|
||||
{ method: 'GET' as const, path: '/leagues/non-existent-league-id/standings', name: 'Get standings for non-existent league' },
|
||||
{ method: 'GET' as const, path: '/leagues/non-existent-league-id/memberships', name: 'Get memberships for non-existent league' },
|
||||
];
|
||||
|
||||
console.log(`\n[LEAGUE API] Testing ${endpoints.length} edge case endpoints with invalid IDs...`);
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
await testEndpoint(request, endpoint);
|
||||
}
|
||||
|
||||
// Check for failures
|
||||
const failures = testResults.filter(r => !r.success);
|
||||
if (failures.length > 0) {
|
||||
console.log('\n❌ FAILURES FOUND:');
|
||||
failures.forEach(r => {
|
||||
console.log(` ${r.method} ${r.endpoint} - ${r.status} - ${r.error || r.response}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Assert all endpoints succeeded (404 is acceptable for non-existent resources)
|
||||
expect(failures.length).toBe(0);
|
||||
});
|
||||
|
||||
test('Edge Cases - Empty results', async ({ request }) => {
|
||||
testResults = [];
|
||||
|
||||
// Test discovery endpoints with filters (if available)
|
||||
// Note: The current API doesn't seem to have filter parameters, but we test the base endpoints
|
||||
|
||||
const endpoints = [
|
||||
{ method: 'GET' as const, path: '/leagues/all-with-capacity', name: 'Get all leagues (empty check)' },
|
||||
{ method: 'GET' as const, path: '/leagues/all-with-capacity-and-scoring', name: 'Get all leagues with scoring (empty check)' },
|
||||
];
|
||||
|
||||
console.log(`\n[LEAGUE API] Testing ${endpoints.length} endpoints for empty result handling...`);
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
await testEndpoint(request, endpoint);
|
||||
}
|
||||
|
||||
// Check for failures
|
||||
const failures = testResults.filter(r => !r.success);
|
||||
if (failures.length > 0) {
|
||||
console.log('\n❌ FAILURES FOUND:');
|
||||
failures.forEach(r => {
|
||||
console.log(` ${r.method} ${r.endpoint} - ${r.status} - ${r.error || r.response}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Assert all endpoints succeeded
|
||||
expect(failures.length).toBe(0);
|
||||
});
|
||||
|
||||
async function testEndpoint(
|
||||
request: import('@playwright/test').APIRequestContext,
|
||||
endpoint: { method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; path: string; name?: string; body?: unknown; requiresAuth?: boolean }
|
||||
): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
const fullUrl = `${API_BASE_URL}${endpoint.path}`;
|
||||
|
||||
console.log(`\n[TEST] ${endpoint.method} ${endpoint.path} (${endpoint.name || 'Unknown'})`);
|
||||
|
||||
try {
|
||||
let response;
|
||||
const headers: Record<string, string> = {};
|
||||
|
||||
// Playwright's request context handles cookies automatically
|
||||
// No need to set Authorization header for cookie-based auth
|
||||
|
||||
switch (endpoint.method) {
|
||||
case 'GET':
|
||||
response = await request.get(fullUrl, { headers });
|
||||
break;
|
||||
case 'POST':
|
||||
response = await request.post(fullUrl, { data: endpoint.body || {}, headers });
|
||||
break;
|
||||
case 'PUT':
|
||||
response = await request.put(fullUrl, { data: endpoint.body || {}, headers });
|
||||
break;
|
||||
case 'DELETE':
|
||||
response = await request.delete(fullUrl, { headers });
|
||||
break;
|
||||
case 'PATCH':
|
||||
response = await request.patch(fullUrl, { data: endpoint.body || {}, headers });
|
||||
break;
|
||||
}
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
const status = response.status();
|
||||
const body = await response.json().catch(() => null);
|
||||
const bodyText = await response.text().catch(() => '');
|
||||
|
||||
// Check for presenter errors
|
||||
const hasPresenterError =
|
||||
bodyText.includes('Presenter not presented') ||
|
||||
bodyText.includes('presenter not presented') ||
|
||||
(body && body.message && body.message.includes('Presenter not presented')) ||
|
||||
(body && body.error && body.error.includes('Presenter not presented'));
|
||||
|
||||
// Success is 200-299 status, or 404 for non-existent resources, and no presenter error
|
||||
const isNotFound = status === 404;
|
||||
const success = (status >= 200 && status < 300 || isNotFound) && !hasPresenterError;
|
||||
|
||||
const result: TestResult = {
|
||||
endpoint: endpoint.path,
|
||||
method: endpoint.method,
|
||||
status,
|
||||
success,
|
||||
hasPresenterError,
|
||||
responseTime,
|
||||
response: body || bodyText.substring(0, 200),
|
||||
};
|
||||
|
||||
if (!success) {
|
||||
result.error = body?.message || bodyText.substring(0, 200);
|
||||
}
|
||||
|
||||
testResults.push(result);
|
||||
allResults.push(result);
|
||||
|
||||
if (hasPresenterError) {
|
||||
console.log(` ❌ PRESENTER ERROR: ${status} - ${body?.message || bodyText.substring(0, 100)}`);
|
||||
} else if (success) {
|
||||
console.log(` ✅ ${status} (${responseTime}ms)`);
|
||||
} else {
|
||||
console.log(` ⚠️ ${status} (${responseTime}ms) - ${body?.message || 'Error'}`);
|
||||
}
|
||||
|
||||
} catch (error: unknown) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
const errorString = error instanceof Error ? error.message : String(error);
|
||||
|
||||
const result: TestResult = {
|
||||
endpoint: endpoint.path,
|
||||
method: endpoint.method,
|
||||
status: 0,
|
||||
success: false,
|
||||
hasPresenterError: false,
|
||||
responseTime,
|
||||
error: errorString,
|
||||
};
|
||||
|
||||
// Check if it's a presenter error
|
||||
if (errorString.includes('Presenter not presented')) {
|
||||
result.hasPresenterError = true;
|
||||
console.log(` ❌ PRESENTER ERROR (exception): ${errorString}`);
|
||||
} else {
|
||||
console.log(` ❌ EXCEPTION: ${errorString}`);
|
||||
}
|
||||
|
||||
testResults.push(result);
|
||||
allResults.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateReport(): Promise<void> {
|
||||
const summary = {
|
||||
total: allResults.length,
|
||||
success: allResults.filter(r => r.success).length,
|
||||
failed: allResults.filter(r => !r.success).length,
|
||||
presenterErrors: allResults.filter(r => r.hasPresenterError).length,
|
||||
avgResponseTime: allResults.reduce((sum, r) => sum + r.responseTime, 0) / allResults.length || 0,
|
||||
};
|
||||
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary,
|
||||
results: allResults,
|
||||
failures: allResults.filter(r => !r.success),
|
||||
};
|
||||
|
||||
// Write JSON report
|
||||
const jsonPath = path.join(__dirname, '../../../league-api-test-report.json');
|
||||
await fs.writeFile(jsonPath, JSON.stringify(report, null, 2));
|
||||
|
||||
// Write Markdown report
|
||||
const mdPath = path.join(__dirname, '../../../league-api-test-report.md');
|
||||
let md = `# League API Test Report\n\n`;
|
||||
md += `**Generated:** ${new Date().toISOString()}\n`;
|
||||
md += `**API Base URL:** ${API_BASE_URL}\n\n`;
|
||||
|
||||
md += `## Summary\n\n`;
|
||||
md += `- **Total Endpoints:** ${summary.total}\n`;
|
||||
md += `- **✅ Success:** ${summary.success}\n`;
|
||||
md += `- **❌ Failed:** ${summary.failed}\n`;
|
||||
md += `- **⚠️ Presenter Errors:** ${summary.presenterErrors}\n`;
|
||||
md += `- **Avg Response Time:** ${summary.avgResponseTime.toFixed(2)}ms\n\n`;
|
||||
|
||||
if (summary.presenterErrors > 0) {
|
||||
md += `## Presenter Errors\n\n`;
|
||||
const presenterFailures = allResults.filter(r => r.hasPresenterError);
|
||||
presenterFailures.forEach((r, i) => {
|
||||
md += `${i + 1}. **${r.method} ${r.endpoint}**\n`;
|
||||
md += ` - Status: ${r.status}\n`;
|
||||
md += ` - Error: ${r.error || 'No error message'}\n\n`;
|
||||
});
|
||||
}
|
||||
|
||||
if (summary.failed > 0 && summary.presenterErrors < summary.failed) {
|
||||
md += `## Other Failures\n\n`;
|
||||
const otherFailures = allResults.filter(r => !r.success && !r.hasPresenterError);
|
||||
otherFailures.forEach((r, i) => {
|
||||
md += `${i + 1}. **${r.method} ${r.endpoint}**\n`;
|
||||
md += ` - Status: ${r.status}\n`;
|
||||
md += ` - Error: ${r.error || 'No error message'}\n\n`;
|
||||
});
|
||||
}
|
||||
|
||||
await fs.writeFile(mdPath, md);
|
||||
|
||||
console.log(`\n📊 Reports generated:`);
|
||||
console.log(` JSON: ${jsonPath}`);
|
||||
console.log(` Markdown: ${mdPath}`);
|
||||
console.log(`\nSummary: ${summary.success}/${summary.total} passed, ${summary.presenterErrors} presenter errors`);
|
||||
}
|
||||
});
|
||||
127
tests/e2e/rating/README.md
Normal file
127
tests/e2e/rating/README.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Rating BDD E2E Tests
|
||||
|
||||
This directory contains BDD (Behavior-Driven Development) E2E tests for the GridPilot Rating system.
|
||||
|
||||
## Overview
|
||||
|
||||
The GridPilot Rating system is a competition rating designed specifically for league racing. Unlike iRating (which is for matchmaking), GridPilot Rating measures:
|
||||
- **Results Strength**: How well you finish relative to field strength
|
||||
- **Consistency**: Stability of finishing positions over a season
|
||||
- **Clean Driving**: Incidents per race, weighted by severity
|
||||
- **Racecraft**: Positions gained/lost vs. incident involvement
|
||||
- **Reliability**: Attendance, DNS/DNF record
|
||||
- **Team Contribution**: Points earned for your team; lineup efficiency
|
||||
|
||||
## Test Files
|
||||
|
||||
### [`rating-profile.spec.ts`](rating-profile.spec.ts)
|
||||
Tests the driver profile rating display, including:
|
||||
- Current GridPilot Rating value
|
||||
- Rating breakdown by component (results, consistency, clean driving, etc.)
|
||||
- Rating trend over time (seasons)
|
||||
- Rating comparison with peers
|
||||
- Rating impact on team contribution
|
||||
|
||||
**Key Scenarios:**
|
||||
- Driver sees their current GridPilot Rating on profile
|
||||
- Driver sees rating breakdown by component
|
||||
- Driver sees rating trend over multiple seasons
|
||||
- Driver sees how rating compares to league peers
|
||||
- Driver sees rating impact on team contribution
|
||||
- Driver sees rating explanation/tooltip
|
||||
- Driver sees rating update after race completion
|
||||
|
||||
### [`rating-calculation.spec.ts`](rating-calculation.spec.ts)
|
||||
Tests the rating calculation logic and updates:
|
||||
- Rating calculation after race completion
|
||||
- Rating update based on finishing position
|
||||
- Rating update based on field strength
|
||||
- Rating update based on incidents
|
||||
- Rating update based on consistency
|
||||
- Rating update based on team contribution
|
||||
- Rating update based on season performance
|
||||
|
||||
**Key Scenarios:**
|
||||
- Rating increases after strong finish against strong field
|
||||
- Rating decreases after poor finish or incidents
|
||||
- Rating reflects consistency over multiple races
|
||||
- Rating accounts for team contribution
|
||||
- Rating updates immediately after results are processed
|
||||
- Rating calculation is transparent and understandable
|
||||
|
||||
### [`rating-leaderboard.spec.ts`](rating-leaderboard.spec.ts)
|
||||
Tests the rating-based leaderboards:
|
||||
- Global driver rankings by GridPilot Rating
|
||||
- League-specific driver rankings
|
||||
- Team rankings based on driver ratings
|
||||
- Rating-based filtering and sorting
|
||||
- Rating-based search functionality
|
||||
|
||||
**Key Scenarios:**
|
||||
- User sees drivers ranked by GridPilot Rating
|
||||
- User can filter drivers by rating range
|
||||
- User can search for drivers by rating
|
||||
- User can sort drivers by different rating components
|
||||
- User sees team rankings based on driver ratings
|
||||
- User sees rating-based leaderboards with accurate data
|
||||
|
||||
## Test Structure
|
||||
|
||||
Each test file follows this pattern:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('GridPilot Rating System', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// TODO: Implement authentication setup
|
||||
});
|
||||
|
||||
test('Driver sees their GridPilot Rating on profile', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Driver views their rating
|
||||
// Given I am a registered driver "John Doe"
|
||||
// And I have completed several races
|
||||
// And I am on my profile page
|
||||
// Then I should see my GridPilot Rating
|
||||
// And I should see the rating breakdown
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Test Philosophy
|
||||
|
||||
These tests follow the BDD E2E testing concept:
|
||||
|
||||
- **Focus on outcomes, not visual implementation**: Tests validate what the user sees and can verify, not how it's rendered
|
||||
- **Use Gherkin syntax**: Tests are written in Given/When/Then format
|
||||
- **Validate final user outcomes**: Tests serve as acceptance criteria for the rating functionality
|
||||
- **Use Playwright**: Tests are implemented using Playwright for browser automation
|
||||
|
||||
## TODO Implementation
|
||||
|
||||
All tests are currently placeholders with TODO comments. The actual test implementation should:
|
||||
|
||||
1. Set up authentication (login as a test driver)
|
||||
2. Navigate to the appropriate page
|
||||
3. Verify the expected outcomes using Playwright assertions
|
||||
4. Handle loading states, error states, and edge cases
|
||||
5. Use test data that matches the expected behavior
|
||||
|
||||
## Test Data
|
||||
|
||||
Tests should use realistic test data that matches the expected behavior:
|
||||
- Driver: "John Doe" or similar test driver with varying performance
|
||||
- Races: Completed races with different results (wins, podiums, DNFs)
|
||||
- Fields: Races with varying field strength (strong vs. weak fields)
|
||||
- Incidents: Races with different incident counts
|
||||
- Teams: Teams with multiple drivers contributing to team score
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Add test data factories/fixtures for consistent test data
|
||||
- Add helper functions for common actions (login, navigation, etc.)
|
||||
- Add visual regression tests for rating display
|
||||
- Add performance tests for rating calculation
|
||||
- Add accessibility tests for rating pages
|
||||
- Add cross-browser compatibility testing
|
||||
129
tests/e2e/rating/rating-calculation.spec.ts
Normal file
129
tests/e2e/rating/rating-calculation.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('GridPilot Rating - Calculation Logic', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// TODO: Implement authentication setup
|
||||
// - Login as test driver
|
||||
// - Ensure test data exists
|
||||
});
|
||||
|
||||
test('Rating increases after strong finish against strong field', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Driver finishes well against strong competition
|
||||
// Given I am a driver with baseline rating
|
||||
// And I complete a race against strong field
|
||||
// And I finish in top positions
|
||||
// When I view my rating after race
|
||||
// Then my rating should increase
|
||||
// And I should see the increase amount
|
||||
});
|
||||
|
||||
test('Rating decreases after poor finish or incidents', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Driver has poor race with incidents
|
||||
// Given I am a driver with baseline rating
|
||||
// And I complete a race with poor finish
|
||||
// And I have multiple incidents
|
||||
// When I view my rating after race
|
||||
// Then my rating should decrease
|
||||
// And I should see the decrease amount
|
||||
});
|
||||
|
||||
test('Rating reflects consistency over multiple races', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Driver shows consistent performance
|
||||
// Given I complete multiple races
|
||||
// And I finish in similar positions each race
|
||||
// When I view my rating
|
||||
// Then my consistency score should be high
|
||||
// And my rating should be stable
|
||||
});
|
||||
|
||||
test('Rating accounts for team contribution', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Driver contributes to team success
|
||||
// Given I am on a team
|
||||
// And I score points for my team
|
||||
// When I view my rating
|
||||
// Then my team contribution score should reflect this
|
||||
// And my overall rating should include team impact
|
||||
});
|
||||
|
||||
test('Rating updates immediately after results are processed', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Race results are processed
|
||||
// Given I just completed a race
|
||||
// And results are being processed
|
||||
// When results are available
|
||||
// Then my rating should update immediately
|
||||
// And I should see the update in real-time
|
||||
});
|
||||
|
||||
test('Rating calculation is transparent and understandable', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Driver wants to understand rating changes
|
||||
// Given I view my rating details
|
||||
// When I see a rating change
|
||||
// Then I should see explanation of what caused it
|
||||
// And I should see breakdown of calculation
|
||||
// And I should see tips for improvement
|
||||
});
|
||||
|
||||
test('Rating handles DNFs appropriately', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Driver has DNF
|
||||
// Given I complete a race
|
||||
// And I have a DNF (Did Not Finish)
|
||||
// When I view my rating
|
||||
// Then my rating should be affected
|
||||
// And my reliability score should decrease
|
||||
// And I should see explanation of DNF impact
|
||||
});
|
||||
|
||||
test('Rating handles DNS appropriately', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Driver has DNS
|
||||
// Given I have a DNS (Did Not Start)
|
||||
// When I view my rating
|
||||
// Then my rating should be affected
|
||||
// And my reliability score should decrease
|
||||
// And I should see explanation of DNS impact
|
||||
});
|
||||
|
||||
test('Rating handles small field sizes appropriately', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Driver races in small field
|
||||
// Given I complete a race with small field
|
||||
// When I view my rating
|
||||
// Then my rating should be normalized for field size
|
||||
// And I should see explanation of field size impact
|
||||
});
|
||||
|
||||
test('Rating handles large field sizes appropriately', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Driver races in large field
|
||||
// Given I complete a race with large field
|
||||
// When I view my rating
|
||||
// Then my rating should be normalized for field size
|
||||
// And I should see explanation of field size impact
|
||||
});
|
||||
|
||||
test('Rating handles clean races appropriately', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Driver has clean race
|
||||
// Given I complete a race with zero incidents
|
||||
// When I view my rating
|
||||
// Then my clean driving score should increase
|
||||
// And my rating should benefit from clean driving
|
||||
});
|
||||
|
||||
test('Rating handles penalty scenarios appropriately', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Driver receives penalty
|
||||
// Given I complete a race
|
||||
// And I receive a penalty
|
||||
// When I view my rating
|
||||
// Then my rating should be affected by penalty
|
||||
// And I should see explanation of penalty impact
|
||||
});
|
||||
});
|
||||
123
tests/e2e/rating/rating-leaderboard.spec.ts
Normal file
123
tests/e2e/rating/rating-leaderboard.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('GridPilot Rating - Leaderboards', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// TODO: Implement authentication setup
|
||||
// - Login as test user
|
||||
// - Ensure test data exists
|
||||
});
|
||||
|
||||
test('User sees drivers ranked by GridPilot Rating', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: User views rating-based leaderboard
|
||||
// Given I am on the leaderboards page
|
||||
// When I view the driver rankings
|
||||
// Then I should see drivers sorted by GridPilot Rating
|
||||
// And I should see rating values for each driver
|
||||
// And I should see ranking numbers
|
||||
});
|
||||
|
||||
test('User can filter drivers by rating range', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: User filters leaderboard by rating
|
||||
// Given I am on the driver leaderboards page
|
||||
// When I set a rating range filter
|
||||
// Then I should see only drivers within that range
|
||||
// And I should see filter summary
|
||||
});
|
||||
|
||||
test('User can search for drivers by rating', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: User searches for specific rating
|
||||
// Given I am on the driver leaderboards page
|
||||
// When I search for drivers with specific rating
|
||||
// Then I should see matching drivers
|
||||
// And I should see search results count
|
||||
});
|
||||
|
||||
test('User can sort drivers by different rating components', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: User sorts leaderboard by rating component
|
||||
// Given I am on the driver leaderboards page
|
||||
// When I sort by "Results Strength"
|
||||
// Then drivers should be sorted by results strength
|
||||
// When I sort by "Clean Driving"
|
||||
// Then drivers should be sorted by clean driving score
|
||||
// And I should see the sort indicator
|
||||
});
|
||||
|
||||
test('User sees team rankings based on driver ratings', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: User views team leaderboards
|
||||
// Given I am on the team leaderboards page
|
||||
// When I view team rankings
|
||||
// Then I should see teams ranked by combined driver ratings
|
||||
// And I should see team rating breakdown
|
||||
// And I should see driver contributions
|
||||
});
|
||||
|
||||
test('User sees rating-based leaderboards with accurate data', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: User verifies leaderboard accuracy
|
||||
// Given I am viewing a rating-based leaderboard
|
||||
// When I check the data
|
||||
// Then ratings should match driver profiles
|
||||
// And rankings should be correct
|
||||
// And calculations should be accurate
|
||||
});
|
||||
|
||||
test('User sees empty state when no rating data exists', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Leaderboard with no data
|
||||
// Given there are no drivers with ratings
|
||||
// When I view the leaderboards
|
||||
// Then I should see empty state
|
||||
// And I should see message about no data
|
||||
});
|
||||
|
||||
test('User sees loading state while leaderboards load', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Leaderboards load slowly
|
||||
// Given I navigate to leaderboards
|
||||
// When data is loading
|
||||
// Then I should see loading skeleton
|
||||
// And I should see loading indicators
|
||||
});
|
||||
|
||||
test('User sees error state when leaderboards fail to load', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Leaderboards fail to load
|
||||
// Given I navigate to leaderboards
|
||||
// When data fails to load
|
||||
// Then I should see error message
|
||||
// And I should see retry button
|
||||
});
|
||||
|
||||
test('User can navigate from leaderboard to driver profile', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: User clicks on driver in leaderboard
|
||||
// Given I am viewing a rating-based leaderboard
|
||||
// When I click on a driver entry
|
||||
// Then I should navigate to that driver's profile
|
||||
// And I should see their detailed rating
|
||||
});
|
||||
|
||||
test('User sees pagination for large leaderboards', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Leaderboard has many drivers
|
||||
// Given there are many drivers with ratings
|
||||
// When I view the leaderboards
|
||||
// Then I should see pagination controls
|
||||
// And I can navigate through pages
|
||||
// And I should see page count
|
||||
});
|
||||
|
||||
test('User sees rating percentile information', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: User wants to know relative standing
|
||||
// Given I am viewing a driver in leaderboard
|
||||
// When I look at their rating
|
||||
// Then I should see percentile (e.g., "Top 10%")
|
||||
// And I should see how many drivers are above/below
|
||||
});
|
||||
});
|
||||
115
tests/e2e/rating/rating-profile.spec.ts
Normal file
115
tests/e2e/rating/rating-profile.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('GridPilot Rating - Profile Display', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// TODO: Implement authentication setup
|
||||
// - Login as test driver
|
||||
// - Ensure driver has rating data
|
||||
});
|
||||
|
||||
test('Driver sees their GridPilot Rating on profile', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Driver views their rating on profile
|
||||
// Given I am a registered driver "John Doe"
|
||||
// And I have completed several races with varying results
|
||||
// And I am on my profile page
|
||||
// Then I should see my GridPilot Rating displayed
|
||||
// And I should see the rating value (e.g., "1500")
|
||||
// And I should see the rating label (e.g., "GridPilot Rating")
|
||||
});
|
||||
|
||||
test('Driver sees rating breakdown by component', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Driver views detailed rating breakdown
|
||||
// Given I am on my profile page
|
||||
// When I view the rating details
|
||||
// Then I should see breakdown by:
|
||||
// - Results Strength
|
||||
// - Consistency
|
||||
// - Clean Driving
|
||||
// - Racecraft
|
||||
// - Reliability
|
||||
// - Team Contribution
|
||||
// And each component should have a score/value
|
||||
});
|
||||
|
||||
test('Driver sees rating trend over multiple seasons', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Driver views rating history
|
||||
// Given I have raced in multiple seasons
|
||||
// When I view my rating history
|
||||
// Then I should see rating trend over time
|
||||
// And I should see rating changes per season
|
||||
// And I should see rating peaks and valleys
|
||||
});
|
||||
|
||||
test('Driver sees rating comparison with league peers', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Driver compares rating with peers
|
||||
// Given I am in a league with other drivers
|
||||
// When I view my rating
|
||||
// Then I should see how my rating compares to league average
|
||||
// And I should see my percentile in the league
|
||||
// And I should see my rank in the league
|
||||
});
|
||||
|
||||
test('Driver sees rating impact on team contribution', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Driver sees how rating affects team
|
||||
// Given I am on a team
|
||||
// When I view my rating
|
||||
// Then I should see my contribution to team score
|
||||
// And I should see my percentage of team total
|
||||
// And I should see how my rating affects team ranking
|
||||
});
|
||||
|
||||
test('Driver sees rating explanation/tooltip', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Driver seeks explanation of rating
|
||||
// Given I am viewing my rating
|
||||
// When I hover over rating components
|
||||
// Then I should see explanation of what each component means
|
||||
// And I should see how each component is calculated
|
||||
// And I should see tips for improving each component
|
||||
});
|
||||
|
||||
test('Driver sees rating update after race completion', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Driver sees rating update after race
|
||||
// Given I just completed a race
|
||||
// When I view my profile
|
||||
// Then I should see my rating has updated
|
||||
// And I should see the change (e.g., "+15")
|
||||
// And I should see what caused the change
|
||||
});
|
||||
|
||||
test('Driver sees empty state when no rating data exists', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: New driver views profile
|
||||
// Given I am a new driver with no races
|
||||
// When I view my profile
|
||||
// Then I should see empty state for rating
|
||||
// And I should see message about rating calculation
|
||||
// And I should see call to action to complete races
|
||||
});
|
||||
|
||||
test('Driver sees loading state while rating loads', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Driver views profile with slow connection
|
||||
// Given I am on my profile page
|
||||
// When rating data is loading
|
||||
// Then I should see loading skeleton
|
||||
// And I should see loading indicator
|
||||
// And I should see placeholder values
|
||||
});
|
||||
|
||||
test('Driver sees error state when rating fails to load', async ({ page }) => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Rating data fails to load
|
||||
// Given I am on my profile page
|
||||
// When rating data fails to load
|
||||
// Then I should see error message
|
||||
// And I should see retry button
|
||||
// And I should see fallback UI
|
||||
});
|
||||
});
|
||||
@@ -1,628 +0,0 @@
|
||||
import { test, expect, Browser, APIRequestContext } from '@playwright/test';
|
||||
import { WebsiteAuthManager, AuthContext } from '../../shared/website/WebsiteAuthManager';
|
||||
import { ConsoleErrorCapture } from '../../shared/website/ConsoleErrorCapture';
|
||||
import { WebsiteRouteManager } from '../../shared/website/WebsiteRouteManager';
|
||||
|
||||
/**
|
||||
* E2E Tests for League Pages with Data Validation
|
||||
*
|
||||
* Tests cover:
|
||||
* 1. /leagues (Discovery Page) - League cards, filters, quick actions
|
||||
* 2. /leagues/[id] (Overview Page) - Stats, next race, season progress
|
||||
* 3. /leagues/[id]/schedule (Schedule Page) - Race list, registration, admin controls
|
||||
* 4. /leagues/[id]/standings (Standings Page) - Trend indicators, stats, team toggle
|
||||
* 5. /leagues/[id]/roster (Roster Page) - Driver cards, admin actions
|
||||
*/
|
||||
|
||||
test.describe('League Pages - E2E with Data Validation', () => {
|
||||
const routeManager = new WebsiteRouteManager();
|
||||
const leagueId = routeManager.resolvePathTemplate('/leagues/[id]', { id: WebsiteRouteManager.IDs.LEAGUE });
|
||||
|
||||
const CONSOLE_ALLOWLIST = [
|
||||
/Download the React DevTools/i,
|
||||
/Next.js-specific warning/i,
|
||||
/Failed to load resource: the server responded with a status of 404/i,
|
||||
/Failed to load resource: the server responded with a status of 403/i,
|
||||
/Failed to load resource: the server responded with a status of 401/i,
|
||||
/Failed to load resource: the server responded with a status of 500/i,
|
||||
/net::ERR_NAME_NOT_RESOLVED/i,
|
||||
/net::ERR_CONNECTION_CLOSED/i,
|
||||
/net::ERR_ACCESS_DENIED/i,
|
||||
/Minified React error #418/i,
|
||||
/Event/i,
|
||||
/An error occurred in the Server Components render/i,
|
||||
/Route Error Boundary/i,
|
||||
];
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const allowedHosts = [
|
||||
new URL(process.env.PLAYWRIGHT_BASE_URL || 'http://website:3000').host,
|
||||
new URL(process.env.API_BASE_URL || 'http://api:3000').host,
|
||||
];
|
||||
|
||||
await page.route('**/*', (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (allowedHosts.includes(url.host) || url.protocol === 'data:') {
|
||||
route.continue();
|
||||
} else {
|
||||
route.abort('accessdenied');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('1. /leagues (Discovery Page)', () => {
|
||||
test('Unauthenticated user can view league discovery page', async ({ page }) => {
|
||||
const capture = new ConsoleErrorCapture(page);
|
||||
capture.setAllowlist(CONSOLE_ALLOWLIST);
|
||||
|
||||
await page.goto('/leagues', { waitUntil: 'commit', timeout: 15000 });
|
||||
|
||||
// Verify page loads successfully
|
||||
expect(page.url()).toContain('/leagues');
|
||||
|
||||
// Verify featured leagues section displays
|
||||
await expect(page.getByTestId('featured-leagues-section')).toBeVisible();
|
||||
|
||||
// Verify league cards are present
|
||||
const leagueCards = page.getByTestId('league-card');
|
||||
await expect(leagueCards.first()).toBeVisible();
|
||||
|
||||
// Verify league cards show correct metadata
|
||||
const firstCard = leagueCards.first();
|
||||
await expect(firstCard.getByTestId('league-card-title')).toBeVisible();
|
||||
await expect(firstCard.getByTestId('league-card-next-race')).toBeVisible();
|
||||
await expect(firstCard.getByTestId('league-card-active-drivers')).toBeVisible();
|
||||
|
||||
// Verify category filters are present
|
||||
await expect(page.getByTestId('category-filters')).toBeVisible();
|
||||
|
||||
// Verify Quick Join/Follow buttons are present
|
||||
await expect(page.getByTestId('quick-join-button')).toBeVisible();
|
||||
|
||||
expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Authenticated user can view league discovery page', async ({ browser, request }) => {
|
||||
const { context, page } = await WebsiteAuthManager.createAuthContext(browser, request, 'auth');
|
||||
const capture = new ConsoleErrorCapture(page);
|
||||
capture.setAllowlist(CONSOLE_ALLOWLIST);
|
||||
|
||||
try {
|
||||
await page.goto('/leagues', { waitUntil: 'commit', timeout: 15000 });
|
||||
|
||||
// Verify page loads successfully
|
||||
expect(page.url()).toContain('/leagues');
|
||||
|
||||
// Verify featured leagues section displays
|
||||
await expect(page.getByTestId('featured-leagues-section')).toBeVisible();
|
||||
|
||||
// Verify league cards are present
|
||||
const leagueCards = page.getByTestId('league-card');
|
||||
await expect(leagueCards.first()).toBeVisible();
|
||||
|
||||
// Verify league cards show correct metadata
|
||||
const firstCard = leagueCards.first();
|
||||
await expect(firstCard.getByTestId('league-card-title')).toBeVisible();
|
||||
await expect(firstCard.getByTestId('league-card-next-race')).toBeVisible();
|
||||
await expect(firstCard.getByTestId('league-card-active-drivers')).toBeVisible();
|
||||
|
||||
// Verify category filters are present
|
||||
await expect(page.getByTestId('category-filters')).toBeVisible();
|
||||
|
||||
// Verify Quick Join/Follow buttons are present
|
||||
await expect(page.getByTestId('quick-join-button')).toBeVisible();
|
||||
|
||||
expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Category filters work correctly', async ({ page }) => {
|
||||
const capture = new ConsoleErrorCapture(page);
|
||||
capture.setAllowlist(CONSOLE_ALLOWLIST);
|
||||
|
||||
await page.goto('/leagues', { waitUntil: 'commit', timeout: 15000 });
|
||||
|
||||
// Verify category filters are present
|
||||
await expect(page.getByTestId('category-filters')).toBeVisible();
|
||||
|
||||
// Click on a category filter
|
||||
const filterButton = page.getByTestId('category-filter-all');
|
||||
await filterButton.click();
|
||||
|
||||
// Wait for filter to apply
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify league cards are still visible after filtering
|
||||
const leagueCards = page.getByTestId('league-card');
|
||||
await expect(leagueCards.first()).toBeVisible();
|
||||
|
||||
expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('2. /leagues/[id] (Overview Page)', () => {
|
||||
test('Unauthenticated user can view league overview', async ({ page }) => {
|
||||
const capture = new ConsoleErrorCapture(page);
|
||||
capture.setAllowlist(CONSOLE_ALLOWLIST);
|
||||
|
||||
await page.goto(leagueId, { waitUntil: 'commit', timeout: 15000 });
|
||||
|
||||
// Verify page loads successfully
|
||||
expect(page.url()).toContain('/leagues/');
|
||||
|
||||
// Verify league name is displayed
|
||||
await expect(page.getByTestId('league-detail-title')).toBeVisible();
|
||||
|
||||
// Verify stats section displays
|
||||
await expect(page.getByTestId('league-stats-section')).toBeVisible();
|
||||
|
||||
// Verify Next Race countdown displays correctly
|
||||
await expect(page.getByTestId('next-race-countdown')).toBeVisible();
|
||||
|
||||
// Verify Season progress bar shows correct percentage
|
||||
await expect(page.getByTestId('season-progress-bar')).toBeVisible();
|
||||
|
||||
// Verify Activity feed shows recent activity
|
||||
await expect(page.getByTestId('activity-feed')).toBeVisible();
|
||||
|
||||
expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Authenticated user can view league overview', async ({ browser, request }) => {
|
||||
const { context, page } = await WebsiteAuthManager.createAuthContext(browser, request, 'auth');
|
||||
const capture = new ConsoleErrorCapture(page);
|
||||
capture.setAllowlist(CONSOLE_ALLOWLIST);
|
||||
|
||||
try {
|
||||
await page.goto(leagueId, { waitUntil: 'commit', timeout: 15000 });
|
||||
|
||||
// Verify page loads successfully
|
||||
expect(page.url()).toContain('/leagues/');
|
||||
|
||||
// Verify league name is displayed
|
||||
await expect(page.getByTestId('league-detail-title')).toBeVisible();
|
||||
|
||||
// Verify stats section displays
|
||||
await expect(page.getByTestId('league-stats-section')).toBeVisible();
|
||||
|
||||
// Verify Next Race countdown displays correctly
|
||||
await expect(page.getByTestId('next-race-countdown')).toBeVisible();
|
||||
|
||||
// Verify Season progress bar shows correct percentage
|
||||
await expect(page.getByTestId('season-progress-bar')).toBeVisible();
|
||||
|
||||
// Verify Activity feed shows recent activity
|
||||
await expect(page.getByTestId('activity-feed')).toBeVisible();
|
||||
|
||||
expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Admin user can view admin widgets', async ({ browser, request }) => {
|
||||
const { context, page } = await WebsiteAuthManager.createAuthContext(browser, request, 'admin');
|
||||
const capture = new ConsoleErrorCapture(page);
|
||||
capture.setAllowlist(CONSOLE_ALLOWLIST);
|
||||
|
||||
try {
|
||||
await page.goto(leagueId, { waitUntil: 'commit', timeout: 15000 });
|
||||
|
||||
// Verify page loads successfully
|
||||
expect(page.url()).toContain('/leagues/');
|
||||
|
||||
// Verify admin widgets are visible for authorized users
|
||||
await expect(page.getByTestId('admin-widgets')).toBeVisible();
|
||||
|
||||
expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Stats match API values', async ({ page, request }) => {
|
||||
const capture = new ConsoleErrorCapture(page);
|
||||
capture.setAllowlist(CONSOLE_ALLOWLIST);
|
||||
|
||||
// Fetch API data
|
||||
const apiResponse = await request.get(`${process.env.API_BASE_URL || 'http://api:3000'}/leagues/${WebsiteRouteManager.IDs.LEAGUE}`);
|
||||
const apiData = await apiResponse.json();
|
||||
|
||||
// Navigate to league overview
|
||||
await page.goto(leagueId, { waitUntil: 'commit', timeout: 15000 });
|
||||
|
||||
// Verify stats match API values
|
||||
const membersStat = page.getByTestId('stat-members');
|
||||
const racesStat = page.getByTestId('stat-races');
|
||||
const avgSofStat = page.getByTestId('stat-avg-sof');
|
||||
|
||||
await expect(membersStat).toBeVisible();
|
||||
await expect(racesStat).toBeVisible();
|
||||
await expect(avgSofStat).toBeVisible();
|
||||
|
||||
// Verify the stats contain expected values from API
|
||||
const membersText = await membersStat.textContent();
|
||||
const racesText = await racesStat.textContent();
|
||||
const avgSofText = await avgSofStat.textContent();
|
||||
|
||||
// Basic validation - stats should not be empty
|
||||
expect(membersText).toBeTruthy();
|
||||
expect(racesText).toBeTruthy();
|
||||
expect(avgSofText).toBeTruthy();
|
||||
|
||||
expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('3. /leagues/[id]/schedule (Schedule Page)', () => {
|
||||
const schedulePath = routeManager.resolvePathTemplate('/leagues/[id]/schedule', { id: WebsiteRouteManager.IDs.LEAGUE });
|
||||
|
||||
test('Unauthenticated user can view schedule page', async ({ page }) => {
|
||||
const capture = new ConsoleErrorCapture(page);
|
||||
capture.setAllowlist(CONSOLE_ALLOWLIST);
|
||||
|
||||
await page.goto(schedulePath, { waitUntil: 'commit', timeout: 15000 });
|
||||
|
||||
// Verify page loads successfully
|
||||
expect(page.url()).toContain('/schedule');
|
||||
|
||||
// Verify races are grouped by month
|
||||
await expect(page.getByTestId('schedule-month-group')).toBeVisible();
|
||||
|
||||
// Verify race list is present
|
||||
const raceItems = page.getByTestId('race-item');
|
||||
await expect(raceItems.first()).toBeVisible();
|
||||
|
||||
expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Authenticated user can view schedule page', async ({ browser, request }) => {
|
||||
const { context, page } = await WebsiteAuthManager.createAuthContext(browser, request, 'auth');
|
||||
const capture = new ConsoleErrorCapture(page);
|
||||
capture.setAllowlist(CONSOLE_ALLOWLIST);
|
||||
|
||||
try {
|
||||
await page.goto(schedulePath, { waitUntil: 'commit', timeout: 15000 });
|
||||
|
||||
// Verify page loads successfully
|
||||
expect(page.url()).toContain('/schedule');
|
||||
|
||||
// Verify races are grouped by month
|
||||
await expect(page.getByTestId('schedule-month-group')).toBeVisible();
|
||||
|
||||
// Verify race list is present
|
||||
const raceItems = page.getByTestId('race-item');
|
||||
await expect(raceItems.first()).toBeVisible();
|
||||
|
||||
// Verify Register/Withdraw buttons are present
|
||||
await expect(page.getByTestId('register-button')).toBeVisible();
|
||||
|
||||
expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Admin user can view admin controls', async ({ browser, request }) => {
|
||||
const { context, page } = await WebsiteAuthManager.createAuthContext(browser, request, 'admin');
|
||||
const capture = new ConsoleErrorCapture(page);
|
||||
capture.setAllowlist(CONSOLE_ALLOWLIST);
|
||||
|
||||
try {
|
||||
await page.goto(schedulePath, { waitUntil: 'commit', timeout: 15000 });
|
||||
|
||||
// Verify page loads successfully
|
||||
expect(page.url()).toContain('/schedule');
|
||||
|
||||
// Verify admin controls are visible for authorized users
|
||||
await expect(page.getByTestId('admin-controls')).toBeVisible();
|
||||
|
||||
expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Race detail modal shows correct data', async ({ page, request }) => {
|
||||
const capture = new ConsoleErrorCapture(page);
|
||||
capture.setAllowlist(CONSOLE_ALLOWLIST);
|
||||
|
||||
// Fetch API data
|
||||
const apiResponse = await request.get(`${process.env.API_BASE_URL || 'http://api:3000'}/leagues/${WebsiteRouteManager.IDs.LEAGUE}/schedule`);
|
||||
const apiData = await apiResponse.json();
|
||||
|
||||
// Navigate to schedule page
|
||||
await page.goto(schedulePath, { waitUntil: 'commit', timeout: 15000 });
|
||||
|
||||
// Click on a race item to open modal
|
||||
const raceItem = page.getByTestId('race-item').first();
|
||||
await raceItem.click();
|
||||
|
||||
// Verify modal is visible
|
||||
await expect(page.getByTestId('race-detail-modal')).toBeVisible();
|
||||
|
||||
// Verify modal contains race data
|
||||
const modalContent = page.getByTestId('race-detail-modal');
|
||||
await expect(modalContent.getByTestId('race-track')).toBeVisible();
|
||||
await expect(modalContent.getByTestId('race-car')).toBeVisible();
|
||||
await expect(modalContent.getByTestId('race-date')).toBeVisible();
|
||||
|
||||
expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('4. /leagues/[id]/standings (Standings Page)', () => {
|
||||
const standingsPath = routeManager.resolvePathTemplate('/leagues/[id]/standings', { id: WebsiteRouteManager.IDs.LEAGUE });
|
||||
|
||||
test('Unauthenticated user can view standings page', async ({ page }) => {
|
||||
const capture = new ConsoleErrorCapture(page);
|
||||
capture.setAllowlist(CONSOLE_ALLOWLIST);
|
||||
|
||||
await page.goto(standingsPath, { waitUntil: 'commit', timeout: 15000 });
|
||||
|
||||
// Verify page loads successfully
|
||||
expect(page.url()).toContain('/standings');
|
||||
|
||||
// Verify standings table is present
|
||||
await expect(page.getByTestId('standings-table')).toBeVisible();
|
||||
|
||||
// Verify trend indicators display correctly
|
||||
await expect(page.getByTestId('trend-indicator')).toBeVisible();
|
||||
|
||||
// Verify championship stats show correct data
|
||||
await expect(page.getByTestId('championship-stats')).toBeVisible();
|
||||
|
||||
expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Authenticated user can view standings page', async ({ browser, request }) => {
|
||||
const { context, page } = await WebsiteAuthManager.createAuthContext(browser, request, 'auth');
|
||||
const capture = new ConsoleErrorCapture(page);
|
||||
capture.setAllowlist(CONSOLE_ALLOWLIST);
|
||||
|
||||
try {
|
||||
await page.goto(standingsPath, { waitUntil: 'commit', timeout: 15000 });
|
||||
|
||||
// Verify page loads successfully
|
||||
expect(page.url()).toContain('/standings');
|
||||
|
||||
// Verify standings table is present
|
||||
await expect(page.getByTestId('standings-table')).toBeVisible();
|
||||
|
||||
// Verify trend indicators display correctly
|
||||
await expect(page.getByTestId('trend-indicator')).toBeVisible();
|
||||
|
||||
// Verify championship stats show correct data
|
||||
await expect(page.getByTestId('championship-stats')).toBeVisible();
|
||||
|
||||
// Verify team standings toggle is present
|
||||
await expect(page.getByTestId('team-standings-toggle')).toBeVisible();
|
||||
|
||||
expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Team standings toggle works correctly', async ({ page }) => {
|
||||
const capture = new ConsoleErrorCapture(page);
|
||||
capture.setAllowlist(CONSOLE_ALLOWLIST);
|
||||
|
||||
await page.goto(standingsPath, { waitUntil: 'commit', timeout: 15000 });
|
||||
|
||||
// Verify team standings toggle is present
|
||||
await expect(page.getByTestId('team-standings-toggle')).toBeVisible();
|
||||
|
||||
// Click on team standings toggle
|
||||
const toggle = page.getByTestId('team-standings-toggle');
|
||||
await toggle.click();
|
||||
|
||||
// Wait for toggle to apply
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify team standings are visible
|
||||
await expect(page.getByTestId('team-standings-table')).toBeVisible();
|
||||
|
||||
expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Drop weeks are marked correctly', async ({ page }) => {
|
||||
const capture = new ConsoleErrorCapture(page);
|
||||
capture.setAllowlist(CONSOLE_ALLOWLIST);
|
||||
|
||||
await page.goto(standingsPath, { waitUntil: 'commit', timeout: 15000 });
|
||||
|
||||
// Verify drop weeks are marked
|
||||
const dropWeeks = page.getByTestId('drop-week-marker');
|
||||
await expect(dropWeeks.first()).toBeVisible();
|
||||
|
||||
expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Standings data matches API values', async ({ page, request }) => {
|
||||
const capture = new ConsoleErrorCapture(page);
|
||||
capture.setAllowlist(CONSOLE_ALLOWLIST);
|
||||
|
||||
// Fetch API data
|
||||
const apiResponse = await request.get(`${process.env.API_BASE_URL || 'http://api:3000'}/leagues/${WebsiteRouteManager.IDs.LEAGUE}/standings`);
|
||||
const apiData = await apiResponse.json();
|
||||
|
||||
// Navigate to standings page
|
||||
await page.goto(standingsPath, { waitUntil: 'commit', timeout: 15000 });
|
||||
|
||||
// Verify standings table is present
|
||||
await expect(page.getByTestId('standings-table')).toBeVisible();
|
||||
|
||||
// Verify table rows match API data
|
||||
const tableRows = page.getByTestId('standings-row');
|
||||
const rowCount = await tableRows.count();
|
||||
|
||||
// Basic validation - should have at least one row
|
||||
expect(rowCount).toBeGreaterThan(0);
|
||||
|
||||
// Verify first row contains expected data
|
||||
const firstRow = tableRows.first();
|
||||
await expect(firstRow.getByTestId('standing-position')).toBeVisible();
|
||||
await expect(firstRow.getByTestId('standing-driver')).toBeVisible();
|
||||
await expect(firstRow.getByTestId('standing-points')).toBeVisible();
|
||||
|
||||
expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('5. /leagues/[id]/roster (Roster Page)', () => {
|
||||
const rosterPath = routeManager.resolvePathTemplate('/leagues/[id]/roster', { id: WebsiteRouteManager.IDs.LEAGUE });
|
||||
|
||||
test('Unauthenticated user can view roster page', async ({ page }) => {
|
||||
const capture = new ConsoleErrorCapture(page);
|
||||
capture.setAllowlist(CONSOLE_ALLOWLIST);
|
||||
|
||||
await page.goto(rosterPath, { waitUntil: 'commit', timeout: 15000 });
|
||||
|
||||
// Verify page loads successfully
|
||||
expect(page.url()).toContain('/roster');
|
||||
|
||||
// Verify driver cards are present
|
||||
const driverCards = page.getByTestId('driver-card');
|
||||
await expect(driverCards.first()).toBeVisible();
|
||||
|
||||
// Verify driver cards show correct stats
|
||||
const firstCard = driverCards.first();
|
||||
await expect(firstCard.getByTestId('driver-card-name')).toBeVisible();
|
||||
await expect(firstCard.getByTestId('driver-card-stats')).toBeVisible();
|
||||
|
||||
expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Authenticated user can view roster page', async ({ browser, request }) => {
|
||||
const { context, page } = await WebsiteAuthManager.createAuthContext(browser, request, 'auth');
|
||||
const capture = new ConsoleErrorCapture(page);
|
||||
capture.setAllowlist(CONSOLE_ALLOWLIST);
|
||||
|
||||
try {
|
||||
await page.goto(rosterPath, { waitUntil: 'commit', timeout: 15000 });
|
||||
|
||||
// Verify page loads successfully
|
||||
expect(page.url()).toContain('/roster');
|
||||
|
||||
// Verify driver cards are present
|
||||
const driverCards = page.getByTestId('driver-card');
|
||||
await expect(driverCards.first()).toBeVisible();
|
||||
|
||||
// Verify driver cards show correct stats
|
||||
const firstCard = driverCards.first();
|
||||
await expect(firstCard.getByTestId('driver-card-name')).toBeVisible();
|
||||
await expect(firstCard.getByTestId('driver-card-stats')).toBeVisible();
|
||||
|
||||
expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Admin user can view admin actions', async ({ browser, request }) => {
|
||||
const { context, page } = await WebsiteAuthManager.createAuthContext(browser, request, 'admin');
|
||||
const capture = new ConsoleErrorCapture(page);
|
||||
capture.setAllowlist(CONSOLE_ALLOWLIST);
|
||||
|
||||
try {
|
||||
await page.goto(rosterPath, { waitUntil: 'commit', timeout: 15000 });
|
||||
|
||||
// Verify page loads successfully
|
||||
expect(page.url()).toContain('/roster');
|
||||
|
||||
// Verify admin actions are visible for authorized users
|
||||
await expect(page.getByTestId('admin-actions')).toBeVisible();
|
||||
|
||||
expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Roster data matches API values', async ({ page, request }) => {
|
||||
const capture = new ConsoleErrorCapture(page);
|
||||
capture.setAllowlist(CONSOLE_ALLOWLIST);
|
||||
|
||||
// Fetch API data
|
||||
const apiResponse = await request.get(`${process.env.API_BASE_URL || 'http://api:3000'}/leagues/${WebsiteRouteManager.IDs.LEAGUE}/memberships`);
|
||||
const apiData = await apiResponse.json();
|
||||
|
||||
// Navigate to roster page
|
||||
await page.goto(rosterPath, { waitUntil: 'commit', timeout: 15000 });
|
||||
|
||||
// Verify driver cards are present
|
||||
const driverCards = page.getByTestId('driver-card');
|
||||
const cardCount = await driverCards.count();
|
||||
|
||||
// Basic validation - should have at least one driver
|
||||
expect(cardCount).toBeGreaterThan(0);
|
||||
|
||||
// Verify first card contains expected data
|
||||
const firstCard = driverCards.first();
|
||||
await expect(firstCard.getByTestId('driver-card-name')).toBeVisible();
|
||||
await expect(firstCard.getByTestId('driver-card-stats')).toBeVisible();
|
||||
|
||||
expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('6. Navigation Between League Pages', () => {
|
||||
test('User can navigate from discovery to league overview', async ({ page }) => {
|
||||
const capture = new ConsoleErrorCapture(page);
|
||||
capture.setAllowlist(CONSOLE_ALLOWLIST);
|
||||
|
||||
// Navigate to leagues discovery page
|
||||
await page.goto('/leagues', { waitUntil: 'commit', timeout: 15000 });
|
||||
|
||||
// Click on a league card
|
||||
const leagueCard = page.getByTestId('league-card').first();
|
||||
await leagueCard.click();
|
||||
|
||||
// Verify navigation to league overview
|
||||
await page.waitForURL(/\/leagues\/[^/]+$/, { timeout: 15000 });
|
||||
expect(page.url()).toMatch(/\/leagues\/[^/]+$/);
|
||||
|
||||
// Verify league overview content is visible
|
||||
await expect(page.getByTestId('league-detail-title')).toBeVisible();
|
||||
|
||||
expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('User can navigate between league sub-pages', async ({ page }) => {
|
||||
const capture = new ConsoleErrorCapture(page);
|
||||
capture.setAllowlist(CONSOLE_ALLOWLIST);
|
||||
|
||||
// Navigate to league overview
|
||||
await page.goto(leagueId, { waitUntil: 'commit', timeout: 15000 });
|
||||
|
||||
// Click on Schedule tab
|
||||
const scheduleTab = page.getByTestId('schedule-tab');
|
||||
await scheduleTab.click();
|
||||
|
||||
// Verify navigation to schedule page
|
||||
await page.waitForURL(/\/leagues\/[^/]+\/schedule$/, { timeout: 15000 });
|
||||
expect(page.url()).toMatch(/\/leagues\/[^/]+\/schedule$/);
|
||||
|
||||
// Click on Standings tab
|
||||
const standingsTab = page.getByTestId('standings-tab');
|
||||
await standingsTab.click();
|
||||
|
||||
// Verify navigation to standings page
|
||||
await page.waitForURL(/\/leagues\/[^/]+\/standings$/, { timeout: 15000 });
|
||||
expect(page.url()).toMatch(/\/leagues\/[^/]+\/standings$/);
|
||||
|
||||
// Click on Roster tab
|
||||
const rosterTab = page.getByTestId('roster-tab');
|
||||
await rosterTab.click();
|
||||
|
||||
// Verify navigation to roster page
|
||||
await page.waitForURL(/\/leagues\/[^/]+\/roster$/, { timeout: 15000 });
|
||||
expect(page.url()).toMatch(/\/leagues\/[^/]+\/roster$/);
|
||||
|
||||
expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,178 +0,0 @@
|
||||
import { test, expect, Browser, APIRequestContext } from '@playwright/test';
|
||||
import { getWebsiteRouteContracts, RouteContract, ScenarioRole } from '../../shared/website/RouteContractSpec';
|
||||
import { WebsiteAuthManager, AuthContext } from '../../shared/website/WebsiteAuthManager';
|
||||
import { ConsoleErrorCapture } from '../../shared/website/ConsoleErrorCapture';
|
||||
import { WebsiteRouteManager } from '../../shared/website/WebsiteRouteManager';
|
||||
|
||||
/**
|
||||
* Optimized Route Coverage E2E
|
||||
*/
|
||||
|
||||
test.describe('Website Route Coverage & Failure Modes', () => {
|
||||
const routeManager = new WebsiteRouteManager();
|
||||
const contracts = getWebsiteRouteContracts();
|
||||
|
||||
const CONSOLE_ALLOWLIST = [
|
||||
/Download the React DevTools/i,
|
||||
/Next.js-specific warning/i,
|
||||
/Failed to load resource: the server responded with a status of 404/i,
|
||||
/Failed to load resource: the server responded with a status of 403/i,
|
||||
/Failed to load resource: the server responded with a status of 401/i,
|
||||
/Failed to load resource: the server responded with a status of 500/i,
|
||||
/net::ERR_NAME_NOT_RESOLVED/i,
|
||||
/net::ERR_CONNECTION_CLOSED/i,
|
||||
/net::ERR_ACCESS_DENIED/i,
|
||||
/Minified React error #418/i,
|
||||
/Event/i,
|
||||
/An error occurred in the Server Components render/i,
|
||||
/Route Error Boundary/i,
|
||||
];
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const allowedHosts = [
|
||||
new URL(process.env.PLAYWRIGHT_BASE_URL || 'http://website:3000').host,
|
||||
new URL(process.env.API_BASE_URL || 'http://api:3000').host,
|
||||
];
|
||||
|
||||
await page.route('**/*', (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (allowedHosts.includes(url.host) || url.protocol === 'data:') {
|
||||
route.continue();
|
||||
} else {
|
||||
route.abort('accessdenied');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('Unauthenticated Access (All Routes)', async ({ page }) => {
|
||||
const capture = new ConsoleErrorCapture(page);
|
||||
capture.setAllowlist(CONSOLE_ALLOWLIST);
|
||||
|
||||
for (const contract of contracts) {
|
||||
const response = await page.goto(contract.path, { timeout: 15000, waitUntil: 'commit' }).catch(() => null);
|
||||
|
||||
if (contract.scenarios.unauth?.expectedStatus === 'redirect') {
|
||||
const currentPath = new URL(page.url()).pathname;
|
||||
if (currentPath !== 'blank') {
|
||||
expect(currentPath.replace(/\/$/, '')).toBe(contract.scenarios.unauth?.expectedRedirectTo?.replace(/\/$/, ''));
|
||||
}
|
||||
} else if (contract.scenarios.unauth?.expectedStatus === 'ok') {
|
||||
if (response?.status()) {
|
||||
// 500 is allowed for the dedicated /500 error page itself
|
||||
if (contract.path === '/500') {
|
||||
expect(response.status()).toBe(500);
|
||||
} else {
|
||||
expect(response.status(), `Failed to load ${contract.path} as unauth`).toBeLessThan(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Public Navigation Presence (Unauthenticated)', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Top nav should be visible
|
||||
await expect(page.getByTestId('public-top-nav')).toBeVisible();
|
||||
|
||||
// Login/Signup actions should be visible
|
||||
await expect(page.getByTestId('public-nav-login')).toBeVisible();
|
||||
await expect(page.getByTestId('public-nav-signup')).toBeVisible();
|
||||
|
||||
// Navigation links should be present in the top nav
|
||||
const topNav = page.getByTestId('public-top-nav');
|
||||
await expect(topNav.locator('a[href="/leagues"]')).toBeVisible();
|
||||
await expect(topNav.locator('a[href="/races"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Role-Based Access (Auth, Admin & Sponsor)', async ({ browser, request }) => {
|
||||
const roles: ScenarioRole[] = ['auth', 'admin', 'sponsor'];
|
||||
|
||||
for (const role of roles) {
|
||||
const { context, page } = await WebsiteAuthManager.createAuthContext(browser, request, role as any);
|
||||
const capture = new ConsoleErrorCapture(page);
|
||||
capture.setAllowlist(CONSOLE_ALLOWLIST);
|
||||
|
||||
for (const contract of contracts) {
|
||||
const scenario = contract.scenarios[role];
|
||||
if (!scenario) continue;
|
||||
|
||||
const response = await page.goto(contract.path, { timeout: 15000, waitUntil: 'commit' }).catch(() => null);
|
||||
|
||||
if (scenario.expectedStatus === 'redirect') {
|
||||
const currentPath = new URL(page.url()).pathname;
|
||||
if (currentPath !== 'blank') {
|
||||
expect(currentPath.replace(/\/$/, '')).toBe(scenario.expectedRedirectTo?.replace(/\/$/, ''));
|
||||
}
|
||||
} else if (scenario.expectedStatus === 'ok') {
|
||||
// If it's 500, it might be a known issue we're tracking via console errors
|
||||
// but we don't want to fail the whole loop here if we want to see all errors
|
||||
if (response?.status() && response.status() >= 500) {
|
||||
console.error(`[Role Access] ${role} got ${response.status()} on ${contract.path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0);
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Client-side Navigation Smoke', async ({ browser, request }) => {
|
||||
const { context, page } = await WebsiteAuthManager.createAuthContext(browser, request, 'auth');
|
||||
const capture = new ConsoleErrorCapture(page);
|
||||
capture.setAllowlist(CONSOLE_ALLOWLIST);
|
||||
|
||||
try {
|
||||
// Start at dashboard
|
||||
await page.goto('/dashboard', { waitUntil: 'commit', timeout: 15000 });
|
||||
expect(page.url()).toContain('/dashboard');
|
||||
|
||||
// Click on Leagues in sidebar
|
||||
const leaguesLink = page.locator('a[href="/leagues"]').first();
|
||||
await leaguesLink.click();
|
||||
|
||||
// Assert URL change
|
||||
await page.waitForURL(/\/leagues/, { timeout: 15000 });
|
||||
expect(page.url()).toContain('/leagues');
|
||||
|
||||
expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Failure Modes', async ({ page, browser, request }) => {
|
||||
// 1. Invalid IDs
|
||||
const edgeCases = routeManager.getParamEdgeCases();
|
||||
for (const edge of edgeCases) {
|
||||
const path = routeManager.resolvePathTemplate(edge.pathTemplate, edge.params);
|
||||
const response = await page.goto(path).catch(() => null);
|
||||
if (response?.status()) expect(response.status()).toBe(404);
|
||||
}
|
||||
|
||||
// 2. Session Drift
|
||||
const driftRoutes = routeManager.getAuthDriftRoutes();
|
||||
const { context: dContext, page: dPage } = await WebsiteAuthManager.createAuthContext(browser, request, 'sponsor');
|
||||
await dContext.clearCookies();
|
||||
await dPage.goto(routeManager.resolvePathTemplate(driftRoutes[0].pathTemplate)).catch(() => null);
|
||||
try {
|
||||
await dPage.waitForURL(url => url.pathname === '/auth/login', { timeout: 5000 });
|
||||
expect(dPage.url()).toContain('/auth/login');
|
||||
} catch (e) {
|
||||
// ignore if it didn't redirect fast enough in this environment
|
||||
}
|
||||
await dContext.close();
|
||||
|
||||
// 3. API 5xx
|
||||
const target = routeManager.getFaultInjectionRoutes()[0];
|
||||
await page.route('**/api/**', async (route) => {
|
||||
await route.fulfill({ status: 500, body: JSON.stringify({ message: 'Error' }) });
|
||||
});
|
||||
await page.goto(routeManager.resolvePathTemplate(target.pathTemplate, target.params)).catch(() => null);
|
||||
const content = await page.content();
|
||||
// Relaxed check for error indicators
|
||||
const hasError = ['error', '500', 'failed', 'wrong'].some(i => content.toLowerCase().includes(i));
|
||||
if (!hasError) console.warn(`[API 5xx] Page did not show obvious error indicator for ${target.pathTemplate}`);
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { join } from 'path';
|
||||
|
||||
export interface ApiServerHarnessOptions {
|
||||
port?: number;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
export class ApiServerHarness {
|
||||
private process: ChildProcess | null = null;
|
||||
private logs: string[] = [];
|
||||
private port: number;
|
||||
|
||||
constructor(options: ApiServerHarnessOptions = {}) {
|
||||
this.port = options.port || 3001;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cwd = join(process.cwd(), 'apps/api');
|
||||
|
||||
this.process = spawn('npm', ['run', 'start:dev'], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: this.port.toString(),
|
||||
GRIDPILOT_API_PERSISTENCE: 'inmemory',
|
||||
ENABLE_BOOTSTRAP: 'true',
|
||||
},
|
||||
shell: true,
|
||||
detached: true,
|
||||
});
|
||||
|
||||
let resolved = false;
|
||||
|
||||
const checkReadiness = async () => {
|
||||
if (resolved) return;
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${this.port}/health`);
|
||||
if (res.ok) {
|
||||
resolved = true;
|
||||
resolve();
|
||||
}
|
||||
} catch (e) {
|
||||
// Not ready yet
|
||||
}
|
||||
};
|
||||
|
||||
this.process.stdout?.on('data', (data) => {
|
||||
const str = data.toString();
|
||||
this.logs.push(str);
|
||||
if (str.includes('Nest application successfully started') || str.includes('started')) {
|
||||
checkReadiness();
|
||||
}
|
||||
});
|
||||
|
||||
this.process.stderr?.on('data', (data) => {
|
||||
const str = data.toString();
|
||||
this.logs.push(str);
|
||||
});
|
||||
|
||||
this.process.on('error', (err) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
this.process.on('exit', (code) => {
|
||||
if (!resolved && code !== 0 && code !== null) {
|
||||
resolved = true;
|
||||
reject(new Error(`API server exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
// Timeout after 60 seconds
|
||||
setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(new Error(`API server failed to start within 60s. Logs:\n${this.getLogTail(20)}`));
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.process && this.process.pid) {
|
||||
try {
|
||||
process.kill(-this.process.pid);
|
||||
} catch (e) {
|
||||
this.process.kill();
|
||||
}
|
||||
this.process = null;
|
||||
}
|
||||
}
|
||||
|
||||
getLogTail(lines: number = 60): string {
|
||||
return this.logs.slice(-lines).join('');
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
|
||||
import { IntegrationTestHarness, createTestHarness } from './index';
|
||||
import { ApiClient } from './api-client';
|
||||
import { DatabaseManager } from './database-manager';
|
||||
import { DataFactory } from './data-factory';
|
||||
|
||||
/**
|
||||
* Shared test context for harness-related integration tests.
|
||||
* Provides a DRY setup for tests that verify the harness infrastructure itself.
|
||||
*/
|
||||
export class HarnessTestContext {
|
||||
private harness: IntegrationTestHarness;
|
||||
|
||||
constructor() {
|
||||
this.harness = createTestHarness();
|
||||
}
|
||||
|
||||
get api(): ApiClient {
|
||||
return this.harness.getApi();
|
||||
}
|
||||
|
||||
get db(): DatabaseManager {
|
||||
return this.harness.getDatabase();
|
||||
}
|
||||
|
||||
get factory(): DataFactory {
|
||||
return this.harness.getFactory();
|
||||
}
|
||||
|
||||
get testHarness(): IntegrationTestHarness {
|
||||
return this.harness;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard setup for harness tests
|
||||
*/
|
||||
async setup() {
|
||||
await this.harness.beforeAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard teardown for harness tests
|
||||
*/
|
||||
async teardown() {
|
||||
await this.harness.afterAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard per-test setup
|
||||
*/
|
||||
async reset() {
|
||||
await this.harness.beforeEach();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to create and register a HarnessTestContext with Vitest hooks
|
||||
*/
|
||||
export function setupHarnessTest() {
|
||||
const context = new HarnessTestContext();
|
||||
|
||||
beforeAll(async () => {
|
||||
await context.setup();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await context.teardown();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await context.reset();
|
||||
});
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { join } from 'path';
|
||||
|
||||
export interface WebsiteServerHarnessOptions {
|
||||
port?: number;
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export class WebsiteServerHarness {
|
||||
private process: ChildProcess | null = null;
|
||||
private logs: string[] = [];
|
||||
private port: number;
|
||||
private options: WebsiteServerHarnessOptions;
|
||||
|
||||
constructor(options: WebsiteServerHarnessOptions = {}) {
|
||||
this.options = options;
|
||||
this.port = options.port || 3000;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cwd = join(process.cwd(), 'apps/website');
|
||||
|
||||
// Use 'npm run dev' or 'npm run start' depending on environment
|
||||
// For integration tests, 'dev' is often easier if we don't want to build first,
|
||||
// but 'start' is more realistic for SSR.
|
||||
// Assuming 'npm run dev' for now as it's faster for local tests.
|
||||
this.process = spawn('npm', ['run', 'dev', '--', '-p', this.port.toString()], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...this.options.env,
|
||||
PORT: this.port.toString(),
|
||||
},
|
||||
shell: true,
|
||||
detached: true, // Start in a new process group
|
||||
});
|
||||
|
||||
let resolved = false;
|
||||
|
||||
const checkReadiness = async () => {
|
||||
if (resolved) return;
|
||||
try {
|
||||
const res = await fetch(`http://localhost:${this.port}`, { method: 'HEAD' });
|
||||
if (res.ok || res.status === 307 || res.status === 200) {
|
||||
resolved = true;
|
||||
resolve();
|
||||
}
|
||||
} catch (e) {
|
||||
// Not ready yet
|
||||
}
|
||||
};
|
||||
|
||||
this.process.stdout?.on('data', (data) => {
|
||||
const str = data.toString();
|
||||
this.logs.push(str);
|
||||
if (str.includes('ready') || str.includes('started') || str.includes('Local:')) {
|
||||
checkReadiness();
|
||||
}
|
||||
});
|
||||
|
||||
this.process.stderr?.on('data', (data) => {
|
||||
const str = data.toString();
|
||||
this.logs.push(str);
|
||||
// Don't console.error here as it might be noisy, but keep in logs
|
||||
});
|
||||
|
||||
this.process.on('error', (err) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
this.process.on('exit', (code) => {
|
||||
if (!resolved && code !== 0 && code !== null) {
|
||||
resolved = true;
|
||||
reject(new Error(`Website server exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
// Timeout after 60 seconds (Next.js dev can be slow)
|
||||
setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
reject(new Error(`Website server failed to start within 60s. Logs:\n${this.getLogTail(20)}`));
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.process && this.process.pid) {
|
||||
try {
|
||||
// Kill the process group since we used detached: true
|
||||
process.kill(-this.process.pid);
|
||||
} catch (e) {
|
||||
// Fallback to normal kill
|
||||
this.process.kill();
|
||||
}
|
||||
this.process = null;
|
||||
}
|
||||
}
|
||||
|
||||
getLogs(): string[] {
|
||||
return this.logs;
|
||||
}
|
||||
|
||||
getLogTail(lines: number = 60): string {
|
||||
return this.logs.slice(-lines).join('');
|
||||
}
|
||||
|
||||
hasErrorPatterns(): boolean {
|
||||
const errorPatterns = [
|
||||
'uncaughtException',
|
||||
'unhandledRejection',
|
||||
// 'Error: ', // Too broad, catches expected API errors
|
||||
];
|
||||
|
||||
// Only fail on actual process-level errors or unexpected server crashes
|
||||
return this.logs.some(log => errorPatterns.some(pattern => log.includes(pattern)));
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* API Client for Integration Tests
|
||||
* Provides typed HTTP client for testing API endpoints
|
||||
*/
|
||||
|
||||
export interface ApiClientConfig {
|
||||
baseUrl: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private baseUrl: string;
|
||||
private timeout: number;
|
||||
|
||||
constructor(config: ApiClientConfig) {
|
||||
this.baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
|
||||
this.timeout = config.timeout || 30000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make HTTP request to API
|
||||
*/
|
||||
private async request<T>(method: string, path: string, body?: unknown, headers: Record<string, string> = {}): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`API Error ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
return (await response.text()) as unknown as T;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error.name === 'AbortError') {
|
||||
throw new Error(`Request timeout after ${this.timeout}ms`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// GET requests
|
||||
async get<T>(path: string, headers?: Record<string, string>): Promise<T> {
|
||||
return this.request<T>('GET', path, undefined, headers);
|
||||
}
|
||||
|
||||
// POST requests
|
||||
async post<T>(path: string, body: unknown, headers?: Record<string, string>): Promise<T> {
|
||||
return this.request<T>('POST', path, body, headers);
|
||||
}
|
||||
|
||||
// PUT requests
|
||||
async put<T>(path: string, body: unknown, headers?: Record<string, string>): Promise<T> {
|
||||
return this.request<T>('PUT', path, body, headers);
|
||||
}
|
||||
|
||||
// PATCH requests
|
||||
async patch<T>(path: string, body: unknown, headers?: Record<string, string>): Promise<T> {
|
||||
return this.request<T>('PATCH', path, body, headers);
|
||||
}
|
||||
|
||||
// DELETE requests
|
||||
async delete<T>(path: string, headers?: Record<string, string>): Promise<T> {
|
||||
return this.request<T>('DELETE', path, undefined, headers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check
|
||||
*/
|
||||
async health(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/health`);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for API to be ready
|
||||
*/
|
||||
async waitForReady(timeout: number = 60000): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
if (await this.health()) {
|
||||
return;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
throw new Error(`API failed to become ready within ${timeout}ms`);
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
/**
|
||||
* Data Factory for Integration Tests
|
||||
* Uses TypeORM repositories to create test data
|
||||
*/
|
||||
|
||||
import { DataSource } from 'typeorm';
|
||||
import { LeagueOrmEntity } from '../../../adapters/racing/persistence/typeorm/entities/LeagueOrmEntity';
|
||||
import { SeasonOrmEntity } from '../../../adapters/racing/persistence/typeorm/entities/SeasonOrmEntity';
|
||||
import { DriverOrmEntity } from '../../../adapters/racing/persistence/typeorm/entities/DriverOrmEntity';
|
||||
import { RaceOrmEntity } from '../../../adapters/racing/persistence/typeorm/entities/RaceOrmEntity';
|
||||
import { ResultOrmEntity } from '../../../adapters/racing/persistence/typeorm/entities/ResultOrmEntity';
|
||||
import { LeagueOrmMapper } from '../../../adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper';
|
||||
import { SeasonOrmMapper } from '../../../adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper';
|
||||
import { RaceOrmMapper } from '../../../adapters/racing/persistence/typeorm/mappers/RaceOrmMapper';
|
||||
import { ResultOrmMapper } from '../../../adapters/racing/persistence/typeorm/mappers/ResultOrmMapper';
|
||||
import { TypeOrmLeagueRepository } from '../../../adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository';
|
||||
import { TypeOrmSeasonRepository } from '../../../adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository';
|
||||
import { TypeOrmRaceRepository } from '../../../adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository';
|
||||
import { TypeOrmResultRepository } from '../../../adapters/racing/persistence/typeorm/repositories/TypeOrmResultRepository';
|
||||
import { TypeOrmDriverRepository } from '../../../adapters/racing/persistence/typeorm/repositories/TypeOrmDriverRepository';
|
||||
import { League } from '../../../core/racing/domain/entities/League';
|
||||
import { Season } from '../../../core/racing/domain/entities/season/Season';
|
||||
import { Driver } from '../../../core/racing/domain/entities/Driver';
|
||||
import { Race } from '../../../core/racing/domain/entities/Race';
|
||||
import { Result } from '../../../core/racing/domain/entities/result/Result';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export class DataFactory {
|
||||
private dataSource: DataSource;
|
||||
private leagueRepo: TypeOrmLeagueRepository;
|
||||
private seasonRepo: TypeOrmSeasonRepository;
|
||||
private driverRepo: TypeOrmDriverRepository;
|
||||
private raceRepo: TypeOrmRaceRepository;
|
||||
private resultRepo: TypeOrmResultRepository;
|
||||
|
||||
constructor(private dbUrl: string) {
|
||||
this.dataSource = new DataSource({
|
||||
type: 'postgres',
|
||||
url: dbUrl,
|
||||
entities: [
|
||||
LeagueOrmEntity,
|
||||
SeasonOrmEntity,
|
||||
DriverOrmEntity,
|
||||
RaceOrmEntity,
|
||||
ResultOrmEntity,
|
||||
],
|
||||
synchronize: false, // Don't sync, use existing schema
|
||||
});
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!this.dataSource.isInitialized) {
|
||||
await this.dataSource.initialize();
|
||||
}
|
||||
|
||||
const leagueMapper = new LeagueOrmMapper();
|
||||
const seasonMapper = new SeasonOrmMapper();
|
||||
const raceMapper = new RaceOrmMapper();
|
||||
const resultMapper = new ResultOrmMapper();
|
||||
|
||||
this.leagueRepo = new TypeOrmLeagueRepository(this.dataSource, leagueMapper);
|
||||
this.seasonRepo = new TypeOrmSeasonRepository(this.dataSource, seasonMapper);
|
||||
this.driverRepo = new TypeOrmDriverRepository(this.dataSource, leagueMapper); // Reuse mapper
|
||||
this.raceRepo = new TypeOrmRaceRepository(this.dataSource, raceMapper);
|
||||
this.resultRepo = new TypeOrmResultRepository(this.dataSource, resultMapper);
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
if (this.dataSource.isInitialized) {
|
||||
await this.dataSource.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test league
|
||||
*/
|
||||
async createLeague(overrides: Partial<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
}> = {}) {
|
||||
const league = League.create({
|
||||
id: overrides.id || uuidv4(),
|
||||
name: overrides.name || 'Test League',
|
||||
description: overrides.description || 'Integration Test League',
|
||||
ownerId: overrides.ownerId || uuidv4(),
|
||||
settings: {
|
||||
enableDriverChampionship: true,
|
||||
enableTeamChampionship: false,
|
||||
enableNationsChampionship: false,
|
||||
enableTrophyChampionship: false,
|
||||
visibility: 'unranked',
|
||||
maxDrivers: 32,
|
||||
},
|
||||
participantCount: 0,
|
||||
});
|
||||
|
||||
await this.leagueRepo.create(league);
|
||||
return league;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test season
|
||||
*/
|
||||
async createSeason(leagueId: string, overrides: Partial<{
|
||||
id: string;
|
||||
name: string;
|
||||
year: number;
|
||||
status: string;
|
||||
}> = {}) {
|
||||
const season = Season.create({
|
||||
id: overrides.id || uuidv4(),
|
||||
leagueId,
|
||||
gameId: 'iracing',
|
||||
name: overrides.name || 'Test Season',
|
||||
year: overrides.year || 2024,
|
||||
order: 1,
|
||||
status: overrides.status || 'active',
|
||||
startDate: new Date(),
|
||||
endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||
schedulePublished: false,
|
||||
});
|
||||
|
||||
await this.seasonRepo.create(season);
|
||||
return season;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test driver
|
||||
*/
|
||||
async createDriver(overrides: Partial<{
|
||||
id: string;
|
||||
name: string;
|
||||
iracingId: string;
|
||||
country: string;
|
||||
}> = {}) {
|
||||
const driver = Driver.create({
|
||||
id: overrides.id || uuidv4(),
|
||||
iracingId: overrides.iracingId || `iracing-${uuidv4()}`,
|
||||
name: overrides.name || 'Test Driver',
|
||||
country: overrides.country || 'US',
|
||||
});
|
||||
|
||||
// Need to insert directly since driver repo might not exist or be different
|
||||
await this.dataSource.getRepository(DriverOrmEntity).save({
|
||||
id: driver.id.toString(),
|
||||
iracingId: driver.iracingId,
|
||||
name: driver.name.toString(),
|
||||
country: driver.country,
|
||||
joinedAt: new Date(),
|
||||
bio: null,
|
||||
category: null,
|
||||
avatarRef: null,
|
||||
});
|
||||
|
||||
return driver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test race
|
||||
*/
|
||||
async createRace(overrides: Partial<{
|
||||
id: string;
|
||||
leagueId: string;
|
||||
scheduledAt: Date;
|
||||
status: string;
|
||||
track: string;
|
||||
car: string;
|
||||
}> = {}) {
|
||||
const race = Race.create({
|
||||
id: overrides.id || uuidv4(),
|
||||
leagueId: overrides.leagueId || uuidv4(),
|
||||
scheduledAt: overrides.scheduledAt || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
track: overrides.track || 'Laguna Seca',
|
||||
car: overrides.car || 'Formula Ford',
|
||||
status: overrides.status || 'scheduled',
|
||||
});
|
||||
|
||||
await this.raceRepo.create(race);
|
||||
return race;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test result
|
||||
*/
|
||||
async createResult(raceId: string, driverId: string, overrides: Partial<{
|
||||
id: string;
|
||||
position: number;
|
||||
fastestLap: number;
|
||||
incidents: number;
|
||||
startPosition: number;
|
||||
}> = {}) {
|
||||
const result = Result.create({
|
||||
id: overrides.id || uuidv4(),
|
||||
raceId,
|
||||
driverId,
|
||||
position: overrides.position || 1,
|
||||
fastestLap: overrides.fastestLap || 0,
|
||||
incidents: overrides.incidents || 0,
|
||||
startPosition: overrides.startPosition || 1,
|
||||
});
|
||||
|
||||
await this.resultRepo.create(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create complete test scenario: league, season, drivers, races
|
||||
*/
|
||||
async createTestScenario() {
|
||||
const league = await this.createLeague();
|
||||
const season = await this.createSeason(league.id.toString());
|
||||
const drivers = await Promise.all([
|
||||
this.createDriver({ name: 'Driver 1' }),
|
||||
this.createDriver({ name: 'Driver 2' }),
|
||||
this.createDriver({ name: 'Driver 3' }),
|
||||
]);
|
||||
const races = await Promise.all([
|
||||
this.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
name: 'Race 1',
|
||||
scheduledAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||
}),
|
||||
this.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
name: 'Race 2',
|
||||
scheduledAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000)
|
||||
}),
|
||||
]);
|
||||
|
||||
return { league, season, drivers, races };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up specific entities
|
||||
*/
|
||||
async deleteEntities(entities: { id: string | number }[], entityType: string) {
|
||||
const repository = this.dataSource.getRepository(entityType);
|
||||
for (const entity of entities) {
|
||||
await repository.delete(entity.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* Database Manager for Integration Tests
|
||||
* Handles database connections, migrations, seeding, and cleanup
|
||||
*/
|
||||
|
||||
import { Pool, PoolClient, QueryResult } from 'pg';
|
||||
import { setTimeout } from 'timers/promises';
|
||||
|
||||
export interface DatabaseConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
user: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class DatabaseManager {
|
||||
private pool: Pool;
|
||||
private client: PoolClient | null = null;
|
||||
|
||||
constructor(config: DatabaseConfig) {
|
||||
this.pool = new Pool({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
database: config.database,
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
max: 1,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 10000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for database to be ready
|
||||
*/
|
||||
async waitForReady(timeout: number = 30000): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
try {
|
||||
const client = await this.pool.connect();
|
||||
await client.query('SELECT 1');
|
||||
client.release();
|
||||
console.log('[DatabaseManager] ✓ Database is ready');
|
||||
return;
|
||||
} catch (error) {
|
||||
await setTimeout(1000);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Database failed to become ready');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a client for transactions
|
||||
*/
|
||||
async getClient(): Promise<PoolClient> {
|
||||
if (!this.client) {
|
||||
this.client = await this.pool.connect();
|
||||
}
|
||||
return this.client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute query with automatic client management
|
||||
*/
|
||||
async query(text: string, params?: unknown[]): Promise<QueryResult> {
|
||||
const client = await this.getClient();
|
||||
return client.query(text, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin transaction
|
||||
*/
|
||||
async begin(): Promise<void> {
|
||||
const client = await this.getClient();
|
||||
await client.query('BEGIN');
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit transaction
|
||||
*/
|
||||
async commit(): Promise<void> {
|
||||
if (this.client) {
|
||||
await this.client.query('COMMIT');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback transaction
|
||||
*/
|
||||
async rollback(): Promise<void> {
|
||||
if (this.client) {
|
||||
await this.client.query('ROLLBACK');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate all tables (for cleanup between tests)
|
||||
*/
|
||||
async truncateAllTables(): Promise<void> {
|
||||
const client = await this.getClient();
|
||||
|
||||
// Get all table names
|
||||
const result = await client.query(`
|
||||
SELECT tablename
|
||||
FROM pg_tables
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename NOT LIKE 'pg_%'
|
||||
AND tablename NOT LIKE 'sql_%'
|
||||
`);
|
||||
|
||||
if (result.rows.length === 0) return;
|
||||
|
||||
// Disable triggers temporarily to allow truncation
|
||||
await client.query('SET session_replication_role = replica');
|
||||
|
||||
const tableNames = result.rows.map(r => r.tablename).join(', ');
|
||||
try {
|
||||
await client.query(`TRUNCATE TABLE ${tableNames} CASCADE`);
|
||||
console.log(`[DatabaseManager] ✓ Truncated tables: ${tableNames}`);
|
||||
} finally {
|
||||
await client.query('SET session_replication_role = DEFAULT');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run database migrations
|
||||
*/
|
||||
async runMigrations(): Promise<void> {
|
||||
// This would typically run TypeORM migrations
|
||||
// For now, we'll assume the API handles this on startup
|
||||
console.log('[DatabaseManager] Migrations handled by API startup');
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed minimal test data
|
||||
*/
|
||||
async seedMinimalData(): Promise<void> {
|
||||
// Insert minimal required data for tests
|
||||
// This will be extended based on test requirements
|
||||
|
||||
console.log('[DatabaseManager] ✓ Minimal test data seeded');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for constraint violations in recent operations
|
||||
*/
|
||||
async getRecentConstraintErrors(since: Date): Promise<string[]> {
|
||||
const client = await this.getClient();
|
||||
|
||||
const result = await client.query(`
|
||||
SELECT
|
||||
sqlstate,
|
||||
message,
|
||||
detail,
|
||||
constraint_name
|
||||
FROM pg_last_error_log()
|
||||
WHERE sqlstate IN ('23505', '23503', '23514')
|
||||
AND log_time > $1
|
||||
ORDER BY log_time DESC
|
||||
`, [since]);
|
||||
|
||||
return (result.rows as { message: string }[]).map(r => r.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get table constraints
|
||||
*/
|
||||
async getTableConstraints(tableName: string): Promise<unknown[]> {
|
||||
const client = await this.getClient();
|
||||
|
||||
const result = await client.query(`
|
||||
SELECT
|
||||
conname as constraint_name,
|
||||
contype as constraint_type,
|
||||
pg_get_constraintdef(oid) as definition
|
||||
FROM pg_constraint
|
||||
WHERE conrelid = $1::regclass
|
||||
ORDER BY contype
|
||||
`, [tableName]);
|
||||
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close connection pool
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (this.client) {
|
||||
this.client.release();
|
||||
this.client = null;
|
||||
}
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
/**
|
||||
* Docker Manager for Integration Tests
|
||||
* Manages Docker Compose services for integration testing
|
||||
*/
|
||||
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import { setTimeout } from 'timers/promises';
|
||||
|
||||
export interface DockerServiceConfig {
|
||||
name: string;
|
||||
port: number;
|
||||
healthCheck: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export class DockerManager {
|
||||
private static instance: DockerManager;
|
||||
private services: Map<string, boolean> = new Map();
|
||||
private composeProject = 'gridpilot-test';
|
||||
private composeFile = 'docker-compose.test.yml';
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): DockerManager {
|
||||
if (!DockerManager.instance) {
|
||||
DockerManager.instance = new DockerManager();
|
||||
}
|
||||
return DockerManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Docker services are already running
|
||||
*/
|
||||
isRunning(): boolean {
|
||||
try {
|
||||
const output = execSync(
|
||||
`docker-compose -p ${this.composeProject} -f ${this.composeFile} ps -q 2>/dev/null || true`,
|
||||
{ encoding: 'utf8' }
|
||||
).trim();
|
||||
return output.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Docker services with dependency checking
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
console.log('[DockerManager] Starting test environment...');
|
||||
|
||||
if (this.isRunning()) {
|
||||
console.log('[DockerManager] Services already running, checking health...');
|
||||
await this.waitForServices();
|
||||
return;
|
||||
}
|
||||
|
||||
// Start services
|
||||
execSync(
|
||||
`COMPOSE_PARALLEL_LIMIT=1 docker-compose -p ${this.composeProject} -f ${this.composeFile} up -d ready api`,
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
|
||||
console.log('[DockerManager] Services starting, waiting for health...');
|
||||
await this.waitForServices();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all services to be healthy using polling
|
||||
*/
|
||||
async waitForServices(): Promise<void> {
|
||||
const services: DockerServiceConfig[] = [
|
||||
{
|
||||
name: 'db',
|
||||
port: 5433,
|
||||
healthCheck: 'pg_isready -U gridpilot_test_user -d gridpilot_test',
|
||||
timeout: 60000
|
||||
},
|
||||
{
|
||||
name: 'api',
|
||||
port: 3101,
|
||||
healthCheck: 'curl -f http://localhost:3101/health',
|
||||
timeout: 90000
|
||||
}
|
||||
];
|
||||
|
||||
for (const service of services) {
|
||||
await this.waitForService(service);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a single service to be healthy
|
||||
*/
|
||||
async waitForService(config: DockerServiceConfig): Promise<void> {
|
||||
const timeout = config.timeout || 30000;
|
||||
const startTime = Date.now();
|
||||
|
||||
console.log(`[DockerManager] Waiting for ${config.name}...`);
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
try {
|
||||
// Try health check command
|
||||
if (config.name === 'db') {
|
||||
// For DB, check if it's ready to accept connections
|
||||
try {
|
||||
execSync(
|
||||
`docker exec ${this.composeProject}-${config.name}-1 ${config.healthCheck} 2>/dev/null`,
|
||||
{ stdio: 'pipe' }
|
||||
);
|
||||
console.log(`[DockerManager] ✓ ${config.name} is healthy`);
|
||||
return;
|
||||
} catch {}
|
||||
} else {
|
||||
// For API, check HTTP endpoint
|
||||
const response = await fetch(`http://localhost:${config.port}/health`);
|
||||
if (response.ok) {
|
||||
console.log(`[DockerManager] ✓ ${config.name} is healthy`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Service not ready yet, continue waiting
|
||||
}
|
||||
|
||||
await setTimeout(1000);
|
||||
}
|
||||
|
||||
throw new Error(`[DockerManager] ${config.name} failed to become healthy within ${timeout}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop Docker services
|
||||
*/
|
||||
stop(): void {
|
||||
console.log('[DockerManager] Stopping test environment...');
|
||||
try {
|
||||
execSync(
|
||||
`docker-compose -p ${this.composeProject} -f ${this.composeFile} down --remove-orphans`,
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[DockerManager] Warning: Failed to stop services cleanly:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up volumes and containers
|
||||
*/
|
||||
clean(): void {
|
||||
console.log('[DockerManager] Cleaning up test environment...');
|
||||
try {
|
||||
execSync(
|
||||
`docker-compose -p ${this.composeProject} -f ${this.composeFile} down -v --remove-orphans --volumes`,
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('[DockerManager] Warning: Failed to clean up cleanly:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command in a service container
|
||||
*/
|
||||
execInService(service: string, command: string): string {
|
||||
try {
|
||||
return execSync(
|
||||
`docker exec ${this.composeProject}-${service}-1 ${command}`,
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to execute command in ${service}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service logs
|
||||
*/
|
||||
getLogs(service: string): string {
|
||||
try {
|
||||
return execSync(
|
||||
`docker logs ${this.composeProject}-${service}-1 --tail 100`,
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
} catch (error) {
|
||||
return `Failed to get logs: ${error}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
/**
|
||||
* Integration Test Harness - Main Entry Point
|
||||
* Provides reusable setup, teardown, and utilities for integration tests
|
||||
*/
|
||||
|
||||
import { DockerManager } from './docker-manager';
|
||||
import { DatabaseManager } from './database-manager';
|
||||
import { ApiClient } from './api-client';
|
||||
import { DataFactory } from './data-factory';
|
||||
|
||||
export interface IntegrationTestConfig {
|
||||
api: {
|
||||
baseUrl: string;
|
||||
port: number;
|
||||
};
|
||||
database: {
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
user: string;
|
||||
password: string;
|
||||
};
|
||||
timeouts?: {
|
||||
setup?: number;
|
||||
teardown?: number;
|
||||
test?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class IntegrationTestHarness {
|
||||
private docker: DockerManager;
|
||||
private database: DatabaseManager;
|
||||
private api: ApiClient;
|
||||
private factory: DataFactory;
|
||||
private config: IntegrationTestConfig;
|
||||
|
||||
constructor(config: IntegrationTestConfig) {
|
||||
this.config = {
|
||||
timeouts: {
|
||||
setup: 120000,
|
||||
teardown: 30000,
|
||||
test: 60000,
|
||||
...config.timeouts,
|
||||
},
|
||||
...config,
|
||||
};
|
||||
|
||||
this.docker = DockerManager.getInstance();
|
||||
this.database = new DatabaseManager(config.database);
|
||||
this.api = new ApiClient({ baseUrl: config.api.baseUrl, timeout: 60000 });
|
||||
|
||||
const { host, port, database, user, password } = config.database;
|
||||
const dbUrl = `postgresql://${user}:${password}@${host}:${port}/${database}`;
|
||||
this.factory = new DataFactory(dbUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup hook - starts Docker services and prepares database
|
||||
* Called once before all tests in a suite
|
||||
*/
|
||||
async beforeAll(): Promise<void> {
|
||||
console.log('[Harness] Starting integration test setup...');
|
||||
|
||||
// Start Docker services
|
||||
await this.docker.start();
|
||||
|
||||
// Wait for database to be ready
|
||||
await this.database.waitForReady(this.config.timeouts?.setup);
|
||||
|
||||
// Wait for API to be ready
|
||||
await this.api.waitForReady(this.config.timeouts?.setup);
|
||||
|
||||
console.log('[Harness] ✓ Setup complete - all services ready');
|
||||
}
|
||||
|
||||
/**
|
||||
* Teardown hook - stops Docker services and cleans up
|
||||
* Called once after all tests in a suite
|
||||
*/
|
||||
async afterAll(): Promise<void> {
|
||||
console.log('[Harness] Starting integration test teardown...');
|
||||
|
||||
try {
|
||||
await this.database.close();
|
||||
this.docker.stop();
|
||||
console.log('[Harness] ✓ Teardown complete');
|
||||
} catch (error) {
|
||||
console.warn('[Harness] Teardown warning:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup hook - prepares database for each test
|
||||
* Called before each test
|
||||
*/
|
||||
async beforeEach(): Promise<void> {
|
||||
// Truncate all tables to ensure clean state
|
||||
await this.database.truncateAllTables();
|
||||
|
||||
// Optionally seed minimal required data
|
||||
// await this.database.seedMinimalData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Teardown hook - cleanup after each test
|
||||
* Called after each test
|
||||
*/
|
||||
async afterEach(): Promise<void> {
|
||||
// Clean up any test-specific resources
|
||||
// This can be extended by individual tests
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database manager
|
||||
*/
|
||||
getDatabase(): DatabaseManager {
|
||||
return this.database;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API client
|
||||
*/
|
||||
getApi(): ApiClient {
|
||||
return this.api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Docker manager
|
||||
*/
|
||||
getDocker(): DockerManager {
|
||||
return this.docker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data factory
|
||||
*/
|
||||
getFactory(): DataFactory {
|
||||
return this.factory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute database transaction with automatic rollback
|
||||
* Useful for tests that need to verify transaction behavior
|
||||
*/
|
||||
async withTransaction<T>(callback: (db: DatabaseManager) => Promise<T>): Promise<T> {
|
||||
await this.database.begin();
|
||||
try {
|
||||
const result = await callback(this.database);
|
||||
await this.database.rollback(); // Always rollback in tests
|
||||
return result;
|
||||
} catch (error) {
|
||||
await this.database.rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to verify constraint violations
|
||||
*/
|
||||
async expectConstraintViolation(
|
||||
operation: () => Promise<unknown>,
|
||||
expectedConstraint?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await operation();
|
||||
throw new Error('Expected constraint violation but operation succeeded');
|
||||
} catch (error) {
|
||||
// Check if it's a constraint violation
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const isConstraintError =
|
||||
message.includes('constraint') ||
|
||||
message.includes('23505') || // Unique violation
|
||||
message.includes('23503') || // Foreign key violation
|
||||
message.includes('23514'); // Check violation
|
||||
|
||||
if (!isConstraintError) {
|
||||
throw new Error(`Expected constraint violation but got: ${message}`);
|
||||
}
|
||||
|
||||
if (expectedConstraint && !message.includes(expectedConstraint)) {
|
||||
throw new Error(`Expected constraint '${expectedConstraint}' but got: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default configuration for docker-compose.test.yml
|
||||
export const DEFAULT_TEST_CONFIG: IntegrationTestConfig = {
|
||||
api: {
|
||||
baseUrl: 'http://localhost:3101',
|
||||
port: 3101,
|
||||
},
|
||||
database: {
|
||||
host: 'localhost',
|
||||
port: 5433,
|
||||
database: 'gridpilot_test',
|
||||
user: 'gridpilot_test_user',
|
||||
password: 'gridpilot_test_pass',
|
||||
},
|
||||
timeouts: {
|
||||
setup: 120000,
|
||||
teardown: 30000,
|
||||
test: 60000,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a test harness with default configuration
|
||||
*/
|
||||
export function createTestHarness(config?: Partial<IntegrationTestConfig>): IntegrationTestHarness {
|
||||
const mergedConfig = {
|
||||
...DEFAULT_TEST_CONFIG,
|
||||
...config,
|
||||
api: { ...DEFAULT_TEST_CONFIG.api, ...config?.api },
|
||||
database: { ...DEFAULT_TEST_CONFIG.database, ...config?.database },
|
||||
timeouts: { ...DEFAULT_TEST_CONFIG.timeouts, ...config?.timeouts },
|
||||
};
|
||||
return new IntegrationTestHarness(mergedConfig);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* Integration Test: ApiClient
|
||||
*
|
||||
* Tests the ApiClient infrastructure for making HTTP requests
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { ApiClient } from '../api-client';
|
||||
|
||||
describe('ApiClient - Infrastructure Tests', () => {
|
||||
let apiClient: ApiClient;
|
||||
let mockServer: { close: () => void; port: number };
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create a mock HTTP server for testing
|
||||
const http = require('http');
|
||||
const server = http.createServer((req: any, res: any) => {
|
||||
if (req.url === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'ok' }));
|
||||
} else if (req.url === '/api/data') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ message: 'success', data: { id: 1, name: 'test' } }));
|
||||
} else if (req.url === '/api/error') {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Internal Server Error' }));
|
||||
} else if (req.url === '/api/slow') {
|
||||
// Simulate slow response
|
||||
setTimeout(() => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ message: 'slow response' }));
|
||||
}, 2000);
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, () => {
|
||||
const port = (server.address() as any).port;
|
||||
mockServer = { close: () => server.close(), port };
|
||||
apiClient = new ApiClient({ baseUrl: `http://localhost:${port}`, timeout: 5000 });
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (mockServer) {
|
||||
mockServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe('HTTP Methods', () => {
|
||||
it('should successfully make a GET request', async () => {
|
||||
const result = await apiClient.get<{ message: string; data: { id: number; name: string } }>('/api/data');
|
||||
expect(result.message).toBe('success');
|
||||
expect(result.data.id).toBe(1);
|
||||
});
|
||||
|
||||
it('should successfully make a POST request with body', async () => {
|
||||
const result = await apiClient.post<{ message: string }>('/api/data', { name: 'test' });
|
||||
expect(result.message).toBe('success');
|
||||
});
|
||||
|
||||
it('should successfully make a PUT request with body', async () => {
|
||||
const result = await apiClient.put<{ message: string }>('/api/data', { id: 1 });
|
||||
expect(result.message).toBe('success');
|
||||
});
|
||||
|
||||
it('should successfully make a PATCH request with body', async () => {
|
||||
const result = await apiClient.patch<{ message: string }>('/api/data', { name: 'patched' });
|
||||
expect(result.message).toBe('success');
|
||||
});
|
||||
|
||||
it('should successfully make a DELETE request', async () => {
|
||||
const result = await apiClient.delete<{ message: string }>('/api/data');
|
||||
expect(result.message).toBe('success');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling & Timeouts', () => {
|
||||
it('should handle HTTP errors gracefully', async () => {
|
||||
await expect(apiClient.get('/api/error')).rejects.toThrow('API Error 500');
|
||||
});
|
||||
|
||||
it('should handle timeout errors', async () => {
|
||||
const shortTimeoutClient = new ApiClient({
|
||||
baseUrl: `http://localhost:${mockServer.port}`,
|
||||
timeout: 100,
|
||||
});
|
||||
await expect(shortTimeoutClient.get('/api/slow')).rejects.toThrow('Request timeout after 100ms');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Health & Readiness', () => {
|
||||
it('should successfully check health endpoint', async () => {
|
||||
expect(await apiClient.health()).toBe(true);
|
||||
});
|
||||
|
||||
it('should wait for API to be ready', async () => {
|
||||
await apiClient.waitForReady(5000);
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* Integration Test: DataFactory
|
||||
*
|
||||
* Tests the DataFactory infrastructure for creating test data
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { setupHarnessTest } from '../HarnessTestContext';
|
||||
|
||||
describe('DataFactory - Infrastructure Tests', () => {
|
||||
const context = setupHarnessTest();
|
||||
|
||||
describe('Entity Creation', () => {
|
||||
it('should create a league entity', async () => {
|
||||
const league = await context.factory.createLeague({
|
||||
name: 'Test League',
|
||||
description: 'Test Description',
|
||||
});
|
||||
|
||||
expect(league).toBeDefined();
|
||||
expect(league.name).toBe('Test League');
|
||||
});
|
||||
|
||||
it('should create a season entity', async () => {
|
||||
const league = await context.factory.createLeague();
|
||||
const season = await context.factory.createSeason(league.id.toString(), {
|
||||
name: 'Test Season',
|
||||
});
|
||||
|
||||
expect(season).toBeDefined();
|
||||
expect(season.leagueId).toBe(league.id.toString());
|
||||
expect(season.name).toBe('Test Season');
|
||||
});
|
||||
|
||||
it('should create a driver entity', async () => {
|
||||
const driver = await context.factory.createDriver({
|
||||
name: 'Test Driver',
|
||||
});
|
||||
|
||||
expect(driver).toBeDefined();
|
||||
expect(driver.name.toString()).toBe('Test Driver');
|
||||
});
|
||||
|
||||
it('should create a race entity', async () => {
|
||||
const league = await context.factory.createLeague();
|
||||
const race = await context.factory.createRace({
|
||||
leagueId: league.id.toString(),
|
||||
track: 'Laguna Seca',
|
||||
});
|
||||
|
||||
expect(race).toBeDefined();
|
||||
expect(race.track).toBe('Laguna Seca');
|
||||
});
|
||||
|
||||
it('should create a result entity', async () => {
|
||||
const league = await context.factory.createLeague();
|
||||
const race = await context.factory.createRace({ leagueId: league.id.toString() });
|
||||
const driver = await context.factory.createDriver();
|
||||
|
||||
const result = await context.factory.createResult(race.id.toString(), driver.id.toString(), {
|
||||
position: 1,
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.position).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scenarios', () => {
|
||||
it('should create a complete test scenario', async () => {
|
||||
const scenario = await context.factory.createTestScenario();
|
||||
|
||||
expect(scenario.league).toBeDefined();
|
||||
expect(scenario.season).toBeDefined();
|
||||
expect(scenario.drivers).toHaveLength(3);
|
||||
expect(scenario.races).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* Integration Test: DatabaseManager
|
||||
*
|
||||
* Tests the DatabaseManager infrastructure for database operations
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { setupHarnessTest } from '../HarnessTestContext';
|
||||
|
||||
describe('DatabaseManager - Infrastructure Tests', () => {
|
||||
const context = setupHarnessTest();
|
||||
|
||||
describe('Query Execution', () => {
|
||||
it('should execute simple SELECT query', async () => {
|
||||
const result = await context.db.query('SELECT 1 as test_value');
|
||||
expect(result.rows[0].test_value).toBe(1);
|
||||
});
|
||||
|
||||
it('should execute query with parameters', async () => {
|
||||
const result = await context.db.query('SELECT $1 as param_value', ['test']);
|
||||
expect(result.rows[0].param_value).toBe('test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Transaction Handling', () => {
|
||||
it('should begin, commit and rollback transactions', async () => {
|
||||
// These methods should not throw
|
||||
await context.db.begin();
|
||||
await context.db.commit();
|
||||
await context.db.begin();
|
||||
await context.db.rollback();
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Table Operations', () => {
|
||||
it('should truncate all tables', async () => {
|
||||
// This verifies the truncate logic doesn't have syntax errors
|
||||
await context.db.truncateAllTables();
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Integration Test: IntegrationTestHarness
|
||||
*
|
||||
* Tests the IntegrationTestHarness orchestration capabilities
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { setupHarnessTest } from '../HarnessTestContext';
|
||||
|
||||
describe('IntegrationTestHarness - Orchestration Tests', () => {
|
||||
const context = setupHarnessTest();
|
||||
|
||||
describe('Accessors', () => {
|
||||
it('should provide access to all managers', () => {
|
||||
expect(context.testHarness.getDatabase()).toBeDefined();
|
||||
expect(context.testHarness.getApi()).toBeDefined();
|
||||
expect(context.testHarness.getDocker()).toBeDefined();
|
||||
expect(context.testHarness.getFactory()).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Transaction Management', () => {
|
||||
it('should execute callback within transaction and rollback', async () => {
|
||||
const result = await context.testHarness.withTransaction(async (db) => {
|
||||
const queryResult = await db.query('SELECT 1 as val');
|
||||
return queryResult.rows[0].val;
|
||||
});
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Constraint Violation Detection', () => {
|
||||
it('should detect constraint violations', async () => {
|
||||
await expect(
|
||||
context.testHarness.expectConstraintViolation(async () => {
|
||||
throw new Error('constraint violation: duplicate key');
|
||||
})
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should fail if no violation occurs', async () => {
|
||||
await expect(
|
||||
context.testHarness.expectConstraintViolation(async () => {
|
||||
// Success
|
||||
})
|
||||
).rejects.toThrow('Expected constraint violation but operation succeeded');
|
||||
});
|
||||
|
||||
it('should fail if different error occurs', async () => {
|
||||
await expect(
|
||||
context.testHarness.expectConstraintViolation(async () => {
|
||||
throw new Error('Some other error');
|
||||
})
|
||||
).rejects.toThrow('Expected constraint violation but got: Some other error');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { InMemoryLeagueRepository } from '../../../adapters/leagues/persistence/inmemory/InMemoryLeagueRepository';
|
||||
import { InMemoryDriverRepository } from '../../../adapters/drivers/persistence/inmemory/InMemoryDriverRepository';
|
||||
import { InMemoryEventPublisher } from '../../../adapters/events/InMemoryEventPublisher';
|
||||
import type { Logger } from '../../../core/shared/domain/Logger';
|
||||
import { CreateLeagueUseCase } from '../../../core/leagues/application/use-cases/CreateLeagueUseCase';
|
||||
import { GetLeagueUseCase } from '../../../core/leagues/application/use-cases/GetLeagueUseCase';
|
||||
import { GetLeagueRosterUseCase } from '../../../core/leagues/application/use-cases/GetLeagueRosterUseCase';
|
||||
@@ -13,6 +14,32 @@ import { DemoteAdminUseCase } from '../../../core/leagues/application/use-cases/
|
||||
import { RemoveMemberUseCase } from '../../../core/leagues/application/use-cases/RemoveMemberUseCase';
|
||||
import { LeagueCreateCommand } from '../../../core/leagues/application/ports/LeagueCreateCommand';
|
||||
|
||||
import { getPointsSystems } from '../../../adapters/bootstrap/PointsSystems';
|
||||
import { InMemoryLeagueRepository as InMemoryRacingLeagueRepository } from '../../../adapters/racing/persistence/inmemory/InMemoryLeagueRepository';
|
||||
import { InMemoryDriverRepository as InMemoryRacingDriverRepository } from '../../../adapters/racing/persistence/inmemory/InMemoryDriverRepository';
|
||||
import { InMemoryRaceRepository } from '../../../adapters/racing/persistence/inmemory/InMemoryRaceRepository';
|
||||
import { InMemoryResultRepository } from '../../../adapters/racing/persistence/inmemory/InMemoryResultRepository';
|
||||
import { InMemorySeasonRepository } from '../../../adapters/racing/persistence/inmemory/InMemorySeasonRepository';
|
||||
import { InMemorySeasonSponsorshipRepository } from '../../../adapters/racing/persistence/inmemory/InMemorySeasonSponsorshipRepository';
|
||||
import { InMemoryRaceRegistrationRepository } from '../../../adapters/racing/persistence/inmemory/InMemoryRaceRegistrationRepository';
|
||||
import { InMemoryLeagueMembershipRepository } from '../../../adapters/racing/persistence/inmemory/InMemoryLeagueMembershipRepository';
|
||||
import { InMemoryStandingRepository } from '../../../adapters/racing/persistence/inmemory/InMemoryStandingRepository';
|
||||
import { InMemoryPenaltyRepository } from '../../../adapters/racing/persistence/inmemory/InMemoryPenaltyRepository';
|
||||
import { InMemoryProtestRepository } from '../../../adapters/racing/persistence/inmemory/InMemoryProtestRepository';
|
||||
import { GetLeagueScheduleUseCase } from '../../../core/racing/application/use-cases/GetLeagueScheduleUseCase';
|
||||
import { CreateLeagueSeasonScheduleRaceUseCase } from '../../../core/racing/application/use-cases/CreateLeagueSeasonScheduleRaceUseCase';
|
||||
import { UpdateLeagueSeasonScheduleRaceUseCase } from '../../../core/racing/application/use-cases/UpdateLeagueSeasonScheduleRaceUseCase';
|
||||
import { DeleteLeagueSeasonScheduleRaceUseCase } from '../../../core/racing/application/use-cases/DeleteLeagueSeasonScheduleRaceUseCase';
|
||||
import { PublishLeagueSeasonScheduleUseCase } from '../../../core/racing/application/use-cases/PublishLeagueSeasonScheduleUseCase';
|
||||
import { UnpublishLeagueSeasonScheduleUseCase } from '../../../core/racing/application/use-cases/UnpublishLeagueSeasonScheduleUseCase';
|
||||
import { RegisterForRaceUseCase } from '../../../core/racing/application/use-cases/RegisterForRaceUseCase';
|
||||
import { WithdrawFromRaceUseCase } from '../../../core/racing/application/use-cases/WithdrawFromRaceUseCase';
|
||||
import { GetLeagueStandingsUseCase } from '../../../core/racing/application/use-cases/GetLeagueStandingsUseCase';
|
||||
import { InMemoryWalletRepository } from '../../../adapters/payments/persistence/inmemory/InMemoryWalletRepository';
|
||||
import { GetLeagueWalletUseCase } from '../../../core/racing/application/use-cases/GetLeagueWalletUseCase';
|
||||
import { WithdrawFromLeagueWalletUseCase } from '../../../core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase';
|
||||
import { InMemoryTransactionRepository } from '../../../adapters/racing/persistence/inmemory/InMemoryTransactionRepository';
|
||||
|
||||
export class LeaguesTestContext {
|
||||
public readonly leagueRepository: InMemoryLeagueRepository;
|
||||
public readonly driverRepository: InMemoryDriverRepository;
|
||||
@@ -29,6 +56,35 @@ export class LeaguesTestContext {
|
||||
public readonly demoteAdminUseCase: DemoteAdminUseCase;
|
||||
public readonly removeMemberUseCase: RemoveMemberUseCase;
|
||||
|
||||
public readonly logger: Logger;
|
||||
public readonly racingLeagueRepository: InMemoryRacingLeagueRepository;
|
||||
public readonly seasonRepository: InMemorySeasonRepository;
|
||||
public readonly seasonSponsorshipRepository: InMemorySeasonSponsorshipRepository;
|
||||
public readonly raceRepository: InMemoryRaceRepository;
|
||||
public readonly resultRepository: InMemoryResultRepository;
|
||||
public readonly standingRepository: InMemoryStandingRepository;
|
||||
public readonly racingDriverRepository: InMemoryRacingDriverRepository;
|
||||
public readonly raceRegistrationRepository: InMemoryRaceRegistrationRepository;
|
||||
public readonly leagueMembershipRepository: InMemoryLeagueMembershipRepository;
|
||||
public readonly penaltyRepository: InMemoryPenaltyRepository;
|
||||
public readonly protestRepository: InMemoryProtestRepository;
|
||||
|
||||
public readonly getLeagueScheduleUseCase: GetLeagueScheduleUseCase;
|
||||
public readonly createLeagueSeasonScheduleRaceUseCase: CreateLeagueSeasonScheduleRaceUseCase;
|
||||
public readonly updateLeagueSeasonScheduleRaceUseCase: UpdateLeagueSeasonScheduleRaceUseCase;
|
||||
public readonly deleteLeagueSeasonScheduleRaceUseCase: DeleteLeagueSeasonScheduleRaceUseCase;
|
||||
public readonly publishLeagueSeasonScheduleUseCase: PublishLeagueSeasonScheduleUseCase;
|
||||
public readonly unpublishLeagueSeasonScheduleUseCase: UnpublishLeagueSeasonScheduleUseCase;
|
||||
public readonly registerForRaceUseCase: RegisterForRaceUseCase;
|
||||
public readonly withdrawFromRaceUseCase: WithdrawFromRaceUseCase;
|
||||
public readonly getLeagueStandingsUseCase: GetLeagueStandingsUseCase;
|
||||
|
||||
public readonly walletRepository: InMemoryWalletRepository;
|
||||
public readonly transactionRepository: InMemoryTransactionRepository;
|
||||
|
||||
public readonly getLeagueWalletUseCase: GetLeagueWalletUseCase;
|
||||
public readonly withdrawFromLeagueWalletUseCase: WithdrawFromLeagueWalletUseCase;
|
||||
|
||||
constructor() {
|
||||
this.leagueRepository = new InMemoryLeagueRepository();
|
||||
this.driverRepository = new InMemoryDriverRepository();
|
||||
@@ -44,12 +100,114 @@ export class LeaguesTestContext {
|
||||
this.promoteMemberUseCase = new PromoteMemberUseCase(this.leagueRepository, this.driverRepository, this.eventPublisher);
|
||||
this.demoteAdminUseCase = new DemoteAdminUseCase(this.leagueRepository, this.driverRepository, this.eventPublisher);
|
||||
this.removeMemberUseCase = new RemoveMemberUseCase(this.leagueRepository, this.driverRepository, this.eventPublisher);
|
||||
|
||||
this.logger = {
|
||||
info: () => {},
|
||||
debug: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
} as unknown as Logger;
|
||||
|
||||
this.racingLeagueRepository = new InMemoryRacingLeagueRepository(this.logger);
|
||||
this.seasonRepository = new InMemorySeasonRepository(this.logger);
|
||||
this.seasonSponsorshipRepository = new InMemorySeasonSponsorshipRepository(this.logger);
|
||||
this.raceRepository = new InMemoryRaceRepository(this.logger);
|
||||
this.resultRepository = new InMemoryResultRepository(this.logger, this.raceRepository);
|
||||
this.standingRepository = new InMemoryStandingRepository(
|
||||
this.logger,
|
||||
getPointsSystems(),
|
||||
this.resultRepository,
|
||||
this.raceRepository,
|
||||
this.racingLeagueRepository,
|
||||
);
|
||||
this.racingDriverRepository = new InMemoryRacingDriverRepository(this.logger);
|
||||
this.raceRegistrationRepository = new InMemoryRaceRegistrationRepository(this.logger);
|
||||
this.leagueMembershipRepository = new InMemoryLeagueMembershipRepository(this.logger);
|
||||
this.penaltyRepository = new InMemoryPenaltyRepository(this.logger);
|
||||
this.protestRepository = new InMemoryProtestRepository(this.logger);
|
||||
|
||||
this.getLeagueScheduleUseCase = new GetLeagueScheduleUseCase(
|
||||
this.racingLeagueRepository,
|
||||
this.seasonRepository,
|
||||
this.raceRepository,
|
||||
this.logger,
|
||||
);
|
||||
|
||||
let raceIdSequence = 0;
|
||||
this.createLeagueSeasonScheduleRaceUseCase = new CreateLeagueSeasonScheduleRaceUseCase(
|
||||
this.seasonRepository,
|
||||
this.raceRepository,
|
||||
this.logger,
|
||||
{
|
||||
generateRaceId: () => `race-${++raceIdSequence}`,
|
||||
},
|
||||
);
|
||||
|
||||
this.updateLeagueSeasonScheduleRaceUseCase = new UpdateLeagueSeasonScheduleRaceUseCase(
|
||||
this.seasonRepository,
|
||||
this.raceRepository,
|
||||
this.logger,
|
||||
);
|
||||
|
||||
this.deleteLeagueSeasonScheduleRaceUseCase = new DeleteLeagueSeasonScheduleRaceUseCase(
|
||||
this.seasonRepository,
|
||||
this.raceRepository,
|
||||
this.logger,
|
||||
);
|
||||
|
||||
this.publishLeagueSeasonScheduleUseCase = new PublishLeagueSeasonScheduleUseCase(this.seasonRepository, this.logger);
|
||||
this.unpublishLeagueSeasonScheduleUseCase = new UnpublishLeagueSeasonScheduleUseCase(this.seasonRepository, this.logger);
|
||||
|
||||
this.registerForRaceUseCase = new RegisterForRaceUseCase(
|
||||
this.raceRegistrationRepository,
|
||||
this.leagueMembershipRepository,
|
||||
this.logger,
|
||||
);
|
||||
|
||||
this.withdrawFromRaceUseCase = new WithdrawFromRaceUseCase(
|
||||
this.raceRepository,
|
||||
this.raceRegistrationRepository,
|
||||
this.logger,
|
||||
);
|
||||
|
||||
this.getLeagueStandingsUseCase = new GetLeagueStandingsUseCase(
|
||||
this.standingRepository,
|
||||
this.racingDriverRepository,
|
||||
);
|
||||
|
||||
this.walletRepository = new InMemoryWalletRepository(this.logger);
|
||||
this.transactionRepository = new InMemoryTransactionRepository(this.logger);
|
||||
|
||||
this.getLeagueWalletUseCase = new GetLeagueWalletUseCase(
|
||||
this.racingLeagueRepository,
|
||||
this.walletRepository,
|
||||
this.transactionRepository,
|
||||
);
|
||||
|
||||
this.withdrawFromLeagueWalletUseCase = new WithdrawFromLeagueWalletUseCase(
|
||||
this.racingLeagueRepository,
|
||||
this.walletRepository,
|
||||
this.transactionRepository,
|
||||
this.logger,
|
||||
);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.leagueRepository.clear();
|
||||
this.driverRepository.clear();
|
||||
this.eventPublisher.clear();
|
||||
|
||||
this.racingLeagueRepository.clear();
|
||||
this.seasonRepository.clear();
|
||||
this.seasonSponsorshipRepository.clear();
|
||||
this.raceRepository.clear();
|
||||
this.leagueMembershipRepository.clear();
|
||||
|
||||
(this.raceRegistrationRepository as unknown as { registrations: Map<string, unknown> }).registrations?.clear?.();
|
||||
(this.resultRepository as unknown as { results: Map<string, unknown> }).results?.clear?.();
|
||||
(this.standingRepository as unknown as { standings: Map<string, unknown> }).standings?.clear?.();
|
||||
(this.racingDriverRepository as unknown as { drivers: Map<string, unknown> }).drivers?.clear?.();
|
||||
(this.racingDriverRepository as unknown as { iracingIdIndex: Map<string, unknown> }).iracingIdIndex?.clear?.();
|
||||
}
|
||||
|
||||
public async createLeague(command: Partial<LeagueCreateCommand> = {}) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,711 +0,0 @@
|
||||
/**
|
||||
* Integration Test: League Sponsorships Use Case Orchestration
|
||||
*
|
||||
* Tests the orchestration logic of league sponsorships-related Use Cases:
|
||||
* - GetLeagueSponsorshipsUseCase: Retrieves league sponsorships overview
|
||||
* - GetLeagueSponsorshipDetailsUseCase: Retrieves details of a specific sponsorship
|
||||
* - GetLeagueSponsorshipApplicationsUseCase: Retrieves sponsorship applications
|
||||
* - GetLeagueSponsorshipOffersUseCase: Retrieves sponsorship offers
|
||||
* - GetLeagueSponsorshipContractsUseCase: Retrieves sponsorship contracts
|
||||
* - GetLeagueSponsorshipPaymentsUseCase: Retrieves sponsorship payments
|
||||
* - GetLeagueSponsorshipReportsUseCase: Retrieves sponsorship reports
|
||||
* - GetLeagueSponsorshipStatisticsUseCase: Retrieves sponsorship statistics
|
||||
* - Validates that Use Cases correctly interact with their Ports (Repositories, Event Publishers)
|
||||
* - Uses In-Memory adapters for fast, deterministic testing
|
||||
*
|
||||
* Focus: Business logic orchestration, NOT UI rendering
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { InMemoryLeagueRepository } from '../../../adapters/leagues/persistence/inmemory/InMemoryLeagueRepository';
|
||||
import { InMemorySponsorshipRepository } from '../../../adapters/sponsorships/persistence/inmemory/InMemorySponsorshipRepository';
|
||||
import { InMemoryEventPublisher } from '../../../adapters/events/InMemoryEventPublisher';
|
||||
import { GetLeagueSponsorshipsUseCase } from '../../../core/leagues/use-cases/GetLeagueSponsorshipsUseCase';
|
||||
import { GetLeagueSponsorshipDetailsUseCase } from '../../../core/leagues/use-cases/GetLeagueSponsorshipDetailsUseCase';
|
||||
import { GetLeagueSponsorshipApplicationsUseCase } from '../../../core/leagues/use-cases/GetLeagueSponsorshipApplicationsUseCase';
|
||||
import { GetLeagueSponsorshipOffersUseCase } from '../../../core/leagues/use-cases/GetLeagueSponsorshipOffersUseCase';
|
||||
import { GetLeagueSponsorshipContractsUseCase } from '../../../core/leagues/use-cases/GetLeagueSponsorshipContractsUseCase';
|
||||
import { GetLeagueSponsorshipPaymentsUseCase } from '../../../core/leagues/use-cases/GetLeagueSponsorshipPaymentsUseCase';
|
||||
import { GetLeagueSponsorshipReportsUseCase } from '../../../core/leagues/use-cases/GetLeagueSponsorshipReportsUseCase';
|
||||
import { GetLeagueSponsorshipStatisticsUseCase } from '../../../core/leagues/use-cases/GetLeagueSponsorshipStatisticsUseCase';
|
||||
import { LeagueSponsorshipsQuery } from '../../../core/leagues/ports/LeagueSponsorshipsQuery';
|
||||
import { LeagueSponsorshipDetailsQuery } from '../../../core/leagues/ports/LeagueSponsorshipDetailsQuery';
|
||||
import { LeagueSponsorshipApplicationsQuery } from '../../../core/leagues/ports/LeagueSponsorshipApplicationsQuery';
|
||||
import { LeagueSponsorshipOffersQuery } from '../../../core/leagues/ports/LeagueSponsorshipOffersQuery';
|
||||
import { LeagueSponsorshipContractsQuery } from '../../../core/leagues/ports/LeagueSponsorshipContractsQuery';
|
||||
import { LeagueSponsorshipPaymentsQuery } from '../../../core/leagues/ports/LeagueSponsorshipPaymentsQuery';
|
||||
import { LeagueSponsorshipReportsQuery } from '../../../core/leagues/ports/LeagueSponsorshipReportsQuery';
|
||||
import { LeagueSponsorshipStatisticsQuery } from '../../../core/leagues/ports/LeagueSponsorshipStatisticsQuery';
|
||||
|
||||
describe('League Sponsorships Use Case Orchestration', () => {
|
||||
let leagueRepository: InMemoryLeagueRepository;
|
||||
let sponsorshipRepository: InMemorySponsorshipRepository;
|
||||
let eventPublisher: InMemoryEventPublisher;
|
||||
let getLeagueSponsorshipsUseCase: GetLeagueSponsorshipsUseCase;
|
||||
let getLeagueSponsorshipDetailsUseCase: GetLeagueSponsorshipDetailsUseCase;
|
||||
let getLeagueSponsorshipApplicationsUseCase: GetLeagueSponsorshipApplicationsUseCase;
|
||||
let getLeagueSponsorshipOffersUseCase: GetLeagueSponsorshipOffersUseCase;
|
||||
let getLeagueSponsorshipContractsUseCase: GetLeagueSponsorshipContractsUseCase;
|
||||
let getLeagueSponsorshipPaymentsUseCase: GetLeagueSponsorshipPaymentsUseCase;
|
||||
let getLeagueSponsorshipReportsUseCase: GetLeagueSponsorshipReportsUseCase;
|
||||
let getLeagueSponsorshipStatisticsUseCase: GetLeagueSponsorshipStatisticsUseCase;
|
||||
|
||||
beforeAll(() => {
|
||||
// TODO: Initialize In-Memory repositories and event publisher
|
||||
// leagueRepository = new InMemoryLeagueRepository();
|
||||
// sponsorshipRepository = new InMemorySponsorshipRepository();
|
||||
// eventPublisher = new InMemoryEventPublisher();
|
||||
// getLeagueSponsorshipsUseCase = new GetLeagueSponsorshipsUseCase({
|
||||
// leagueRepository,
|
||||
// sponsorshipRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
// getLeagueSponsorshipDetailsUseCase = new GetLeagueSponsorshipDetailsUseCase({
|
||||
// leagueRepository,
|
||||
// sponsorshipRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
// getLeagueSponsorshipApplicationsUseCase = new GetLeagueSponsorshipApplicationsUseCase({
|
||||
// leagueRepository,
|
||||
// sponsorshipRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
// getLeagueSponsorshipOffersUseCase = new GetLeagueSponsorshipOffersUseCase({
|
||||
// leagueRepository,
|
||||
// sponsorshipRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
// getLeagueSponsorshipContractsUseCase = new GetLeagueSponsorshipContractsUseCase({
|
||||
// leagueRepository,
|
||||
// sponsorshipRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
// getLeagueSponsorshipPaymentsUseCase = new GetLeagueSponsorshipPaymentsUseCase({
|
||||
// leagueRepository,
|
||||
// sponsorshipRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
// getLeagueSponsorshipReportsUseCase = new GetLeagueSponsorshipReportsUseCase({
|
||||
// leagueRepository,
|
||||
// sponsorshipRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
// getLeagueSponsorshipStatisticsUseCase = new GetLeagueSponsorshipStatisticsUseCase({
|
||||
// leagueRepository,
|
||||
// sponsorshipRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// TODO: Clear all In-Memory repositories before each test
|
||||
// leagueRepository.clear();
|
||||
// sponsorshipRepository.clear();
|
||||
// eventPublisher.clear();
|
||||
});
|
||||
|
||||
describe('GetLeagueSponsorshipsUseCase - Success Path', () => {
|
||||
it('should retrieve league sponsorships overview', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorships overview
|
||||
// Given: A league exists with sponsorships
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called with league ID
|
||||
// Then: The result should show sponsorships overview
|
||||
// And: EventPublisher should emit LeagueSponsorshipsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve active sponsorships', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views active sponsorships
|
||||
// Given: A league exists with active sponsorships
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called with league ID
|
||||
// Then: The result should show active sponsorships
|
||||
// And: EventPublisher should emit LeagueSponsorshipsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve pending sponsorships', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views pending sponsorships
|
||||
// Given: A league exists with pending sponsorships
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called with league ID
|
||||
// Then: The result should show pending sponsorships
|
||||
// And: EventPublisher should emit LeagueSponsorshipsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve expired sponsorships', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views expired sponsorships
|
||||
// Given: A league exists with expired sponsorships
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called with league ID
|
||||
// Then: The result should show expired sponsorships
|
||||
// And: EventPublisher should emit LeagueSponsorshipsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship statistics', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship statistics
|
||||
// Given: A league exists with sponsorship statistics
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called with league ID
|
||||
// Then: The result should show sponsorship statistics
|
||||
// And: EventPublisher should emit LeagueSponsorshipsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship revenue', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship revenue
|
||||
// Given: A league exists with sponsorship revenue
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called with league ID
|
||||
// Then: The result should show sponsorship revenue
|
||||
// And: EventPublisher should emit LeagueSponsorshipsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship exposure', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship exposure
|
||||
// Given: A league exists with sponsorship exposure
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called with league ID
|
||||
// Then: The result should show sponsorship exposure
|
||||
// And: EventPublisher should emit LeagueSponsorshipsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship reports', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship reports
|
||||
// Given: A league exists with sponsorship reports
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called with league ID
|
||||
// Then: The result should show sponsorship reports
|
||||
// And: EventPublisher should emit LeagueSponsorshipsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship activity log', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship activity log
|
||||
// Given: A league exists with sponsorship activity
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called with league ID
|
||||
// Then: The result should show sponsorship activity log
|
||||
// And: EventPublisher should emit LeagueSponsorshipsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship alerts', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship alerts
|
||||
// Given: A league exists with sponsorship alerts
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called with league ID
|
||||
// Then: The result should show sponsorship alerts
|
||||
// And: EventPublisher should emit LeagueSponsorshipsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship settings', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship settings
|
||||
// Given: A league exists with sponsorship settings
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called with league ID
|
||||
// Then: The result should show sponsorship settings
|
||||
// And: EventPublisher should emit LeagueSponsorshipsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship templates', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship templates
|
||||
// Given: A league exists with sponsorship templates
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called with league ID
|
||||
// Then: The result should show sponsorship templates
|
||||
// And: EventPublisher should emit LeagueSponsorshipsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship guidelines', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship guidelines
|
||||
// Given: A league exists with sponsorship guidelines
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called with league ID
|
||||
// Then: The result should show sponsorship guidelines
|
||||
// And: EventPublisher should emit LeagueSponsorshipsAccessedEvent
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLeagueSponsorshipsUseCase - Edge Cases', () => {
|
||||
it('should handle league with no sponsorships', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with no sponsorships
|
||||
// Given: A league exists
|
||||
// And: The league has no sponsorships
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called with league ID
|
||||
// Then: The result should show empty sponsorships list
|
||||
// And: EventPublisher should emit LeagueSponsorshipsAccessedEvent
|
||||
});
|
||||
|
||||
it('should handle league with no active sponsorships', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with no active sponsorships
|
||||
// Given: A league exists
|
||||
// And: The league has no active sponsorships
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called with league ID
|
||||
// Then: The result should show empty active sponsorships list
|
||||
// And: EventPublisher should emit LeagueSponsorshipsAccessedEvent
|
||||
});
|
||||
|
||||
it('should handle league with no pending sponsorships', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with no pending sponsorships
|
||||
// Given: A league exists
|
||||
// And: The league has no pending sponsorships
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called with league ID
|
||||
// Then: The result should show empty pending sponsorships list
|
||||
// And: EventPublisher should emit LeagueSponsorshipsAccessedEvent
|
||||
});
|
||||
|
||||
it('should handle league with no expired sponsorships', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with no expired sponsorships
|
||||
// Given: A league exists
|
||||
// And: The league has no expired sponsorships
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called with league ID
|
||||
// Then: The result should show empty expired sponsorships list
|
||||
// And: EventPublisher should emit LeagueSponsorshipsAccessedEvent
|
||||
});
|
||||
|
||||
it('should handle league with no sponsorship reports', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with no sponsorship reports
|
||||
// Given: A league exists
|
||||
// And: The league has no sponsorship reports
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called with league ID
|
||||
// Then: The result should show empty sponsorship reports list
|
||||
// And: EventPublisher should emit LeagueSponsorshipsAccessedEvent
|
||||
});
|
||||
|
||||
it('should handle league with no sponsorship alerts', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with no sponsorship alerts
|
||||
// Given: A league exists
|
||||
// And: The league has no sponsorship alerts
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called with league ID
|
||||
// Then: The result should show no alerts
|
||||
// And: EventPublisher should emit LeagueSponsorshipsAccessedEvent
|
||||
});
|
||||
|
||||
it('should handle league with no sponsorship templates', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with no sponsorship templates
|
||||
// Given: A league exists
|
||||
// And: The league has no sponsorship templates
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called with league ID
|
||||
// Then: The result should show no templates
|
||||
// And: EventPublisher should emit LeagueSponsorshipsAccessedEvent
|
||||
});
|
||||
|
||||
it('should handle league with no sponsorship guidelines', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with no sponsorship guidelines
|
||||
// Given: A league exists
|
||||
// And: The league has no sponsorship guidelines
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called with league ID
|
||||
// Then: The result should show no guidelines
|
||||
// And: EventPublisher should emit LeagueSponsorshipsAccessedEvent
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLeagueSponsorshipsUseCase - Error Handling', () => {
|
||||
it('should throw error when league does not exist', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Non-existent league
|
||||
// Given: No league exists with the given ID
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called with non-existent league ID
|
||||
// Then: Should throw LeagueNotFoundError
|
||||
// And: EventPublisher should NOT emit any events
|
||||
});
|
||||
|
||||
it('should throw error when league ID is invalid', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Invalid league ID
|
||||
// Given: An invalid league ID (e.g., empty string, null, undefined)
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called with invalid league ID
|
||||
// Then: Should throw ValidationError
|
||||
// And: EventPublisher should NOT emit any events
|
||||
});
|
||||
|
||||
it('should handle repository errors gracefully', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Repository throws error
|
||||
// Given: A league exists
|
||||
// And: SponsorshipRepository throws an error during query
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called
|
||||
// Then: Should propagate the error appropriately
|
||||
// And: EventPublisher should NOT emit any events
|
||||
});
|
||||
});
|
||||
|
||||
describe('League Sponsorships Data Orchestration', () => {
|
||||
it('should correctly format sponsorships overview', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Sponsorships overview formatting
|
||||
// Given: A league exists with sponsorships
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called
|
||||
// Then: Sponsorships overview should show:
|
||||
// - Total sponsorships
|
||||
// - Active sponsorships
|
||||
// - Pending sponsorships
|
||||
// - Expired sponsorships
|
||||
// - Total revenue
|
||||
});
|
||||
|
||||
it('should correctly format sponsorship details', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Sponsorship details formatting
|
||||
// Given: A league exists with sponsorships
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called
|
||||
// Then: Sponsorship details should show:
|
||||
// - Sponsor name
|
||||
// - Sponsorship type
|
||||
// - Amount
|
||||
// - Duration
|
||||
// - Status
|
||||
// - Start date
|
||||
// - End date
|
||||
});
|
||||
|
||||
it('should correctly format sponsorship statistics', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Sponsorship statistics formatting
|
||||
// Given: A league exists with sponsorship statistics
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called
|
||||
// Then: Sponsorship statistics should show:
|
||||
// - Total revenue
|
||||
// - Average sponsorship value
|
||||
// - Sponsorship growth rate
|
||||
// - Sponsor retention rate
|
||||
});
|
||||
|
||||
it('should correctly format sponsorship revenue', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Sponsorship revenue formatting
|
||||
// Given: A league exists with sponsorship revenue
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called
|
||||
// Then: Sponsorship revenue should show:
|
||||
// - Total revenue
|
||||
// - Revenue by sponsor
|
||||
// - Revenue by type
|
||||
// - Revenue by period
|
||||
});
|
||||
|
||||
it('should correctly format sponsorship exposure', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Sponsorship exposure formatting
|
||||
// Given: A league exists with sponsorship exposure
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called
|
||||
// Then: Sponsorship exposure should show:
|
||||
// - Impressions
|
||||
// - Clicks
|
||||
// - Engagement rate
|
||||
// - Brand visibility
|
||||
});
|
||||
|
||||
it('should correctly format sponsorship reports', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Sponsorship reports formatting
|
||||
// Given: A league exists with sponsorship reports
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called
|
||||
// Then: Sponsorship reports should show:
|
||||
// - Report type
|
||||
// - Report period
|
||||
// - Key metrics
|
||||
// - Recommendations
|
||||
});
|
||||
|
||||
it('should correctly format sponsorship activity log', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Sponsorship activity log formatting
|
||||
// Given: A league exists with sponsorship activity
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called
|
||||
// Then: Sponsorship activity log should show:
|
||||
// - Timestamp
|
||||
// - Action type
|
||||
// - User
|
||||
// - Details
|
||||
});
|
||||
|
||||
it('should correctly format sponsorship alerts', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Sponsorship alerts formatting
|
||||
// Given: A league exists with sponsorship alerts
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called
|
||||
// Then: Sponsorship alerts should show:
|
||||
// - Alert type
|
||||
// - Timestamp
|
||||
// - Details
|
||||
});
|
||||
|
||||
it('should correctly format sponsorship settings', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Sponsorship settings formatting
|
||||
// Given: A league exists with sponsorship settings
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called
|
||||
// Then: Sponsorship settings should show:
|
||||
// - Minimum sponsorship amount
|
||||
// - Maximum sponsorship amount
|
||||
// - Approval process
|
||||
// - Payment terms
|
||||
});
|
||||
|
||||
it('should correctly format sponsorship templates', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Sponsorship templates formatting
|
||||
// Given: A league exists with sponsorship templates
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called
|
||||
// Then: Sponsorship templates should show:
|
||||
// - Template name
|
||||
// - Template content
|
||||
// - Usage instructions
|
||||
});
|
||||
|
||||
it('should correctly format sponsorship guidelines', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Sponsorship guidelines formatting
|
||||
// Given: A league exists with sponsorship guidelines
|
||||
// When: GetLeagueSponsorshipsUseCase.execute() is called
|
||||
// Then: Sponsorship guidelines should show:
|
||||
// - Guidelines content
|
||||
// - Rules
|
||||
// - Restrictions
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLeagueSponsorshipDetailsUseCase - Success Path', () => {
|
||||
it('should retrieve sponsorship details', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship details
|
||||
// Given: A league exists with a sponsorship
|
||||
// When: GetLeagueSponsorshipDetailsUseCase.execute() is called with league ID and sponsorship ID
|
||||
// Then: The result should show sponsorship details
|
||||
// And: EventPublisher should emit LeagueSponsorshipDetailsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship with all metadata', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship with metadata
|
||||
// Given: A league exists with a sponsorship
|
||||
// When: GetLeagueSponsorshipDetailsUseCase.execute() is called with league ID and sponsorship ID
|
||||
// Then: The result should show sponsorship with all metadata
|
||||
// And: EventPublisher should emit LeagueSponsorshipDetailsAccessedEvent
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLeagueSponsorshipApplicationsUseCase - Success Path', () => {
|
||||
it('should retrieve sponsorship applications with pagination', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship applications with pagination
|
||||
// Given: A league exists with many sponsorship applications
|
||||
// When: GetLeagueSponsorshipApplicationsUseCase.execute() is called with league ID and pagination
|
||||
// Then: The result should show paginated sponsorship applications
|
||||
// And: EventPublisher should emit LeagueSponsorshipApplicationsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship applications filtered by status', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship applications filtered by status
|
||||
// Given: A league exists with sponsorship applications of different statuses
|
||||
// When: GetLeagueSponsorshipApplicationsUseCase.execute() is called with league ID and status filter
|
||||
// Then: The result should show filtered sponsorship applications
|
||||
// And: EventPublisher should emit LeagueSponsorshipApplicationsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship applications filtered by date range', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship applications filtered by date range
|
||||
// Given: A league exists with sponsorship applications over time
|
||||
// When: GetLeagueSponsorshipApplicationsUseCase.execute() is called with league ID and date range
|
||||
// Then: The result should show filtered sponsorship applications
|
||||
// And: EventPublisher should emit LeagueSponsorshipApplicationsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship applications sorted by date', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship applications sorted by date
|
||||
// Given: A league exists with sponsorship applications
|
||||
// When: GetLeagueSponsorshipApplicationsUseCase.execute() is called with league ID and sort order
|
||||
// Then: The result should show sorted sponsorship applications
|
||||
// And: EventPublisher should emit LeagueSponsorshipApplicationsAccessedEvent
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLeagueSponsorshipOffersUseCase - Success Path', () => {
|
||||
it('should retrieve sponsorship offers with pagination', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship offers with pagination
|
||||
// Given: A league exists with many sponsorship offers
|
||||
// When: GetLeagueSponsorshipOffersUseCase.execute() is called with league ID and pagination
|
||||
// Then: The result should show paginated sponsorship offers
|
||||
// And: EventPublisher should emit LeagueSponsorshipOffersAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship offers filtered by status', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship offers filtered by status
|
||||
// Given: A league exists with sponsorship offers of different statuses
|
||||
// When: GetLeagueSponsorshipOffersUseCase.execute() is called with league ID and status filter
|
||||
// Then: The result should show filtered sponsorship offers
|
||||
// And: EventPublisher should emit LeagueSponsorshipOffersAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship offers filtered by date range', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship offers filtered by date range
|
||||
// Given: A league exists with sponsorship offers over time
|
||||
// When: GetLeagueSponsorshipOffersUseCase.execute() is called with league ID and date range
|
||||
// Then: The result should show filtered sponsorship offers
|
||||
// And: EventPublisher should emit LeagueSponsorshipOffersAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship offers sorted by date', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship offers sorted by date
|
||||
// Given: A league exists with sponsorship offers
|
||||
// When: GetLeagueSponsorshipOffersUseCase.execute() is called with league ID and sort order
|
||||
// Then: The result should show sorted sponsorship offers
|
||||
// And: EventPublisher should emit LeagueSponsorshipOffersAccessedEvent
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLeagueSponsorshipContractsUseCase - Success Path', () => {
|
||||
it('should retrieve sponsorship contracts with pagination', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship contracts with pagination
|
||||
// Given: A league exists with many sponsorship contracts
|
||||
// When: GetLeagueSponsorshipContractsUseCase.execute() is called with league ID and pagination
|
||||
// Then: The result should show paginated sponsorship contracts
|
||||
// And: EventPublisher should emit LeagueSponsorshipContractsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship contracts filtered by status', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship contracts filtered by status
|
||||
// Given: A league exists with sponsorship contracts of different statuses
|
||||
// When: GetLeagueSponsorshipContractsUseCase.execute() is called with league ID and status filter
|
||||
// Then: The result should show filtered sponsorship contracts
|
||||
// And: EventPublisher should emit LeagueSponsorshipContractsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship contracts filtered by date range', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship contracts filtered by date range
|
||||
// Given: A league exists with sponsorship contracts over time
|
||||
// When: GetLeagueSponsorshipContractsUseCase.execute() is called with league ID and date range
|
||||
// Then: The result should show filtered sponsorship contracts
|
||||
// And: EventPublisher should emit LeagueSponsorshipContractsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship contracts sorted by date', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship contracts sorted by date
|
||||
// Given: A league exists with sponsorship contracts
|
||||
// When: GetLeagueSponsorshipContractsUseCase.execute() is called with league ID and sort order
|
||||
// Then: The result should show sorted sponsorship contracts
|
||||
// And: EventPublisher should emit LeagueSponsorshipContractsAccessedEvent
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLeagueSponsorshipPaymentsUseCase - Success Path', () => {
|
||||
it('should retrieve sponsorship payments with pagination', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship payments with pagination
|
||||
// Given: A league exists with many sponsorship payments
|
||||
// When: GetLeagueSponsorshipPaymentsUseCase.execute() is called with league ID and pagination
|
||||
// Then: The result should show paginated sponsorship payments
|
||||
// And: EventPublisher should emit LeagueSponsorshipPaymentsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship payments filtered by status', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship payments filtered by status
|
||||
// Given: A league exists with sponsorship payments of different statuses
|
||||
// When: GetLeagueSponsorshipPaymentsUseCase.execute() is called with league ID and status filter
|
||||
// Then: The result should show filtered sponsorship payments
|
||||
// And: EventPublisher should emit LeagueSponsorshipPaymentsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship payments filtered by date range', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship payments filtered by date range
|
||||
// Given: A league exists with sponsorship payments over time
|
||||
// When: GetLeagueSponsorshipPaymentsUseCase.execute() is called with league ID and date range
|
||||
// Then: The result should show filtered sponsorship payments
|
||||
// And: EventPublisher should emit LeagueSponsorshipPaymentsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship payments sorted by date', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship payments sorted by date
|
||||
// Given: A league exists with sponsorship payments
|
||||
// When: GetLeagueSponsorshipPaymentsUseCase.execute() is called with league ID and sort order
|
||||
// Then: The result should show sorted sponsorship payments
|
||||
// And: EventPublisher should emit LeagueSponsorshipPaymentsAccessedEvent
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLeagueSponsorshipReportsUseCase - Success Path', () => {
|
||||
it('should retrieve sponsorship reports with pagination', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship reports with pagination
|
||||
// Given: A league exists with many sponsorship reports
|
||||
// When: GetLeagueSponsorshipReportsUseCase.execute() is called with league ID and pagination
|
||||
// Then: The result should show paginated sponsorship reports
|
||||
// And: EventPublisher should emit LeagueSponsorshipReportsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship reports filtered by type', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship reports filtered by type
|
||||
// Given: A league exists with sponsorship reports of different types
|
||||
// When: GetLeagueSponsorshipReportsUseCase.execute() is called with league ID and type filter
|
||||
// Then: The result should show filtered sponsorship reports
|
||||
// And: EventPublisher should emit LeagueSponsorshipReportsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship reports filtered by date range', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship reports filtered by date range
|
||||
// Given: A league exists with sponsorship reports over time
|
||||
// When: GetLeagueSponsorshipReportsUseCase.execute() is called with league ID and date range
|
||||
// Then: The result should show filtered sponsorship reports
|
||||
// And: EventPublisher should emit LeagueSponsorshipReportsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship reports sorted by date', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship reports sorted by date
|
||||
// Given: A league exists with sponsorship reports
|
||||
// When: GetLeagueSponsorshipReportsUseCase.execute() is called with league ID and sort order
|
||||
// Then: The result should show sorted sponsorship reports
|
||||
// And: EventPublisher should emit LeagueSponsorshipReportsAccessedEvent
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLeagueSponsorshipStatisticsUseCase - Success Path', () => {
|
||||
it('should retrieve sponsorship statistics', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship statistics
|
||||
// Given: A league exists with sponsorship statistics
|
||||
// When: GetLeagueSponsorshipStatisticsUseCase.execute() is called with league ID
|
||||
// Then: The result should show sponsorship statistics
|
||||
// And: EventPublisher should emit LeagueSponsorshipStatisticsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship statistics with date range', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship statistics with date range
|
||||
// Given: A league exists with sponsorship statistics
|
||||
// When: GetLeagueSponsorshipStatisticsUseCase.execute() is called with league ID and date range
|
||||
// Then: The result should show sponsorship statistics for the date range
|
||||
// And: EventPublisher should emit LeagueSponsorshipStatisticsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve sponsorship statistics with granularity', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views sponsorship statistics with granularity
|
||||
// Given: A league exists with sponsorship statistics
|
||||
// When: GetLeagueSponsorshipStatisticsUseCase.execute() is called with league ID and granularity
|
||||
// Then: The result should show sponsorship statistics with the specified granularity
|
||||
// And: EventPublisher should emit LeagueSponsorshipStatisticsAccessedEvent
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,296 +0,0 @@
|
||||
/**
|
||||
* Integration Test: League Standings Use Case Orchestration
|
||||
*
|
||||
* Tests the orchestration logic of league standings-related Use Cases:
|
||||
* - GetLeagueStandingsUseCase: Retrieves championship standings with driver statistics
|
||||
* - Validates that Use Cases correctly interact with their Ports (Repositories, Event Publishers)
|
||||
* - Uses In-Memory adapters for fast, deterministic testing
|
||||
*
|
||||
* Focus: Business logic orchestration, NOT UI rendering
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { InMemoryLeagueRepository } from '../../../adapters/leagues/persistence/inmemory/InMemoryLeagueRepository';
|
||||
import { InMemoryDriverRepository } from '../../../adapters/drivers/persistence/inmemory/InMemoryDriverRepository';
|
||||
import { InMemoryRaceRepository } from '../../../adapters/races/persistence/inmemory/InMemoryRaceRepository';
|
||||
import { InMemoryEventPublisher } from '../../../adapters/events/InMemoryEventPublisher';
|
||||
import { GetLeagueStandingsUseCase } from '../../../core/leagues/use-cases/GetLeagueStandingsUseCase';
|
||||
import { LeagueStandingsQuery } from '../../../core/leagues/ports/LeagueStandingsQuery';
|
||||
|
||||
describe('League Standings Use Case Orchestration', () => {
|
||||
let leagueRepository: InMemoryLeagueRepository;
|
||||
let driverRepository: InMemoryDriverRepository;
|
||||
let raceRepository: InMemoryRaceRepository;
|
||||
let eventPublisher: InMemoryEventPublisher;
|
||||
let getLeagueStandingsUseCase: GetLeagueStandingsUseCase;
|
||||
|
||||
beforeAll(() => {
|
||||
// TODO: Initialize In-Memory repositories and event publisher
|
||||
// leagueRepository = new InMemoryLeagueRepository();
|
||||
// driverRepository = new InMemoryDriverRepository();
|
||||
// raceRepository = new InMemoryRaceRepository();
|
||||
// eventPublisher = new InMemoryEventPublisher();
|
||||
// getLeagueStandingsUseCase = new GetLeagueStandingsUseCase({
|
||||
// leagueRepository,
|
||||
// driverRepository,
|
||||
// raceRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// TODO: Clear all In-Memory repositories before each test
|
||||
// leagueRepository.clear();
|
||||
// driverRepository.clear();
|
||||
// raceRepository.clear();
|
||||
// eventPublisher.clear();
|
||||
});
|
||||
|
||||
describe('GetLeagueStandingsUseCase - Success Path', () => {
|
||||
it('should retrieve championship standings with all driver statistics', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with complete standings
|
||||
// Given: A league exists with multiple drivers
|
||||
// And: Each driver has points, wins, podiums, starts, DNFs
|
||||
// And: Each driver has win rate, podium rate, DNF rate
|
||||
// And: Each driver has average finish position
|
||||
// And: Each driver has best and worst finish position
|
||||
// And: Each driver has average points per race
|
||||
// And: Each driver has total points
|
||||
// And: Each driver has points behind leader
|
||||
// And: Each driver has points ahead of next driver
|
||||
// And: Each driver has gap to leader
|
||||
// And: Each driver has gap to next driver
|
||||
// When: GetLeagueStandingsUseCase.execute() is called with league ID
|
||||
// Then: The result should contain all drivers ranked by points
|
||||
// And: Each driver should display their position
|
||||
// And: EventPublisher should emit LeagueStandingsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve standings with minimal driver statistics', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with minimal standings
|
||||
// Given: A league exists with drivers who have minimal statistics
|
||||
// When: GetLeagueStandingsUseCase.execute() is called with league ID
|
||||
// Then: The result should contain drivers with basic statistics
|
||||
// And: EventPublisher should emit LeagueStandingsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve standings with drivers who have no recent results', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with drivers who have no recent results
|
||||
// Given: A league exists with drivers who have no recent results
|
||||
// When: GetLeagueStandingsUseCase.execute() is called with league ID
|
||||
// Then: The result should contain drivers with no recent results
|
||||
// And: EventPublisher should emit LeagueStandingsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve standings with drivers who have no career history', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with drivers who have no career history
|
||||
// Given: A league exists with drivers who have no career history
|
||||
// When: GetLeagueStandingsUseCase.execute() is called with league ID
|
||||
// Then: The result should contain drivers with no career history
|
||||
// And: EventPublisher should emit LeagueStandingsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve standings with drivers who have championship standings but no other data', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with drivers who have championship standings but no other data
|
||||
// Given: A league exists with drivers who have championship standings
|
||||
// And: The drivers have no career history
|
||||
// And: The drivers have no recent race results
|
||||
// When: GetLeagueStandingsUseCase.execute() is called with league ID
|
||||
// Then: The result should contain drivers with championship standings
|
||||
// And: Career history section should be empty
|
||||
// And: Recent race results section should be empty
|
||||
// And: EventPublisher should emit LeagueStandingsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve standings with drivers who have social links but no team affiliation', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with drivers who have social links but no team affiliation
|
||||
// Given: A league exists with drivers who have social links
|
||||
// And: The drivers have no team affiliation
|
||||
// When: GetLeagueStandingsUseCase.execute() is called with league ID
|
||||
// Then: The result should contain drivers with social links
|
||||
// And: Team affiliation section should be empty
|
||||
// And: EventPublisher should emit LeagueStandingsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve standings with drivers who have team affiliation but no social links', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with drivers who have team affiliation but no social links
|
||||
// Given: A league exists with drivers who have team affiliation
|
||||
// And: The drivers have no social links
|
||||
// When: GetLeagueStandingsUseCase.execute() is called with league ID
|
||||
// Then: The result should contain drivers with team affiliation
|
||||
// And: Social links section should be empty
|
||||
// And: EventPublisher should emit LeagueStandingsAccessedEvent
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLeagueStandingsUseCase - Edge Cases', () => {
|
||||
it('should handle drivers with no career history', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Drivers with no career history
|
||||
// Given: A league exists
|
||||
// And: The drivers have no career history
|
||||
// When: GetLeagueStandingsUseCase.execute() is called with league ID
|
||||
// Then: The result should contain drivers
|
||||
// And: Career history section should be empty
|
||||
// And: EventPublisher should emit LeagueStandingsAccessedEvent
|
||||
});
|
||||
|
||||
it('should handle drivers with no recent race results', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Drivers with no recent race results
|
||||
// Given: A league exists
|
||||
// And: The drivers have no recent race results
|
||||
// When: GetLeagueStandingsUseCase.execute() is called with league ID
|
||||
// Then: The result should contain drivers
|
||||
// And: Recent race results section should be empty
|
||||
// And: EventPublisher should emit LeagueStandingsAccessedEvent
|
||||
});
|
||||
|
||||
it('should handle drivers with no championship standings', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Drivers with no championship standings
|
||||
// Given: A league exists
|
||||
// And: The drivers have no championship standings
|
||||
// When: GetLeagueStandingsUseCase.execute() is called with league ID
|
||||
// Then: The result should contain drivers
|
||||
// And: Championship standings section should be empty
|
||||
// And: EventPublisher should emit LeagueStandingsAccessedEvent
|
||||
});
|
||||
|
||||
it('should handle drivers with no data at all', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Drivers with absolutely no data
|
||||
// Given: A league exists
|
||||
// And: The drivers have no statistics
|
||||
// And: The drivers have no career history
|
||||
// And: The drivers have no recent race results
|
||||
// And: The drivers have no championship standings
|
||||
// And: The drivers have no social links
|
||||
// And: The drivers have no team affiliation
|
||||
// When: GetLeagueStandingsUseCase.execute() is called with league ID
|
||||
// Then: The result should contain drivers
|
||||
// And: All sections should be empty or show default values
|
||||
// And: EventPublisher should emit LeagueStandingsAccessedEvent
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLeagueStandingsUseCase - Error Handling', () => {
|
||||
it('should throw error when league does not exist', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Non-existent league
|
||||
// Given: No league exists with the given ID
|
||||
// When: GetLeagueStandingsUseCase.execute() is called with non-existent league ID
|
||||
// Then: Should throw LeagueNotFoundError
|
||||
// And: EventPublisher should NOT emit any events
|
||||
});
|
||||
|
||||
it('should throw error when league ID is invalid', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Invalid league ID
|
||||
// Given: An invalid league ID (e.g., empty string, null, undefined)
|
||||
// When: GetLeagueStandingsUseCase.execute() is called with invalid league ID
|
||||
// Then: Should throw ValidationError
|
||||
// And: EventPublisher should NOT emit any events
|
||||
});
|
||||
|
||||
it('should handle repository errors gracefully', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Repository throws error
|
||||
// Given: A league exists
|
||||
// And: LeagueRepository throws an error during query
|
||||
// When: GetLeagueStandingsUseCase.execute() is called
|
||||
// Then: Should propagate the error appropriately
|
||||
// And: EventPublisher should NOT emit any events
|
||||
});
|
||||
});
|
||||
|
||||
describe('League Standings Data Orchestration', () => {
|
||||
it('should correctly calculate driver statistics from race results', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Driver statistics calculation
|
||||
// Given: A league exists
|
||||
// And: A driver has 10 completed races
|
||||
// And: The driver has 3 wins
|
||||
// And: The driver has 5 podiums
|
||||
// When: GetLeagueStandingsUseCase.execute() is called
|
||||
// Then: Driver statistics should show:
|
||||
// - Starts: 10
|
||||
// - Wins: 3
|
||||
// - Podiums: 5
|
||||
// - Rating: Calculated based on performance
|
||||
// - Rank: Calculated based on rating
|
||||
});
|
||||
|
||||
it('should correctly format career history with league and team information', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Career history formatting
|
||||
// Given: A league exists
|
||||
// And: A driver has participated in 2 leagues
|
||||
// And: The driver has been on 3 teams across seasons
|
||||
// When: GetLeagueStandingsUseCase.execute() is called
|
||||
// Then: Career history should show:
|
||||
// - League A: Season 2024, Team X
|
||||
// - League B: Season 2024, Team Y
|
||||
// - League A: Season 2023, Team Z
|
||||
});
|
||||
|
||||
it('should correctly format recent race results with proper details', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Recent race results formatting
|
||||
// Given: A league exists
|
||||
// And: A driver has 5 recent race results
|
||||
// When: GetLeagueStandingsUseCase.execute() is called
|
||||
// Then: Recent race results should show:
|
||||
// - Race name
|
||||
// - Track name
|
||||
// - Finishing position
|
||||
// - Points earned
|
||||
// - Race date (sorted newest first)
|
||||
});
|
||||
|
||||
it('should correctly aggregate championship standings across leagues', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Championship standings aggregation
|
||||
// Given: A league exists
|
||||
// And: A driver is in 2 championships
|
||||
// And: In Championship A: Position 5, 150 points, 20 drivers
|
||||
// And: In Championship B: Position 12, 85 points, 15 drivers
|
||||
// When: GetLeagueStandingsUseCase.execute() is called
|
||||
// Then: Championship standings should show:
|
||||
// - League A: Position 5, 150 points, 20 drivers
|
||||
// - League B: Position 12, 85 points, 15 drivers
|
||||
});
|
||||
|
||||
it('should correctly format social links with proper URLs', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Social links formatting
|
||||
// Given: A league exists
|
||||
// And: A driver has social links (Discord, Twitter, iRacing)
|
||||
// When: GetLeagueStandingsUseCase.execute() is called
|
||||
// Then: Social links should show:
|
||||
// - Discord: https://discord.gg/username
|
||||
// - Twitter: https://twitter.com/username
|
||||
// - iRacing: https://members.iracing.com/membersite/member/profile?username=username
|
||||
});
|
||||
|
||||
it('should correctly format team affiliation with role', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Team affiliation formatting
|
||||
// Given: A league exists
|
||||
// And: A driver is affiliated with Team XYZ
|
||||
// And: The driver's role is "Driver"
|
||||
// When: GetLeagueStandingsUseCase.execute() is called
|
||||
// Then: Team affiliation should show:
|
||||
// - Team name: Team XYZ
|
||||
// - Team logo: (if available)
|
||||
// - Driver role: Driver
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,487 +0,0 @@
|
||||
/**
|
||||
* Integration Test: League Stewarding Use Case Orchestration
|
||||
*
|
||||
* Tests the orchestration logic of league stewarding-related Use Cases:
|
||||
* - GetLeagueStewardingUseCase: Retrieves stewarding dashboard with pending protests, resolved cases, penalties
|
||||
* - ReviewProtestUseCase: Steward reviews a protest
|
||||
* - IssuePenaltyUseCase: Steward issues a penalty
|
||||
* - EditPenaltyUseCase: Steward edits an existing penalty
|
||||
* - RevokePenaltyUseCase: Steward revokes a penalty
|
||||
* - ReviewAppealUseCase: Steward reviews an appeal
|
||||
* - FinalizeProtestDecisionUseCase: Steward finalizes a protest decision
|
||||
* - FinalizeAppealDecisionUseCase: Steward finalizes an appeal decision
|
||||
* - NotifyDriversOfDecisionUseCase: Steward notifies drivers of a decision
|
||||
* - Validates that Use Cases correctly interact with their Ports (Repositories, Event Publishers)
|
||||
* - Uses In-Memory adapters for fast, deterministic testing
|
||||
*
|
||||
* Focus: Business logic orchestration, NOT UI rendering
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { InMemoryLeagueRepository } from '../../../adapters/leagues/persistence/inmemory/InMemoryLeagueRepository';
|
||||
import { InMemoryDriverRepository } from '../../../adapters/drivers/persistence/inmemory/InMemoryDriverRepository';
|
||||
import { InMemoryRaceRepository } from '../../../adapters/races/persistence/inmemory/InMemoryRaceRepository';
|
||||
import { InMemoryEventPublisher } from '../../../adapters/events/InMemoryEventPublisher';
|
||||
import { GetLeagueStewardingUseCase } from '../../../core/leagues/use-cases/GetLeagueStewardingUseCase';
|
||||
import { ReviewProtestUseCase } from '../../../core/leagues/use-cases/ReviewProtestUseCase';
|
||||
import { IssuePenaltyUseCase } from '../../../core/leagues/use-cases/IssuePenaltyUseCase';
|
||||
import { EditPenaltyUseCase } from '../../../core/leagues/use-cases/EditPenaltyUseCase';
|
||||
import { RevokePenaltyUseCase } from '../../../core/leagues/use-cases/RevokePenaltyUseCase';
|
||||
import { ReviewAppealUseCase } from '../../../core/leagues/use-cases/ReviewAppealUseCase';
|
||||
import { FinalizeProtestDecisionUseCase } from '../../../core/leagues/use-cases/FinalizeProtestDecisionUseCase';
|
||||
import { FinalizeAppealDecisionUseCase } from '../../../core/leagues/use-cases/FinalizeAppealDecisionUseCase';
|
||||
import { NotifyDriversOfDecisionUseCase } from '../../../core/leagues/use-cases/NotifyDriversOfDecisionUseCase';
|
||||
import { LeagueStewardingQuery } from '../../../core/leagues/ports/LeagueStewardingQuery';
|
||||
import { ReviewProtestCommand } from '../../../core/leagues/ports/ReviewProtestCommand';
|
||||
import { IssuePenaltyCommand } from '../../../core/leagues/ports/IssuePenaltyCommand';
|
||||
import { EditPenaltyCommand } from '../../../core/leagues/ports/EditPenaltyCommand';
|
||||
import { RevokePenaltyCommand } from '../../../core/leagues/ports/RevokePenaltyCommand';
|
||||
import { ReviewAppealCommand } from '../../../core/leagues/ports/ReviewAppealCommand';
|
||||
import { FinalizeProtestDecisionCommand } from '../../../core/leagues/ports/FinalizeProtestDecisionCommand';
|
||||
import { FinalizeAppealDecisionCommand } from '../../../core/leagues/ports/FinalizeAppealDecisionCommand';
|
||||
import { NotifyDriversOfDecisionCommand } from '../../../core/leagues/ports/NotifyDriversOfDecisionCommand';
|
||||
|
||||
describe('League Stewarding Use Case Orchestration', () => {
|
||||
let leagueRepository: InMemoryLeagueRepository;
|
||||
let driverRepository: InMemoryDriverRepository;
|
||||
let raceRepository: InMemoryRaceRepository;
|
||||
let eventPublisher: InMemoryEventPublisher;
|
||||
let getLeagueStewardingUseCase: GetLeagueStewardingUseCase;
|
||||
let reviewProtestUseCase: ReviewProtestUseCase;
|
||||
let issuePenaltyUseCase: IssuePenaltyUseCase;
|
||||
let editPenaltyUseCase: EditPenaltyUseCase;
|
||||
let revokePenaltyUseCase: RevokePenaltyUseCase;
|
||||
let reviewAppealUseCase: ReviewAppealUseCase;
|
||||
let finalizeProtestDecisionUseCase: FinalizeProtestDecisionUseCase;
|
||||
let finalizeAppealDecisionUseCase: FinalizeAppealDecisionUseCase;
|
||||
let notifyDriversOfDecisionUseCase: NotifyDriversOfDecisionUseCase;
|
||||
|
||||
beforeAll(() => {
|
||||
// TODO: Initialize In-Memory repositories and event publisher
|
||||
// leagueRepository = new InMemoryLeagueRepository();
|
||||
// driverRepository = new InMemoryDriverRepository();
|
||||
// raceRepository = new InMemoryRaceRepository();
|
||||
// eventPublisher = new InMemoryEventPublisher();
|
||||
// getLeagueStewardingUseCase = new GetLeagueStewardingUseCase({
|
||||
// leagueRepository,
|
||||
// driverRepository,
|
||||
// raceRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
// reviewProtestUseCase = new ReviewProtestUseCase({
|
||||
// leagueRepository,
|
||||
// driverRepository,
|
||||
// raceRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
// issuePenaltyUseCase = new IssuePenaltyUseCase({
|
||||
// leagueRepository,
|
||||
// driverRepository,
|
||||
// raceRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
// editPenaltyUseCase = new EditPenaltyUseCase({
|
||||
// leagueRepository,
|
||||
// driverRepository,
|
||||
// raceRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
// revokePenaltyUseCase = new RevokePenaltyUseCase({
|
||||
// leagueRepository,
|
||||
// driverRepository,
|
||||
// raceRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
// reviewAppealUseCase = new ReviewAppealUseCase({
|
||||
// leagueRepository,
|
||||
// driverRepository,
|
||||
// raceRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
// finalizeProtestDecisionUseCase = new FinalizeProtestDecisionUseCase({
|
||||
// leagueRepository,
|
||||
// driverRepository,
|
||||
// raceRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
// finalizeAppealDecisionUseCase = new FinalizeAppealDecisionUseCase({
|
||||
// leagueRepository,
|
||||
// driverRepository,
|
||||
// raceRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
// notifyDriversOfDecisionUseCase = new NotifyDriversOfDecisionUseCase({
|
||||
// leagueRepository,
|
||||
// driverRepository,
|
||||
// raceRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// TODO: Clear all In-Memory repositories before each test
|
||||
// leagueRepository.clear();
|
||||
// driverRepository.clear();
|
||||
// raceRepository.clear();
|
||||
// eventPublisher.clear();
|
||||
});
|
||||
|
||||
describe('GetLeagueStewardingUseCase - Success Path', () => {
|
||||
it('should retrieve stewarding dashboard with pending protests', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Steward views stewarding dashboard
|
||||
// Given: A league exists with pending protests
|
||||
// When: GetLeagueStewardingUseCase.execute() is called with league ID
|
||||
// Then: The result should show total pending protests
|
||||
// And: The result should show total resolved cases
|
||||
// And: The result should show total penalties issued
|
||||
// And: EventPublisher should emit LeagueStewardingAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve list of pending protests', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Steward views pending protests
|
||||
// Given: A league exists with pending protests
|
||||
// When: GetLeagueStewardingUseCase.execute() is called with league ID
|
||||
// Then: The result should show a list of pending protests
|
||||
// And: Each protest should display race, lap, drivers involved, and status
|
||||
// And: EventPublisher should emit LeagueStewardingAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve list of resolved cases', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Steward views resolved cases
|
||||
// Given: A league exists with resolved cases
|
||||
// When: GetLeagueStewardingUseCase.execute() is called with league ID
|
||||
// Then: The result should show a list of resolved cases
|
||||
// And: Each case should display the final decision
|
||||
// And: EventPublisher should emit LeagueStewardingAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve list of penalties', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Steward views penalty list
|
||||
// Given: A league exists with penalties
|
||||
// When: GetLeagueStewardingUseCase.execute() is called with league ID
|
||||
// Then: The result should show a list of all penalties issued
|
||||
// And: Each penalty should display driver, race, type, and status
|
||||
// And: EventPublisher should emit LeagueStewardingAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve stewarding statistics', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Steward views stewarding statistics
|
||||
// Given: A league exists with stewarding statistics
|
||||
// When: GetLeagueStewardingUseCase.execute() is called with league ID
|
||||
// Then: The result should show stewarding statistics
|
||||
// And: Statistics should include average resolution time, etc.
|
||||
// And: EventPublisher should emit LeagueStewardingAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve stewarding activity log', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Steward views stewarding activity log
|
||||
// Given: A league exists with stewarding activity
|
||||
// When: GetLeagueStewardingUseCase.execute() is called with league ID
|
||||
// Then: The result should show an activity log of all stewarding actions
|
||||
// And: EventPublisher should emit LeagueStewardingAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve steward performance metrics', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Steward views performance metrics
|
||||
// Given: A league exists with steward performance metrics
|
||||
// When: GetLeagueStewardingUseCase.execute() is called with league ID
|
||||
// Then: The result should show performance metrics for the stewarding team
|
||||
// And: EventPublisher should emit LeagueStewardingAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve steward workload', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Steward views workload
|
||||
// Given: A league exists with steward workload
|
||||
// When: GetLeagueStewardingUseCase.execute() is called with league ID
|
||||
// Then: The result should show the workload distribution among stewards
|
||||
// And: EventPublisher should emit LeagueStewardingAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve steward availability', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Steward views availability
|
||||
// Given: A league exists with steward availability
|
||||
// When: GetLeagueStewardingUseCase.execute() is called with league ID
|
||||
// Then: The result should show the availability of other stewards
|
||||
// And: EventPublisher should emit LeagueStewardingAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve stewarding notifications', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Steward views notifications
|
||||
// Given: A league exists with stewarding notifications
|
||||
// When: GetLeagueStewardingUseCase.execute() is called with league ID
|
||||
// Then: The result should show notifications for new protests, appeals, etc.
|
||||
// And: EventPublisher should emit LeagueStewardingAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve stewarding help and documentation', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Steward views help
|
||||
// Given: A league exists with stewarding help
|
||||
// When: GetLeagueStewardingUseCase.execute() is called with league ID
|
||||
// Then: The result should show links to stewarding help and documentation
|
||||
// And: EventPublisher should emit LeagueStewardingAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve stewarding templates', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Steward views templates
|
||||
// Given: A league exists with stewarding templates
|
||||
// When: GetLeagueStewardingUseCase.execute() is called with league ID
|
||||
// Then: The result should show stewarding decision templates
|
||||
// And: EventPublisher should emit LeagueStewardingAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve stewarding reports', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Steward views reports
|
||||
// Given: A league exists with stewarding reports
|
||||
// When: GetLeagueStewardingUseCase.execute() is called with league ID
|
||||
// Then: The result should show comprehensive stewarding reports
|
||||
// And: EventPublisher should emit LeagueStewardingAccessedEvent
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLeagueStewardingUseCase - Edge Cases', () => {
|
||||
it('should handle league with no pending protests', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with no pending protests
|
||||
// Given: A league exists
|
||||
// And: The league has no pending protests
|
||||
// When: GetLeagueStewardingUseCase.execute() is called with league ID
|
||||
// Then: The result should show 0 pending protests
|
||||
// And: EventPublisher should emit LeagueStewardingAccessedEvent
|
||||
});
|
||||
|
||||
it('should handle league with no resolved cases', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with no resolved cases
|
||||
// Given: A league exists
|
||||
// And: The league has no resolved cases
|
||||
// When: GetLeagueStewardingUseCase.execute() is called with league ID
|
||||
// Then: The result should show 0 resolved cases
|
||||
// And: EventPublisher should emit LeagueStewardingAccessedEvent
|
||||
});
|
||||
|
||||
it('should handle league with no penalties issued', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with no penalties issued
|
||||
// Given: A league exists
|
||||
// And: The league has no penalties issued
|
||||
// When: GetLeagueStewardingUseCase.execute() is called with league ID
|
||||
// Then: The result should show 0 penalties issued
|
||||
// And: EventPublisher should emit LeagueStewardingAccessedEvent
|
||||
});
|
||||
|
||||
it('should handle league with no stewarding activity', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with no stewarding activity
|
||||
// Given: A league exists
|
||||
// And: The league has no stewarding activity
|
||||
// When: GetLeagueStewardingUseCase.execute() is called with league ID
|
||||
// Then: The result should show empty activity log
|
||||
// And: EventPublisher should emit LeagueStewardingAccessedEvent
|
||||
});
|
||||
|
||||
it('should handle league with no stewarding notifications', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with no stewarding notifications
|
||||
// Given: A league exists
|
||||
// And: The league has no stewarding notifications
|
||||
// When: GetLeagueStewardingUseCase.execute() is called with league ID
|
||||
// Then: The result should show no notifications
|
||||
// And: EventPublisher should emit LeagueStewardingAccessedEvent
|
||||
});
|
||||
|
||||
it('should handle league with no stewarding templates', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with no stewarding templates
|
||||
// Given: A league exists
|
||||
// And: The league has no stewarding templates
|
||||
// When: GetLeagueStewardingUseCase.execute() is called with league ID
|
||||
// Then: The result should show no templates
|
||||
// And: EventPublisher should emit LeagueStewardingAccessedEvent
|
||||
});
|
||||
|
||||
it('should handle league with no stewarding reports', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with no stewarding reports
|
||||
// Given: A league exists
|
||||
// And: The league has no stewarding reports
|
||||
// When: GetLeagueStewardingUseCase.execute() is called with league ID
|
||||
// Then: The result should show no reports
|
||||
// And: EventPublisher should emit LeagueStewardingAccessedEvent
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLeagueStewardingUseCase - Error Handling', () => {
|
||||
it('should throw error when league does not exist', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Non-existent league
|
||||
// Given: No league exists with the given ID
|
||||
// When: GetLeagueStewardingUseCase.execute() is called with non-existent league ID
|
||||
// Then: Should throw LeagueNotFoundError
|
||||
// And: EventPublisher should NOT emit any events
|
||||
});
|
||||
|
||||
it('should throw error when league ID is invalid', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Invalid league ID
|
||||
// Given: An invalid league ID (e.g., empty string, null, undefined)
|
||||
// When: GetLeagueStewardingUseCase.execute() is called with invalid league ID
|
||||
// Then: Should throw ValidationError
|
||||
// And: EventPublisher should NOT emit any events
|
||||
});
|
||||
|
||||
it('should handle repository errors gracefully', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Repository throws error
|
||||
// Given: A league exists
|
||||
// And: LeagueRepository throws an error during query
|
||||
// When: GetLeagueStewardingUseCase.execute() is called
|
||||
// Then: Should propagate the error appropriately
|
||||
// And: EventPublisher should NOT emit any events
|
||||
});
|
||||
});
|
||||
|
||||
describe('League Stewarding Data Orchestration', () => {
|
||||
it('should correctly format protest details with evidence', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Protest details formatting
|
||||
// Given: A league exists with protests
|
||||
// When: GetLeagueStewardingUseCase.execute() is called
|
||||
// Then: Protest details should show:
|
||||
// - Race information
|
||||
// - Lap number
|
||||
// - Drivers involved
|
||||
// - Evidence (video links, screenshots)
|
||||
// - Status (pending, resolved)
|
||||
});
|
||||
|
||||
it('should correctly format penalty details with type and amount', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Penalty details formatting
|
||||
// Given: A league exists with penalties
|
||||
// When: GetLeagueStewardingUseCase.execute() is called
|
||||
// Then: Penalty details should show:
|
||||
// - Driver name
|
||||
// - Race information
|
||||
// - Penalty type
|
||||
// - Penalty amount
|
||||
// - Status (issued, revoked)
|
||||
});
|
||||
|
||||
it('should correctly format stewarding statistics', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Stewarding statistics formatting
|
||||
// Given: A league exists with stewarding statistics
|
||||
// When: GetLeagueStewardingUseCase.execute() is called
|
||||
// Then: Stewarding statistics should show:
|
||||
// - Average resolution time
|
||||
// - Average protest resolution time
|
||||
// - Average penalty appeal success rate
|
||||
// - Average protest success rate
|
||||
// - Average stewarding action success rate
|
||||
});
|
||||
|
||||
it('should correctly format stewarding activity log', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Stewarding activity log formatting
|
||||
// Given: A league exists with stewarding activity
|
||||
// When: GetLeagueStewardingUseCase.execute() is called
|
||||
// Then: Stewarding activity log should show:
|
||||
// - Timestamp
|
||||
// - Action type
|
||||
// - Steward name
|
||||
// - Details
|
||||
});
|
||||
|
||||
it('should correctly format steward performance metrics', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Steward performance metrics formatting
|
||||
// Given: A league exists with steward performance metrics
|
||||
// When: GetLeagueStewardingUseCase.execute() is called
|
||||
// Then: Steward performance metrics should show:
|
||||
// - Number of cases handled
|
||||
// - Average resolution time
|
||||
// - Success rate
|
||||
// - Workload distribution
|
||||
});
|
||||
|
||||
it('should correctly format steward workload distribution', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Steward workload distribution formatting
|
||||
// Given: A league exists with steward workload
|
||||
// When: GetLeagueStewardingUseCase.execute() is called
|
||||
// Then: Steward workload should show:
|
||||
// - Number of cases per steward
|
||||
// - Workload percentage
|
||||
// - Availability status
|
||||
});
|
||||
|
||||
it('should correctly format steward availability', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Steward availability formatting
|
||||
// Given: A league exists with steward availability
|
||||
// When: GetLeagueStewardingUseCase.execute() is called
|
||||
// Then: Steward availability should show:
|
||||
// - Steward name
|
||||
// - Availability status
|
||||
// - Next available time
|
||||
});
|
||||
|
||||
it('should correctly format stewarding notifications', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Stewarding notifications formatting
|
||||
// Given: A league exists with stewarding notifications
|
||||
// When: GetLeagueStewardingUseCase.execute() is called
|
||||
// Then: Stewarding notifications should show:
|
||||
// - Notification type
|
||||
// - Timestamp
|
||||
// - Details
|
||||
});
|
||||
|
||||
it('should correctly format stewarding help and documentation', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Stewarding help and documentation formatting
|
||||
// Given: A league exists with stewarding help
|
||||
// When: GetLeagueStewardingUseCase.execute() is called
|
||||
// Then: Stewarding help should show:
|
||||
// - Links to documentation
|
||||
// - Help articles
|
||||
// - Contact information
|
||||
});
|
||||
|
||||
it('should correctly format stewarding templates', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Stewarding templates formatting
|
||||
// Given: A league exists with stewarding templates
|
||||
// When: GetLeagueStewardingUseCase.execute() is called
|
||||
// Then: Stewarding templates should show:
|
||||
// - Template name
|
||||
// - Template content
|
||||
// - Usage instructions
|
||||
});
|
||||
|
||||
it('should correctly format stewarding reports', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Stewarding reports formatting
|
||||
// Given: A league exists with stewarding reports
|
||||
// When: GetLeagueStewardingUseCase.execute() is called
|
||||
// Then: Stewarding reports should show:
|
||||
// - Report type
|
||||
// - Report period
|
||||
// - Key metrics
|
||||
// - Recommendations
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,879 +0,0 @@
|
||||
/**
|
||||
* Integration Test: League Wallet Use Case Orchestration
|
||||
*
|
||||
* Tests the orchestration logic of league wallet-related Use Cases:
|
||||
* - GetLeagueWalletUseCase: Retrieves league wallet balance and transaction history
|
||||
* - GetLeagueWalletBalanceUseCase: Retrieves current league wallet balance
|
||||
* - GetLeagueWalletTransactionsUseCase: Retrieves league wallet transaction history
|
||||
* - GetLeagueWalletTransactionDetailsUseCase: Retrieves details of a specific transaction
|
||||
* - GetLeagueWalletWithdrawalHistoryUseCase: Retrieves withdrawal history
|
||||
* - GetLeagueWalletDepositHistoryUseCase: Retrieves deposit history
|
||||
* - GetLeagueWalletPayoutHistoryUseCase: Retrieves payout history
|
||||
* - GetLeagueWalletRefundHistoryUseCase: Retrieves refund history
|
||||
* - GetLeagueWalletFeeHistoryUseCase: Retrieves fee history
|
||||
* - GetLeagueWalletPrizeHistoryUseCase: Retrieves prize history
|
||||
* - Validates that Use Cases correctly interact with their Ports (Repositories, Event Publishers)
|
||||
* - Uses In-Memory adapters for fast, deterministic testing
|
||||
*
|
||||
* Focus: Business logic orchestration, NOT UI rendering
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { InMemoryLeagueRepository } from '../../../adapters/leagues/persistence/inmemory/InMemoryLeagueRepository';
|
||||
import { InMemoryWalletRepository } from '../../../adapters/payments/persistence/inmemory/InMemoryWalletRepository';
|
||||
import { InMemoryTransactionRepository } from '../../../adapters/payments/persistence/inmemory/InMemoryTransactionRepository';
|
||||
import { InMemoryEventPublisher } from '../../../adapters/events/InMemoryEventPublisher';
|
||||
import { GetLeagueWalletUseCase } from '../../../core/leagues/use-cases/GetLeagueWalletUseCase';
|
||||
import { GetLeagueWalletBalanceUseCase } from '../../../core/leagues/use-cases/GetLeagueWalletBalanceUseCase';
|
||||
import { GetLeagueWalletTransactionsUseCase } from '../../../core/leagues/use-cases/GetLeagueWalletTransactionsUseCase';
|
||||
import { GetLeagueWalletTransactionDetailsUseCase } from '../../../core/leagues/use-cases/GetLeagueWalletTransactionDetailsUseCase';
|
||||
import { GetLeagueWalletWithdrawalHistoryUseCase } from '../../../core/leagues/use-cases/GetLeagueWalletWithdrawalHistoryUseCase';
|
||||
import { GetLeagueWalletDepositHistoryUseCase } from '../../../core/leagues/use-cases/GetLeagueWalletDepositHistoryUseCase';
|
||||
import { GetLeagueWalletPayoutHistoryUseCase } from '../../../core/leagues/use-cases/GetLeagueWalletPayoutHistoryUseCase';
|
||||
import { GetLeagueWalletRefundHistoryUseCase } from '../../../core/leagues/use-cases/GetLeagueWalletRefundHistoryUseCase';
|
||||
import { GetLeagueWalletFeeHistoryUseCase } from '../../../core/leagues/use-cases/GetLeagueWalletFeeHistoryUseCase';
|
||||
import { GetLeagueWalletPrizeHistoryUseCase } from '../../../core/leagues/use-cases/GetLeagueWalletPrizeHistoryUseCase';
|
||||
import { LeagueWalletQuery } from '../../../core/leagues/ports/LeagueWalletQuery';
|
||||
import { LeagueWalletBalanceQuery } from '../../../core/leagues/ports/LeagueWalletBalanceQuery';
|
||||
import { LeagueWalletTransactionsQuery } from '../../../core/leagues/ports/LeagueWalletTransactionsQuery';
|
||||
import { LeagueWalletTransactionDetailsQuery } from '../../../core/leagues/ports/LeagueWalletTransactionDetailsQuery';
|
||||
import { LeagueWalletWithdrawalHistoryQuery } from '../../../core/leagues/ports/LeagueWalletWithdrawalHistoryQuery';
|
||||
import { LeagueWalletDepositHistoryQuery } from '../../../core/leagues/ports/LeagueWalletDepositHistoryQuery';
|
||||
import { LeagueWalletPayoutHistoryQuery } from '../../../core/leagues/ports/LeagueWalletPayoutHistoryQuery';
|
||||
import { LeagueWalletRefundHistoryQuery } from '../../../core/leagues/ports/LeagueWalletRefundHistoryQuery';
|
||||
import { LeagueWalletFeeHistoryQuery } from '../../../core/leagues/ports/LeagueWalletFeeHistoryQuery';
|
||||
import { LeagueWalletPrizeHistoryQuery } from '../../../core/leagues/ports/LeagueWalletPrizeHistoryQuery';
|
||||
|
||||
describe('League Wallet Use Case Orchestration', () => {
|
||||
let leagueRepository: InMemoryLeagueRepository;
|
||||
let walletRepository: InMemoryWalletRepository;
|
||||
let transactionRepository: InMemoryTransactionRepository;
|
||||
let eventPublisher: InMemoryEventPublisher;
|
||||
let getLeagueWalletUseCase: GetLeagueWalletUseCase;
|
||||
let getLeagueWalletBalanceUseCase: GetLeagueWalletBalanceUseCase;
|
||||
let getLeagueWalletTransactionsUseCase: GetLeagueWalletTransactionsUseCase;
|
||||
let getLeagueWalletTransactionDetailsUseCase: GetLeagueWalletTransactionDetailsUseCase;
|
||||
let getLeagueWalletWithdrawalHistoryUseCase: GetLeagueWalletWithdrawalHistoryUseCase;
|
||||
let getLeagueWalletDepositHistoryUseCase: GetLeagueWalletDepositHistoryUseCase;
|
||||
let getLeagueWalletPayoutHistoryUseCase: GetLeagueWalletPayoutHistoryUseCase;
|
||||
let getLeagueWalletRefundHistoryUseCase: GetLeagueWalletRefundHistoryUseCase;
|
||||
let getLeagueWalletFeeHistoryUseCase: GetLeagueWalletFeeHistoryUseCase;
|
||||
let getLeagueWalletPrizeHistoryUseCase: GetLeagueWalletPrizeHistoryUseCase;
|
||||
|
||||
beforeAll(() => {
|
||||
// TODO: Initialize In-Memory repositories and event publisher
|
||||
// leagueRepository = new InMemoryLeagueRepository();
|
||||
// walletRepository = new InMemoryWalletRepository();
|
||||
// transactionRepository = new InMemoryTransactionRepository();
|
||||
// eventPublisher = new InMemoryEventPublisher();
|
||||
// getLeagueWalletUseCase = new GetLeagueWalletUseCase({
|
||||
// leagueRepository,
|
||||
// walletRepository,
|
||||
// transactionRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
// getLeagueWalletBalanceUseCase = new GetLeagueWalletBalanceUseCase({
|
||||
// leagueRepository,
|
||||
// walletRepository,
|
||||
// transactionRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
// getLeagueWalletTransactionsUseCase = new GetLeagueWalletTransactionsUseCase({
|
||||
// leagueRepository,
|
||||
// walletRepository,
|
||||
// transactionRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
// getLeagueWalletTransactionDetailsUseCase = new GetLeagueWalletTransactionDetailsUseCase({
|
||||
// leagueRepository,
|
||||
// walletRepository,
|
||||
// transactionRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
// getLeagueWalletWithdrawalHistoryUseCase = new GetLeagueWalletWithdrawalHistoryUseCase({
|
||||
// leagueRepository,
|
||||
// walletRepository,
|
||||
// transactionRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
// getLeagueWalletDepositHistoryUseCase = new GetLeagueWalletDepositHistoryUseCase({
|
||||
// leagueRepository,
|
||||
// walletRepository,
|
||||
// transactionRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
// getLeagueWalletPayoutHistoryUseCase = new GetLeagueWalletPayoutHistoryUseCase({
|
||||
// leagueRepository,
|
||||
// walletRepository,
|
||||
// transactionRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
// getLeagueWalletRefundHistoryUseCase = new GetLeagueWalletRefundHistoryUseCase({
|
||||
// leagueRepository,
|
||||
// walletRepository,
|
||||
// transactionRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
// getLeagueWalletFeeHistoryUseCase = new GetLeagueWalletFeeHistoryUseCase({
|
||||
// leagueRepository,
|
||||
// walletRepository,
|
||||
// transactionRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
// getLeagueWalletPrizeHistoryUseCase = new GetLeagueWalletPrizeHistoryUseCase({
|
||||
// leagueRepository,
|
||||
// walletRepository,
|
||||
// transactionRepository,
|
||||
// eventPublisher,
|
||||
// });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// TODO: Clear all In-Memory repositories before each test
|
||||
// leagueRepository.clear();
|
||||
// walletRepository.clear();
|
||||
// transactionRepository.clear();
|
||||
// eventPublisher.clear();
|
||||
});
|
||||
|
||||
describe('GetLeagueWalletUseCase - Success Path', () => {
|
||||
it('should retrieve league wallet overview', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views league wallet overview
|
||||
// Given: A league exists with a wallet
|
||||
// When: GetLeagueWalletUseCase.execute() is called with league ID
|
||||
// Then: The result should show wallet overview
|
||||
// And: EventPublisher should emit LeagueWalletAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve wallet balance', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views wallet balance
|
||||
// Given: A league exists with a wallet
|
||||
// When: GetLeagueWalletUseCase.execute() is called with league ID
|
||||
// Then: The result should show current balance
|
||||
// And: EventPublisher should emit LeagueWalletAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve transaction history', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views transaction history
|
||||
// Given: A league exists with transactions
|
||||
// When: GetLeagueWalletUseCase.execute() is called with league ID
|
||||
// Then: The result should show transaction history
|
||||
// And: EventPublisher should emit LeagueWalletAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve withdrawal history', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views withdrawal history
|
||||
// Given: A league exists with withdrawals
|
||||
// When: GetLeagueWalletUseCase.execute() is called with league ID
|
||||
// Then: The result should show withdrawal history
|
||||
// And: EventPublisher should emit LeagueWalletAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve deposit history', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views deposit history
|
||||
// Given: A league exists with deposits
|
||||
// When: GetLeagueWalletUseCase.execute() is called with league ID
|
||||
// Then: The result should show deposit history
|
||||
// And: EventPublisher should emit LeagueWalletAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve payout history', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views payout history
|
||||
// Given: A league exists with payouts
|
||||
// When: GetLeagueWalletUseCase.execute() is called with league ID
|
||||
// Then: The result should show payout history
|
||||
// And: EventPublisher should emit LeagueWalletAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve refund history', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views refund history
|
||||
// Given: A league exists with refunds
|
||||
// When: GetLeagueWalletUseCase.execute() is called with league ID
|
||||
// Then: The result should show refund history
|
||||
// And: EventPublisher should emit LeagueWalletAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve fee history', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views fee history
|
||||
// Given: A league exists with fees
|
||||
// When: GetLeagueWalletUseCase.execute() is called with league ID
|
||||
// Then: The result should show fee history
|
||||
// And: EventPublisher should emit LeagueWalletAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve prize history', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views prize history
|
||||
// Given: A league exists with prizes
|
||||
// When: GetLeagueWalletUseCase.execute() is called with league ID
|
||||
// Then: The result should show prize history
|
||||
// And: EventPublisher should emit LeagueWalletAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve wallet statistics', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views wallet statistics
|
||||
// Given: A league exists with wallet statistics
|
||||
// When: GetLeagueWalletUseCase.execute() is called with league ID
|
||||
// Then: The result should show wallet statistics
|
||||
// And: EventPublisher should emit LeagueWalletAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve wallet activity log', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views wallet activity log
|
||||
// Given: A league exists with wallet activity
|
||||
// When: GetLeagueWalletUseCase.execute() is called with league ID
|
||||
// Then: The result should show wallet activity log
|
||||
// And: EventPublisher should emit LeagueWalletAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve wallet alerts', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views wallet alerts
|
||||
// Given: A league exists with wallet alerts
|
||||
// When: GetLeagueWalletUseCase.execute() is called with league ID
|
||||
// Then: The result should show wallet alerts
|
||||
// And: EventPublisher should emit LeagueWalletAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve wallet settings', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views wallet settings
|
||||
// Given: A league exists with wallet settings
|
||||
// When: GetLeagueWalletUseCase.execute() is called with league ID
|
||||
// Then: The result should show wallet settings
|
||||
// And: EventPublisher should emit LeagueWalletAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve wallet reports', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views wallet reports
|
||||
// Given: A league exists with wallet reports
|
||||
// When: GetLeagueWalletUseCase.execute() is called with league ID
|
||||
// Then: The result should show wallet reports
|
||||
// And: EventPublisher should emit LeagueWalletAccessedEvent
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLeagueWalletUseCase - Edge Cases', () => {
|
||||
it('should handle league with no transactions', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with no transactions
|
||||
// Given: A league exists
|
||||
// And: The league has no transactions
|
||||
// When: GetLeagueWalletUseCase.execute() is called with league ID
|
||||
// Then: The result should show empty transaction history
|
||||
// And: EventPublisher should emit LeagueWalletAccessedEvent
|
||||
});
|
||||
|
||||
it('should handle league with no withdrawals', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with no withdrawals
|
||||
// Given: A league exists
|
||||
// And: The league has no withdrawals
|
||||
// When: GetLeagueWalletUseCase.execute() is called with league ID
|
||||
// Then: The result should show empty withdrawal history
|
||||
// And: EventPublisher should emit LeagueWalletAccessedEvent
|
||||
});
|
||||
|
||||
it('should handle league with no deposits', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with no deposits
|
||||
// Given: A league exists
|
||||
// And: The league has no deposits
|
||||
// When: GetLeagueWalletUseCase.execute() is called with league ID
|
||||
// Then: The result should show empty deposit history
|
||||
// And: EventPublisher should emit LeagueWalletAccessedEvent
|
||||
});
|
||||
|
||||
it('should handle league with no payouts', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with no payouts
|
||||
// Given: A league exists
|
||||
// And: The league has no payouts
|
||||
// When: GetLeagueWalletUseCase.execute() is called with league ID
|
||||
// Then: The result should show empty payout history
|
||||
// And: EventPublisher should emit LeagueWalletAccessedEvent
|
||||
});
|
||||
|
||||
it('should handle league with no refunds', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with no refunds
|
||||
// Given: A league exists
|
||||
// And: The league has no refunds
|
||||
// When: GetLeagueWalletUseCase.execute() is called with league ID
|
||||
// Then: The result should show empty refund history
|
||||
// And: EventPublisher should emit LeagueWalletAccessedEvent
|
||||
});
|
||||
|
||||
it('should handle league with no fees', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with no fees
|
||||
// Given: A league exists
|
||||
// And: The league has no fees
|
||||
// When: GetLeagueWalletUseCase.execute() is called with league ID
|
||||
// Then: The result should show empty fee history
|
||||
// And: EventPublisher should emit LeagueWalletAccessedEvent
|
||||
});
|
||||
|
||||
it('should handle league with no prizes', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with no prizes
|
||||
// Given: A league exists
|
||||
// And: The league has no prizes
|
||||
// When: GetLeagueWalletUseCase.execute() is called with league ID
|
||||
// Then: The result should show empty prize history
|
||||
// And: EventPublisher should emit LeagueWalletAccessedEvent
|
||||
});
|
||||
|
||||
it('should handle league with no wallet alerts', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with no wallet alerts
|
||||
// Given: A league exists
|
||||
// And: The league has no wallet alerts
|
||||
// When: GetLeagueWalletUseCase.execute() is called with league ID
|
||||
// Then: The result should show no alerts
|
||||
// And: EventPublisher should emit LeagueWalletAccessedEvent
|
||||
});
|
||||
|
||||
it('should handle league with no wallet reports', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: League with no wallet reports
|
||||
// Given: A league exists
|
||||
// And: The league has no wallet reports
|
||||
// When: GetLeagueWalletUseCase.execute() is called with league ID
|
||||
// Then: The result should show no reports
|
||||
// And: EventPublisher should emit LeagueWalletAccessedEvent
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLeagueWalletUseCase - Error Handling', () => {
|
||||
it('should throw error when league does not exist', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Non-existent league
|
||||
// Given: No league exists with the given ID
|
||||
// When: GetLeagueWalletUseCase.execute() is called with non-existent league ID
|
||||
// Then: Should throw LeagueNotFoundError
|
||||
// And: EventPublisher should NOT emit any events
|
||||
});
|
||||
|
||||
it('should throw error when league ID is invalid', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Invalid league ID
|
||||
// Given: An invalid league ID (e.g., empty string, null, undefined)
|
||||
// When: GetLeagueWalletUseCase.execute() is called with invalid league ID
|
||||
// Then: Should throw ValidationError
|
||||
// And: EventPublisher should NOT emit any events
|
||||
});
|
||||
|
||||
it('should handle repository errors gracefully', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Repository throws error
|
||||
// Given: A league exists
|
||||
// And: WalletRepository throws an error during query
|
||||
// When: GetLeagueWalletUseCase.execute() is called
|
||||
// Then: Should propagate the error appropriately
|
||||
// And: EventPublisher should NOT emit any events
|
||||
});
|
||||
});
|
||||
|
||||
describe('League Wallet Data Orchestration', () => {
|
||||
it('should correctly format wallet balance', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Wallet balance formatting
|
||||
// Given: A league exists with a wallet
|
||||
// When: GetLeagueWalletUseCase.execute() is called
|
||||
// Then: Wallet balance should show:
|
||||
// - Current balance
|
||||
// - Available balance
|
||||
// - Pending balance
|
||||
// - Currency
|
||||
});
|
||||
|
||||
it('should correctly format transaction history', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Transaction history formatting
|
||||
// Given: A league exists with transactions
|
||||
// When: GetLeagueWalletUseCase.execute() is called
|
||||
// Then: Transaction history should show:
|
||||
// - Transaction ID
|
||||
// - Transaction type
|
||||
// - Amount
|
||||
// - Date
|
||||
// - Status
|
||||
// - Description
|
||||
});
|
||||
|
||||
it('should correctly format withdrawal history', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Withdrawal history formatting
|
||||
// Given: A league exists with withdrawals
|
||||
// When: GetLeagueWalletUseCase.execute() is called
|
||||
// Then: Withdrawal history should show:
|
||||
// - Withdrawal ID
|
||||
// - Amount
|
||||
// - Date
|
||||
// - Status
|
||||
// - Destination
|
||||
});
|
||||
|
||||
it('should correctly format deposit history', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Deposit history formatting
|
||||
// Given: A league exists with deposits
|
||||
// When: GetLeagueWalletUseCase.execute() is called
|
||||
// Then: Deposit history should show:
|
||||
// - Deposit ID
|
||||
// - Amount
|
||||
// - Date
|
||||
// - Status
|
||||
// - Source
|
||||
});
|
||||
|
||||
it('should correctly format payout history', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Payout history formatting
|
||||
// Given: A league exists with payouts
|
||||
// When: GetLeagueWalletUseCase.execute() is called
|
||||
// Then: Payout history should show:
|
||||
// - Payout ID
|
||||
// - Amount
|
||||
// - Date
|
||||
// - Status
|
||||
// - Recipient
|
||||
});
|
||||
|
||||
it('should correctly format refund history', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Refund history formatting
|
||||
// Given: A league exists with refunds
|
||||
// When: GetLeagueWalletUseCase.execute() is called
|
||||
// Then: Refund history should show:
|
||||
// - Refund ID
|
||||
// - Amount
|
||||
// - Date
|
||||
// - Status
|
||||
// - Reason
|
||||
});
|
||||
|
||||
it('should correctly format fee history', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Fee history formatting
|
||||
// Given: A league exists with fees
|
||||
// When: GetLeagueWalletUseCase.execute() is called
|
||||
// Then: Fee history should show:
|
||||
// - Fee ID
|
||||
// - Amount
|
||||
// - Date
|
||||
// - Type
|
||||
// - Description
|
||||
});
|
||||
|
||||
it('should correctly format prize history', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Prize history formatting
|
||||
// Given: A league exists with prizes
|
||||
// When: GetLeagueWalletUseCase.execute() is called
|
||||
// Then: Prize history should show:
|
||||
// - Prize ID
|
||||
// - Amount
|
||||
// - Date
|
||||
// - Type
|
||||
// - Recipient
|
||||
});
|
||||
|
||||
it('should correctly format wallet statistics', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Wallet statistics formatting
|
||||
// Given: A league exists with wallet statistics
|
||||
// When: GetLeagueWalletUseCase.execute() is called
|
||||
// Then: Wallet statistics should show:
|
||||
// - Total deposits
|
||||
// - Total withdrawals
|
||||
// - Total payouts
|
||||
// - Total fees
|
||||
// - Total prizes
|
||||
// - Net balance
|
||||
});
|
||||
|
||||
it('should correctly format wallet activity log', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Wallet activity log formatting
|
||||
// Given: A league exists with wallet activity
|
||||
// When: GetLeagueWalletUseCase.execute() is called
|
||||
// Then: Wallet activity log should show:
|
||||
// - Timestamp
|
||||
// - Action type
|
||||
// - User
|
||||
// - Details
|
||||
});
|
||||
|
||||
it('should correctly format wallet alerts', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Wallet alerts formatting
|
||||
// Given: A league exists with wallet alerts
|
||||
// When: GetLeagueWalletUseCase.execute() is called
|
||||
// Then: Wallet alerts should show:
|
||||
// - Alert type
|
||||
// - Timestamp
|
||||
// - Details
|
||||
});
|
||||
|
||||
it('should correctly format wallet settings', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Wallet settings formatting
|
||||
// Given: A league exists with wallet settings
|
||||
// When: GetLeagueWalletUseCase.execute() is called
|
||||
// Then: Wallet settings should show:
|
||||
// - Currency
|
||||
// - Auto-payout settings
|
||||
// - Fee settings
|
||||
// - Prize settings
|
||||
});
|
||||
|
||||
it('should correctly format wallet reports', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Wallet reports formatting
|
||||
// Given: A league exists with wallet reports
|
||||
// When: GetLeagueWalletUseCase.execute() is called
|
||||
// Then: Wallet reports should show:
|
||||
// - Report type
|
||||
// - Report period
|
||||
// - Key metrics
|
||||
// - Recommendations
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLeagueWalletBalanceUseCase - Success Path', () => {
|
||||
it('should retrieve current wallet balance', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views current wallet balance
|
||||
// Given: A league exists with a wallet
|
||||
// When: GetLeagueWalletBalanceUseCase.execute() is called with league ID
|
||||
// Then: The result should show current balance
|
||||
// And: EventPublisher should emit LeagueWalletBalanceAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve available balance', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views available balance
|
||||
// Given: A league exists with a wallet
|
||||
// When: GetLeagueWalletBalanceUseCase.execute() is called with league ID
|
||||
// Then: The result should show available balance
|
||||
// And: EventPublisher should emit LeagueWalletBalanceAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve pending balance', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views pending balance
|
||||
// Given: A league exists with a wallet
|
||||
// When: GetLeagueWalletBalanceUseCase.execute() is called with league ID
|
||||
// Then: The result should show pending balance
|
||||
// And: EventPublisher should emit LeagueWalletBalanceAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve balance in correct currency', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views balance in correct currency
|
||||
// Given: A league exists with a wallet
|
||||
// When: GetLeagueWalletBalanceUseCase.execute() is called with league ID
|
||||
// Then: The result should show balance in correct currency
|
||||
// And: EventPublisher should emit LeagueWalletBalanceAccessedEvent
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLeagueWalletTransactionsUseCase - Success Path', () => {
|
||||
it('should retrieve transaction history with pagination', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views transaction history with pagination
|
||||
// Given: A league exists with many transactions
|
||||
// When: GetLeagueWalletTransactionsUseCase.execute() is called with league ID and pagination
|
||||
// Then: The result should show paginated transaction history
|
||||
// And: EventPublisher should emit LeagueWalletTransactionsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve transaction history filtered by type', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views transaction history filtered by type
|
||||
// Given: A league exists with transactions of different types
|
||||
// When: GetLeagueWalletTransactionsUseCase.execute() is called with league ID and type filter
|
||||
// Then: The result should show filtered transaction history
|
||||
// And: EventPublisher should emit LeagueWalletTransactionsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve transaction history filtered by date range', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views transaction history filtered by date range
|
||||
// Given: A league exists with transactions over time
|
||||
// When: GetLeagueWalletTransactionsUseCase.execute() is called with league ID and date range
|
||||
// Then: The result should show filtered transaction history
|
||||
// And: EventPublisher should emit LeagueWalletTransactionsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve transaction history sorted by date', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views transaction history sorted by date
|
||||
// Given: A league exists with transactions
|
||||
// When: GetLeagueWalletTransactionsUseCase.execute() is called with league ID and sort order
|
||||
// Then: The result should show sorted transaction history
|
||||
// And: EventPublisher should emit LeagueWalletTransactionsAccessedEvent
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLeagueWalletTransactionDetailsUseCase - Success Path', () => {
|
||||
it('should retrieve transaction details', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views transaction details
|
||||
// Given: A league exists with a transaction
|
||||
// When: GetLeagueWalletTransactionDetailsUseCase.execute() is called with league ID and transaction ID
|
||||
// Then: The result should show transaction details
|
||||
// And: EventPublisher should emit LeagueWalletTransactionDetailsAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve transaction with all metadata', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views transaction with metadata
|
||||
// Given: A league exists with a transaction
|
||||
// When: GetLeagueWalletTransactionDetailsUseCase.execute() is called with league ID and transaction ID
|
||||
// Then: The result should show transaction with all metadata
|
||||
// And: EventPublisher should emit LeagueWalletTransactionDetailsAccessedEvent
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLeagueWalletWithdrawalHistoryUseCase - Success Path', () => {
|
||||
it('should retrieve withdrawal history with pagination', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views withdrawal history with pagination
|
||||
// Given: A league exists with many withdrawals
|
||||
// When: GetLeagueWalletWithdrawalHistoryUseCase.execute() is called with league ID and pagination
|
||||
// Then: The result should show paginated withdrawal history
|
||||
// And: EventPublisher should emit LeagueWalletWithdrawalHistoryAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve withdrawal history filtered by status', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views withdrawal history filtered by status
|
||||
// Given: A league exists with withdrawals of different statuses
|
||||
// When: GetLeagueWalletWithdrawalHistoryUseCase.execute() is called with league ID and status filter
|
||||
// Then: The result should show filtered withdrawal history
|
||||
// And: EventPublisher should emit LeagueWalletWithdrawalHistoryAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve withdrawal history filtered by date range', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views withdrawal history filtered by date range
|
||||
// Given: A league exists with withdrawals over time
|
||||
// When: GetLeagueWalletWithdrawalHistoryUseCase.execute() is called with league ID and date range
|
||||
// Then: The result should show filtered withdrawal history
|
||||
// And: EventPublisher should emit LeagueWalletWithdrawalHistoryAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve withdrawal history sorted by date', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views withdrawal history sorted by date
|
||||
// Given: A league exists with withdrawals
|
||||
// When: GetLeagueWalletWithdrawalHistoryUseCase.execute() is called with league ID and sort order
|
||||
// Then: The result should show sorted withdrawal history
|
||||
// And: EventPublisher should emit LeagueWalletWithdrawalHistoryAccessedEvent
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLeagueWalletDepositHistoryUseCase - Success Path', () => {
|
||||
it('should retrieve deposit history with pagination', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views deposit history with pagination
|
||||
// Given: A league exists with many deposits
|
||||
// When: GetLeagueWalletDepositHistoryUseCase.execute() is called with league ID and pagination
|
||||
// Then: The result should show paginated deposit history
|
||||
// And: EventPublisher should emit LeagueWalletDepositHistoryAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve deposit history filtered by status', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views deposit history filtered by status
|
||||
// Given: A league exists with deposits of different statuses
|
||||
// When: GetLeagueWalletDepositHistoryUseCase.execute() is called with league ID and status filter
|
||||
// Then: The result should show filtered deposit history
|
||||
// And: EventPublisher should emit LeagueWalletDepositHistoryAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve deposit history filtered by date range', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views deposit history filtered by date range
|
||||
// Given: A league exists with deposits over time
|
||||
// When: GetLeagueWalletDepositHistoryUseCase.execute() is called with league ID and date range
|
||||
// Then: The result should show filtered deposit history
|
||||
// And: EventPublisher should emit LeagueWalletDepositHistoryAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve deposit history sorted by date', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views deposit history sorted by date
|
||||
// Given: A league exists with deposits
|
||||
// When: GetLeagueWalletDepositHistoryUseCase.execute() is called with league ID and sort order
|
||||
// Then: The result should show sorted deposit history
|
||||
// And: EventPublisher should emit LeagueWalletDepositHistoryAccessedEvent
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLeagueWalletPayoutHistoryUseCase - Success Path', () => {
|
||||
it('should retrieve payout history with pagination', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views payout history with pagination
|
||||
// Given: A league exists with many payouts
|
||||
// When: GetLeagueWalletPayoutHistoryUseCase.execute() is called with league ID and pagination
|
||||
// Then: The result should show paginated payout history
|
||||
// And: EventPublisher should emit LeagueWalletPayoutHistoryAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve payout history filtered by status', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views payout history filtered by status
|
||||
// Given: A league exists with payouts of different statuses
|
||||
// When: GetLeagueWalletPayoutHistoryUseCase.execute() is called with league ID and status filter
|
||||
// Then: The result should show filtered payout history
|
||||
// And: EventPublisher should emit LeagueWalletPayoutHistoryAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve payout history filtered by date range', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views payout history filtered by date range
|
||||
// Given: A league exists with payouts over time
|
||||
// When: GetLeagueWalletPayoutHistoryUseCase.execute() is called with league ID and date range
|
||||
// Then: The result should show filtered payout history
|
||||
// And: EventPublisher should emit LeagueWalletPayoutHistoryAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve payout history sorted by date', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views payout history sorted by date
|
||||
// Given: A league exists with payouts
|
||||
// When: GetLeagueWalletPayoutHistoryUseCase.execute() is called with league ID and sort order
|
||||
// Then: The result should show sorted payout history
|
||||
// And: EventPublisher should emit LeagueWalletPayoutHistoryAccessedEvent
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLeagueWalletRefundHistoryUseCase - Success Path', () => {
|
||||
it('should retrieve refund history with pagination', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views refund history with pagination
|
||||
// Given: A league exists with many refunds
|
||||
// When: GetLeagueWalletRefundHistoryUseCase.execute() is called with league ID and pagination
|
||||
// Then: The result should show paginated refund history
|
||||
// And: EventPublisher should emit LeagueWalletRefundHistoryAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve refund history filtered by status', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views refund history filtered by status
|
||||
// Given: A league exists with refunds of different statuses
|
||||
// When: GetLeagueWalletRefundHistoryUseCase.execute() is called with league ID and status filter
|
||||
// Then: The result should show filtered refund history
|
||||
// And: EventPublisher should emit LeagueWalletRefundHistoryAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve refund history filtered by date range', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views refund history filtered by date range
|
||||
// Given: A league exists with refunds over time
|
||||
// When: GetLeagueWalletRefundHistoryUseCase.execute() is called with league ID and date range
|
||||
// Then: The result should show filtered refund history
|
||||
// And: EventPublisher should emit LeagueWalletRefundHistoryAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve refund history sorted by date', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views refund history sorted by date
|
||||
// Given: A league exists with refunds
|
||||
// When: GetLeagueWalletRefundHistoryUseCase.execute() is called with league ID and sort order
|
||||
// Then: The result should show sorted refund history
|
||||
// And: EventPublisher should emit LeagueWalletRefundHistoryAccessedEvent
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLeagueWalletFeeHistoryUseCase - Success Path', () => {
|
||||
it('should retrieve fee history with pagination', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views fee history with pagination
|
||||
// Given: A league exists with many fees
|
||||
// When: GetLeagueWalletFeeHistoryUseCase.execute() is called with league ID and pagination
|
||||
// Then: The result should show paginated fee history
|
||||
// And: EventPublisher should emit LeagueWalletFeeHistoryAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve fee history filtered by type', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views fee history filtered by type
|
||||
// Given: A league exists with fees of different types
|
||||
// When: GetLeagueWalletFeeHistoryUseCase.execute() is called with league ID and type filter
|
||||
// Then: The result should show filtered fee history
|
||||
// And: EventPublisher should emit LeagueWalletFeeHistoryAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve fee history filtered by date range', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views fee history filtered by date range
|
||||
// Given: A league exists with fees over time
|
||||
// When: GetLeagueWalletFeeHistoryUseCase.execute() is called with league ID and date range
|
||||
// Then: The result should show filtered fee history
|
||||
// And: EventPublisher should emit LeagueWalletFeeHistoryAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve fee history sorted by date', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views fee history sorted by date
|
||||
// Given: A league exists with fees
|
||||
// When: GetLeagueWalletFeeHistoryUseCase.execute() is called with league ID and sort order
|
||||
// Then: The result should show sorted fee history
|
||||
// And: EventPublisher should emit LeagueWalletFeeHistoryAccessedEvent
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetLeagueWalletPrizeHistoryUseCase - Success Path', () => {
|
||||
it('should retrieve prize history with pagination', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views prize history with pagination
|
||||
// Given: A league exists with many prizes
|
||||
// When: GetLeagueWalletPrizeHistoryUseCase.execute() is called with league ID and pagination
|
||||
// Then: The result should show paginated prize history
|
||||
// And: EventPublisher should emit LeagueWalletPrizeHistoryAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve prize history filtered by type', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views prize history filtered by type
|
||||
// Given: A league exists with prizes of different types
|
||||
// When: GetLeagueWalletPrizeHistoryUseCase.execute() is called with league ID and type filter
|
||||
// Then: The result should show filtered prize history
|
||||
// And: EventPublisher should emit LeagueWalletPrizeHistoryAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve prize history filtered by date range', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views prize history filtered by date range
|
||||
// Given: A league exists with prizes over time
|
||||
// When: GetLeagueWalletPrizeHistoryUseCase.execute() is called with league ID and date range
|
||||
// Then: The result should show filtered prize history
|
||||
// And: EventPublisher should emit LeagueWalletPrizeHistoryAccessedEvent
|
||||
});
|
||||
|
||||
it('should retrieve prize history sorted by date', async () => {
|
||||
// TODO: Implement test
|
||||
// Scenario: Admin views prize history sorted by date
|
||||
// Given: A league exists with prizes
|
||||
// When: GetLeagueWalletPrizeHistoryUseCase.execute() is called with league ID and sort order
|
||||
// Then: The result should show sorted prize history
|
||||
// And: EventPublisher should emit LeagueWalletPrizeHistoryAccessedEvent
|
||||
});
|
||||
});
|
||||
});
|
||||
239
tests/integration/leagues/schedule/GetLeagueSchedule.test.ts
Normal file
239
tests/integration/leagues/schedule/GetLeagueSchedule.test.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { LeaguesTestContext } from '../LeaguesTestContext';
|
||||
import { League as RacingLeague } from '../../../../core/racing/domain/entities/League';
|
||||
import { Season } from '../../../../core/racing/domain/entities/season/Season';
|
||||
import { Race } from '../../../../core/racing/domain/entities/Race';
|
||||
|
||||
describe('League Schedule - GetLeagueScheduleUseCase', () => {
|
||||
let context: LeaguesTestContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = new LeaguesTestContext();
|
||||
context.clear();
|
||||
});
|
||||
|
||||
const seedRacingLeague = async (params: { leagueId: string }) => {
|
||||
const league = RacingLeague.create({
|
||||
id: params.leagueId,
|
||||
name: 'Racing League',
|
||||
description: 'League used for schedule integration tests',
|
||||
ownerId: 'driver-123',
|
||||
});
|
||||
|
||||
await context.racingLeagueRepository.create(league);
|
||||
return league;
|
||||
};
|
||||
|
||||
const seedSeason = async (params: {
|
||||
seasonId: string;
|
||||
leagueId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
status?: 'planned' | 'active' | 'completed' | 'archived' | 'cancelled';
|
||||
schedulePublished?: boolean;
|
||||
}) => {
|
||||
const season = Season.create({
|
||||
id: params.seasonId,
|
||||
leagueId: params.leagueId,
|
||||
gameId: 'iracing',
|
||||
name: 'Season 1',
|
||||
status: params.status ?? 'active',
|
||||
startDate: params.startDate,
|
||||
endDate: params.endDate,
|
||||
...(params.schedulePublished !== undefined ? { schedulePublished: params.schedulePublished } : {}),
|
||||
});
|
||||
|
||||
await context.seasonRepository.add(season);
|
||||
return season;
|
||||
};
|
||||
|
||||
it('returns schedule for active season and races within season window', async () => {
|
||||
const leagueId = 'league-1';
|
||||
await seedRacingLeague({ leagueId });
|
||||
|
||||
const seasonId = 'season-jan';
|
||||
await seedSeason({
|
||||
seasonId,
|
||||
leagueId,
|
||||
startDate: new Date('2025-01-01T00:00:00Z'),
|
||||
endDate: new Date('2025-01-31T23:59:59Z'),
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const createRace1 = await context.createLeagueSeasonScheduleRaceUseCase.execute({
|
||||
leagueId,
|
||||
seasonId,
|
||||
track: 'Track A',
|
||||
car: 'Car A',
|
||||
scheduledAt: new Date('2025-01-10T20:00:00Z'),
|
||||
});
|
||||
expect(createRace1.isOk()).toBe(true);
|
||||
|
||||
const createRace2 = await context.createLeagueSeasonScheduleRaceUseCase.execute({
|
||||
leagueId,
|
||||
seasonId,
|
||||
track: 'Track B',
|
||||
car: 'Car B',
|
||||
scheduledAt: new Date('2025-01-20T20:00:00Z'),
|
||||
});
|
||||
expect(createRace2.isOk()).toBe(true);
|
||||
|
||||
const result = await context.getLeagueScheduleUseCase.execute({ leagueId });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const value = result.unwrap();
|
||||
expect(value.league.id.toString()).toBe(leagueId);
|
||||
expect(value.seasonId).toBe(seasonId);
|
||||
expect(value.published).toBe(false);
|
||||
expect(value.races.map(r => r.race.track)).toEqual(['Track A', 'Track B']);
|
||||
});
|
||||
|
||||
it('scopes schedule by seasonId (no season date bleed)', async () => {
|
||||
const leagueId = 'league-1';
|
||||
await seedRacingLeague({ leagueId });
|
||||
|
||||
const janSeasonId = 'season-jan';
|
||||
const febSeasonId = 'season-feb';
|
||||
|
||||
await seedSeason({
|
||||
seasonId: janSeasonId,
|
||||
leagueId,
|
||||
startDate: new Date('2025-01-01T00:00:00Z'),
|
||||
endDate: new Date('2025-01-31T23:59:59Z'),
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await seedSeason({
|
||||
seasonId: febSeasonId,
|
||||
leagueId,
|
||||
startDate: new Date('2025-02-01T00:00:00Z'),
|
||||
endDate: new Date('2025-02-28T23:59:59Z'),
|
||||
status: 'planned',
|
||||
});
|
||||
|
||||
const janRace = await context.createLeagueSeasonScheduleRaceUseCase.execute({
|
||||
leagueId,
|
||||
seasonId: janSeasonId,
|
||||
track: 'Track Jan',
|
||||
car: 'Car Jan',
|
||||
scheduledAt: new Date('2025-01-10T20:00:00Z'),
|
||||
});
|
||||
expect(janRace.isOk()).toBe(true);
|
||||
|
||||
const febRace = await context.createLeagueSeasonScheduleRaceUseCase.execute({
|
||||
leagueId,
|
||||
seasonId: febSeasonId,
|
||||
track: 'Track Feb',
|
||||
car: 'Car Feb',
|
||||
scheduledAt: new Date('2025-02-10T20:00:00Z'),
|
||||
});
|
||||
expect(febRace.isOk()).toBe(true);
|
||||
|
||||
const janResult = await context.getLeagueScheduleUseCase.execute({
|
||||
leagueId,
|
||||
seasonId: janSeasonId,
|
||||
});
|
||||
|
||||
expect(janResult.isOk()).toBe(true);
|
||||
const janValue = janResult.unwrap();
|
||||
expect(janValue.seasonId).toBe(janSeasonId);
|
||||
expect(janValue.races.map(r => r.race.track)).toEqual(['Track Jan']);
|
||||
|
||||
const febResult = await context.getLeagueScheduleUseCase.execute({
|
||||
leagueId,
|
||||
seasonId: febSeasonId,
|
||||
});
|
||||
|
||||
expect(febResult.isOk()).toBe(true);
|
||||
const febValue = febResult.unwrap();
|
||||
expect(febValue.seasonId).toBe(febSeasonId);
|
||||
expect(febValue.races.map(r => r.race.track)).toEqual(['Track Feb']);
|
||||
});
|
||||
|
||||
it('returns all races when no seasons exist for league', async () => {
|
||||
const leagueId = 'league-1';
|
||||
await seedRacingLeague({ leagueId });
|
||||
|
||||
await context.raceRepository.create(
|
||||
Race.create({
|
||||
id: 'race-1',
|
||||
leagueId,
|
||||
scheduledAt: new Date('2025-01-10T20:00:00Z'),
|
||||
track: 'Track 1',
|
||||
car: 'Car 1',
|
||||
}),
|
||||
);
|
||||
|
||||
await context.raceRepository.create(
|
||||
Race.create({
|
||||
id: 'race-2',
|
||||
leagueId,
|
||||
scheduledAt: new Date('2025-01-15T20:00:00Z'),
|
||||
track: 'Track 2',
|
||||
car: 'Car 2',
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await context.getLeagueScheduleUseCase.execute({ leagueId });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const value = result.unwrap();
|
||||
expect(value.seasonId).toBe('no-season');
|
||||
expect(value.published).toBe(false);
|
||||
expect(value.races.map(r => r.race.track)).toEqual(['Track 1', 'Track 2']);
|
||||
});
|
||||
|
||||
it('reflects schedule published state from the selected season', async () => {
|
||||
const leagueId = 'league-1';
|
||||
await seedRacingLeague({ leagueId });
|
||||
|
||||
const seasonId = 'season-1';
|
||||
await seedSeason({
|
||||
seasonId,
|
||||
leagueId,
|
||||
startDate: new Date('2025-01-01T00:00:00Z'),
|
||||
endDate: new Date('2025-01-31T23:59:59Z'),
|
||||
status: 'active',
|
||||
schedulePublished: false,
|
||||
});
|
||||
|
||||
const pre = await context.getLeagueScheduleUseCase.execute({ leagueId });
|
||||
expect(pre.isOk()).toBe(true);
|
||||
expect(pre.unwrap().published).toBe(false);
|
||||
|
||||
const publish = await context.publishLeagueSeasonScheduleUseCase.execute({ leagueId, seasonId });
|
||||
expect(publish.isOk()).toBe(true);
|
||||
|
||||
const post = await context.getLeagueScheduleUseCase.execute({ leagueId });
|
||||
expect(post.isOk()).toBe(true);
|
||||
expect(post.unwrap().published).toBe(true);
|
||||
});
|
||||
|
||||
it('returns LEAGUE_NOT_FOUND when league does not exist', async () => {
|
||||
const result = await context.getLeagueScheduleUseCase.execute({ leagueId: 'missing-league' });
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('LEAGUE_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('returns SEASON_NOT_FOUND when requested season does not belong to the league', async () => {
|
||||
const leagueId = 'league-1';
|
||||
await seedRacingLeague({ leagueId });
|
||||
|
||||
await seedSeason({
|
||||
seasonId: 'season-other',
|
||||
leagueId: 'league-other',
|
||||
startDate: new Date('2025-01-01T00:00:00Z'),
|
||||
endDate: new Date('2025-01-31T23:59:59Z'),
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
const result = await context.getLeagueScheduleUseCase.execute({
|
||||
leagueId,
|
||||
seasonId: 'season-other',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('SEASON_NOT_FOUND');
|
||||
});
|
||||
});
|
||||
174
tests/integration/leagues/schedule/RaceManagement.test.ts
Normal file
174
tests/integration/leagues/schedule/RaceManagement.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { LeaguesTestContext } from '../LeaguesTestContext';
|
||||
import { League as RacingLeague } from '../../../../core/racing/domain/entities/League';
|
||||
import { Season } from '../../../../core/racing/domain/entities/season/Season';
|
||||
|
||||
describe('League Schedule - Race Management', () => {
|
||||
let context: LeaguesTestContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = new LeaguesTestContext();
|
||||
context.clear();
|
||||
});
|
||||
|
||||
const seedRacingLeague = async (params: { leagueId: string }) => {
|
||||
const league = RacingLeague.create({
|
||||
id: params.leagueId,
|
||||
name: 'Racing League',
|
||||
description: 'League used for schedule integration tests',
|
||||
ownerId: 'driver-123',
|
||||
});
|
||||
|
||||
await context.racingLeagueRepository.create(league);
|
||||
return league;
|
||||
};
|
||||
|
||||
const seedSeason = async (params: {
|
||||
seasonId: string;
|
||||
leagueId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
status?: 'planned' | 'active' | 'completed' | 'archived' | 'cancelled';
|
||||
}) => {
|
||||
const season = Season.create({
|
||||
id: params.seasonId,
|
||||
leagueId: params.leagueId,
|
||||
gameId: 'iracing',
|
||||
name: 'Season 1',
|
||||
status: params.status ?? 'active',
|
||||
startDate: params.startDate,
|
||||
endDate: params.endDate,
|
||||
});
|
||||
|
||||
await context.seasonRepository.add(season);
|
||||
return season;
|
||||
};
|
||||
|
||||
it('creates a race in a season schedule', async () => {
|
||||
const leagueId = 'league-1';
|
||||
await seedRacingLeague({ leagueId });
|
||||
|
||||
const seasonId = 'season-1';
|
||||
await seedSeason({
|
||||
seasonId,
|
||||
leagueId,
|
||||
startDate: new Date('2025-01-01T00:00:00Z'),
|
||||
endDate: new Date('2025-01-31T23:59:59Z'),
|
||||
});
|
||||
|
||||
const create = await context.createLeagueSeasonScheduleRaceUseCase.execute({
|
||||
leagueId,
|
||||
seasonId,
|
||||
track: 'Monza',
|
||||
car: 'GT3',
|
||||
scheduledAt: new Date('2025-01-10T20:00:00Z'),
|
||||
});
|
||||
|
||||
expect(create.isOk()).toBe(true);
|
||||
const { raceId } = create.unwrap();
|
||||
|
||||
const schedule = await context.getLeagueScheduleUseCase.execute({ leagueId, seasonId });
|
||||
expect(schedule.isOk()).toBe(true);
|
||||
expect(schedule.unwrap().races.map(r => r.race.id)).toEqual([raceId]);
|
||||
});
|
||||
|
||||
it('updates an existing scheduled race (track/car/when)', async () => {
|
||||
const leagueId = 'league-1';
|
||||
await seedRacingLeague({ leagueId });
|
||||
|
||||
const seasonId = 'season-1';
|
||||
await seedSeason({
|
||||
seasonId,
|
||||
leagueId,
|
||||
startDate: new Date('2025-01-01T00:00:00Z'),
|
||||
endDate: new Date('2025-01-31T23:59:59Z'),
|
||||
});
|
||||
|
||||
const create = await context.createLeagueSeasonScheduleRaceUseCase.execute({
|
||||
leagueId,
|
||||
seasonId,
|
||||
track: 'Old Track',
|
||||
car: 'Old Car',
|
||||
scheduledAt: new Date('2025-01-10T20:00:00Z'),
|
||||
});
|
||||
expect(create.isOk()).toBe(true);
|
||||
const { raceId } = create.unwrap();
|
||||
|
||||
const update = await context.updateLeagueSeasonScheduleRaceUseCase.execute({
|
||||
leagueId,
|
||||
seasonId,
|
||||
raceId,
|
||||
track: 'New Track',
|
||||
car: 'New Car',
|
||||
scheduledAt: new Date('2025-01-20T20:00:00Z'),
|
||||
});
|
||||
|
||||
expect(update.isOk()).toBe(true);
|
||||
|
||||
const schedule = await context.getLeagueScheduleUseCase.execute({ leagueId, seasonId });
|
||||
expect(schedule.isOk()).toBe(true);
|
||||
const race = schedule.unwrap().races[0]?.race;
|
||||
expect(race?.id).toBe(raceId);
|
||||
expect(race?.track).toBe('New Track');
|
||||
expect(race?.car).toBe('New Car');
|
||||
expect(race?.scheduledAt.toISOString()).toBe('2025-01-20T20:00:00.000Z');
|
||||
});
|
||||
|
||||
it('deletes a scheduled race from the season', async () => {
|
||||
const leagueId = 'league-1';
|
||||
await seedRacingLeague({ leagueId });
|
||||
|
||||
const seasonId = 'season-1';
|
||||
await seedSeason({
|
||||
seasonId,
|
||||
leagueId,
|
||||
startDate: new Date('2025-01-01T00:00:00Z'),
|
||||
endDate: new Date('2025-01-31T23:59:59Z'),
|
||||
});
|
||||
|
||||
const create = await context.createLeagueSeasonScheduleRaceUseCase.execute({
|
||||
leagueId,
|
||||
seasonId,
|
||||
track: 'Track 1',
|
||||
car: 'Car 1',
|
||||
scheduledAt: new Date('2025-01-10T20:00:00Z'),
|
||||
});
|
||||
expect(create.isOk()).toBe(true);
|
||||
const { raceId } = create.unwrap();
|
||||
|
||||
const pre = await context.getLeagueScheduleUseCase.execute({ leagueId, seasonId });
|
||||
expect(pre.isOk()).toBe(true);
|
||||
expect(pre.unwrap().races.map(r => r.race.id)).toEqual([raceId]);
|
||||
|
||||
const del = await context.deleteLeagueSeasonScheduleRaceUseCase.execute({ leagueId, seasonId, raceId });
|
||||
expect(del.isOk()).toBe(true);
|
||||
|
||||
const post = await context.getLeagueScheduleUseCase.execute({ leagueId, seasonId });
|
||||
expect(post.isOk()).toBe(true);
|
||||
expect(post.unwrap().races).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects creating a race outside the season window', async () => {
|
||||
const leagueId = 'league-1';
|
||||
await seedRacingLeague({ leagueId });
|
||||
|
||||
const seasonId = 'season-1';
|
||||
await seedSeason({
|
||||
seasonId,
|
||||
leagueId,
|
||||
startDate: new Date('2025-01-01T00:00:00Z'),
|
||||
endDate: new Date('2025-01-31T23:59:59Z'),
|
||||
});
|
||||
|
||||
const create = await context.createLeagueSeasonScheduleRaceUseCase.execute({
|
||||
leagueId,
|
||||
seasonId,
|
||||
track: 'Track',
|
||||
car: 'Car',
|
||||
scheduledAt: new Date('2025-02-10T20:00:00Z'),
|
||||
});
|
||||
|
||||
expect(create.isErr()).toBe(true);
|
||||
expect(create.unwrapErr().code).toBe('RACE_OUTSIDE_SEASON_WINDOW');
|
||||
});
|
||||
});
|
||||
178
tests/integration/leagues/schedule/RaceRegistration.test.ts
Normal file
178
tests/integration/leagues/schedule/RaceRegistration.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { LeaguesTestContext } from '../LeaguesTestContext';
|
||||
import { League as RacingLeague } from '../../../../core/racing/domain/entities/League';
|
||||
import { Season } from '../../../../core/racing/domain/entities/season/Season';
|
||||
import { LeagueMembership } from '../../../../core/racing/domain/entities/LeagueMembership';
|
||||
|
||||
// Note: the current racing module does not expose explicit "open/close registration" use-cases.
|
||||
// Registration is modeled via membership + registrations repository interactions.
|
||||
|
||||
describe('League Schedule - Race Registration', () => {
|
||||
let context: LeaguesTestContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = new LeaguesTestContext();
|
||||
context.clear();
|
||||
});
|
||||
|
||||
const seedRacingLeague = async (params: { leagueId: string }) => {
|
||||
const league = RacingLeague.create({
|
||||
id: params.leagueId,
|
||||
name: 'Racing League',
|
||||
description: 'League used for registration integration tests',
|
||||
ownerId: 'driver-123',
|
||||
});
|
||||
|
||||
await context.racingLeagueRepository.create(league);
|
||||
return league;
|
||||
};
|
||||
|
||||
const seedSeason = async (params: {
|
||||
seasonId: string;
|
||||
leagueId: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
}) => {
|
||||
const season = Season.create({
|
||||
id: params.seasonId,
|
||||
leagueId: params.leagueId,
|
||||
gameId: 'iracing',
|
||||
name: 'Season 1',
|
||||
status: 'active',
|
||||
startDate: params.startDate ?? new Date('2025-01-01T00:00:00Z'),
|
||||
endDate: params.endDate ?? new Date('2025-01-31T23:59:59Z'),
|
||||
});
|
||||
|
||||
await context.seasonRepository.add(season);
|
||||
return season;
|
||||
};
|
||||
|
||||
const seedActiveMembership = async (params: { leagueId: string; driverId: string }) => {
|
||||
const membership = LeagueMembership.create({
|
||||
leagueId: params.leagueId,
|
||||
driverId: params.driverId,
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await context.leagueMembershipRepository.saveMembership(membership);
|
||||
return membership;
|
||||
};
|
||||
|
||||
it('registers an active league member for a race', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const seasonId = 'season-1';
|
||||
const driverId = 'driver-1';
|
||||
|
||||
await seedRacingLeague({ leagueId });
|
||||
await seedSeason({ leagueId, seasonId });
|
||||
await seedActiveMembership({ leagueId, driverId });
|
||||
|
||||
const createRace = await context.createLeagueSeasonScheduleRaceUseCase.execute({
|
||||
leagueId,
|
||||
seasonId,
|
||||
track: 'Monza',
|
||||
car: 'GT3',
|
||||
scheduledAt: new Date('2025-01-10T20:00:00Z'),
|
||||
});
|
||||
expect(createRace.isOk()).toBe(true);
|
||||
const { raceId } = createRace.unwrap();
|
||||
|
||||
const register = await context.registerForRaceUseCase.execute({
|
||||
leagueId,
|
||||
raceId,
|
||||
driverId,
|
||||
});
|
||||
|
||||
expect(register.isOk()).toBe(true);
|
||||
expect(register.unwrap()).toEqual({ raceId, driverId, status: 'registered' });
|
||||
|
||||
const isRegistered = await context.raceRegistrationRepository.isRegistered(raceId, driverId);
|
||||
expect(isRegistered).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects registration when driver is not an active member', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const seasonId = 'season-1';
|
||||
|
||||
await seedRacingLeague({ leagueId });
|
||||
await seedSeason({ leagueId, seasonId });
|
||||
|
||||
const createRace = await context.createLeagueSeasonScheduleRaceUseCase.execute({
|
||||
leagueId,
|
||||
seasonId,
|
||||
track: 'Monza',
|
||||
car: 'GT3',
|
||||
scheduledAt: new Date('2025-01-10T20:00:00Z'),
|
||||
});
|
||||
expect(createRace.isOk()).toBe(true);
|
||||
|
||||
const { raceId } = createRace.unwrap();
|
||||
const result = await context.registerForRaceUseCase.execute({ leagueId, raceId, driverId: 'driver-missing' });
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('NOT_ACTIVE_MEMBER');
|
||||
});
|
||||
|
||||
it('rejects duplicate registration', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const seasonId = 'season-1';
|
||||
const driverId = 'driver-1';
|
||||
|
||||
await seedRacingLeague({ leagueId });
|
||||
await seedSeason({ leagueId, seasonId });
|
||||
await seedActiveMembership({ leagueId, driverId });
|
||||
|
||||
const createRace = await context.createLeagueSeasonScheduleRaceUseCase.execute({
|
||||
leagueId,
|
||||
seasonId,
|
||||
track: 'Monza',
|
||||
car: 'GT3',
|
||||
scheduledAt: new Date('2025-01-10T20:00:00Z'),
|
||||
});
|
||||
expect(createRace.isOk()).toBe(true);
|
||||
const { raceId } = createRace.unwrap();
|
||||
|
||||
const first = await context.registerForRaceUseCase.execute({ leagueId, raceId, driverId });
|
||||
expect(first.isOk()).toBe(true);
|
||||
|
||||
const second = await context.registerForRaceUseCase.execute({ leagueId, raceId, driverId });
|
||||
expect(second.isErr()).toBe(true);
|
||||
expect(second.unwrapErr().code).toBe('ALREADY_REGISTERED');
|
||||
});
|
||||
|
||||
it('withdraws an existing registration for an upcoming race', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const seasonId = 'season-1';
|
||||
const driverId = 'driver-1';
|
||||
|
||||
await seedRacingLeague({ leagueId });
|
||||
await seedSeason({
|
||||
leagueId,
|
||||
seasonId,
|
||||
startDate: new Date('2000-01-01T00:00:00Z'),
|
||||
endDate: new Date('2100-12-31T23:59:59Z'),
|
||||
});
|
||||
await seedActiveMembership({ leagueId, driverId });
|
||||
|
||||
const createRace = await context.createLeagueSeasonScheduleRaceUseCase.execute({
|
||||
leagueId,
|
||||
seasonId,
|
||||
track: 'Monza',
|
||||
car: 'GT3',
|
||||
scheduledAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
});
|
||||
expect(createRace.isOk()).toBe(true);
|
||||
const { raceId } = createRace.unwrap();
|
||||
|
||||
const register = await context.registerForRaceUseCase.execute({ leagueId, raceId, driverId });
|
||||
expect(register.isOk()).toBe(true);
|
||||
|
||||
const withdraw = await context.withdrawFromRaceUseCase.execute({ raceId, driverId });
|
||||
expect(withdraw.isOk()).toBe(true);
|
||||
expect(withdraw.unwrap()).toEqual({ raceId, driverId, status: 'withdrawn' });
|
||||
|
||||
const isRegistered = await context.raceRegistrationRepository.isRegistered(raceId, driverId);
|
||||
expect(isRegistered).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { LeaguesTestContext } from '../LeaguesTestContext';
|
||||
import { League } from '../../../../core/racing/domain/entities/League';
|
||||
import { Season } from '../../../../core/racing/domain/entities/season/Season';
|
||||
import { SeasonSponsorship } from '../../../../core/racing/domain/entities/season/SeasonSponsorship';
|
||||
import { Money } from '../../../../core/racing/domain/value-objects/Money';
|
||||
import { GetSeasonSponsorshipsUseCase } from '../../../../core/racing/application/use-cases/GetSeasonSponsorshipsUseCase';
|
||||
import { LeagueMembership } from '../../../../core/racing/domain/entities/LeagueMembership';
|
||||
import { Race } from '../../../../core/racing/domain/entities/Race';
|
||||
|
||||
describe('League Sponsorships - GetSeasonSponsorshipsUseCase', () => {
|
||||
let context: LeaguesTestContext;
|
||||
let useCase: GetSeasonSponsorshipsUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
context = new LeaguesTestContext();
|
||||
context.clear();
|
||||
|
||||
useCase = new GetSeasonSponsorshipsUseCase(
|
||||
context.seasonSponsorshipRepository,
|
||||
context.seasonRepository,
|
||||
context.racingLeagueRepository,
|
||||
context.leagueMembershipRepository,
|
||||
context.raceRepository,
|
||||
);
|
||||
});
|
||||
|
||||
const seedLeague = async (params: { leagueId: string }) => {
|
||||
const league = League.create({
|
||||
id: params.leagueId,
|
||||
name: 'League 1',
|
||||
description: 'League used for sponsorship integration tests',
|
||||
ownerId: 'owner-1',
|
||||
});
|
||||
|
||||
await context.racingLeagueRepository.create(league);
|
||||
return league;
|
||||
};
|
||||
|
||||
const seedSeason = async (params: { seasonId: string; leagueId: string }) => {
|
||||
const season = Season.create({
|
||||
id: params.seasonId,
|
||||
leagueId: params.leagueId,
|
||||
gameId: 'iracing',
|
||||
name: 'Season 1',
|
||||
status: 'active',
|
||||
startDate: new Date('2025-01-01T00:00:00.000Z'),
|
||||
endDate: new Date('2025-02-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
await context.seasonRepository.create(season);
|
||||
return season;
|
||||
};
|
||||
|
||||
const seedLeagueMembers = async (params: { leagueId: string; count: number }) => {
|
||||
for (let i = 0; i < params.count; i++) {
|
||||
const membership = LeagueMembership.create({
|
||||
id: `membership-${i + 1}`,
|
||||
leagueId: params.leagueId,
|
||||
driverId: `driver-${i + 1}`,
|
||||
role: 'member',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await context.leagueMembershipRepository.saveMembership(membership);
|
||||
}
|
||||
};
|
||||
|
||||
const seedRaces = async (params: { leagueId: string }) => {
|
||||
await context.raceRepository.create(
|
||||
Race.create({
|
||||
id: 'race-1',
|
||||
leagueId: params.leagueId,
|
||||
track: 'Track 1',
|
||||
car: 'GT3',
|
||||
scheduledAt: new Date('2025-01-10T20:00:00.000Z'),
|
||||
status: 'completed',
|
||||
}),
|
||||
);
|
||||
|
||||
await context.raceRepository.create(
|
||||
Race.create({
|
||||
id: 'race-2',
|
||||
leagueId: params.leagueId,
|
||||
track: 'Track 2',
|
||||
car: 'GT3',
|
||||
scheduledAt: new Date('2025-01-20T20:00:00.000Z'),
|
||||
status: 'completed',
|
||||
}),
|
||||
);
|
||||
|
||||
await context.raceRepository.create(
|
||||
Race.create({
|
||||
id: 'race-3',
|
||||
leagueId: params.leagueId,
|
||||
track: 'Track 3',
|
||||
car: 'GT3',
|
||||
scheduledAt: new Date('2025-01-25T20:00:00.000Z'),
|
||||
status: 'planned',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
it('returns sponsorships with computed league/season metrics', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const seasonId = 'season-1';
|
||||
|
||||
await seedLeague({ leagueId });
|
||||
await seedSeason({ seasonId, leagueId });
|
||||
await seedLeagueMembers({ leagueId, count: 3 });
|
||||
await seedRaces({ leagueId });
|
||||
|
||||
const sponsorship = SeasonSponsorship.create({
|
||||
id: 'sponsorship-1',
|
||||
seasonId,
|
||||
leagueId,
|
||||
sponsorId: 'sponsor-1',
|
||||
tier: 'main',
|
||||
pricing: Money.create(1000, 'USD'),
|
||||
status: 'active',
|
||||
createdAt: new Date('2025-01-01T00:00:00.000Z'),
|
||||
activatedAt: new Date('2025-01-02T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
await context.seasonSponsorshipRepository.create(sponsorship);
|
||||
|
||||
const result = await useCase.execute({ seasonId });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const view = result.unwrap();
|
||||
|
||||
expect(view.seasonId).toBe(seasonId);
|
||||
expect(view.sponsorships).toHaveLength(1);
|
||||
|
||||
const detail = view.sponsorships[0]!;
|
||||
expect(detail.id).toBe('sponsorship-1');
|
||||
expect(detail.leagueId).toBe(leagueId);
|
||||
expect(detail.leagueName).toBe('League 1');
|
||||
expect(detail.seasonId).toBe(seasonId);
|
||||
expect(detail.seasonName).toBe('Season 1');
|
||||
|
||||
expect(detail.metrics.drivers).toBe(3);
|
||||
expect(detail.metrics.races).toBe(3);
|
||||
expect(detail.metrics.completedRaces).toBe(2);
|
||||
expect(detail.metrics.impressions).toBe(2 * 3 * 100);
|
||||
|
||||
expect(detail.pricing).toEqual({ amount: 1000, currency: 'USD' });
|
||||
expect(detail.platformFee).toEqual({ amount: 100, currency: 'USD' });
|
||||
expect(detail.netAmount).toEqual({ amount: 900, currency: 'USD' });
|
||||
});
|
||||
|
||||
it('returns SEASON_NOT_FOUND when season does not exist', async () => {
|
||||
const result = await useCase.execute({ seasonId: 'missing-season' });
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('SEASON_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('returns LEAGUE_NOT_FOUND when league for season does not exist', async () => {
|
||||
await context.seasonRepository.create(
|
||||
Season.create({
|
||||
id: 'season-1',
|
||||
leagueId: 'missing-league',
|
||||
gameId: 'iracing',
|
||||
name: 'Season 1',
|
||||
status: 'active',
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await useCase.execute({ seasonId: 'season-1' });
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('LEAGUE_NOT_FOUND');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { NotificationService } from '../../../../core/notifications/application/ports/NotificationService';
|
||||
import type { WalletRepository } from '../../../../core/payments/domain/repositories/WalletRepository';
|
||||
import type { Logger } from '../../../../core/shared/domain/Logger';
|
||||
import { LeagueWallet } from '../../../../core/racing/domain/entities/league-wallet/LeagueWallet';
|
||||
import { League } from '../../../../core/racing/domain/entities/League';
|
||||
import { Season } from '../../../../core/racing/domain/entities/season/Season';
|
||||
import { SponsorshipRequest } from '../../../../core/racing/domain/entities/SponsorshipRequest';
|
||||
import { Money } from '../../../../core/racing/domain/value-objects/Money';
|
||||
import { Sponsor } from '../../../../core/racing/domain/entities/sponsor/Sponsor';
|
||||
import { SponsorshipPricing } from '../../../../core/racing/domain/value-objects/SponsorshipPricing';
|
||||
|
||||
import { ApplyForSponsorshipUseCase } from '../../../../core/racing/application/use-cases/ApplyForSponsorshipUseCase';
|
||||
import { GetPendingSponsorshipRequestsUseCase } from '../../../../core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
|
||||
import { AcceptSponsorshipRequestUseCase } from '../../../../core/racing/application/use-cases/AcceptSponsorshipRequestUseCase';
|
||||
import { RejectSponsorshipRequestUseCase } from '../../../../core/racing/application/use-cases/RejectSponsorshipRequestUseCase';
|
||||
|
||||
import { LeaguesTestContext } from '../LeaguesTestContext';
|
||||
import { SponsorTestContext } from '../../sponsor/SponsorTestContext';
|
||||
import { InMemorySponsorshipRequestRepository } from '../../../../adapters/racing/persistence/inmemory/InMemorySponsorshipRequestRepository';
|
||||
import { InMemoryLeagueWalletRepository } from '../../../../adapters/racing/persistence/inmemory/InMemoryLeagueWalletRepository';
|
||||
import { InMemoryWalletRepository } from '../../../../adapters/payments/persistence/inmemory/InMemoryWalletRepository';
|
||||
|
||||
const createNoopNotificationService = (): NotificationService =>
|
||||
({
|
||||
sendNotification: vi.fn(async () => undefined),
|
||||
}) as unknown as NotificationService;
|
||||
|
||||
const createNoopLogger = (): Logger =>
|
||||
({
|
||||
info: () => {},
|
||||
debug: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
}) as unknown as Logger;
|
||||
|
||||
describe('League Sponsorships - Sponsorship Applications', () => {
|
||||
let leagues: LeaguesTestContext;
|
||||
let sponsors: SponsorTestContext;
|
||||
|
||||
let sponsorshipRequestRepo: InMemorySponsorshipRequestRepository;
|
||||
let sponsorWalletRepo: WalletRepository;
|
||||
let leagueWalletRepo: InMemoryLeagueWalletRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
leagues = new LeaguesTestContext();
|
||||
leagues.clear();
|
||||
|
||||
sponsors = new SponsorTestContext();
|
||||
sponsors.clear();
|
||||
|
||||
sponsorshipRequestRepo = new InMemorySponsorshipRequestRepository(createNoopLogger());
|
||||
sponsorWalletRepo = new InMemoryWalletRepository(createNoopLogger());
|
||||
leagueWalletRepo = new InMemoryLeagueWalletRepository(createNoopLogger());
|
||||
});
|
||||
|
||||
const seedLeagueAndSeason = async (params: { leagueId: string; seasonId: string }) => {
|
||||
const league = League.create({
|
||||
id: params.leagueId,
|
||||
name: 'League 1',
|
||||
description: 'League used for sponsorship integration tests',
|
||||
ownerId: 'owner-1',
|
||||
});
|
||||
await leagues.racingLeagueRepository.create(league);
|
||||
|
||||
const season = Season.create({
|
||||
id: params.seasonId,
|
||||
leagueId: params.leagueId,
|
||||
gameId: 'iracing',
|
||||
name: 'Season 1',
|
||||
status: 'active',
|
||||
startDate: new Date('2025-01-01T00:00:00.000Z'),
|
||||
endDate: new Date('2025-02-01T00:00:00.000Z'),
|
||||
});
|
||||
await leagues.seasonRepository.create(season);
|
||||
|
||||
return { league, season };
|
||||
};
|
||||
|
||||
it('allows a sponsor to apply for a season sponsorship and lists it as pending', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const seasonId = 'season-1';
|
||||
const sponsorId = 'sponsor-1';
|
||||
|
||||
await seedLeagueAndSeason({ leagueId, seasonId });
|
||||
|
||||
const sponsor = Sponsor.create({
|
||||
id: sponsorId,
|
||||
name: 'Acme',
|
||||
contactEmail: 'acme@example.com',
|
||||
});
|
||||
await sponsors.sponsorRepository.create(sponsor);
|
||||
|
||||
const pricing = SponsorshipPricing.create({
|
||||
acceptingApplications: true,
|
||||
})
|
||||
.updateMainSlot({
|
||||
available: true,
|
||||
maxSlots: 1,
|
||||
price: Money.create(1000, 'USD'),
|
||||
benefits: ['logo'],
|
||||
})
|
||||
.updateSecondarySlot({
|
||||
available: true,
|
||||
maxSlots: 2,
|
||||
price: Money.create(500, 'USD'),
|
||||
benefits: ['mention'],
|
||||
});
|
||||
|
||||
await sponsors.sponsorshipPricingRepository.save('season', seasonId, pricing);
|
||||
|
||||
const applyUseCase = new ApplyForSponsorshipUseCase(
|
||||
sponsorshipRequestRepo,
|
||||
sponsors.sponsorshipPricingRepository,
|
||||
sponsors.sponsorRepository,
|
||||
sponsors.logger,
|
||||
);
|
||||
|
||||
const apply = await applyUseCase.execute({
|
||||
sponsorId,
|
||||
entityType: 'season',
|
||||
entityId: seasonId,
|
||||
tier: 'main',
|
||||
offeredAmount: 1000,
|
||||
currency: 'USD',
|
||||
message: 'We would like to sponsor',
|
||||
});
|
||||
|
||||
expect(apply.isOk()).toBe(true);
|
||||
|
||||
const getPending = new GetPendingSponsorshipRequestsUseCase(sponsorshipRequestRepo, sponsors.sponsorRepository);
|
||||
const pending = await getPending.execute({ entityType: 'season', entityId: seasonId });
|
||||
|
||||
expect(pending.isOk()).toBe(true);
|
||||
const value = pending.unwrap();
|
||||
expect(value.totalCount).toBe(1);
|
||||
expect(value.requests[0]!.request.status).toBe('pending');
|
||||
expect(value.requests[0]!.sponsor?.id.toString()).toBe(sponsorId);
|
||||
});
|
||||
|
||||
it('accepts a pending season sponsorship request, creates a sponsorship, and updates wallets', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const seasonId = 'season-1';
|
||||
const sponsorId = 'sponsor-1';
|
||||
|
||||
await seedLeagueAndSeason({ leagueId, seasonId });
|
||||
|
||||
const sponsor = Sponsor.create({
|
||||
id: sponsorId,
|
||||
name: 'Acme',
|
||||
contactEmail: 'acme@example.com',
|
||||
});
|
||||
await sponsors.sponsorRepository.create(sponsor);
|
||||
|
||||
const request = SponsorshipRequest.create({
|
||||
id: 'req-1',
|
||||
sponsorId,
|
||||
entityType: 'season',
|
||||
entityId: seasonId,
|
||||
tier: 'main',
|
||||
offeredAmount: Money.create(1000, 'USD'),
|
||||
message: 'Please accept',
|
||||
});
|
||||
await sponsorshipRequestRepo.create(request);
|
||||
|
||||
await sponsorWalletRepo.create({
|
||||
id: sponsorId,
|
||||
leagueId: 'n/a',
|
||||
balance: 1500,
|
||||
totalRevenue: 0,
|
||||
totalPlatformFees: 0,
|
||||
totalWithdrawn: 0,
|
||||
currency: 'USD',
|
||||
createdAt: new Date('2025-01-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
const leagueWallet = LeagueWallet.create({
|
||||
id: leagueId,
|
||||
leagueId,
|
||||
balance: Money.create(0, 'USD'),
|
||||
});
|
||||
await leagueWalletRepo.create(leagueWallet);
|
||||
|
||||
const notificationService = createNoopNotificationService();
|
||||
|
||||
const acceptUseCase = new AcceptSponsorshipRequestUseCase(
|
||||
sponsorshipRequestRepo,
|
||||
leagues.seasonSponsorshipRepository,
|
||||
leagues.seasonRepository,
|
||||
notificationService,
|
||||
async () => ({ success: true, transactionId: 'tx-1' }),
|
||||
sponsorWalletRepo,
|
||||
leagueWalletRepo,
|
||||
createNoopLogger(),
|
||||
);
|
||||
|
||||
const result = await acceptUseCase.execute({ requestId: 'req-1', respondedBy: 'owner-1' });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
|
||||
const updatedSponsorWallet = await sponsorWalletRepo.findById(sponsorId);
|
||||
expect(updatedSponsorWallet?.balance).toBe(500);
|
||||
|
||||
const updatedLeagueWallet = await leagueWalletRepo.findById(leagueId);
|
||||
expect(updatedLeagueWallet?.balance.amount).toBe(900);
|
||||
|
||||
expect((notificationService.sendNotification as unknown as ReturnType<typeof vi.fn>)).toHaveBeenCalledTimes(1);
|
||||
|
||||
const sponsorships = await leagues.seasonSponsorshipRepository.findBySeasonId(seasonId);
|
||||
expect(sponsorships).toHaveLength(1);
|
||||
expect(sponsorships[0]!.status).toBe('active');
|
||||
});
|
||||
|
||||
it('rejects a pending sponsorship request', async () => {
|
||||
const sponsorId = 'sponsor-1';
|
||||
|
||||
const request = SponsorshipRequest.create({
|
||||
id: 'req-1',
|
||||
sponsorId,
|
||||
entityType: 'season',
|
||||
entityId: 'season-1',
|
||||
tier: 'main',
|
||||
offeredAmount: Money.create(1000, 'USD'),
|
||||
});
|
||||
await sponsorshipRequestRepo.create(request);
|
||||
|
||||
const rejectUseCase = new RejectSponsorshipRequestUseCase(sponsorshipRequestRepo, createNoopLogger());
|
||||
|
||||
const result = await rejectUseCase.execute({ requestId: 'req-1', respondedBy: 'owner-1', reason: 'Not a fit' });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
|
||||
const updated = await sponsorshipRequestRepo.findById('req-1');
|
||||
expect(updated?.status).toBe('rejected');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { LeaguesTestContext } from '../LeaguesTestContext';
|
||||
import { League } from '../../../../core/racing/domain/entities/League';
|
||||
import { Season } from '../../../../core/racing/domain/entities/season/Season';
|
||||
import { SeasonSponsorship } from '../../../../core/racing/domain/entities/season/SeasonSponsorship';
|
||||
import { Money } from '../../../../core/racing/domain/value-objects/Money';
|
||||
|
||||
describe('League Sponsorships - Sponsorship Management', () => {
|
||||
let context: LeaguesTestContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = new LeaguesTestContext();
|
||||
context.clear();
|
||||
});
|
||||
|
||||
const seedLeagueAndSeason = async (params: { leagueId: string; seasonId: string }) => {
|
||||
const league = League.create({
|
||||
id: params.leagueId,
|
||||
name: 'League 1',
|
||||
description: 'League used for sponsorship integration tests',
|
||||
ownerId: 'owner-1',
|
||||
});
|
||||
await context.racingLeagueRepository.create(league);
|
||||
|
||||
const season = Season.create({
|
||||
id: params.seasonId,
|
||||
leagueId: params.leagueId,
|
||||
gameId: 'iracing',
|
||||
name: 'Season 1',
|
||||
status: 'active',
|
||||
startDate: new Date('2025-01-01T00:00:00.000Z'),
|
||||
endDate: new Date('2025-02-01T00:00:00.000Z'),
|
||||
});
|
||||
await context.seasonRepository.create(season);
|
||||
|
||||
return { league, season };
|
||||
};
|
||||
|
||||
it('adds a season sponsorship to the repository', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const seasonId = 'season-1';
|
||||
|
||||
await seedLeagueAndSeason({ leagueId, seasonId });
|
||||
|
||||
const sponsorship = SeasonSponsorship.create({
|
||||
id: 'sponsorship-1',
|
||||
seasonId,
|
||||
leagueId,
|
||||
sponsorId: 'sponsor-1',
|
||||
tier: 'main',
|
||||
pricing: Money.create(1000, 'USD'),
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await context.seasonSponsorshipRepository.create(sponsorship);
|
||||
|
||||
const found = await context.seasonSponsorshipRepository.findById('sponsorship-1');
|
||||
expect(found).not.toBeNull();
|
||||
expect(found?.id).toBe('sponsorship-1');
|
||||
expect(found?.seasonId).toBe(seasonId);
|
||||
expect(found?.leagueId).toBe(leagueId);
|
||||
});
|
||||
|
||||
it('edits sponsorship pricing via repository update', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const seasonId = 'season-1';
|
||||
|
||||
await seedLeagueAndSeason({ leagueId, seasonId });
|
||||
|
||||
const sponsorship = SeasonSponsorship.create({
|
||||
id: 'sponsorship-1',
|
||||
seasonId,
|
||||
leagueId,
|
||||
sponsorId: 'sponsor-1',
|
||||
tier: 'main',
|
||||
pricing: Money.create(1000, 'USD'),
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await context.seasonSponsorshipRepository.create(sponsorship);
|
||||
|
||||
const updated = sponsorship.withPricing(Money.create(1500, 'USD'));
|
||||
await context.seasonSponsorshipRepository.update(updated);
|
||||
|
||||
const found = await context.seasonSponsorshipRepository.findById('sponsorship-1');
|
||||
expect(found).not.toBeNull();
|
||||
expect(found?.pricing.amount).toBe(1500);
|
||||
expect(found?.pricing.currency).toBe('USD');
|
||||
});
|
||||
|
||||
it('deletes a sponsorship from the repository', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const seasonId = 'season-1';
|
||||
|
||||
await seedLeagueAndSeason({ leagueId, seasonId });
|
||||
|
||||
const sponsorship = SeasonSponsorship.create({
|
||||
id: 'sponsorship-1',
|
||||
seasonId,
|
||||
leagueId,
|
||||
sponsorId: 'sponsor-1',
|
||||
tier: 'main',
|
||||
pricing: Money.create(1000, 'USD'),
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await context.seasonSponsorshipRepository.create(sponsorship);
|
||||
expect(await context.seasonSponsorshipRepository.exists('sponsorship-1')).toBe(true);
|
||||
|
||||
await context.seasonSponsorshipRepository.delete('sponsorship-1');
|
||||
|
||||
expect(await context.seasonSponsorshipRepository.exists('sponsorship-1')).toBe(false);
|
||||
const found = await context.seasonSponsorshipRepository.findById('sponsorship-1');
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
113
tests/integration/leagues/standings/GetLeagueStandings.test.ts
Normal file
113
tests/integration/leagues/standings/GetLeagueStandings.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { LeaguesTestContext } from '../LeaguesTestContext';
|
||||
import { Driver } from '../../../../core/racing/domain/entities/Driver';
|
||||
import { Standing } from '../../../../core/racing/domain/entities/Standing';
|
||||
|
||||
describe('GetLeagueStandings', () => {
|
||||
let context: LeaguesTestContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = new LeaguesTestContext();
|
||||
});
|
||||
|
||||
describe('Success Path', () => {
|
||||
it('should retrieve championship standings with all driver statistics', async () => {
|
||||
// Given: A league exists with multiple drivers
|
||||
const leagueId = 'league-123';
|
||||
const driver1Id = 'driver-1';
|
||||
const driver2Id = 'driver-2';
|
||||
|
||||
await context.racingDriverRepository.create(Driver.create({
|
||||
id: driver1Id,
|
||||
name: 'Driver One',
|
||||
iracingId: 'ir-1',
|
||||
country: 'US',
|
||||
}));
|
||||
|
||||
await context.racingDriverRepository.create(Driver.create({
|
||||
id: driver2Id,
|
||||
name: 'Driver Two',
|
||||
iracingId: 'ir-2',
|
||||
country: 'DE',
|
||||
}));
|
||||
|
||||
// And: Each driver has points
|
||||
await context.standingRepository.save(Standing.create({
|
||||
leagueId,
|
||||
driverId: driver1Id,
|
||||
points: 100,
|
||||
position: 1,
|
||||
}));
|
||||
|
||||
await context.standingRepository.save(Standing.create({
|
||||
leagueId,
|
||||
driverId: driver2Id,
|
||||
points: 80,
|
||||
position: 2,
|
||||
}));
|
||||
|
||||
// When: GetLeagueStandingsUseCase.execute() is called with league ID
|
||||
const result = await context.getLeagueStandingsUseCase.execute({ leagueId });
|
||||
|
||||
// Then: The result should contain all drivers ranked by points
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.standings).toHaveLength(2);
|
||||
expect(data.standings[0].driverId).toBe(driver1Id);
|
||||
expect(data.standings[0].points).toBe(100);
|
||||
expect(data.standings[0].rank).toBe(1);
|
||||
expect(data.standings[1].driverId).toBe(driver2Id);
|
||||
expect(data.standings[1].points).toBe(80);
|
||||
expect(data.standings[1].rank).toBe(2);
|
||||
});
|
||||
|
||||
it('should retrieve standings with minimal driver statistics', async () => {
|
||||
const leagueId = 'league-123';
|
||||
const driverId = 'driver-1';
|
||||
|
||||
await context.racingDriverRepository.create(Driver.create({
|
||||
id: driverId,
|
||||
name: 'Driver One',
|
||||
iracingId: 'ir-1',
|
||||
country: 'US',
|
||||
}));
|
||||
|
||||
await context.standingRepository.save(Standing.create({
|
||||
leagueId,
|
||||
driverId,
|
||||
points: 10,
|
||||
position: 1,
|
||||
}));
|
||||
|
||||
const result = await context.getLeagueStandingsUseCase.execute({ leagueId });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap().standings).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle drivers with no championship standings', async () => {
|
||||
const leagueId = 'league-empty';
|
||||
const result = await context.getLeagueStandingsUseCase.execute({ leagueId });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap().standings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle repository errors gracefully', async () => {
|
||||
// Mock repository error
|
||||
context.standingRepository.findByLeagueId = async () => {
|
||||
throw new Error('Database error');
|
||||
};
|
||||
|
||||
const result = await context.getLeagueStandingsUseCase.execute({ leagueId: 'any' });
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
// The Result class in this project seems to use .error for the error value
|
||||
expect((result as any).error.code).toBe('REPOSITORY_ERROR');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { LeaguesTestContext } from '../LeaguesTestContext';
|
||||
import { Driver } from '../../../../core/racing/domain/entities/Driver';
|
||||
import { Standing } from '../../../../core/racing/domain/entities/Standing';
|
||||
import { Result } from '../../../../core/racing/domain/entities/result/Result';
|
||||
import { Race } from '../../../../core/racing/domain/entities/Race';
|
||||
import { League } from '../../../../core/racing/domain/entities/League';
|
||||
|
||||
describe('StandingsCalculation', () => {
|
||||
let context: LeaguesTestContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = new LeaguesTestContext();
|
||||
});
|
||||
|
||||
it('should correctly calculate driver statistics from race results', async () => {
|
||||
// Given: A league exists
|
||||
const leagueId = 'league-123';
|
||||
const driverId = 'driver-1';
|
||||
|
||||
await context.racingLeagueRepository.create(League.create({
|
||||
id: leagueId,
|
||||
name: 'Test League',
|
||||
description: 'Test Description',
|
||||
ownerId: 'owner-1',
|
||||
}));
|
||||
|
||||
await context.racingDriverRepository.create(Driver.create({
|
||||
id: driverId,
|
||||
name: 'Driver One',
|
||||
iracingId: 'ir-1',
|
||||
country: 'US',
|
||||
}));
|
||||
|
||||
// And: A driver has completed races
|
||||
const race1Id = 'race-1';
|
||||
const race2Id = 'race-2';
|
||||
|
||||
await context.raceRepository.create(Race.create({
|
||||
id: race1Id,
|
||||
leagueId,
|
||||
scheduledAt: new Date(),
|
||||
track: 'Daytona',
|
||||
car: 'GT3',
|
||||
status: 'completed',
|
||||
}));
|
||||
|
||||
await context.raceRepository.create(Race.create({
|
||||
id: race2Id,
|
||||
leagueId,
|
||||
scheduledAt: new Date(),
|
||||
track: 'Sebring',
|
||||
car: 'GT3',
|
||||
status: 'completed',
|
||||
}));
|
||||
|
||||
// And: The driver has results (1 win, 1 podium)
|
||||
await context.resultRepository.create(Result.create({
|
||||
id: 'res-1',
|
||||
raceId: race1Id,
|
||||
driverId,
|
||||
position: 1,
|
||||
fastestLap: 120000,
|
||||
incidents: 0,
|
||||
startPosition: 1,
|
||||
}));
|
||||
|
||||
await context.resultRepository.create(Result.create({
|
||||
id: 'res-2',
|
||||
raceId: race2Id,
|
||||
driverId,
|
||||
position: 3,
|
||||
fastestLap: 121000,
|
||||
incidents: 2,
|
||||
startPosition: 5,
|
||||
}));
|
||||
|
||||
// When: Standings are recalculated
|
||||
await context.standingRepository.recalculate(leagueId);
|
||||
|
||||
// Then: Driver statistics should show correct values
|
||||
const standings = await context.standingRepository.findByLeagueId(leagueId);
|
||||
const driverStanding = standings.find(s => s.driverId.toString() === driverId);
|
||||
|
||||
expect(driverStanding).toBeDefined();
|
||||
expect(driverStanding?.wins).toBe(1);
|
||||
expect(driverStanding?.racesCompleted).toBe(2);
|
||||
// Points depend on the points system (default f1-2024: 1st=25, 3rd=15)
|
||||
expect(driverStanding?.points.toNumber()).toBe(40);
|
||||
});
|
||||
});
|
||||
414
tests/integration/leagues/stewarding/GetLeagueStewarding.test.ts
Normal file
414
tests/integration/leagues/stewarding/GetLeagueStewarding.test.ts
Normal file
@@ -0,0 +1,414 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { LeaguesTestContext } from '../LeaguesTestContext';
|
||||
import { League as RacingLeague } from '../../../../core/racing/domain/entities/League';
|
||||
import { Race } from '../../../../core/racing/domain/entities/Race';
|
||||
import { Driver } from '../../../../core/racing/domain/entities/Driver';
|
||||
import { Protest } from '../../../../core/racing/domain/entities/Protest';
|
||||
import { Penalty } from '../../../../core/racing/domain/entities/penalty/Penalty';
|
||||
import { GetLeagueProtestsUseCase } from '../../../../core/racing/application/use-cases/GetLeagueProtestsUseCase';
|
||||
import { GetRacePenaltiesUseCase } from '../../../../core/racing/application/use-cases/GetRacePenaltiesUseCase';
|
||||
|
||||
describe('League Stewarding - GetLeagueStewarding', () => {
|
||||
let context: LeaguesTestContext;
|
||||
let getLeagueProtestsUseCase: GetLeagueProtestsUseCase;
|
||||
let getRacePenaltiesUseCase: GetRacePenaltiesUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
context = new LeaguesTestContext();
|
||||
context.clear();
|
||||
|
||||
getLeagueProtestsUseCase = new GetLeagueProtestsUseCase(
|
||||
context.raceRepository,
|
||||
context.protestRepository,
|
||||
context.racingDriverRepository,
|
||||
context.racingLeagueRepository,
|
||||
);
|
||||
|
||||
getRacePenaltiesUseCase = new GetRacePenaltiesUseCase(
|
||||
context.penaltyRepository,
|
||||
context.racingDriverRepository,
|
||||
);
|
||||
});
|
||||
|
||||
const seedRacingLeague = async (params: { leagueId: string }) => {
|
||||
const league = RacingLeague.create({
|
||||
id: params.leagueId,
|
||||
name: 'Racing League',
|
||||
description: 'League used for stewarding integration tests',
|
||||
ownerId: 'driver-123',
|
||||
});
|
||||
|
||||
await context.racingLeagueRepository.create(league);
|
||||
return league;
|
||||
};
|
||||
|
||||
const seedRace = async (params: { raceId: string; leagueId: string }) => {
|
||||
const race = Race.create({
|
||||
id: params.raceId,
|
||||
leagueId: params.leagueId,
|
||||
track: 'Track 1',
|
||||
car: 'GT3',
|
||||
scheduledAt: new Date('2025-01-10T20:00:00Z'),
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
await context.raceRepository.create(race);
|
||||
return race;
|
||||
};
|
||||
|
||||
const seedDriver = async (params: { driverId: string; iracingId?: string }) => {
|
||||
const driver = Driver.create({
|
||||
id: params.driverId,
|
||||
name: 'Driver Name',
|
||||
iracingId: params.iracingId || `ir-${params.driverId}`,
|
||||
country: 'US',
|
||||
});
|
||||
|
||||
await context.racingDriverRepository.create(driver);
|
||||
return driver;
|
||||
};
|
||||
|
||||
const seedProtest = async (params: {
|
||||
protestId: string;
|
||||
raceId: string;
|
||||
protestingDriverId: string;
|
||||
accusedDriverId: string;
|
||||
status?: string;
|
||||
}) => {
|
||||
const protest = Protest.create({
|
||||
id: params.protestId,
|
||||
raceId: params.raceId,
|
||||
protestingDriverId: params.protestingDriverId,
|
||||
accusedDriverId: params.accusedDriverId,
|
||||
incident: {
|
||||
lap: 5,
|
||||
description: 'Contact on corner entry',
|
||||
},
|
||||
status: params.status || 'pending',
|
||||
});
|
||||
|
||||
await context.protestRepository.create(protest);
|
||||
return protest;
|
||||
};
|
||||
|
||||
const seedPenalty = async (params: {
|
||||
penaltyId: string;
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
raceId?: string;
|
||||
status?: string;
|
||||
}) => {
|
||||
const penalty = Penalty.create({
|
||||
id: params.penaltyId,
|
||||
leagueId: params.leagueId,
|
||||
driverId: params.driverId,
|
||||
type: 'time_penalty',
|
||||
value: 5,
|
||||
reason: 'Contact on corner entry',
|
||||
issuedBy: 'steward-1',
|
||||
status: params.status || 'pending',
|
||||
...(params.raceId && { raceId: params.raceId }),
|
||||
});
|
||||
|
||||
await context.penaltyRepository.create(penalty);
|
||||
return penalty;
|
||||
};
|
||||
|
||||
describe('Success Path', () => {
|
||||
it('should retrieve league protests with driver and race details', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const raceId = 'race-1';
|
||||
const protestingDriverId = 'driver-1';
|
||||
const accusedDriverId = 'driver-2';
|
||||
|
||||
await seedRacingLeague({ leagueId });
|
||||
await seedRace({ raceId, leagueId });
|
||||
await seedDriver({ driverId: protestingDriverId });
|
||||
await seedDriver({ driverId: accusedDriverId });
|
||||
await seedProtest({
|
||||
protestId: 'protest-1',
|
||||
raceId,
|
||||
protestingDriverId,
|
||||
accusedDriverId,
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const result = await getLeagueProtestsUseCase.execute({ leagueId });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.league.id.toString()).toBe(leagueId);
|
||||
expect(data.protests).toHaveLength(1);
|
||||
expect(data.protests[0].protest.id.toString()).toBe('protest-1');
|
||||
expect(data.protests[0].protest.status.toString()).toBe('pending');
|
||||
expect(data.protests[0].race?.id.toString()).toBe(raceId);
|
||||
expect(data.protests[0].protestingDriver?.id.toString()).toBe(protestingDriverId);
|
||||
expect(data.protests[0].accusedDriver?.id.toString()).toBe(accusedDriverId);
|
||||
});
|
||||
|
||||
it('should retrieve penalties with driver details', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const raceId = 'race-1';
|
||||
const driverId = 'driver-1';
|
||||
|
||||
await seedRacingLeague({ leagueId });
|
||||
await seedRace({ raceId, leagueId });
|
||||
await seedDriver({ driverId });
|
||||
await seedPenalty({
|
||||
penaltyId: 'penalty-1',
|
||||
leagueId,
|
||||
driverId,
|
||||
raceId,
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const result = await getRacePenaltiesUseCase.execute({ raceId });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.penalties).toHaveLength(1);
|
||||
expect(data.penalties[0].id.toString()).toBe('penalty-1');
|
||||
expect(data.penalties[0].status.toString()).toBe('pending');
|
||||
expect(data.drivers).toHaveLength(1);
|
||||
expect(data.drivers[0].id.toString()).toBe(driverId);
|
||||
});
|
||||
|
||||
it('should retrieve multiple protests for a league', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const raceId = 'race-1';
|
||||
const protestingDriverId = 'driver-1';
|
||||
const accusedDriverId = 'driver-2';
|
||||
|
||||
await seedRacingLeague({ leagueId });
|
||||
await seedRace({ raceId, leagueId });
|
||||
await seedDriver({ driverId: protestingDriverId });
|
||||
await seedDriver({ driverId: accusedDriverId });
|
||||
await seedProtest({
|
||||
protestId: 'protest-1',
|
||||
raceId,
|
||||
protestingDriverId,
|
||||
accusedDriverId,
|
||||
status: 'pending',
|
||||
});
|
||||
await seedProtest({
|
||||
protestId: 'protest-2',
|
||||
raceId,
|
||||
protestingDriverId: accusedDriverId,
|
||||
accusedDriverId: protestingDriverId,
|
||||
status: 'under_review',
|
||||
});
|
||||
|
||||
const result = await getLeagueProtestsUseCase.execute({ leagueId });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.protests).toHaveLength(2);
|
||||
expect(data.protests.map(p => p.protest.id.toString()).sort()).toEqual(['protest-1', 'protest-2']);
|
||||
});
|
||||
|
||||
it('should retrieve multiple penalties for a race', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const raceId = 'race-1';
|
||||
const driverId1 = 'driver-1';
|
||||
const driverId2 = 'driver-2';
|
||||
|
||||
await seedRacingLeague({ leagueId });
|
||||
await seedRace({ raceId, leagueId });
|
||||
await seedDriver({ driverId: driverId1 });
|
||||
await seedDriver({ driverId: driverId2 });
|
||||
await seedPenalty({
|
||||
penaltyId: 'penalty-1',
|
||||
leagueId,
|
||||
driverId: driverId1,
|
||||
raceId,
|
||||
status: 'pending',
|
||||
});
|
||||
await seedPenalty({
|
||||
penaltyId: 'penalty-2',
|
||||
leagueId,
|
||||
driverId: driverId2,
|
||||
raceId,
|
||||
status: 'applied',
|
||||
});
|
||||
|
||||
const result = await getRacePenaltiesUseCase.execute({ raceId });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.penalties).toHaveLength(2);
|
||||
expect(data.penalties.map(p => p.id.toString()).sort()).toEqual(['penalty-1', 'penalty-2']);
|
||||
expect(data.drivers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should retrieve resolved protests', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const raceId = 'race-1';
|
||||
const protestingDriverId = 'driver-1';
|
||||
const accusedDriverId = 'driver-2';
|
||||
|
||||
await seedRacingLeague({ leagueId });
|
||||
await seedRace({ raceId, leagueId });
|
||||
await seedDriver({ driverId: protestingDriverId });
|
||||
await seedDriver({ driverId: accusedDriverId });
|
||||
await seedProtest({
|
||||
protestId: 'protest-1',
|
||||
raceId,
|
||||
protestingDriverId,
|
||||
accusedDriverId,
|
||||
status: 'upheld',
|
||||
});
|
||||
await seedProtest({
|
||||
protestId: 'protest-2',
|
||||
raceId,
|
||||
protestingDriverId,
|
||||
accusedDriverId,
|
||||
status: 'dismissed',
|
||||
});
|
||||
|
||||
const result = await getLeagueProtestsUseCase.execute({ leagueId });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.protests).toHaveLength(2);
|
||||
expect(data.protests.filter(p => p.protest.status.toString() === 'upheld')).toHaveLength(1);
|
||||
expect(data.protests.filter(p => p.protest.status.toString() === 'dismissed')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should retrieve applied penalties', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const raceId = 'race-1';
|
||||
const driverId = 'driver-1';
|
||||
|
||||
await seedRacingLeague({ leagueId });
|
||||
await seedRace({ raceId, leagueId });
|
||||
await seedDriver({ driverId });
|
||||
await seedPenalty({
|
||||
penaltyId: 'penalty-1',
|
||||
leagueId,
|
||||
driverId,
|
||||
raceId,
|
||||
status: 'applied',
|
||||
});
|
||||
|
||||
const result = await getRacePenaltiesUseCase.execute({ raceId });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.penalties).toHaveLength(1);
|
||||
expect(data.penalties[0].status.toString()).toBe('applied');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle league with no protests', async () => {
|
||||
const leagueId = 'league-empty';
|
||||
await seedRacingLeague({ leagueId });
|
||||
|
||||
const result = await getLeagueProtestsUseCase.execute({ leagueId });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.protests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle race with no penalties', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const raceId = 'race-1';
|
||||
|
||||
await seedRacingLeague({ leagueId });
|
||||
await seedRace({ raceId, leagueId });
|
||||
|
||||
const result = await getRacePenaltiesUseCase.execute({ raceId });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.penalties).toHaveLength(0);
|
||||
expect(data.drivers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle league with no races', async () => {
|
||||
const leagueId = 'league-empty';
|
||||
await seedRacingLeague({ leagueId });
|
||||
|
||||
const result = await getLeagueProtestsUseCase.execute({ leagueId });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.protests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle protest with missing driver details', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const raceId = 'race-1';
|
||||
const protestingDriverId = 'driver-1';
|
||||
const accusedDriverId = 'driver-2';
|
||||
|
||||
await seedRacingLeague({ leagueId });
|
||||
await seedRace({ raceId, leagueId });
|
||||
// Don't seed drivers
|
||||
await seedProtest({
|
||||
protestId: 'protest-1',
|
||||
raceId,
|
||||
protestingDriverId,
|
||||
accusedDriverId,
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const result = await getLeagueProtestsUseCase.execute({ leagueId });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.protests).toHaveLength(1);
|
||||
expect(data.protests[0].protestingDriver).toBeNull();
|
||||
expect(data.protests[0].accusedDriver).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle penalty with missing driver details', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const raceId = 'race-1';
|
||||
const driverId = 'driver-1';
|
||||
|
||||
await seedRacingLeague({ leagueId });
|
||||
await seedRace({ raceId, leagueId });
|
||||
// Don't seed driver
|
||||
await seedPenalty({
|
||||
penaltyId: 'penalty-1',
|
||||
leagueId,
|
||||
driverId,
|
||||
raceId,
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const result = await getRacePenaltiesUseCase.execute({ raceId });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.penalties).toHaveLength(1);
|
||||
expect(data.drivers).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should return LEAGUE_NOT_FOUND when league does not exist', async () => {
|
||||
const result = await getLeagueProtestsUseCase.execute({ leagueId: 'missing-league' });
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('LEAGUE_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('should handle repository errors gracefully', async () => {
|
||||
const leagueId = 'league-1';
|
||||
await seedRacingLeague({ leagueId });
|
||||
|
||||
// Mock repository error
|
||||
context.raceRepository.findByLeagueId = async () => {
|
||||
throw new Error('Database error');
|
||||
};
|
||||
|
||||
const result = await getLeagueProtestsUseCase.execute({ leagueId });
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,767 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { LeaguesTestContext } from '../LeaguesTestContext';
|
||||
import { League as RacingLeague } from '../../../../core/racing/domain/entities/League';
|
||||
import { Race } from '../../../../core/racing/domain/entities/Race';
|
||||
import { Driver } from '../../../../core/racing/domain/entities/Driver';
|
||||
import { Protest } from '../../../../core/racing/domain/entities/Protest';
|
||||
import { Penalty } from '../../../../core/racing/domain/entities/penalty/Penalty';
|
||||
import { LeagueMembership } from '../../../../core/racing/domain/entities/LeagueMembership';
|
||||
import { ReviewProtestUseCase } from '../../../../core/racing/application/use-cases/ReviewProtestUseCase';
|
||||
import { ApplyPenaltyUseCase } from '../../../../core/racing/application/use-cases/ApplyPenaltyUseCase';
|
||||
import { QuickPenaltyUseCase } from '../../../../core/racing/application/use-cases/QuickPenaltyUseCase';
|
||||
import { FileProtestUseCase } from '../../../../core/racing/application/use-cases/FileProtestUseCase';
|
||||
import { RequestProtestDefenseUseCase } from '../../../../core/racing/application/use-cases/RequestProtestDefenseUseCase';
|
||||
import { SubmitProtestDefenseUseCase } from '../../../../core/racing/application/use-cases/SubmitProtestDefenseUseCase';
|
||||
|
||||
describe('League Stewarding - StewardingManagement', () => {
|
||||
let context: LeaguesTestContext;
|
||||
let reviewProtestUseCase: ReviewProtestUseCase;
|
||||
let applyPenaltyUseCase: ApplyPenaltyUseCase;
|
||||
let quickPenaltyUseCase: QuickPenaltyUseCase;
|
||||
let fileProtestUseCase: FileProtestUseCase;
|
||||
let requestProtestDefenseUseCase: RequestProtestDefenseUseCase;
|
||||
let submitProtestDefenseUseCase: SubmitProtestDefenseUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
context = new LeaguesTestContext();
|
||||
context.clear();
|
||||
|
||||
reviewProtestUseCase = new ReviewProtestUseCase(
|
||||
context.protestRepository,
|
||||
context.raceRepository,
|
||||
context.leagueMembershipRepository,
|
||||
context.logger,
|
||||
);
|
||||
|
||||
applyPenaltyUseCase = new ApplyPenaltyUseCase(
|
||||
context.penaltyRepository,
|
||||
context.protestRepository,
|
||||
context.raceRepository,
|
||||
context.leagueMembershipRepository,
|
||||
context.logger,
|
||||
);
|
||||
|
||||
quickPenaltyUseCase = new QuickPenaltyUseCase(
|
||||
context.penaltyRepository,
|
||||
context.raceRepository,
|
||||
context.leagueMembershipRepository,
|
||||
context.logger,
|
||||
);
|
||||
|
||||
fileProtestUseCase = new FileProtestUseCase(
|
||||
context.protestRepository,
|
||||
context.raceRepository,
|
||||
context.leagueMembershipRepository,
|
||||
context.racingDriverRepository,
|
||||
);
|
||||
|
||||
requestProtestDefenseUseCase = new RequestProtestDefenseUseCase(
|
||||
context.protestRepository,
|
||||
context.raceRepository,
|
||||
context.leagueMembershipRepository,
|
||||
context.logger,
|
||||
);
|
||||
|
||||
submitProtestDefenseUseCase = new SubmitProtestDefenseUseCase(
|
||||
context.racingLeagueRepository,
|
||||
context.protestRepository,
|
||||
context.logger,
|
||||
);
|
||||
});
|
||||
|
||||
const seedRacingLeague = async (params: { leagueId: string }) => {
|
||||
const league = RacingLeague.create({
|
||||
id: params.leagueId,
|
||||
name: 'Racing League',
|
||||
description: 'League used for stewarding integration tests',
|
||||
ownerId: 'driver-123',
|
||||
});
|
||||
|
||||
await context.racingLeagueRepository.create(league);
|
||||
return league;
|
||||
};
|
||||
|
||||
const seedRace = async (params: { raceId: string; leagueId: string }) => {
|
||||
const race = Race.create({
|
||||
id: params.raceId,
|
||||
leagueId: params.leagueId,
|
||||
track: 'Track 1',
|
||||
car: 'GT3',
|
||||
scheduledAt: new Date('2025-01-10T20:00:00Z'),
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
await context.raceRepository.create(race);
|
||||
return race;
|
||||
};
|
||||
|
||||
const seedDriver = async (params: { driverId: string; iracingId?: string }) => {
|
||||
const driver = Driver.create({
|
||||
id: params.driverId,
|
||||
name: 'Driver Name',
|
||||
iracingId: params.iracingId || `ir-${params.driverId}`,
|
||||
country: 'US',
|
||||
});
|
||||
|
||||
await context.racingDriverRepository.create(driver);
|
||||
return driver;
|
||||
};
|
||||
|
||||
const seedProtest = async (params: {
|
||||
protestId: string;
|
||||
raceId: string;
|
||||
protestingDriverId: string;
|
||||
accusedDriverId: string;
|
||||
status?: string;
|
||||
}) => {
|
||||
const protest = Protest.create({
|
||||
id: params.protestId,
|
||||
raceId: params.raceId,
|
||||
protestingDriverId: params.protestingDriverId,
|
||||
accusedDriverId: params.accusedDriverId,
|
||||
incident: {
|
||||
lap: 5,
|
||||
description: 'Contact on corner entry',
|
||||
},
|
||||
status: params.status || 'pending',
|
||||
});
|
||||
|
||||
await context.protestRepository.create(protest);
|
||||
return protest;
|
||||
};
|
||||
|
||||
const seedPenalty = async (params: {
|
||||
penaltyId: string;
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
raceId?: string;
|
||||
status?: string;
|
||||
}) => {
|
||||
const penalty = Penalty.create({
|
||||
id: params.penaltyId,
|
||||
leagueId: params.leagueId,
|
||||
driverId: params.driverId,
|
||||
type: 'time_penalty',
|
||||
value: 5,
|
||||
reason: 'Contact on corner entry',
|
||||
issuedBy: 'steward-1',
|
||||
status: params.status || 'pending',
|
||||
...(params.raceId && { raceId: params.raceId }),
|
||||
});
|
||||
|
||||
await context.penaltyRepository.create(penalty);
|
||||
return penalty;
|
||||
};
|
||||
|
||||
describe('Review Protest', () => {
|
||||
it('should review a pending protest and mark it as under review', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const raceId = 'race-1';
|
||||
const protestingDriverId = 'driver-1';
|
||||
const accusedDriverId = 'driver-2';
|
||||
const stewardId = 'steward-1';
|
||||
|
||||
await seedRacingLeague({ leagueId });
|
||||
await seedRace({ raceId, leagueId });
|
||||
await seedDriver({ driverId: protestingDriverId });
|
||||
await seedDriver({ driverId: accusedDriverId });
|
||||
await seedDriver({ driverId: stewardId });
|
||||
|
||||
// Add steward as admin
|
||||
await context.leagueMembershipRepository.saveMembership(
|
||||
LeagueMembership.create({
|
||||
leagueId,
|
||||
driverId: stewardId,
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
})
|
||||
);
|
||||
|
||||
const protest = await seedProtest({
|
||||
protestId: 'protest-1',
|
||||
raceId,
|
||||
protestingDriverId,
|
||||
accusedDriverId,
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const result = await reviewProtestUseCase.execute({
|
||||
protestId: 'protest-1',
|
||||
stewardId,
|
||||
decision: 'uphold',
|
||||
decisionNotes: 'Contact was avoidable',
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.protestId).toBe('protest-1');
|
||||
expect(data.leagueId).toBe(leagueId);
|
||||
|
||||
const updatedProtest = await context.protestRepository.findById('protest-1');
|
||||
expect(updatedProtest?.status.toString()).toBe('upheld');
|
||||
expect(updatedProtest?.reviewedBy).toBe('steward-1');
|
||||
});
|
||||
|
||||
it('should uphold a protest and create a penalty', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const raceId = 'race-1';
|
||||
const protestingDriverId = 'driver-1';
|
||||
const accusedDriverId = 'driver-2';
|
||||
const stewardId = 'steward-1';
|
||||
|
||||
await seedRacingLeague({ leagueId });
|
||||
await seedRace({ raceId, leagueId });
|
||||
await seedDriver({ driverId: protestingDriverId });
|
||||
await seedDriver({ driverId: accusedDriverId });
|
||||
await seedDriver({ driverId: stewardId });
|
||||
|
||||
// Add steward as admin
|
||||
await context.leagueMembershipRepository.saveMembership(
|
||||
LeagueMembership.create({
|
||||
leagueId,
|
||||
driverId: stewardId,
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
})
|
||||
);
|
||||
|
||||
const protest = await seedProtest({
|
||||
protestId: 'protest-1',
|
||||
raceId,
|
||||
protestingDriverId,
|
||||
accusedDriverId,
|
||||
status: 'under_review',
|
||||
});
|
||||
|
||||
const result = await reviewProtestUseCase.execute({
|
||||
protestId: protest.id.toString(),
|
||||
stewardId,
|
||||
decision: 'uphold',
|
||||
decisionNotes: 'Contact was avoidable',
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.protestId).toBe('protest-1');
|
||||
expect(data.leagueId).toBe(leagueId);
|
||||
|
||||
const updatedProtest = await context.protestRepository.findById('protest-1');
|
||||
expect(updatedProtest?.status.toString()).toBe('upheld');
|
||||
expect(updatedProtest?.reviewedBy).toBe('steward-1');
|
||||
expect(updatedProtest?.decisionNotes).toBe('Contact was avoidable');
|
||||
});
|
||||
|
||||
it('should dismiss a protest', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const raceId = 'race-1';
|
||||
const protestingDriverId = 'driver-1';
|
||||
const accusedDriverId = 'driver-2';
|
||||
const stewardId = 'steward-1';
|
||||
|
||||
await seedRacingLeague({ leagueId });
|
||||
await seedRace({ raceId, leagueId });
|
||||
await seedDriver({ driverId: protestingDriverId });
|
||||
await seedDriver({ driverId: accusedDriverId });
|
||||
await seedDriver({ driverId: stewardId });
|
||||
|
||||
// Add steward as admin
|
||||
await context.leagueMembershipRepository.saveMembership(
|
||||
LeagueMembership.create({
|
||||
leagueId,
|
||||
driverId: stewardId,
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
})
|
||||
);
|
||||
|
||||
const protest = await seedProtest({
|
||||
protestId: 'protest-1',
|
||||
raceId,
|
||||
protestingDriverId,
|
||||
accusedDriverId,
|
||||
status: 'under_review',
|
||||
});
|
||||
|
||||
const result = await reviewProtestUseCase.execute({
|
||||
protestId: protest.id.toString(),
|
||||
stewardId,
|
||||
decision: 'dismiss',
|
||||
decisionNotes: 'No contact occurred',
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.protestId).toBe('protest-1');
|
||||
expect(data.leagueId).toBe(leagueId);
|
||||
|
||||
const updatedProtest = await context.protestRepository.findById('protest-1');
|
||||
expect(updatedProtest?.status.toString()).toBe('dismissed');
|
||||
expect(updatedProtest?.reviewedBy).toBe('steward-1');
|
||||
expect(updatedProtest?.decisionNotes).toBe('No contact occurred');
|
||||
});
|
||||
|
||||
it('should return PROTEST_NOT_FOUND when protest does not exist', async () => {
|
||||
const result = await reviewProtestUseCase.execute({
|
||||
protestId: 'missing-protest',
|
||||
stewardId: 'steward-1',
|
||||
decision: 'uphold',
|
||||
decisionNotes: 'Notes',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('PROTEST_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('should return RACE_NOT_FOUND when race does not exist', async () => {
|
||||
const protest = Protest.create({
|
||||
id: 'protest-1',
|
||||
raceId: 'missing-race',
|
||||
protestingDriverId: 'driver-1',
|
||||
accusedDriverId: 'driver-2',
|
||||
incident: {
|
||||
lap: 5,
|
||||
description: 'Contact on corner entry',
|
||||
},
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
await context.protestRepository.create(protest);
|
||||
|
||||
const result = await reviewProtestUseCase.execute({
|
||||
protestId: 'protest-1',
|
||||
stewardId: 'steward-1',
|
||||
decision: 'uphold',
|
||||
decisionNotes: 'Notes',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('RACE_NOT_FOUND');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Apply Penalty', () => {
|
||||
it('should apply a penalty to a driver', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const raceId = 'race-1';
|
||||
const driverId = 'driver-1';
|
||||
const stewardId = 'steward-1';
|
||||
|
||||
await seedRacingLeague({ leagueId });
|
||||
await seedRace({ raceId, leagueId });
|
||||
await seedDriver({ driverId });
|
||||
await seedDriver({ driverId: stewardId });
|
||||
|
||||
// Add steward as admin
|
||||
await context.leagueMembershipRepository.saveMembership(
|
||||
LeagueMembership.create({
|
||||
leagueId,
|
||||
driverId: stewardId,
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
})
|
||||
);
|
||||
|
||||
const result = await applyPenaltyUseCase.execute({
|
||||
raceId,
|
||||
driverId,
|
||||
type: 'time_penalty',
|
||||
value: 5,
|
||||
reason: 'Contact on corner entry',
|
||||
stewardId,
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.penaltyId).toBeDefined();
|
||||
|
||||
const penalty = await context.penaltyRepository.findById(data.penaltyId);
|
||||
expect(penalty).not.toBeNull();
|
||||
expect(penalty?.type.toString()).toBe('time_penalty');
|
||||
expect(penalty?.value).toBe(5);
|
||||
expect(penalty?.reason.toString()).toBe('Contact on corner entry');
|
||||
expect(penalty?.issuedBy).toBe('steward-1');
|
||||
expect(penalty?.status.toString()).toBe('pending');
|
||||
});
|
||||
|
||||
it('should apply a penalty linked to a protest', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const raceId = 'race-1';
|
||||
const protestingDriverId = 'driver-1';
|
||||
const accusedDriverId = 'driver-2';
|
||||
const stewardId = 'steward-1';
|
||||
|
||||
await seedRacingLeague({ leagueId });
|
||||
await seedRace({ raceId, leagueId });
|
||||
await seedDriver({ driverId: protestingDriverId });
|
||||
await seedDriver({ driverId: accusedDriverId });
|
||||
await seedDriver({ driverId: stewardId });
|
||||
|
||||
// Add steward as admin
|
||||
await context.leagueMembershipRepository.saveMembership(
|
||||
LeagueMembership.create({
|
||||
leagueId,
|
||||
driverId: stewardId,
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
})
|
||||
);
|
||||
|
||||
const protest = await seedProtest({
|
||||
protestId: 'protest-1',
|
||||
raceId,
|
||||
protestingDriverId,
|
||||
accusedDriverId,
|
||||
status: 'upheld',
|
||||
});
|
||||
|
||||
const result = await applyPenaltyUseCase.execute({
|
||||
raceId,
|
||||
driverId: accusedDriverId,
|
||||
type: 'time_penalty',
|
||||
value: 10,
|
||||
reason: 'Contact on corner entry',
|
||||
stewardId,
|
||||
protestId: protest.id.toString(),
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.penaltyId).toBeDefined();
|
||||
|
||||
const penalty = await context.penaltyRepository.findById(data.penaltyId);
|
||||
expect(penalty).not.toBeNull();
|
||||
expect(penalty?.protestId?.toString()).toBe('protest-1');
|
||||
});
|
||||
|
||||
it('should return RACE_NOT_FOUND when race does not exist', async () => {
|
||||
const result = await applyPenaltyUseCase.execute({
|
||||
raceId: 'missing-race',
|
||||
driverId: 'driver-1',
|
||||
type: 'time_penalty',
|
||||
value: 5,
|
||||
reason: 'Contact on corner entry',
|
||||
stewardId: 'steward-1',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('RACE_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('should return INSUFFICIENT_AUTHORITY when steward is not admin', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const raceId = 'race-1';
|
||||
const driverId = 'driver-1';
|
||||
const stewardId = 'steward-1';
|
||||
|
||||
await seedRacingLeague({ leagueId });
|
||||
await seedRace({ raceId, leagueId });
|
||||
await seedDriver({ driverId });
|
||||
await seedDriver({ driverId: stewardId });
|
||||
|
||||
const result = await applyPenaltyUseCase.execute({
|
||||
raceId,
|
||||
driverId,
|
||||
type: 'time_penalty',
|
||||
value: 5,
|
||||
reason: 'Contact on corner entry',
|
||||
stewardId,
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('INSUFFICIENT_AUTHORITY');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Quick Penalty', () => {
|
||||
it('should create a quick penalty without a protest', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const raceId = 'race-1';
|
||||
const driverId = 'driver-1';
|
||||
const adminId = 'steward-1';
|
||||
|
||||
await seedRacingLeague({ leagueId });
|
||||
await seedRace({ raceId, leagueId });
|
||||
await seedDriver({ driverId });
|
||||
await seedDriver({ driverId: adminId });
|
||||
|
||||
// Add admin as admin
|
||||
await context.leagueMembershipRepository.saveMembership(
|
||||
LeagueMembership.create({
|
||||
leagueId,
|
||||
driverId: adminId,
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
})
|
||||
);
|
||||
|
||||
const result = await quickPenaltyUseCase.execute({
|
||||
raceId,
|
||||
driverId,
|
||||
adminId,
|
||||
infractionType: 'unsafe_rejoin',
|
||||
severity: 'minor',
|
||||
notes: 'Speeding in pit lane',
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.penaltyId).toBeDefined();
|
||||
expect(data.raceId).toBe(raceId);
|
||||
expect(data.driverId).toBe(driverId);
|
||||
|
||||
const penalty = await context.penaltyRepository.findById(data.penaltyId);
|
||||
expect(penalty).not.toBeNull();
|
||||
expect(penalty?.raceId?.toString()).toBe(raceId);
|
||||
expect(penalty?.status.toString()).toBe('applied');
|
||||
});
|
||||
|
||||
it('should return RACE_NOT_FOUND when race does not exist', async () => {
|
||||
const result = await quickPenaltyUseCase.execute({
|
||||
raceId: 'missing-race',
|
||||
driverId: 'driver-1',
|
||||
adminId: 'steward-1',
|
||||
infractionType: 'track_limits',
|
||||
severity: 'minor',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('RACE_NOT_FOUND');
|
||||
});
|
||||
});
|
||||
|
||||
describe('File Protest', () => {
|
||||
it('should file a new protest', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const raceId = 'race-1';
|
||||
const protestingDriverId = 'driver-1';
|
||||
const accusedDriverId = 'driver-2';
|
||||
|
||||
await seedRacingLeague({ leagueId });
|
||||
await seedRace({ raceId, leagueId });
|
||||
await seedDriver({ driverId: protestingDriverId });
|
||||
await seedDriver({ driverId: accusedDriverId });
|
||||
|
||||
// Add drivers as members
|
||||
await context.leagueMembershipRepository.saveMembership(
|
||||
LeagueMembership.create({
|
||||
leagueId,
|
||||
driverId: protestingDriverId,
|
||||
role: 'driver',
|
||||
status: 'active',
|
||||
})
|
||||
);
|
||||
await context.leagueMembershipRepository.saveMembership(
|
||||
LeagueMembership.create({
|
||||
leagueId,
|
||||
driverId: accusedDriverId,
|
||||
role: 'driver',
|
||||
status: 'active',
|
||||
})
|
||||
);
|
||||
|
||||
const result = await fileProtestUseCase.execute({
|
||||
raceId,
|
||||
protestingDriverId,
|
||||
accusedDriverId,
|
||||
incident: {
|
||||
lap: 5,
|
||||
description: 'Contact on corner entry',
|
||||
},
|
||||
comment: 'This was a dangerous move',
|
||||
proofVideoUrl: 'https://example.com/video.mp4',
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.protest.id).toBeDefined();
|
||||
expect(data.protest.raceId).toBe(raceId);
|
||||
|
||||
const protest = await context.protestRepository.findById(data.protest.id);
|
||||
expect(protest).not.toBeNull();
|
||||
expect(protest?.raceId.toString()).toBe(raceId);
|
||||
expect(protest?.protestingDriverId.toString()).toBe(protestingDriverId);
|
||||
expect(protest?.accusedDriverId.toString()).toBe(accusedDriverId);
|
||||
expect(protest?.status.toString()).toBe('pending');
|
||||
expect(protest?.comment).toBe('This was a dangerous move');
|
||||
expect(protest?.proofVideoUrl).toBe('https://example.com/video.mp4');
|
||||
});
|
||||
|
||||
it('should return RACE_NOT_FOUND when race does not exist', async () => {
|
||||
const result = await fileProtestUseCase.execute({
|
||||
raceId: 'missing-race',
|
||||
protestingDriverId: 'driver-1',
|
||||
accusedDriverId: 'driver-2',
|
||||
incident: {
|
||||
lap: 5,
|
||||
description: 'Contact on corner entry',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('RACE_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('should return DRIVER_NOT_FOUND when protesting driver does not exist', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const raceId = 'race-1';
|
||||
|
||||
await seedRacingLeague({ leagueId });
|
||||
await seedRace({ raceId, leagueId });
|
||||
|
||||
const result = await fileProtestUseCase.execute({
|
||||
raceId,
|
||||
protestingDriverId: 'missing-driver',
|
||||
accusedDriverId: 'driver-2',
|
||||
incident: {
|
||||
lap: 5,
|
||||
description: 'Contact on corner entry',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('NOT_MEMBER');
|
||||
});
|
||||
|
||||
it('should return DRIVER_NOT_FOUND when accused driver does not exist', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const raceId = 'race-1';
|
||||
const protestingDriverId = 'driver-1';
|
||||
|
||||
await seedRacingLeague({ leagueId });
|
||||
await seedRace({ raceId, leagueId });
|
||||
await seedDriver({ driverId: protestingDriverId });
|
||||
|
||||
// Add protesting driver as member
|
||||
await context.leagueMembershipRepository.saveMembership(
|
||||
LeagueMembership.create({
|
||||
leagueId,
|
||||
driverId: protestingDriverId,
|
||||
role: 'driver',
|
||||
status: 'active',
|
||||
})
|
||||
);
|
||||
|
||||
const result = await fileProtestUseCase.execute({
|
||||
raceId,
|
||||
protestingDriverId,
|
||||
accusedDriverId: 'missing-driver',
|
||||
incident: {
|
||||
lap: 5,
|
||||
description: 'Contact on corner entry',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request Protest Defense', () => {
|
||||
it('should request defense for a pending protest', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const raceId = 'race-1';
|
||||
const protestingDriverId = 'driver-1';
|
||||
const accusedDriverId = 'driver-2';
|
||||
const stewardId = 'steward-1';
|
||||
|
||||
await seedRacingLeague({ leagueId });
|
||||
await seedRace({ raceId, leagueId });
|
||||
await seedDriver({ driverId: protestingDriverId });
|
||||
await seedDriver({ driverId: accusedDriverId });
|
||||
await seedDriver({ driverId: stewardId });
|
||||
|
||||
// Add steward as admin
|
||||
await context.leagueMembershipRepository.saveMembership(
|
||||
LeagueMembership.create({
|
||||
leagueId,
|
||||
driverId: stewardId,
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
})
|
||||
);
|
||||
|
||||
const protest = await seedProtest({
|
||||
protestId: 'protest-1',
|
||||
raceId,
|
||||
protestingDriverId,
|
||||
accusedDriverId,
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const result = await requestProtestDefenseUseCase.execute({
|
||||
protestId: protest.id.toString(),
|
||||
stewardId,
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.protestId).toBe('protest-1');
|
||||
|
||||
const updatedProtest = await context.protestRepository.findById('protest-1');
|
||||
expect(updatedProtest?.status.toString()).toBe('awaiting_defense');
|
||||
expect(updatedProtest?.defenseRequestedBy).toBe('steward-1');
|
||||
});
|
||||
|
||||
it('should return PROTEST_NOT_FOUND when protest does not exist', async () => {
|
||||
const result = await requestProtestDefenseUseCase.execute({
|
||||
protestId: 'missing-protest',
|
||||
stewardId: 'steward-1',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('PROTEST_NOT_FOUND');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Submit Protest Defense', () => {
|
||||
it('should submit defense for a protest awaiting defense', async () => {
|
||||
const leagueId = 'league-1';
|
||||
const raceId = 'race-1';
|
||||
const protestingDriverId = 'driver-1';
|
||||
const accusedDriverId = 'driver-2';
|
||||
|
||||
await seedRacingLeague({ leagueId });
|
||||
await seedRace({ raceId, leagueId });
|
||||
await seedDriver({ driverId: protestingDriverId });
|
||||
await seedDriver({ driverId: accusedDriverId });
|
||||
const protest = await seedProtest({
|
||||
protestId: 'protest-1',
|
||||
raceId,
|
||||
protestingDriverId,
|
||||
accusedDriverId,
|
||||
status: 'awaiting_defense',
|
||||
});
|
||||
|
||||
const result = await submitProtestDefenseUseCase.execute({
|
||||
leagueId,
|
||||
protestId: protest.id,
|
||||
driverId: accusedDriverId,
|
||||
defenseText: 'I was not at fault',
|
||||
videoUrl: 'https://example.com/defense.mp4',
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.protestId).toBe('protest-1');
|
||||
|
||||
const updatedProtest = await context.protestRepository.findById('protest-1');
|
||||
expect(updatedProtest?.status.toString()).toBe('under_review');
|
||||
expect(updatedProtest?.defense?.statement.toString()).toBe('I was not at fault');
|
||||
expect(updatedProtest?.defense?.videoUrl?.toString()).toBe('https://example.com/defense.mp4');
|
||||
});
|
||||
|
||||
it('should return PROTEST_NOT_FOUND when protest does not exist', async () => {
|
||||
const leagueId = 'league-1';
|
||||
await seedRacingLeague({ leagueId });
|
||||
|
||||
const result = await submitProtestDefenseUseCase.execute({
|
||||
leagueId,
|
||||
protestId: 'missing-protest',
|
||||
driverId: 'driver-2',
|
||||
defenseText: 'I was not at fault',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('PROTEST_NOT_FOUND');
|
||||
});
|
||||
});
|
||||
});
|
||||
164
tests/integration/leagues/wallet/WalletManagement.test.ts
Normal file
164
tests/integration/leagues/wallet/WalletManagement.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { LeaguesTestContext } from '../LeaguesTestContext';
|
||||
import { League } from '../../../../core/racing/domain/entities/League';
|
||||
import { LeagueWallet } from '../../../../core/racing/domain/entities/league-wallet/LeagueWallet';
|
||||
import { Transaction } from '../../../../core/racing/domain/entities/league-wallet/Transaction';
|
||||
import { Money } from '../../../../core/racing/domain/value-objects/Money';
|
||||
|
||||
describe('WalletManagement', () => {
|
||||
let context: LeaguesTestContext;
|
||||
|
||||
beforeEach(() => {
|
||||
context = new LeaguesTestContext();
|
||||
context.walletRepository.clear();
|
||||
context.transactionRepository.clear();
|
||||
});
|
||||
|
||||
describe('GetLeagueWalletUseCase - Success Path', () => {
|
||||
it('should retrieve current wallet balance', async () => {
|
||||
const leagueId = 'league-123';
|
||||
const ownerId = 'owner-1';
|
||||
|
||||
await context.racingLeagueRepository.create(League.create({
|
||||
id: leagueId,
|
||||
name: 'Test League',
|
||||
description: 'Test league description',
|
||||
ownerId: ownerId,
|
||||
}));
|
||||
|
||||
const balance = Money.create(1000, 'USD');
|
||||
await context.walletRepository.create(LeagueWallet.create({
|
||||
id: 'wallet-1',
|
||||
leagueId,
|
||||
balance,
|
||||
}));
|
||||
|
||||
const result = await context.getLeagueWalletUseCase.execute({ leagueId });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap().aggregates.balance.amount).toBe(1000);
|
||||
});
|
||||
|
||||
it('should retrieve transaction history', async () => {
|
||||
const leagueId = 'league-123';
|
||||
const ownerId = 'owner-1';
|
||||
|
||||
await context.racingLeagueRepository.create(League.create({
|
||||
id: leagueId,
|
||||
name: 'Test League',
|
||||
description: 'Test league description',
|
||||
ownerId: ownerId,
|
||||
}));
|
||||
|
||||
const wallet = LeagueWallet.create({
|
||||
id: 'wallet-1',
|
||||
leagueId,
|
||||
balance: Money.create(1000, 'USD'),
|
||||
});
|
||||
await context.walletRepository.create(wallet);
|
||||
|
||||
const tx = Transaction.create({
|
||||
id: 'tx1',
|
||||
walletId: wallet.id,
|
||||
type: 'sponsorship_payment',
|
||||
amount: Money.create(1000, 'USD'),
|
||||
description: 'Deposit',
|
||||
});
|
||||
await context.transactionRepository.create(tx);
|
||||
|
||||
const result = await context.getLeagueWalletUseCase.execute({ leagueId });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap().transactions).toHaveLength(1);
|
||||
expect(result.unwrap().transactions[0].id.toString()).toBe('tx1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WithdrawFromLeagueWalletUseCase - Success Path', () => {
|
||||
it('should allow owner to withdraw funds', async () => {
|
||||
const leagueId = 'league-123';
|
||||
const ownerId = 'owner-1';
|
||||
|
||||
await context.racingLeagueRepository.create(League.create({
|
||||
id: leagueId,
|
||||
name: 'Test League',
|
||||
description: 'Test league description',
|
||||
ownerId: ownerId,
|
||||
}));
|
||||
|
||||
const wallet = LeagueWallet.create({
|
||||
id: 'wallet-1',
|
||||
leagueId,
|
||||
balance: Money.create(1000, 'USD'),
|
||||
});
|
||||
await context.walletRepository.create(wallet);
|
||||
|
||||
const result = await context.withdrawFromLeagueWalletUseCase.execute({
|
||||
leagueId,
|
||||
requestedById: ownerId,
|
||||
amount: 500,
|
||||
currency: 'USD',
|
||||
reason: 'Test withdrawal'
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap().walletBalanceAfter.amount).toBe(500);
|
||||
|
||||
const walletAfter = await context.walletRepository.findByLeagueId(leagueId);
|
||||
expect(walletAfter?.balance.amount).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WalletManagement - Error Handling', () => {
|
||||
it('should return error when league does not exist', async () => {
|
||||
const result = await context.getLeagueWalletUseCase.execute({ leagueId: 'non-existent' });
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect((result as any).error.code).toBe('LEAGUE_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('should return error when wallet does not exist', async () => {
|
||||
const leagueId = 'league-123';
|
||||
await context.racingLeagueRepository.create(League.create({
|
||||
id: leagueId,
|
||||
name: 'Test League',
|
||||
description: 'Test league description',
|
||||
ownerId: 'owner-1',
|
||||
}));
|
||||
|
||||
const result = await context.getLeagueWalletUseCase.execute({ leagueId });
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect((result as any).error.code).toBe('WALLET_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('should prevent non-owner from withdrawing', async () => {
|
||||
const leagueId = 'league-123';
|
||||
const ownerId = 'owner-1';
|
||||
const otherId = 'other-user';
|
||||
|
||||
await context.racingLeagueRepository.create(League.create({
|
||||
id: leagueId,
|
||||
name: 'Test League',
|
||||
description: 'Test league description',
|
||||
ownerId: ownerId,
|
||||
}));
|
||||
|
||||
await context.walletRepository.create(LeagueWallet.create({
|
||||
id: 'wallet-1',
|
||||
leagueId,
|
||||
balance: Money.create(1000, 'USD'),
|
||||
}));
|
||||
|
||||
const result = await context.withdrawFromLeagueWalletUseCase.execute({
|
||||
leagueId,
|
||||
requestedById: otherId,
|
||||
amount: 500,
|
||||
currency: 'USD'
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect((result as any).error.code).toBe('UNAUTHORIZED_WITHDRAWAL');
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user