1 Commits

Author SHA1 Message Date
12027793b1 contract tests
Some checks failed
Contract Testing / contract-tests (pull_request) Failing after 5m47s
Contract Testing / contract-snapshot (pull_request) Has been skipped
2026-01-22 17:31:54 +01:00
241 changed files with 29601 additions and 12381 deletions

View File

@@ -1,186 +0,0 @@
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
# Job 1: Lint and Typecheck (Fast feedback)
lint-typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
- name: Run Typecheck
run: npm run typecheck
# Job 2: Unit and Integration Tests
tests:
runs-on: ubuntu-latest
needs: lint-typecheck
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run Unit Tests
run: npm run test:unit
- name: Run Integration Tests
run: npm run test:integration
# Job 3: Contract Tests (API/Website compatibility)
contract-tests:
runs-on: ubuntu-latest
needs: lint-typecheck
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run API Contract Validation
run: npm run test:api:contracts
- name: Generate OpenAPI spec
run: npm run api:generate-spec
- name: Generate TypeScript types
run: npm run api:generate-types
- name: Run Contract Compatibility Check
run: npm run test:contract:compatibility
- name: Verify Website Type Checking
run: npm run website:type-check
- name: Upload generated types as artifacts
uses: actions/upload-artifact@v3
with:
name: generated-types
path: apps/website/lib/types/generated/
retention-days: 7
# Job 4: E2E Tests (Only on main/develop push, not on PRs)
e2e-tests:
runs-on: ubuntu-latest
needs: [lint-typecheck, tests, contract-tests]
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop')
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Run E2E Tests
run: npm run test:e2e
# Job 5: Comment PR with results (Only on PRs)
comment-pr:
runs-on: ubuntu-latest
needs: [lint-typecheck, tests, contract-tests]
if: github.event_name == 'pull_request'
steps:
- name: Comment PR with results
uses: actions/github-script@v6
with:
script: |
const fs = require('fs');
const path = require('path');
// Read any contract change reports
const reportPath = path.join(process.cwd(), 'contract-report.json');
if (fs.existsSync(reportPath)) {
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
const comment = `
## 🔍 CI Results
✅ **All checks passed!**
### Changes Summary:
- Total changes: ${report.totalChanges}
- Breaking changes: ${report.breakingChanges}
- Added: ${report.added}
- Removed: ${report.removed}
- Modified: ${report.modified}
Generated types are available as artifacts.
`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: comment
});
}
# Job 6: Commit generated types (Only on main branch push)
commit-types:
runs-on: ubuntu-latest
needs: [lint-typecheck, tests, contract-tests]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Generate and snapshot types
run: |
npm run api:generate-spec
npm run api:generate-types
- name: Commit generated types
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add apps/website/lib/types/generated/
git diff --staged --quiet || git commit -m "chore: update generated API types [skip ci]"
git push

110
.github/workflows/contract-testing.yml vendored Normal file
View File

@@ -0,0 +1,110 @@
name: Contract Testing
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
contract-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run API contract validation
run: npm run test:api:contracts
- name: Generate OpenAPI spec
run: npm run api:generate-spec
- name: Generate TypeScript types
run: npm run api:generate-types
- name: Run contract compatibility check
run: npm run test:contract:compatibility
- name: Verify website type checking
run: npm run website:type-check
- name: Upload generated types as artifacts
uses: actions/upload-artifact@v3
with:
name: generated-types
path: apps/website/lib/types/generated/
retention-days: 7
- name: Comment PR with results
if: github.event_name == 'pull_request'
uses: actions/github-script@v6
with:
script: |
const fs = require('fs');
const path = require('path');
// Read any contract change reports
const reportPath = path.join(process.cwd(), 'contract-report.json');
if (fs.existsSync(reportPath)) {
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
const comment = `
## 🔍 Contract Testing Results
✅ **All contract tests passed!**
### Changes Summary:
- Total changes: ${report.totalChanges}
- Breaking changes: ${report.breakingChanges}
- Added: ${report.added}
- Removed: ${report.removed}
- Modified: ${report.modified}
Generated types are available as artifacts.
`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: comment
});
}
contract-snapshot:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Generate and snapshot types
run: |
npm run api:generate-spec
npm run api:generate-types
- name: Commit generated types
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add apps/website/lib/types/generated/
git diff --staged --quiet || git commit -m "chore: update generated API types [skip ci]"
git push

View File

@@ -1 +1 @@
npx lint-staged
npm test

View File

@@ -56,7 +56,7 @@ npm test
Individual applications support hot reload and watch mode during development:
- **web-api**: Backend REST API server
- **web-client**: Frontend React application
- **web-client**: Frontend React application
- **companion**: Desktop companion application
## Testing Commands
@@ -64,28 +64,12 @@ Individual applications support hot reload and watch mode during development:
GridPilot follows strict BDD (Behavior-Driven Development) with comprehensive test coverage.
### Local Verification Pipeline
GridPilot uses **lint-staged** to automatically validate only changed files on commit:
- `eslint --fix` runs on changed JS/TS/TSX files
- `vitest related --run` runs tests related to changed files
- `prettier --write` formats JSON, MD, and YAML files
This ensures fast commits without running the full test suite.
### Pre-Push Hook
A **pre-push hook** runs the full verification pipeline before pushing to remote:
- `npm run lint` - Check for linting errors
- `npm run typecheck` - Verify TypeScript types
- `npm run test:unit` - Run unit tests
- `npm run test:integration` - Run integration tests
You can skip this with `git push --no-verify` if needed.
Run this sequence before pushing to ensure correctness:
```bash
npm run lint && npm run typecheck && npm run test:unit && npm run test:integration
```
### Individual Commands
```bash
# Run all tests
npm test
@@ -163,4 +147,4 @@ Comprehensive documentation is available in the [`/docs`](docs/) directory:
## License
MIT License - see [LICENSE](LICENSE) file for details.
MIT License - see [LICENSE](LICENSE) file for details.

View File

@@ -3,23 +3,10 @@ import {
DashboardAccessedEvent,
DashboardErrorEvent,
} from '../../core/dashboard/application/ports/DashboardEventPublisher';
import {
LeagueEventPublisher,
LeagueCreatedEvent,
LeagueUpdatedEvent,
LeagueDeletedEvent,
LeagueAccessedEvent,
LeagueRosterAccessedEvent,
} from '../../core/leagues/application/ports/LeagueEventPublisher';
export class InMemoryEventPublisher implements DashboardEventPublisher, LeagueEventPublisher {
export class InMemoryEventPublisher implements DashboardEventPublisher {
private dashboardAccessedEvents: DashboardAccessedEvent[] = [];
private dashboardErrorEvents: DashboardErrorEvent[] = [];
private leagueCreatedEvents: LeagueCreatedEvent[] = [];
private leagueUpdatedEvents: LeagueUpdatedEvent[] = [];
private leagueDeletedEvents: LeagueDeletedEvent[] = [];
private leagueAccessedEvents: LeagueAccessedEvent[] = [];
private leagueRosterAccessedEvents: LeagueRosterAccessedEvent[] = [];
private shouldFail: boolean = false;
async publishDashboardAccessed(event: DashboardAccessedEvent): Promise<void> {
@@ -32,31 +19,6 @@ export class InMemoryEventPublisher implements DashboardEventPublisher, LeagueEv
this.dashboardErrorEvents.push(event);
}
async emitLeagueCreated(event: LeagueCreatedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.leagueCreatedEvents.push(event);
}
async emitLeagueUpdated(event: LeagueUpdatedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.leagueUpdatedEvents.push(event);
}
async emitLeagueDeleted(event: LeagueDeletedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.leagueDeletedEvents.push(event);
}
async emitLeagueAccessed(event: LeagueAccessedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.leagueAccessedEvents.push(event);
}
async emitLeagueRosterAccessed(event: LeagueRosterAccessedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.leagueRosterAccessedEvents.push(event);
}
getDashboardAccessedEventCount(): number {
return this.dashboardAccessedEvents.length;
}
@@ -65,42 +27,9 @@ export class InMemoryEventPublisher implements DashboardEventPublisher, LeagueEv
return this.dashboardErrorEvents.length;
}
getLeagueCreatedEventCount(): number {
return this.leagueCreatedEvents.length;
}
getLeagueUpdatedEventCount(): number {
return this.leagueUpdatedEvents.length;
}
getLeagueDeletedEventCount(): number {
return this.leagueDeletedEvents.length;
}
getLeagueAccessedEventCount(): number {
return this.leagueAccessedEvents.length;
}
getLeagueRosterAccessedEventCount(): number {
return this.leagueRosterAccessedEvents.length;
}
getLeagueRosterAccessedEvents(): LeagueRosterAccessedEvent[] {
return [...this.leagueRosterAccessedEvents];
}
getLeagueCreatedEvents(): LeagueCreatedEvent[] {
return [...this.leagueCreatedEvents];
}
clear(): void {
this.dashboardAccessedEvents = [];
this.dashboardErrorEvents = [];
this.leagueCreatedEvents = [];
this.leagueUpdatedEvents = [];
this.leagueDeletedEvents = [];
this.leagueAccessedEvents = [];
this.leagueRosterAccessedEvents = [];
this.shouldFail = false;
}

View File

@@ -1,175 +0,0 @@
/**
* In-Memory Health Event Publisher
*
* Tracks health-related events for testing purposes.
* This publisher allows verification of event emission patterns
* without requiring external event bus infrastructure.
*/
import {
HealthEventPublisher,
HealthCheckCompletedEvent,
HealthCheckFailedEvent,
HealthCheckTimeoutEvent,
ConnectedEvent,
DisconnectedEvent,
DegradedEvent,
CheckingEvent,
} from '../../../core/health/ports/HealthEventPublisher';
export interface HealthCheckCompletedEventWithType {
type: 'HealthCheckCompleted';
healthy: boolean;
responseTime: number;
timestamp: Date;
endpoint?: string;
}
export interface HealthCheckFailedEventWithType {
type: 'HealthCheckFailed';
error: string;
timestamp: Date;
endpoint?: string;
}
export interface HealthCheckTimeoutEventWithType {
type: 'HealthCheckTimeout';
timestamp: Date;
endpoint?: string;
}
export interface ConnectedEventWithType {
type: 'Connected';
timestamp: Date;
responseTime: number;
}
export interface DisconnectedEventWithType {
type: 'Disconnected';
timestamp: Date;
consecutiveFailures: number;
}
export interface DegradedEventWithType {
type: 'Degraded';
timestamp: Date;
reliability: number;
}
export interface CheckingEventWithType {
type: 'Checking';
timestamp: Date;
}
export type HealthEvent =
| HealthCheckCompletedEventWithType
| HealthCheckFailedEventWithType
| HealthCheckTimeoutEventWithType
| ConnectedEventWithType
| DisconnectedEventWithType
| DegradedEventWithType
| CheckingEventWithType;
export class InMemoryHealthEventPublisher implements HealthEventPublisher {
private events: HealthEvent[] = [];
private shouldFail: boolean = false;
/**
* Publish a health check completed event
*/
async publishHealthCheckCompleted(event: HealthCheckCompletedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.events.push({ type: 'HealthCheckCompleted', ...event });
}
/**
* Publish a health check failed event
*/
async publishHealthCheckFailed(event: HealthCheckFailedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.events.push({ type: 'HealthCheckFailed', ...event });
}
/**
* Publish a health check timeout event
*/
async publishHealthCheckTimeout(event: HealthCheckTimeoutEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.events.push({ type: 'HealthCheckTimeout', ...event });
}
/**
* Publish a connected event
*/
async publishConnected(event: ConnectedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.events.push({ type: 'Connected', ...event });
}
/**
* Publish a disconnected event
*/
async publishDisconnected(event: DisconnectedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.events.push({ type: 'Disconnected', ...event });
}
/**
* Publish a degraded event
*/
async publishDegraded(event: DegradedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.events.push({ type: 'Degraded', ...event });
}
/**
* Publish a checking event
*/
async publishChecking(event: CheckingEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.events.push({ type: 'Checking', ...event });
}
/**
* Get all published events
*/
getEvents(): HealthEvent[] {
return [...this.events];
}
/**
* Get events by type
*/
getEventsByType<T extends HealthEvent['type']>(type: T): Extract<HealthEvent, { type: T }>[] {
return this.events.filter((event): event is Extract<HealthEvent, { type: T }> => event.type === type);
}
/**
* Get the count of events
*/
getEventCount(): number {
return this.events.length;
}
/**
* Get the count of events by type
*/
getEventCountByType(type: HealthEvent['type']): number {
return this.events.filter(event => event.type === type).length;
}
/**
* Clear all published events
*/
clear(): void {
this.events = [];
this.shouldFail = false;
}
/**
* Configure the publisher to fail on publish
*/
setShouldFail(shouldFail: boolean): void {
this.shouldFail = shouldFail;
}
}

View File

@@ -1,197 +0,0 @@
/**
* In-Memory Health Check Adapter
*
* Simulates API health check responses for testing purposes.
* This adapter allows controlled testing of health check scenarios
* without making actual HTTP requests.
*/
import {
HealthCheckQuery,
ConnectionStatus,
ConnectionHealth,
HealthCheckResult,
} from '../../../../core/health/ports/HealthCheckQuery';
export interface HealthCheckResponse {
healthy: boolean;
responseTime: number;
error?: string;
timestamp: Date;
}
export class InMemoryHealthCheckAdapter implements HealthCheckQuery {
private responses: Map<string, HealthCheckResponse> = new Map();
public shouldFail: boolean = false;
public failError: string = 'Network error';
private responseTime: number = 50;
private health: ConnectionHealth = {
status: 'disconnected',
lastCheck: null,
lastSuccess: null,
lastFailure: null,
consecutiveFailures: 0,
totalRequests: 0,
successfulRequests: 0,
failedRequests: 0,
averageResponseTime: 0,
};
/**
* Configure the adapter to return a specific response
*/
configureResponse(endpoint: string, response: HealthCheckResponse): void {
this.responses.set(endpoint, response);
}
/**
* Configure the adapter to fail all requests
*/
setShouldFail(shouldFail: boolean, error?: string): void {
this.shouldFail = shouldFail;
if (error) {
this.failError = error;
}
}
/**
* Set the response time for health checks
*/
setResponseTime(time: number): void {
this.responseTime = time;
}
/**
* Perform a health check against an endpoint
*/
async performHealthCheck(): Promise<HealthCheckResult> {
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, this.responseTime));
if (this.shouldFail) {
this.recordFailure(this.failError);
return {
healthy: false,
responseTime: this.responseTime,
error: this.failError,
timestamp: new Date(),
};
}
// Default successful response
this.recordSuccess(this.responseTime);
return {
healthy: true,
responseTime: this.responseTime,
timestamp: new Date(),
};
}
/**
* Get current connection status
*/
getStatus(): ConnectionStatus {
return this.health.status;
}
/**
* Get detailed health information
*/
getHealth(): ConnectionHealth {
return { ...this.health };
}
/**
* Get reliability percentage
*/
getReliability(): number {
if (this.health.totalRequests === 0) return 0;
return (this.health.successfulRequests / this.health.totalRequests) * 100;
}
/**
* Check if API is currently available
*/
isAvailable(): boolean {
return this.health.status === 'connected' || this.health.status === 'degraded';
}
/**
* Record a successful health check
*/
private recordSuccess(responseTime: number): void {
this.health.totalRequests++;
this.health.successfulRequests++;
this.health.consecutiveFailures = 0;
this.health.lastSuccess = new Date();
this.health.lastCheck = new Date();
// Update average response time
const total = this.health.successfulRequests;
this.health.averageResponseTime =
((this.health.averageResponseTime * (total - 1)) + responseTime) / total;
this.updateStatus();
}
/**
* Record a failed health check
*/
private recordFailure(error: string): void {
this.health.totalRequests++;
this.health.failedRequests++;
this.health.consecutiveFailures++;
this.health.lastFailure = new Date();
this.health.lastCheck = new Date();
this.updateStatus();
}
/**
* Update connection status based on current metrics
*/
private updateStatus(): void {
const reliability = this.health.totalRequests > 0
? this.health.successfulRequests / this.health.totalRequests
: 0;
// More nuanced status determination
if (this.health.totalRequests === 0) {
// No requests yet - don't assume disconnected
this.health.status = 'checking';
} else if (this.health.consecutiveFailures >= 3) {
// Multiple consecutive failures indicates real connectivity issue
this.health.status = 'disconnected';
} else if (reliability < 0.7 && this.health.totalRequests >= 5) {
// Only degrade if we have enough samples and reliability is low
this.health.status = 'degraded';
} else if (reliability >= 0.7 || this.health.successfulRequests > 0) {
// If we have any successes, we're connected
this.health.status = 'connected';
} else {
// Default to checking if uncertain
this.health.status = 'checking';
}
}
/**
* Clear all configured responses and settings
*/
clear(): void {
this.responses.clear();
this.shouldFail = false;
this.failError = 'Network error';
this.responseTime = 50;
this.health = {
status: 'disconnected',
lastCheck: null,
lastSuccess: null,
lastFailure: null,
consecutiveFailures: 0,
totalRequests: 0,
successfulRequests: 0,
failedRequests: 0,
averageResponseTime: 0,
};
}
}

View File

@@ -1,70 +0,0 @@
/**
* Infrastructure Adapter: InMemoryLeaderboardsEventPublisher
*
* In-memory implementation of LeaderboardsEventPublisher.
* Stores events in arrays for testing purposes.
*/
import {
LeaderboardsEventPublisher,
GlobalLeaderboardsAccessedEvent,
DriverRankingsAccessedEvent,
TeamRankingsAccessedEvent,
LeaderboardsErrorEvent,
} from '../../../core/leaderboards/application/ports/LeaderboardsEventPublisher';
export class InMemoryLeaderboardsEventPublisher implements LeaderboardsEventPublisher {
private globalLeaderboardsAccessedEvents: GlobalLeaderboardsAccessedEvent[] = [];
private driverRankingsAccessedEvents: DriverRankingsAccessedEvent[] = [];
private teamRankingsAccessedEvents: TeamRankingsAccessedEvent[] = [];
private leaderboardsErrorEvents: LeaderboardsErrorEvent[] = [];
private shouldFail: boolean = false;
async publishGlobalLeaderboardsAccessed(event: GlobalLeaderboardsAccessedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.globalLeaderboardsAccessedEvents.push(event);
}
async publishDriverRankingsAccessed(event: DriverRankingsAccessedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.driverRankingsAccessedEvents.push(event);
}
async publishTeamRankingsAccessed(event: TeamRankingsAccessedEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.teamRankingsAccessedEvents.push(event);
}
async publishLeaderboardsError(event: LeaderboardsErrorEvent): Promise<void> {
if (this.shouldFail) throw new Error('Event publisher failed');
this.leaderboardsErrorEvents.push(event);
}
getGlobalLeaderboardsAccessedEventCount(): number {
return this.globalLeaderboardsAccessedEvents.length;
}
getDriverRankingsAccessedEventCount(): number {
return this.driverRankingsAccessedEvents.length;
}
getTeamRankingsAccessedEventCount(): number {
return this.teamRankingsAccessedEvents.length;
}
getLeaderboardsErrorEventCount(): number {
return this.leaderboardsErrorEvents.length;
}
clear(): void {
this.globalLeaderboardsAccessedEvents = [];
this.driverRankingsAccessedEvents = [];
this.teamRankingsAccessedEvents = [];
this.leaderboardsErrorEvents = [];
this.shouldFail = false;
}
setShouldFail(shouldFail: boolean): void {
this.shouldFail = shouldFail;
}
}

View File

@@ -1,44 +0,0 @@
/**
* Infrastructure Adapter: InMemoryLeaderboardsRepository
*
* In-memory implementation of LeaderboardsRepository.
* Stores data in a Map structure.
*/
import {
LeaderboardsRepository,
LeaderboardDriverData,
LeaderboardTeamData,
} from '../../../../core/leaderboards/application/ports/LeaderboardsRepository';
export class InMemoryLeaderboardsRepository implements LeaderboardsRepository {
private drivers: Map<string, LeaderboardDriverData> = new Map();
private teams: Map<string, LeaderboardTeamData> = new Map();
async findAllDrivers(): Promise<LeaderboardDriverData[]> {
return Array.from(this.drivers.values());
}
async findAllTeams(): Promise<LeaderboardTeamData[]> {
return Array.from(this.teams.values());
}
async findDriversByTeamId(teamId: string): Promise<LeaderboardDriverData[]> {
return Array.from(this.drivers.values()).filter(
(driver) => driver.teamId === teamId,
);
}
addDriver(driver: LeaderboardDriverData): void {
this.drivers.set(driver.id, driver);
}
addTeam(team: LeaderboardTeamData): void {
this.teams.set(team.id, team);
}
clear(): void {
this.drivers.clear();
this.teams.clear();
}
}

View File

@@ -1,84 +0,0 @@
import {
LeagueEventPublisher,
LeagueCreatedEvent,
LeagueUpdatedEvent,
LeagueDeletedEvent,
LeagueAccessedEvent,
LeagueRosterAccessedEvent,
} from '../../../core/leagues/application/ports/LeagueEventPublisher';
export class InMemoryLeagueEventPublisher implements LeagueEventPublisher {
private leagueCreatedEvents: LeagueCreatedEvent[] = [];
private leagueUpdatedEvents: LeagueUpdatedEvent[] = [];
private leagueDeletedEvents: LeagueDeletedEvent[] = [];
private leagueAccessedEvents: LeagueAccessedEvent[] = [];
private leagueRosterAccessedEvents: LeagueRosterAccessedEvent[] = [];
async emitLeagueCreated(event: LeagueCreatedEvent): Promise<void> {
this.leagueCreatedEvents.push(event);
}
async emitLeagueUpdated(event: LeagueUpdatedEvent): Promise<void> {
this.leagueUpdatedEvents.push(event);
}
async emitLeagueDeleted(event: LeagueDeletedEvent): Promise<void> {
this.leagueDeletedEvents.push(event);
}
async emitLeagueAccessed(event: LeagueAccessedEvent): Promise<void> {
this.leagueAccessedEvents.push(event);
}
async emitLeagueRosterAccessed(event: LeagueRosterAccessedEvent): Promise<void> {
this.leagueRosterAccessedEvents.push(event);
}
getLeagueCreatedEventCount(): number {
return this.leagueCreatedEvents.length;
}
getLeagueUpdatedEventCount(): number {
return this.leagueUpdatedEvents.length;
}
getLeagueDeletedEventCount(): number {
return this.leagueDeletedEvents.length;
}
getLeagueAccessedEventCount(): number {
return this.leagueAccessedEvents.length;
}
getLeagueRosterAccessedEventCount(): number {
return this.leagueRosterAccessedEvents.length;
}
clear(): void {
this.leagueCreatedEvents = [];
this.leagueUpdatedEvents = [];
this.leagueDeletedEvents = [];
this.leagueAccessedEvents = [];
this.leagueRosterAccessedEvents = [];
}
getLeagueCreatedEvents(): LeagueCreatedEvent[] {
return [...this.leagueCreatedEvents];
}
getLeagueUpdatedEvents(): LeagueUpdatedEvent[] {
return [...this.leagueUpdatedEvents];
}
getLeagueDeletedEvents(): LeagueDeletedEvent[] {
return [...this.leagueDeletedEvents];
}
getLeagueAccessedEvents(): LeagueAccessedEvent[] {
return [...this.leagueAccessedEvents];
}
getLeagueRosterAccessedEvents(): LeagueRosterAccessedEvent[] {
return [...this.leagueRosterAccessedEvents];
}
}

View File

@@ -1,364 +1,64 @@
import {
LeagueRepository,
LeagueData,
LeagueStats,
LeagueFinancials,
LeagueStewardingMetrics,
LeaguePerformanceMetrics,
LeagueRatingMetrics,
LeagueTrendMetrics,
LeagueSuccessRateMetrics,
LeagueResolutionTimeMetrics,
LeagueComplexSuccessRateMetrics,
LeagueComplexResolutionTimeMetrics,
LeagueMember,
LeaguePendingRequest,
} from '../../../../core/leagues/application/ports/LeagueRepository';
import { LeagueStandingData } from '../../../../core/dashboard/application/ports/DashboardRepository';
DashboardRepository,
DriverData,
RaceData,
LeagueStandingData,
ActivityData,
FriendData,
} from '../../../../core/dashboard/application/ports/DashboardRepository';
export class InMemoryLeagueRepository implements LeagueRepository {
private leagues: Map<string, LeagueData> = new Map();
private leagueStats: Map<string, LeagueStats> = new Map();
private leagueFinancials: Map<string, LeagueFinancials> = new Map();
private leagueStewardingMetrics: Map<string, LeagueStewardingMetrics> = new Map();
private leaguePerformanceMetrics: Map<string, LeaguePerformanceMetrics> = new Map();
private leagueRatingMetrics: Map<string, LeagueRatingMetrics> = new Map();
private leagueTrendMetrics: Map<string, LeagueTrendMetrics> = new Map();
private leagueSuccessRateMetrics: Map<string, LeagueSuccessRateMetrics> = new Map();
private leagueResolutionTimeMetrics: Map<string, LeagueResolutionTimeMetrics> = new Map();
private leagueComplexSuccessRateMetrics: Map<string, LeagueComplexSuccessRateMetrics> = new Map();
private leagueComplexResolutionTimeMetrics: Map<string, LeagueComplexResolutionTimeMetrics> = new Map();
export class InMemoryLeagueRepository implements DashboardRepository {
private drivers: Map<string, DriverData> = new Map();
private upcomingRaces: Map<string, RaceData[]> = new Map();
private leagueStandings: Map<string, LeagueStandingData[]> = new Map();
private leagueMembers: Map<string, LeagueMember[]> = new Map();
private leaguePendingRequests: Map<string, LeaguePendingRequest[]> = new Map();
private recentActivity: Map<string, ActivityData[]> = new Map();
private friends: Map<string, FriendData[]> = new Map();
async create(league: LeagueData): Promise<LeagueData> {
this.leagues.set(league.id, league);
return league;
async findDriverById(driverId: string): Promise<DriverData | null> {
return this.drivers.get(driverId) || null;
}
async findById(id: string): Promise<LeagueData | null> {
return this.leagues.get(id) || null;
}
async findByName(name: string): Promise<LeagueData | null> {
for (const league of Array.from(this.leagues.values())) {
if (league.name === name) {
return league;
}
}
return null;
}
async findByOwner(ownerId: string): Promise<LeagueData[]> {
const leagues: LeagueData[] = [];
for (const league of Array.from(this.leagues.values())) {
if (league.ownerId === ownerId) {
leagues.push(league);
}
}
return leagues;
}
async search(query: string): Promise<LeagueData[]> {
const results: LeagueData[] = [];
const lowerQuery = query.toLowerCase();
for (const league of Array.from(this.leagues.values())) {
if (
league.name.toLowerCase().includes(lowerQuery) ||
league.description?.toLowerCase().includes(lowerQuery)
) {
results.push(league);
}
}
return results;
}
async update(id: string, updates: Partial<LeagueData>): Promise<LeagueData> {
const league = this.leagues.get(id);
if (!league) {
throw new Error(`League with id ${id} not found`);
}
const updated = { ...league, ...updates };
this.leagues.set(id, updated);
return updated;
}
async delete(id: string): Promise<void> {
this.leagues.delete(id);
this.leagueStats.delete(id);
this.leagueFinancials.delete(id);
this.leagueStewardingMetrics.delete(id);
this.leaguePerformanceMetrics.delete(id);
this.leagueRatingMetrics.delete(id);
this.leagueTrendMetrics.delete(id);
this.leagueSuccessRateMetrics.delete(id);
this.leagueResolutionTimeMetrics.delete(id);
this.leagueComplexSuccessRateMetrics.delete(id);
this.leagueComplexResolutionTimeMetrics.delete(id);
}
async getStats(leagueId: string): Promise<LeagueStats> {
return this.leagueStats.get(leagueId) || this.createDefaultStats(leagueId);
}
async updateStats(leagueId: string, stats: LeagueStats): Promise<LeagueStats> {
this.leagueStats.set(leagueId, stats);
return stats;
}
async getFinancials(leagueId: string): Promise<LeagueFinancials> {
return this.leagueFinancials.get(leagueId) || this.createDefaultFinancials(leagueId);
}
async updateFinancials(leagueId: string, financials: LeagueFinancials): Promise<LeagueFinancials> {
this.leagueFinancials.set(leagueId, financials);
return financials;
}
async getStewardingMetrics(leagueId: string): Promise<LeagueStewardingMetrics> {
return this.leagueStewardingMetrics.get(leagueId) || this.createDefaultStewardingMetrics(leagueId);
}
async updateStewardingMetrics(leagueId: string, metrics: LeagueStewardingMetrics): Promise<LeagueStewardingMetrics> {
this.leagueStewardingMetrics.set(leagueId, metrics);
return metrics;
}
async getPerformanceMetrics(leagueId: string): Promise<LeaguePerformanceMetrics> {
return this.leaguePerformanceMetrics.get(leagueId) || this.createDefaultPerformanceMetrics(leagueId);
}
async updatePerformanceMetrics(leagueId: string, metrics: LeaguePerformanceMetrics): Promise<LeaguePerformanceMetrics> {
this.leaguePerformanceMetrics.set(leagueId, metrics);
return metrics;
}
async getRatingMetrics(leagueId: string): Promise<LeagueRatingMetrics> {
return this.leagueRatingMetrics.get(leagueId) || this.createDefaultRatingMetrics(leagueId);
}
async updateRatingMetrics(leagueId: string, metrics: LeagueRatingMetrics): Promise<LeagueRatingMetrics> {
this.leagueRatingMetrics.set(leagueId, metrics);
return metrics;
}
async getTrendMetrics(leagueId: string): Promise<LeagueTrendMetrics> {
return this.leagueTrendMetrics.get(leagueId) || this.createDefaultTrendMetrics(leagueId);
}
async updateTrendMetrics(leagueId: string, metrics: LeagueTrendMetrics): Promise<LeagueTrendMetrics> {
this.leagueTrendMetrics.set(leagueId, metrics);
return metrics;
}
async getSuccessRateMetrics(leagueId: string): Promise<LeagueSuccessRateMetrics> {
return this.leagueSuccessRateMetrics.get(leagueId) || this.createDefaultSuccessRateMetrics(leagueId);
}
async updateSuccessRateMetrics(leagueId: string, metrics: LeagueSuccessRateMetrics): Promise<LeagueSuccessRateMetrics> {
this.leagueSuccessRateMetrics.set(leagueId, metrics);
return metrics;
}
async getResolutionTimeMetrics(leagueId: string): Promise<LeagueResolutionTimeMetrics> {
return this.leagueResolutionTimeMetrics.get(leagueId) || this.createDefaultResolutionTimeMetrics(leagueId);
}
async updateResolutionTimeMetrics(leagueId: string, metrics: LeagueResolutionTimeMetrics): Promise<LeagueResolutionTimeMetrics> {
this.leagueResolutionTimeMetrics.set(leagueId, metrics);
return metrics;
}
async getComplexSuccessRateMetrics(leagueId: string): Promise<LeagueComplexSuccessRateMetrics> {
return this.leagueComplexSuccessRateMetrics.get(leagueId) || this.createDefaultComplexSuccessRateMetrics(leagueId);
}
async updateComplexSuccessRateMetrics(leagueId: string, metrics: LeagueComplexSuccessRateMetrics): Promise<LeagueComplexSuccessRateMetrics> {
this.leagueComplexSuccessRateMetrics.set(leagueId, metrics);
return metrics;
}
async getComplexResolutionTimeMetrics(leagueId: string): Promise<LeagueComplexResolutionTimeMetrics> {
return this.leagueComplexResolutionTimeMetrics.get(leagueId) || this.createDefaultComplexResolutionTimeMetrics(leagueId);
}
async updateComplexResolutionTimeMetrics(leagueId: string, metrics: LeagueComplexResolutionTimeMetrics): Promise<LeagueComplexResolutionTimeMetrics> {
this.leagueComplexResolutionTimeMetrics.set(leagueId, metrics);
return metrics;
}
clear(): void {
this.leagues.clear();
this.leagueStats.clear();
this.leagueFinancials.clear();
this.leagueStewardingMetrics.clear();
this.leaguePerformanceMetrics.clear();
this.leagueRatingMetrics.clear();
this.leagueTrendMetrics.clear();
this.leagueSuccessRateMetrics.clear();
this.leagueResolutionTimeMetrics.clear();
this.leagueComplexSuccessRateMetrics.clear();
this.leagueComplexResolutionTimeMetrics.clear();
this.leagueStandings.clear();
this.leagueMembers.clear();
this.leaguePendingRequests.clear();
}
addLeagueStandings(driverId: string, standings: LeagueStandingData[]): void {
this.leagueStandings.set(driverId, standings);
async getUpcomingRaces(driverId: string): Promise<RaceData[]> {
return this.upcomingRaces.get(driverId) || [];
}
async getLeagueStandings(driverId: string): Promise<LeagueStandingData[]> {
return this.leagueStandings.get(driverId) || [];
}
async addLeagueMembers(leagueId: string, members: LeagueMember[]): Promise<void> {
const current = this.leagueMembers.get(leagueId) || [];
this.leagueMembers.set(leagueId, [...current, ...members]);
async getRecentActivity(driverId: string): Promise<ActivityData[]> {
return this.recentActivity.get(driverId) || [];
}
async getLeagueMembers(leagueId: string): Promise<LeagueMember[]> {
return this.leagueMembers.get(leagueId) || [];
async getFriends(driverId: string): Promise<FriendData[]> {
return this.friends.get(driverId) || [];
}
async updateLeagueMember(leagueId: string, driverId: string, updates: Partial<LeagueMember>): Promise<void> {
const members = this.leagueMembers.get(leagueId) || [];
const index = members.findIndex(m => m.driverId === driverId);
if (index !== -1) {
members[index] = { ...members[index], ...updates } as LeagueMember;
this.leagueMembers.set(leagueId, [...members]);
}
addDriver(driver: DriverData): void {
this.drivers.set(driver.id, driver);
}
async removeLeagueMember(leagueId: string, driverId: string): Promise<void> {
const members = this.leagueMembers.get(leagueId) || [];
this.leagueMembers.set(leagueId, members.filter(m => m.driverId !== driverId));
addUpcomingRaces(driverId: string, races: RaceData[]): void {
this.upcomingRaces.set(driverId, races);
}
async addPendingRequests(leagueId: string, requests: LeaguePendingRequest[]): Promise<void> {
const current = this.leaguePendingRequests.get(leagueId) || [];
this.leaguePendingRequests.set(leagueId, [...current, ...requests]);
addLeagueStandings(driverId: string, standings: LeagueStandingData[]): void {
this.leagueStandings.set(driverId, standings);
}
async getPendingRequests(leagueId: string): Promise<LeaguePendingRequest[]> {
return this.leaguePendingRequests.get(leagueId) || [];
addRecentActivity(driverId: string, activities: ActivityData[]): void {
this.recentActivity.set(driverId, activities);
}
async removePendingRequest(leagueId: string, requestId: string): Promise<void> {
const current = this.leaguePendingRequests.get(leagueId) || [];
this.leaguePendingRequests.set(leagueId, current.filter(r => r.id !== requestId));
addFriends(driverId: string, friends: FriendData[]): void {
this.friends.set(driverId, friends);
}
private createDefaultStats(leagueId: string): LeagueStats {
return {
leagueId,
memberCount: 1,
raceCount: 0,
sponsorCount: 0,
prizePool: 0,
rating: 0,
reviewCount: 0,
};
}
private createDefaultFinancials(leagueId: string): LeagueFinancials {
return {
leagueId,
walletBalance: 0,
totalRevenue: 0,
totalFees: 0,
pendingPayouts: 0,
netBalance: 0,
};
}
private createDefaultStewardingMetrics(leagueId: string): LeagueStewardingMetrics {
return {
leagueId,
averageResolutionTime: 0,
averageProtestResolutionTime: 0,
averagePenaltyAppealSuccessRate: 0,
averageProtestSuccessRate: 0,
averageStewardingActionSuccessRate: 0,
};
}
private createDefaultPerformanceMetrics(leagueId: string): LeaguePerformanceMetrics {
return {
leagueId,
averageLapTime: 0,
averageFieldSize: 0,
averageIncidentCount: 0,
averagePenaltyCount: 0,
averageProtestCount: 0,
averageStewardingActionCount: 0,
};
}
private createDefaultRatingMetrics(leagueId: string): LeagueRatingMetrics {
return {
leagueId,
overallRating: 0,
ratingTrend: 0,
rankTrend: 0,
pointsTrend: 0,
winRateTrend: 0,
podiumRateTrend: 0,
dnfRateTrend: 0,
};
}
private createDefaultTrendMetrics(leagueId: string): LeagueTrendMetrics {
return {
leagueId,
incidentRateTrend: 0,
penaltyRateTrend: 0,
protestRateTrend: 0,
stewardingActionRateTrend: 0,
stewardingTimeTrend: 0,
protestResolutionTimeTrend: 0,
};
}
private createDefaultSuccessRateMetrics(leagueId: string): LeagueSuccessRateMetrics {
return {
leagueId,
penaltyAppealSuccessRate: 0,
protestSuccessRate: 0,
stewardingActionSuccessRate: 0,
stewardingActionAppealSuccessRate: 0,
stewardingActionPenaltySuccessRate: 0,
stewardingActionProtestSuccessRate: 0,
};
}
private createDefaultResolutionTimeMetrics(leagueId: string): LeagueResolutionTimeMetrics {
return {
leagueId,
averageStewardingTime: 0,
averageProtestResolutionTime: 0,
averageStewardingActionAppealPenaltyProtestResolutionTime: 0,
};
}
private createDefaultComplexSuccessRateMetrics(leagueId: string): LeagueComplexSuccessRateMetrics {
return {
leagueId,
stewardingActionAppealPenaltyProtestSuccessRate: 0,
stewardingActionAppealProtestSuccessRate: 0,
stewardingActionPenaltyProtestSuccessRate: 0,
stewardingActionAppealPenaltyProtestSuccessRate2: 0,
};
}
private createDefaultComplexResolutionTimeMetrics(leagueId: string): LeagueComplexResolutionTimeMetrics {
return {
leagueId,
stewardingActionAppealPenaltyProtestResolutionTime: 0,
stewardingActionAppealProtestResolutionTime: 0,
stewardingActionPenaltyProtestResolutionTime: 0,
stewardingActionAppealPenaltyProtestResolutionTime2: 0,
};
clear(): void {
this.drivers.clear();
this.upcomingRaces.clear();
this.leagueStandings.clear();
this.recentActivity.clear();
this.friends.clear();
}
}

View File

@@ -1,93 +0,0 @@
/**
* Infrastructure Adapter: InMemoryMediaEventPublisher
*
* In-memory implementation of MediaEventPublisher for testing purposes.
* Stores events in memory for verification in integration tests.
*/
import type { Logger } from '@core/shared/domain/Logger';
import type { DomainEvent } from '@core/shared/domain/DomainEvent';
export interface MediaEvent {
eventType: string;
aggregateId: string;
eventData: unknown;
occurredAt: Date;
}
export class InMemoryMediaEventPublisher {
private events: MediaEvent[] = [];
constructor(private readonly logger: Logger) {
this.logger.info('[InMemoryMediaEventPublisher] Initialized.');
}
/**
* Publish a domain event
*/
async publish(event: DomainEvent): Promise<void> {
this.logger.debug(`[InMemoryMediaEventPublisher] Publishing event: ${event.eventType} for aggregate: ${event.aggregateId}`);
const mediaEvent: MediaEvent = {
eventType: event.eventType,
aggregateId: event.aggregateId,
eventData: event.eventData,
occurredAt: event.occurredAt,
};
this.events.push(mediaEvent);
this.logger.info(`Event ${event.eventType} published successfully.`);
}
/**
* Get all published events
*/
getEvents(): MediaEvent[] {
return [...this.events];
}
/**
* Get events by event type
*/
getEventsByType(eventType: string): MediaEvent[] {
return this.events.filter(event => event.eventType === eventType);
}
/**
* Get events by aggregate ID
*/
getEventsByAggregateId(aggregateId: string): MediaEvent[] {
return this.events.filter(event => event.aggregateId === aggregateId);
}
/**
* Get the total number of events
*/
getEventCount(): number {
return this.events.length;
}
/**
* Clear all events
*/
clear(): void {
this.events = [];
this.logger.info('[InMemoryMediaEventPublisher] All events cleared.');
}
/**
* Check if an event of a specific type was published
*/
hasEvent(eventType: string): boolean {
return this.events.some(event => event.eventType === eventType);
}
/**
* Check if an event was published for a specific aggregate
*/
hasEventForAggregate(eventType: string, aggregateId: string): boolean {
return this.events.some(
event => event.eventType === eventType && event.aggregateId === aggregateId
);
}
}

View File

@@ -18,12 +18,6 @@ export class InMemoryAvatarGenerationRepository implements AvatarGenerationRepos
}
}
clear(): void {
this.requests.clear();
this.userRequests.clear();
this.logger.info('InMemoryAvatarGenerationRepository cleared.');
}
async save(request: AvatarGenerationRequest): Promise<void> {
this.logger.debug(`[InMemoryAvatarGenerationRepository] Saving avatar generation request: ${request.id} for user ${request.userId}.`);
this.requests.set(request.id, request);

View File

@@ -1,121 +0,0 @@
/**
* Infrastructure Adapter: InMemoryAvatarRepository
*
* In-memory implementation of AvatarRepository for testing purposes.
* Stores avatar entities in memory for fast, deterministic testing.
*/
import type { Avatar } from '@core/media/domain/entities/Avatar';
import type { AvatarRepository } from '@core/media/domain/repositories/AvatarRepository';
import type { Logger } from '@core/shared/domain/Logger';
export class InMemoryAvatarRepository implements AvatarRepository {
private avatars: Map<string, Avatar> = new Map();
private driverAvatars: Map<string, Avatar[]> = new Map();
constructor(private readonly logger: Logger) {
this.logger.info('[InMemoryAvatarRepository] Initialized.');
}
async save(avatar: Avatar): Promise<void> {
this.logger.debug(`[InMemoryAvatarRepository] Saving avatar: ${avatar.id} for driver: ${avatar.driverId}`);
// Store by ID
this.avatars.set(avatar.id, avatar);
// Store by driver ID
if (!this.driverAvatars.has(avatar.driverId)) {
this.driverAvatars.set(avatar.driverId, []);
}
const driverAvatars = this.driverAvatars.get(avatar.driverId)!;
const existingIndex = driverAvatars.findIndex(a => a.id === avatar.id);
if (existingIndex > -1) {
driverAvatars[existingIndex] = avatar;
} else {
driverAvatars.push(avatar);
}
this.logger.info(`Avatar ${avatar.id} for driver ${avatar.driverId} saved successfully.`);
}
async findById(id: string): Promise<Avatar | null> {
this.logger.debug(`[InMemoryAvatarRepository] Finding avatar by ID: ${id}`);
const avatar = this.avatars.get(id) ?? null;
if (avatar) {
this.logger.info(`Found avatar by ID: ${id}`);
} else {
this.logger.warn(`Avatar with ID ${id} not found.`);
}
return avatar;
}
async findActiveByDriverId(driverId: string): Promise<Avatar | null> {
this.logger.debug(`[InMemoryAvatarRepository] Finding active avatar for driver: ${driverId}`);
const driverAvatars = this.driverAvatars.get(driverId) ?? [];
const activeAvatar = driverAvatars.find(avatar => avatar.isActive) ?? null;
if (activeAvatar) {
this.logger.info(`Found active avatar for driver ${driverId}: ${activeAvatar.id}`);
} else {
this.logger.warn(`No active avatar found for driver: ${driverId}`);
}
return activeAvatar;
}
async findByDriverId(driverId: string): Promise<Avatar[]> {
this.logger.debug(`[InMemoryAvatarRepository] Finding all avatars for driver: ${driverId}`);
const driverAvatars = this.driverAvatars.get(driverId) ?? [];
this.logger.info(`Found ${driverAvatars.length} avatars for driver ${driverId}.`);
return driverAvatars;
}
async delete(id: string): Promise<void> {
this.logger.debug(`[InMemoryAvatarRepository] Deleting avatar with ID: ${id}`);
const avatarToDelete = this.avatars.get(id);
if (!avatarToDelete) {
this.logger.warn(`Avatar with ID ${id} not found for deletion.`);
return;
}
// Remove from avatars map
this.avatars.delete(id);
// Remove from driver avatars
const driverAvatars = this.driverAvatars.get(avatarToDelete.driverId);
if (driverAvatars) {
const filtered = driverAvatars.filter(avatar => avatar.id !== id);
if (filtered.length > 0) {
this.driverAvatars.set(avatarToDelete.driverId, filtered);
} else {
this.driverAvatars.delete(avatarToDelete.driverId);
}
}
this.logger.info(`Avatar ${id} deleted successfully.`);
}
/**
* Clear all avatars from the repository
*/
clear(): void {
this.avatars.clear();
this.driverAvatars.clear();
this.logger.info('[InMemoryAvatarRepository] All avatars cleared.');
}
/**
* Get the total number of avatars stored
*/
get size(): number {
return this.avatars.size;
}
}

View File

@@ -1,106 +0,0 @@
/**
* Infrastructure Adapter: InMemoryMediaRepository
*
* In-memory implementation of MediaRepository for testing purposes.
* Stores media entities in memory for fast, deterministic testing.
*/
import type { Media } from '@core/media/domain/entities/Media';
import type { MediaRepository } from '@core/media/domain/repositories/MediaRepository';
import type { Logger } from '@core/shared/domain/Logger';
export class InMemoryMediaRepository implements MediaRepository {
private media: Map<string, Media> = new Map();
private uploadedByMedia: Map<string, Media[]> = new Map();
constructor(private readonly logger: Logger) {
this.logger.info('[InMemoryMediaRepository] Initialized.');
}
async save(media: Media): Promise<void> {
this.logger.debug(`[InMemoryMediaRepository] Saving media: ${media.id} for uploader: ${media.uploadedBy}`);
// Store by ID
this.media.set(media.id, media);
// Store by uploader
if (!this.uploadedByMedia.has(media.uploadedBy)) {
this.uploadedByMedia.set(media.uploadedBy, []);
}
const uploaderMedia = this.uploadedByMedia.get(media.uploadedBy)!;
const existingIndex = uploaderMedia.findIndex(m => m.id === media.id);
if (existingIndex > -1) {
uploaderMedia[existingIndex] = media;
} else {
uploaderMedia.push(media);
}
this.logger.info(`Media ${media.id} for uploader ${media.uploadedBy} saved successfully.`);
}
async findById(id: string): Promise<Media | null> {
this.logger.debug(`[InMemoryMediaRepository] Finding media by ID: ${id}`);
const media = this.media.get(id) ?? null;
if (media) {
this.logger.info(`Found media by ID: ${id}`);
} else {
this.logger.warn(`Media with ID ${id} not found.`);
}
return media;
}
async findByUploadedBy(uploadedBy: string): Promise<Media[]> {
this.logger.debug(`[InMemoryMediaRepository] Finding all media for uploader: ${uploadedBy}`);
const uploaderMedia = this.uploadedByMedia.get(uploadedBy) ?? [];
this.logger.info(`Found ${uploaderMedia.length} media files for uploader ${uploadedBy}.`);
return uploaderMedia;
}
async delete(id: string): Promise<void> {
this.logger.debug(`[InMemoryMediaRepository] Deleting media with ID: ${id}`);
const mediaToDelete = this.media.get(id);
if (!mediaToDelete) {
this.logger.warn(`Media with ID ${id} not found for deletion.`);
return;
}
// Remove from media map
this.media.delete(id);
// Remove from uploader media
const uploaderMedia = this.uploadedByMedia.get(mediaToDelete.uploadedBy);
if (uploaderMedia) {
const filtered = uploaderMedia.filter(media => media.id !== id);
if (filtered.length > 0) {
this.uploadedByMedia.set(mediaToDelete.uploadedBy, filtered);
} else {
this.uploadedByMedia.delete(mediaToDelete.uploadedBy);
}
}
this.logger.info(`Media ${id} deleted successfully.`);
}
/**
* Clear all media from the repository
*/
clear(): void {
this.media.clear();
this.uploadedByMedia.clear();
this.logger.info('[InMemoryMediaRepository] All media cleared.');
}
/**
* Get the total number of media files stored
*/
get size(): number {
return this.media.size;
}
}

View File

@@ -1,22 +0,0 @@
import type { AvatarGenerationPort, AvatarGenerationOptions, AvatarGenerationResult } from '@core/media/application/ports/AvatarGenerationPort';
import type { Logger } from '@core/shared/domain/Logger';
export class InMemoryAvatarGenerationAdapter implements AvatarGenerationPort {
constructor(private readonly logger: Logger) {
this.logger.info('InMemoryAvatarGenerationAdapter initialized.');
}
async generateAvatars(options: AvatarGenerationOptions): Promise<AvatarGenerationResult> {
this.logger.debug('[InMemoryAvatarGenerationAdapter] Generating avatars (mock).', { options });
const avatars = Array.from({ length: options.count }, (_, i) => ({
url: `https://example.com/generated-avatar-${i + 1}.png`,
thumbnailUrl: `https://example.com/generated-avatar-${i + 1}-thumb.png`,
}));
return Promise.resolve({
success: true,
avatars,
});
}
}

View File

@@ -1,109 +0,0 @@
/**
* Infrastructure Adapter: InMemoryMediaStorageAdapter
*
* In-memory implementation of MediaStoragePort for testing purposes.
* Simulates file storage without actual filesystem operations.
*/
import type { MediaStoragePort, UploadOptions, UploadResult } from '@core/media/application/ports/MediaStoragePort';
import type { Logger } from '@core/shared/domain/Logger';
export class InMemoryMediaStorageAdapter implements MediaStoragePort {
private storage: Map<string, Buffer> = new Map();
private metadata: Map<string, { size: number; contentType: string }> = new Map();
constructor(private readonly logger: Logger) {
this.logger.info('[InMemoryMediaStorageAdapter] Initialized.');
}
async uploadMedia(buffer: Buffer, options: UploadOptions): Promise<UploadResult> {
this.logger.debug(`[InMemoryMediaStorageAdapter] Uploading media: ${options.filename}`);
// Validate content type
const allowedTypes = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/gif'];
if (!allowedTypes.includes(options.mimeType)) {
return {
success: false,
errorMessage: `Content type ${options.mimeType} is not allowed`,
};
}
// Generate storage key
const storageKey = `/media/uploaded/${Date.now()}-${options.filename.replace(/[^a-zA-Z0-9.-]/g, '_')}`;
// Store buffer and metadata
this.storage.set(storageKey, buffer);
this.metadata.set(storageKey, {
size: buffer.length,
contentType: options.mimeType,
});
this.logger.info(`Media uploaded successfully: ${storageKey}`);
return {
success: true,
filename: options.filename,
url: storageKey,
};
}
async deleteMedia(storageKey: string): Promise<void> {
this.logger.debug(`[InMemoryMediaStorageAdapter] Deleting media: ${storageKey}`);
this.storage.delete(storageKey);
this.metadata.delete(storageKey);
this.logger.info(`Media deleted successfully: ${storageKey}`);
}
async getBytes(storageKey: string): Promise<Buffer | null> {
this.logger.debug(`[InMemoryMediaStorageAdapter] Getting bytes for: ${storageKey}`);
const buffer = this.storage.get(storageKey) ?? null;
if (buffer) {
this.logger.info(`Retrieved bytes for: ${storageKey}`);
} else {
this.logger.warn(`No bytes found for: ${storageKey}`);
}
return buffer;
}
async getMetadata(storageKey: string): Promise<{ size: number; contentType: string } | null> {
this.logger.debug(`[InMemoryMediaStorageAdapter] Getting metadata for: ${storageKey}`);
const meta = this.metadata.get(storageKey) ?? null;
if (meta) {
this.logger.info(`Retrieved metadata for: ${storageKey}`);
} else {
this.logger.warn(`No metadata found for: ${storageKey}`);
}
return meta;
}
/**
* Clear all stored media
*/
clear(): void {
this.storage.clear();
this.metadata.clear();
this.logger.info('[InMemoryMediaStorageAdapter] All media cleared.');
}
/**
* Get the total number of stored media files
*/
get size(): number {
return this.storage.size;
}
/**
* Check if a storage key exists
*/
has(storageKey: string): boolean {
return this.storage.has(storageKey);
}
}

View File

@@ -6,33 +6,34 @@ import type { Payment, PaymentType } from '@core/payments/domain/entities/Paymen
import type { PaymentRepository } from '@core/payments/domain/repositories/PaymentRepository';
import type { Logger } from '@core/shared/domain/Logger';
const payments: Map<string, Payment> = new Map();
export class InMemoryPaymentRepository implements PaymentRepository {
private payments: Map<string, Payment> = new Map();
constructor(private readonly logger: Logger) {}
async findById(id: string): Promise<Payment | null> {
this.logger.debug('[InMemoryPaymentRepository] findById', { id });
return this.payments.get(id) || null;
return payments.get(id) || null;
}
async findByLeagueId(leagueId: string): Promise<Payment[]> {
this.logger.debug('[InMemoryPaymentRepository] findByLeagueId', { leagueId });
return Array.from(this.payments.values()).filter(p => p.leagueId === leagueId);
return Array.from(payments.values()).filter(p => p.leagueId === leagueId);
}
async findByPayerId(payerId: string): Promise<Payment[]> {
this.logger.debug('[InMemoryPaymentRepository] findByPayerId', { payerId });
return Array.from(this.payments.values()).filter(p => p.payerId === payerId);
return Array.from(payments.values()).filter(p => p.payerId === payerId);
}
async findByType(type: PaymentType): Promise<Payment[]> {
this.logger.debug('[InMemoryPaymentRepository] findByType', { type });
return Array.from(this.payments.values()).filter(p => p.type === type);
return Array.from(payments.values()).filter(p => p.type === type);
}
async findByFilters(filters: { leagueId?: string; payerId?: string; type?: PaymentType }): Promise<Payment[]> {
this.logger.debug('[InMemoryPaymentRepository] findByFilters', { filters });
let results = Array.from(this.payments.values());
let results = Array.from(payments.values());
if (filters.leagueId) {
results = results.filter(p => p.leagueId === filters.leagueId);
@@ -49,17 +50,13 @@ export class InMemoryPaymentRepository implements PaymentRepository {
async create(payment: Payment): Promise<Payment> {
this.logger.debug('[InMemoryPaymentRepository] create', { payment });
this.payments.set(payment.id, payment);
payments.set(payment.id, payment);
return payment;
}
async update(payment: Payment): Promise<Payment> {
this.logger.debug('[InMemoryPaymentRepository] update', { payment });
this.payments.set(payment.id, payment);
payments.set(payment.id, payment);
return payment;
}
clear(): void {
this.payments.clear();
}
}
}

View File

@@ -93,12 +93,6 @@ export class InMemoryDriverRepository implements DriverRepository {
return Promise.resolve(this.iracingIdIndex.has(iracingId));
}
async clear(): Promise<void> {
this.logger.info('[InMemoryDriverRepository] Clearing all drivers');
this.drivers.clear();
this.iracingIdIndex.clear();
}
// Serialization methods for persistence
serialize(driver: Driver): Record<string, unknown> {
return {

View File

@@ -92,9 +92,4 @@ export class InMemoryLeagueMembershipRepository implements LeagueMembershipRepos
}
return Promise.resolve();
}
clear(): void {
this.memberships.clear();
this.joinRequests.clear();
}
}

View File

@@ -14,10 +14,6 @@ export class InMemoryLeagueRepository implements LeagueRepository {
this.logger.info('InMemoryLeagueRepository initialized');
}
clear(): void {
this.leagues.clear();
}
async findById(id: string): Promise<League | null> {
this.logger.debug(`Attempting to find league with ID: ${id}.`);
try {

View File

@@ -105,8 +105,4 @@ export class InMemoryRaceRepository implements RaceRepository {
this.logger.debug(`[InMemoryRaceRepository] Checking existence of race with ID: ${id}.`);
return Promise.resolve(this.races.has(id));
}
clear(): void {
this.races.clear();
}
}

View File

@@ -218,15 +218,10 @@ export class InMemoryResultRepository implements ResultRepository {
}
}
async clear(): Promise<void> {
this.logger.debug('[InMemoryResultRepository] Clearing all results.');
this.results.clear();
}
/**
* Utility method to generate a new UUID
*/
static generateId(): string {
return uuidv4();
}
}
}

View File

@@ -83,8 +83,4 @@ export class InMemorySeasonRepository implements SeasonRepository {
);
return Promise.resolve(activeSeasons);
}
clear(): void {
this.seasons.clear();
}
}

View File

@@ -95,9 +95,4 @@ export class InMemorySponsorRepository implements SponsorRepository {
this.logger.debug(`[InMemorySponsorRepository] Checking existence of sponsor with ID: ${id}`);
return Promise.resolve(this.sponsors.has(id));
}
clear(): void {
this.sponsors.clear();
this.emailIndex.clear();
}
}

View File

@@ -99,12 +99,4 @@ export class InMemorySponsorshipPricingRepository implements SponsorshipPricingR
throw error;
}
}
async create(pricing: any): Promise<void> {
await this.save(pricing.entityType, pricing.entityId, pricing);
}
clear(): void {
this.pricings.clear();
}
}

View File

@@ -109,8 +109,4 @@ export class InMemorySponsorshipRequestRepository implements SponsorshipRequestR
this.logger.debug(`[InMemorySponsorshipRequestRepository] Checking existence of request with ID: ${id}.`);
return Promise.resolve(this.requests.has(id));
}
clear(): void {
this.requests.clear();
}
}

View File

@@ -166,11 +166,6 @@ export class InMemoryStandingRepository implements StandingRepository {
}
}
async clear(): Promise<void> {
this.logger.debug('Clearing all standings.');
this.standings.clear();
}
async recalculate(leagueId: string): Promise<Standing[]> {
this.logger.debug(`Recalculating standings for league id: ${leagueId}`);
try {
@@ -273,4 +268,4 @@ export class InMemoryStandingRepository implements StandingRepository {
throw error;
}
}
}
}

View File

@@ -212,10 +212,4 @@ async getMembership(teamId: string, driverId: string): Promise<TeamMembership |
throw error;
}
}
async clear(): Promise<void> {
this.logger.info('[InMemoryTeamMembershipRepository] Clearing all memberships and join requests');
this.membershipsByTeam.clear();
this.joinRequestsByTeam.clear();
}
}

View File

@@ -124,11 +124,6 @@ export class InMemoryTeamRepository implements TeamRepository {
}
}
async clear(): Promise<void> {
this.logger.info('[InMemoryTeamRepository] Clearing all teams');
this.teams.clear();
}
// Serialization methods for persistence
serialize(team: Team): Record<string, unknown> {
return {

View File

@@ -104,9 +104,4 @@ export class InMemoryDriverExtendedProfileProvider implements DriverExtendedProf
openToRequests: hash % 2 === 0,
};
}
clear(): void {
this.logger.info('[InMemoryDriverExtendedProfileProvider] Clearing all data');
// No data to clear as this provider generates data on-the-fly
}
}

View File

@@ -32,9 +32,4 @@ export class InMemoryDriverRatingProvider implements DriverRatingProvider {
}
return ratingsMap;
}
clear(): void {
this.logger.info('[InMemoryDriverRatingProvider] Clearing all data');
// No data to clear as this provider generates data on-the-fly
}
}

View File

@@ -153,10 +153,4 @@ export class InMemorySocialGraphRepository implements SocialGraphRepository {
throw error;
}
}
async clear(): Promise<void> {
this.logger.info('[InMemorySocialGraphRepository] Clearing all friendships and drivers');
this.friendships = [];
this.driversById.clear();
}
}

View File

@@ -6,7 +6,7 @@ import { Provider } from '@nestjs/common';
import {
ANALYTICS_ENGAGEMENT_REPOSITORY_TOKEN,
ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN,
} from '../../persistence/analytics/AnalyticsPersistenceTokens';
} from '../../../../persistence/analytics/AnalyticsPersistenceTokens';
const LOGGER_TOKEN = 'Logger';

View File

@@ -140,9 +140,10 @@ export const SponsorProviders: Provider[] = [
useFactory: (
paymentRepo: PaymentRepository,
seasonSponsorshipRepo: SeasonSponsorshipRepository,
sponsorRepo: SponsorRepository,
) => new GetSponsorBillingUseCase(paymentRepo, seasonSponsorshipRepo, sponsorRepo),
inject: [PAYMENT_REPOSITORY_TOKEN, SEASON_SPONSORSHIP_REPOSITORY_TOKEN, SPONSOR_REPOSITORY_TOKEN],
) => {
return new GetSponsorBillingUseCase(paymentRepo, seasonSponsorshipRepo);
},
inject: [PAYMENT_REPOSITORY_TOKEN, SEASON_SPONSORSHIP_REPOSITORY_TOKEN],
},
{
provide: GET_ENTITY_SPONSORSHIP_PRICING_USE_CASE_TOKEN,

View File

@@ -9,9 +9,6 @@ import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
*/
export class LeaguesViewDataBuilder {
static build(apiDto: AllLeaguesWithCapacityAndScoringDTO): LeaguesViewData {
if (!apiDto || !Array.isArray(apiDto.leagues)) {
return { leagues: [] };
}
return {
leagues: apiDto.leagues.map((league) => ({
id: league.id,

View File

@@ -2,16 +2,14 @@ import { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
import { Result } from '@/lib/contracts/Result';
import { LeagueService, type LeagueDetailData } from '@/lib/services/leagues/LeagueService';
import { type PresentationError, mapToPresentationError } from '@/lib/contracts/page-queries/PresentationError';
import { LeagueDetailViewDataBuilder } from '@/lib/builders/view-data/LeagueDetailViewDataBuilder';
import { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData';
/**
* LeagueDetail page query
* Returns the raw API DTO for the league detail page
* No DI container usage - constructs dependencies explicitly
*/
export class LeagueDetailPageQuery implements PageQuery<LeagueDetailViewData, string, PresentationError> {
async execute(leagueId: string): Promise<Result<LeagueDetailViewData, PresentationError>> {
export class LeagueDetailPageQuery implements PageQuery<LeagueDetailData, string, PresentationError> {
async execute(leagueId: string): Promise<Result<LeagueDetailData, PresentationError>> {
const service = new LeagueService();
const result = await service.getLeagueDetailData(leagueId);
@@ -19,12 +17,11 @@ export class LeagueDetailPageQuery implements PageQuery<LeagueDetailViewData, st
return Result.err(mapToPresentationError(result.getError()));
}
const viewData = LeagueDetailViewDataBuilder.build(result.unwrap());
return Result.ok(viewData);
return Result.ok(result.unwrap());
}
// Static method to avoid object construction in server code
static async execute(leagueId: string): Promise<Result<LeagueDetailViewData, PresentationError>> {
static async execute(leagueId: string): Promise<Result<LeagueDetailData, PresentationError>> {
const query = new LeagueDetailPageQuery();
return query.execute(leagueId);
}

View File

@@ -33,11 +33,7 @@ export class LeaguesPageQuery implements PageQuery<LeaguesViewData, void> {
}
// Transform to ViewData using builder
const apiDto = result.unwrap();
if (!apiDto || !apiDto.leagues) {
return Result.err('UNKNOWN_ERROR');
}
const viewData = LeaguesViewDataBuilder.build(apiDto);
const viewData = LeaguesViewDataBuilder.build(result.unwrap());
return Result.ok(viewData);
}

View File

@@ -169,28 +169,27 @@ export class LeagueService implements Service {
this.racesApiClient.getPageData(leagueId),
]);
if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') {
if (process.env.NODE_ENV !== 'production') {
const membershipCount = Array.isArray(memberships?.members) ? memberships.members.length : 0;
const racesCount = Array.isArray(racesPageData?.races) ? racesPageData.races.length : 0;
const race0 = racesCount > 0 ? racesPageData.races[0] : null;
console.info(
'[LeagueService.getLeagueDetailData] baseUrl=%s leagueId=%s memberships=%d races=%d race0=%o apiDto=%o',
'[LeagueService.getLeagueDetailData] baseUrl=%s leagueId=%s memberships=%d races=%d race0=%o',
this.baseUrl,
leagueId,
membershipCount,
racesCount,
race0,
apiDto
);
}
if (!apiDto || !apiDto.leagues) {
return Result.err({ type: 'notFound', message: 'Leagues not found' });
}
const leagues = Array.isArray(apiDto.leagues) ? apiDto.leagues : [];
const league = leagues.find(l => l.id === leagueId);
const league = apiDto.leagues.find(l => l.id === leagueId);
if (!league) {
return Result.err({ type: 'notFound', message: 'League not found' });
}
@@ -221,7 +220,7 @@ export class LeagueService implements Service {
console.warn('Failed to fetch league scoring config', e);
}
const races: RaceDTO[] = (racesPageData?.races || []).map((r) => ({
const races: RaceDTO[] = (racesPageData.races || []).map((r) => ({
id: r.id,
name: `${r.track} - ${r.car}`,
date: r.scheduledAt,

View File

@@ -1,64 +0,0 @@
/**
* Dashboard DTO (Data Transfer Object)
*
* Represents the complete dashboard data structure returned to the client.
*/
/**
* Driver statistics section
*/
export interface DriverStatisticsDTO {
rating: number;
rank: number;
starts: number;
wins: number;
podiums: number;
leagues: number;
}
/**
* Upcoming race section
*/
export interface UpcomingRaceDTO {
trackName: string;
carType: string;
scheduledDate: string;
timeUntilRace: string;
}
/**
* Championship standing section
*/
export interface ChampionshipStandingDTO {
leagueName: string;
position: number;
points: number;
totalDrivers: number;
}
/**
* Recent activity section
*/
export interface RecentActivityDTO {
type: 'race_result' | 'league_invitation' | 'achievement' | 'other';
description: string;
timestamp: string;
status: 'success' | 'info' | 'warning' | 'error';
}
/**
* Dashboard DTO
*
* Complete dashboard data structure for a driver.
*/
export interface DashboardDTO {
driver: {
id: string;
name: string;
avatar?: string;
};
statistics: DriverStatisticsDTO;
upcomingRaces: UpcomingRaceDTO[];
championshipStandings: ChampionshipStandingDTO[];
recentActivity: RecentActivityDTO[];
}

View File

@@ -1,43 +0,0 @@
/**
* Dashboard Event Publisher Port
*
* Defines the interface for publishing dashboard-related events.
*/
/**
* Dashboard accessed event
*/
export interface DashboardAccessedEvent {
type: 'dashboard_accessed';
driverId: string;
timestamp: Date;
}
/**
* Dashboard error event
*/
export interface DashboardErrorEvent {
type: 'dashboard_error';
driverId: string;
error: string;
timestamp: Date;
}
/**
* Dashboard Event Publisher Interface
*
* Publishes events related to dashboard operations.
*/
export interface DashboardEventPublisher {
/**
* Publish a dashboard accessed event
* @param event - The event to publish
*/
publishDashboardAccessed(event: DashboardAccessedEvent): Promise<void>;
/**
* Publish a dashboard error event
* @param event - The event to publish
*/
publishDashboardError(event: DashboardErrorEvent): Promise<void>;
}

View File

@@ -1,9 +0,0 @@
/**
* Dashboard Query
*
* Query object for fetching dashboard data.
*/
export interface DashboardQuery {
driverId: string;
}

View File

@@ -1,107 +0,0 @@
/**
* Dashboard Repository Port
*
* Defines the interface for accessing dashboard-related data.
* This is a read-only repository for dashboard data aggregation.
*/
/**
* Driver data for dashboard display
*/
export interface DriverData {
id: string;
name: string;
avatar?: string;
rating: number;
rank: number;
starts: number;
wins: number;
podiums: number;
leagues: number;
}
/**
* Race data for upcoming races section
*/
export interface RaceData {
id: string;
trackName: string;
carType: string;
scheduledDate: Date;
timeUntilRace?: string;
}
/**
* League standing data for championship standings section
*/
export interface LeagueStandingData {
leagueId: string;
leagueName: string;
position: number;
points: number;
totalDrivers: number;
}
/**
* Activity data for recent activity feed
*/
export interface ActivityData {
id: string;
type: 'race_result' | 'league_invitation' | 'achievement' | 'other';
description: string;
timestamp: Date;
status: 'success' | 'info' | 'warning' | 'error';
}
/**
* Friend data for social section
*/
export interface FriendData {
id: string;
name: string;
avatar?: string;
rating: number;
}
/**
* Dashboard Repository Interface
*
* Provides access to all data needed for the dashboard.
* Each method returns data for a specific driver.
*/
export interface DashboardRepository {
/**
* Find a driver by ID
* @param driverId - The driver ID
* @returns Driver data or null if not found
*/
findDriverById(driverId: string): Promise<DriverData | null>;
/**
* Get upcoming races for a driver
* @param driverId - The driver ID
* @returns Array of upcoming races
*/
getUpcomingRaces(driverId: string): Promise<RaceData[]>;
/**
* Get league standings for a driver
* @param driverId - The driver ID
* @returns Array of league standings
*/
getLeagueStandings(driverId: string): Promise<LeagueStandingData[]>;
/**
* Get recent activity for a driver
* @param driverId - The driver ID
* @returns Array of recent activities
*/
getRecentActivity(driverId: string): Promise<ActivityData[]>;
/**
* Get friends for a driver
* @param driverId - The driver ID
* @returns Array of friends
*/
getFriends(driverId: string): Promise<FriendData[]>;
}

View File

@@ -1,18 +0,0 @@
/**
* Dashboard Presenter
*
* Transforms dashboard data into DTO format for presentation.
*/
import { DashboardDTO } from '../dto/DashboardDTO';
export class DashboardPresenter {
/**
* Present dashboard data as DTO
* @param data - Dashboard data
* @returns Dashboard DTO
*/
present(data: DashboardDTO): DashboardDTO {
return data;
}
}

View File

@@ -1,194 +0,0 @@
/**
* Get Dashboard Use Case
*
* Orchestrates the retrieval of dashboard data for a driver.
* Aggregates data from multiple repositories and returns a unified dashboard view.
*/
import { DashboardRepository, RaceData, LeagueStandingData, ActivityData } from '../ports/DashboardRepository';
import { DashboardQuery } from '../ports/DashboardQuery';
import { DashboardDTO } from '../dto/DashboardDTO';
import { DashboardEventPublisher } from '../ports/DashboardEventPublisher';
import { DriverNotFoundError } from '../../domain/errors/DriverNotFoundError';
import { ValidationError } from '../../../shared/errors/ValidationError';
import { Logger } from '../../../shared/domain/Logger';
export interface GetDashboardUseCasePorts {
driverRepository: DashboardRepository;
raceRepository: DashboardRepository;
leagueRepository: DashboardRepository;
activityRepository: DashboardRepository;
eventPublisher: DashboardEventPublisher;
logger: Logger;
}
export class GetDashboardUseCase {
constructor(private readonly ports: GetDashboardUseCasePorts) {}
async execute(query: DashboardQuery): Promise<DashboardDTO> {
// Validate input
this.validateQuery(query);
// Find driver
const driver = await this.ports.driverRepository.findDriverById(query.driverId);
if (!driver) {
throw new DriverNotFoundError(query.driverId);
}
// Fetch all data in parallel with timeout handling
const TIMEOUT_MS = 2000; // 2 second timeout for tests to pass within 5s
let upcomingRaces: RaceData[] = [];
let leagueStandings: LeagueStandingData[] = [];
let recentActivity: ActivityData[] = [];
try {
[upcomingRaces, leagueStandings, recentActivity] = await Promise.all([
Promise.race([
this.ports.raceRepository.getUpcomingRaces(query.driverId),
new Promise<RaceData[]>((resolve) =>
setTimeout(() => resolve([]), TIMEOUT_MS)
),
]),
Promise.race([
this.ports.leagueRepository.getLeagueStandings(query.driverId),
new Promise<LeagueStandingData[]>((resolve) =>
setTimeout(() => resolve([]), TIMEOUT_MS)
),
]),
Promise.race([
this.ports.activityRepository.getRecentActivity(query.driverId),
new Promise<ActivityData[]>((resolve) =>
setTimeout(() => resolve([]), TIMEOUT_MS)
),
]),
]);
} catch (error) {
this.ports.logger.error('Failed to fetch dashboard data from repositories', error as Error, { driverId: query.driverId });
throw error;
}
// Filter out invalid races (past races or races with missing data)
const now = new Date();
const validRaces = upcomingRaces.filter(race => {
// Check if race has required fields
if (!race.trackName || !race.carType || !race.scheduledDate) {
return false;
}
// Check if race is in the future
return race.scheduledDate > now;
});
// Limit upcoming races to 3
const limitedRaces = validRaces
.sort((a, b) => a.scheduledDate.getTime() - b.scheduledDate.getTime())
.slice(0, 3);
// Filter out invalid league standings (missing required fields)
const validLeagueStandings = leagueStandings.filter(standing => {
// Check if standing has required fields
if (!standing.leagueName || standing.position === null || standing.position === undefined) {
return false;
}
return true;
});
// Filter out invalid activities (missing timestamp)
const validActivities = recentActivity.filter(activity => {
// Check if activity has required fields
if (!activity.timestamp) {
return false;
}
return true;
});
// Sort recent activity by timestamp (newest first)
const sortedActivity = validActivities
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
// Transform to DTO
const driverDto: DashboardDTO['driver'] = {
id: driver.id,
name: driver.name,
};
if (driver.avatar) {
driverDto.avatar = driver.avatar;
}
const result: DashboardDTO = {
driver: driverDto,
statistics: {
rating: driver.rating,
rank: driver.rank,
starts: driver.starts,
wins: driver.wins,
podiums: driver.podiums,
leagues: driver.leagues,
},
upcomingRaces: limitedRaces.map(race => ({
trackName: race.trackName,
carType: race.carType,
scheduledDate: race.scheduledDate.toISOString(),
timeUntilRace: race.timeUntilRace || this.calculateTimeUntilRace(race.scheduledDate),
})),
championshipStandings: validLeagueStandings.map(standing => ({
leagueName: standing.leagueName,
position: standing.position,
points: standing.points,
totalDrivers: standing.totalDrivers,
})),
recentActivity: sortedActivity.map(activity => ({
type: activity.type,
description: activity.description,
timestamp: activity.timestamp.toISOString(),
status: activity.status,
})),
};
// Publish event
try {
await this.ports.eventPublisher.publishDashboardAccessed({
type: 'dashboard_accessed',
driverId: query.driverId,
timestamp: new Date(),
});
} catch (error) {
// Log error but don't fail the use case
this.ports.logger.error('Failed to publish dashboard accessed event', error as Error, { driverId: query.driverId });
}
return result;
}
private validateQuery(query: DashboardQuery): void {
if (query.driverId === '') {
throw new ValidationError('Driver ID cannot be empty');
}
if (!query.driverId || typeof query.driverId !== 'string') {
throw new ValidationError('Driver ID must be a valid string');
}
if (query.driverId.trim().length === 0) {
throw new ValidationError('Driver ID cannot be empty');
}
}
private calculateTimeUntilRace(scheduledDate: Date): string {
const now = new Date();
const diff = scheduledDate.getTime() - now.getTime();
if (diff <= 0) {
return 'Race started';
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (days > 0) {
return `${days} day${days > 1 ? 's' : ''} ${hours} hour${hours > 1 ? 's' : ''}`;
}
if (hours > 0) {
return `${hours} hour${hours > 1 ? 's' : ''} ${minutes} minute${minutes > 1 ? 's' : ''}`;
}
return `${minutes} minute${minutes > 1 ? 's' : ''}`;
}
}

View File

@@ -1,16 +0,0 @@
/**
* Driver Not Found Error
*
* Thrown when a driver with the specified ID cannot be found.
*/
export class DriverNotFoundError extends Error {
readonly type = 'domain';
readonly context = 'dashboard';
readonly kind = 'not_found';
constructor(driverId: string) {
super(`Driver with ID "${driverId}" not found`);
this.name = 'DriverNotFoundError';
}
}

View File

@@ -1,54 +0,0 @@
/**
* Health Check Query Port
*
* Defines the interface for querying health status.
* This port is implemented by adapters that can perform health checks.
*/
export interface HealthCheckQuery {
/**
* Perform a health check
*/
performHealthCheck(): Promise<HealthCheckResult>;
/**
* Get current connection status
*/
getStatus(): ConnectionStatus;
/**
* Get detailed health information
*/
getHealth(): ConnectionHealth;
/**
* Get reliability percentage
*/
getReliability(): number;
/**
* Check if API is currently available
*/
isAvailable(): boolean;
}
export type ConnectionStatus = 'connected' | 'disconnected' | 'degraded' | 'checking';
export interface ConnectionHealth {
status: ConnectionStatus;
lastCheck: Date | null;
lastSuccess: Date | null;
lastFailure: Date | null;
consecutiveFailures: number;
totalRequests: number;
successfulRequests: number;
failedRequests: number;
averageResponseTime: number;
}
export interface HealthCheckResult {
healthy: boolean;
responseTime: number;
error?: string;
timestamp: Date;
}

View File

@@ -1,80 +0,0 @@
/**
* Health Event Publisher Port
*
* Defines the interface for publishing health-related events.
* This port is implemented by adapters that can publish events.
*/
export interface HealthEventPublisher {
/**
* Publish a health check completed event
*/
publishHealthCheckCompleted(event: HealthCheckCompletedEvent): Promise<void>;
/**
* Publish a health check failed event
*/
publishHealthCheckFailed(event: HealthCheckFailedEvent): Promise<void>;
/**
* Publish a health check timeout event
*/
publishHealthCheckTimeout(event: HealthCheckTimeoutEvent): Promise<void>;
/**
* Publish a connected event
*/
publishConnected(event: ConnectedEvent): Promise<void>;
/**
* Publish a disconnected event
*/
publishDisconnected(event: DisconnectedEvent): Promise<void>;
/**
* Publish a degraded event
*/
publishDegraded(event: DegradedEvent): Promise<void>;
/**
* Publish a checking event
*/
publishChecking(event: CheckingEvent): Promise<void>;
}
export interface HealthCheckCompletedEvent {
healthy: boolean;
responseTime: number;
timestamp: Date;
endpoint?: string;
}
export interface HealthCheckFailedEvent {
error: string;
timestamp: Date;
endpoint?: string;
}
export interface HealthCheckTimeoutEvent {
timestamp: Date;
endpoint?: string;
}
export interface ConnectedEvent {
timestamp: Date;
responseTime: number;
}
export interface DisconnectedEvent {
timestamp: Date;
consecutiveFailures: number;
}
export interface DegradedEvent {
timestamp: Date;
reliability: number;
}
export interface CheckingEvent {
timestamp: Date;
}

View File

@@ -1,62 +0,0 @@
/**
* CheckApiHealthUseCase
*
* Executes health checks and returns status.
* This Use Case orchestrates the health check process and emits events.
*/
import { HealthCheckQuery, HealthCheckResult } from '../ports/HealthCheckQuery';
import { HealthEventPublisher } from '../ports/HealthEventPublisher';
export interface CheckApiHealthUseCasePorts {
healthCheckAdapter: HealthCheckQuery;
eventPublisher: HealthEventPublisher;
}
export class CheckApiHealthUseCase {
constructor(private readonly ports: CheckApiHealthUseCasePorts) {}
/**
* Execute a health check
*/
async execute(): Promise<HealthCheckResult> {
const { healthCheckAdapter, eventPublisher } = this.ports;
try {
// Perform the health check
const result = await healthCheckAdapter.performHealthCheck();
// Emit appropriate event based on result
if (result.healthy) {
await eventPublisher.publishHealthCheckCompleted({
healthy: result.healthy,
responseTime: result.responseTime,
timestamp: result.timestamp,
});
} else {
await eventPublisher.publishHealthCheckFailed({
error: result.error || 'Unknown error',
timestamp: result.timestamp,
});
}
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const timestamp = new Date();
// Emit failed event
await eventPublisher.publishHealthCheckFailed({
error: errorMessage,
timestamp,
});
return {
healthy: false,
responseTime: 0,
error: errorMessage,
timestamp,
};
}
}
}

View File

@@ -1,52 +0,0 @@
/**
* GetConnectionStatusUseCase
*
* Retrieves current connection status and metrics.
* This Use Case orchestrates the retrieval of connection status information.
*/
import { HealthCheckQuery, ConnectionHealth, ConnectionStatus } from '../ports/HealthCheckQuery';
export interface GetConnectionStatusUseCasePorts {
healthCheckAdapter: HealthCheckQuery;
}
export interface ConnectionStatusResult {
status: ConnectionStatus;
reliability: number;
totalRequests: number;
successfulRequests: number;
failedRequests: number;
consecutiveFailures: number;
averageResponseTime: number;
lastCheck: Date | null;
lastSuccess: Date | null;
lastFailure: Date | null;
}
export class GetConnectionStatusUseCase {
constructor(private readonly ports: GetConnectionStatusUseCasePorts) {}
/**
* Execute to get current connection status
*/
async execute(): Promise<ConnectionStatusResult> {
const { healthCheckAdapter } = this.ports;
const health = healthCheckAdapter.getHealth();
const reliability = healthCheckAdapter.getReliability();
return {
status: health.status,
reliability,
totalRequests: health.totalRequests,
successfulRequests: health.successfulRequests,
failedRequests: health.failedRequests,
consecutiveFailures: health.consecutiveFailures,
averageResponseTime: health.averageResponseTime,
lastCheck: health.lastCheck,
lastSuccess: health.lastSuccess,
lastFailure: health.lastFailure,
};
}
}

View File

@@ -1,77 +0,0 @@
/**
* Driver Rankings Query Port
*
* Defines the interface for querying driver rankings data.
* This is a read-only query with search, filter, and sort capabilities.
*/
/**
* Query input for driver rankings
*/
export interface DriverRankingsQuery {
/**
* Search term for filtering drivers by name (case-insensitive)
*/
search?: string;
/**
* Minimum rating filter
*/
minRating?: number;
/**
* Filter by team ID
*/
teamId?: string;
/**
* Sort field (default: rating)
*/
sortBy?: 'rating' | 'name' | 'rank' | 'raceCount';
/**
* Sort order (default: desc)
*/
sortOrder?: 'asc' | 'desc';
/**
* Page number (default: 1)
*/
page?: number;
/**
* Number of results per page (default: 20)
*/
limit?: number;
}
/**
* Driver entry for rankings
*/
export interface DriverRankingEntry {
rank: number;
id: string;
name: string;
rating: number;
teamId?: string;
teamName?: string;
raceCount: number;
}
/**
* Pagination metadata
*/
export interface PaginationMetadata {
total: number;
page: number;
limit: number;
totalPages: number;
}
/**
* Driver rankings result
*/
export interface DriverRankingsResult {
drivers: DriverRankingEntry[];
pagination: PaginationMetadata;
}

View File

@@ -1,54 +0,0 @@
/**
* Global Leaderboards Query Port
*
* Defines the interface for querying global leaderboards data.
* This is a read-only query for retrieving top drivers and teams.
*/
/**
* Query input for global leaderboards
*/
export interface GlobalLeaderboardsQuery {
/**
* Maximum number of drivers to return (default: 10)
*/
driverLimit?: number;
/**
* Maximum number of teams to return (default: 10)
*/
teamLimit?: number;
}
/**
* Driver entry for global leaderboards
*/
export interface GlobalLeaderboardDriverEntry {
rank: number;
id: string;
name: string;
rating: number;
teamId?: string;
teamName?: string;
raceCount: number;
}
/**
* Team entry for global leaderboards
*/
export interface GlobalLeaderboardTeamEntry {
rank: number;
id: string;
name: string;
rating: number;
memberCount: number;
raceCount: number;
}
/**
* Global leaderboards result
*/
export interface GlobalLeaderboardsResult {
drivers: GlobalLeaderboardDriverEntry[];
teams: GlobalLeaderboardTeamEntry[];
}

View File

@@ -1,69 +0,0 @@
/**
* Leaderboards Event Publisher Port
*
* Defines the interface for publishing leaderboards-related events.
*/
/**
* Global leaderboards accessed event
*/
export interface GlobalLeaderboardsAccessedEvent {
type: 'global_leaderboards_accessed';
timestamp: Date;
}
/**
* Driver rankings accessed event
*/
export interface DriverRankingsAccessedEvent {
type: 'driver_rankings_accessed';
timestamp: Date;
}
/**
* Team rankings accessed event
*/
export interface TeamRankingsAccessedEvent {
type: 'team_rankings_accessed';
timestamp: Date;
}
/**
* Leaderboards error event
*/
export interface LeaderboardsErrorEvent {
type: 'leaderboards_error';
error: string;
timestamp: Date;
}
/**
* Leaderboards Event Publisher Interface
*
* Publishes events related to leaderboards operations.
*/
export interface LeaderboardsEventPublisher {
/**
* Publish a global leaderboards accessed event
* @param event - The event to publish
*/
publishGlobalLeaderboardsAccessed(event: GlobalLeaderboardsAccessedEvent): Promise<void>;
/**
* Publish a driver rankings accessed event
* @param event - The event to publish
*/
publishDriverRankingsAccessed(event: DriverRankingsAccessedEvent): Promise<void>;
/**
* Publish a team rankings accessed event
* @param event - The event to publish
*/
publishTeamRankingsAccessed(event: TeamRankingsAccessedEvent): Promise<void>;
/**
* Publish a leaderboards error event
* @param event - The event to publish
*/
publishLeaderboardsError(event: LeaderboardsErrorEvent): Promise<void>;
}

View File

@@ -1,55 +0,0 @@
/**
* Leaderboards Repository Port
*
* Defines the interface for accessing leaderboards-related data.
* This is a read-only repository for leaderboards data aggregation.
*/
/**
* Driver data for leaderboards
*/
export interface LeaderboardDriverData {
id: string;
name: string;
rating: number;
teamId?: string;
teamName?: string;
raceCount: number;
}
/**
* Team data for leaderboards
*/
export interface LeaderboardTeamData {
id: string;
name: string;
rating: number;
memberCount: number;
raceCount: number;
}
/**
* Leaderboards Repository Interface
*
* Provides access to all data needed for leaderboards.
*/
export interface LeaderboardsRepository {
/**
* Find all drivers for leaderboards
* @returns Array of driver data
*/
findAllDrivers(): Promise<LeaderboardDriverData[]>;
/**
* Find all teams for leaderboards
* @returns Array of team data
*/
findAllTeams(): Promise<LeaderboardTeamData[]>;
/**
* Find drivers by team ID
* @param teamId - The team ID
* @returns Array of driver data
*/
findDriversByTeamId(teamId: string): Promise<LeaderboardDriverData[]>;
}

View File

@@ -1,76 +0,0 @@
/**
* Team Rankings Query Port
*
* Defines the interface for querying team rankings data.
* This is a read-only query with search, filter, and sort capabilities.
*/
/**
* Query input for team rankings
*/
export interface TeamRankingsQuery {
/**
* Search term for filtering teams by name (case-insensitive)
*/
search?: string;
/**
* Minimum rating filter
*/
minRating?: number;
/**
* Minimum member count filter
*/
minMemberCount?: number;
/**
* Sort field (default: rating)
*/
sortBy?: 'rating' | 'name' | 'rank' | 'memberCount';
/**
* Sort order (default: desc)
*/
sortOrder?: 'asc' | 'desc';
/**
* Page number (default: 1)
*/
page?: number;
/**
* Number of results per page (default: 20)
*/
limit?: number;
}
/**
* Team entry for rankings
*/
export interface TeamRankingEntry {
rank: number;
id: string;
name: string;
rating: number;
memberCount: number;
raceCount: number;
}
/**
* Pagination metadata
*/
export interface PaginationMetadata {
total: number;
page: number;
limit: number;
totalPages: number;
}
/**
* Team rankings result
*/
export interface TeamRankingsResult {
teams: TeamRankingEntry[];
pagination: PaginationMetadata;
}

View File

@@ -1,163 +0,0 @@
/**
* Get Driver Rankings Use Case
*
* Orchestrates the retrieval of driver rankings data.
* Aggregates data from repositories and returns drivers with search, filter, and sort capabilities.
*/
import { LeaderboardsRepository } from '../ports/LeaderboardsRepository';
import { LeaderboardsEventPublisher } from '../ports/LeaderboardsEventPublisher';
import {
DriverRankingsQuery,
DriverRankingsResult,
DriverRankingEntry,
PaginationMetadata,
} from '../ports/DriverRankingsQuery';
import { ValidationError } from '../../../shared/errors/ValidationError';
export interface GetDriverRankingsUseCasePorts {
leaderboardsRepository: LeaderboardsRepository;
eventPublisher: LeaderboardsEventPublisher;
}
export class GetDriverRankingsUseCase {
constructor(private readonly ports: GetDriverRankingsUseCasePorts) {}
async execute(query: DriverRankingsQuery = {}): Promise<DriverRankingsResult> {
try {
// Validate query parameters
this.validateQuery(query);
const page = query.page ?? 1;
const limit = query.limit ?? 20;
// Fetch all drivers
const allDrivers = await this.ports.leaderboardsRepository.findAllDrivers();
// Apply search filter
let filteredDrivers = allDrivers;
if (query.search) {
const searchLower = query.search.toLowerCase();
filteredDrivers = filteredDrivers.filter((driver) =>
driver.name.toLowerCase().includes(searchLower),
);
}
// Apply rating filter
if (query.minRating !== undefined) {
filteredDrivers = filteredDrivers.filter(
(driver) => driver.rating >= query.minRating!,
);
}
// Apply team filter
if (query.teamId) {
filteredDrivers = filteredDrivers.filter(
(driver) => driver.teamId === query.teamId,
);
}
// Sort drivers
const sortBy = query.sortBy ?? 'rating';
const sortOrder = query.sortOrder ?? 'desc';
filteredDrivers.sort((a, b) => {
let comparison = 0;
switch (sortBy) {
case 'rating':
comparison = a.rating - b.rating;
break;
case 'name':
comparison = a.name.localeCompare(b.name);
break;
case 'rank':
comparison = 0;
break;
case 'raceCount':
comparison = a.raceCount - b.raceCount;
break;
}
// If primary sort is equal, always use name ASC as secondary sort
if (comparison === 0 && sortBy !== 'name') {
comparison = a.name.localeCompare(b.name);
// Secondary sort should not be affected by sortOrder of primary field?
// Actually, usually secondary sort is always ASC or follows primary.
// Let's keep it simple: if primary is equal, use name ASC.
return comparison;
}
return sortOrder === 'asc' ? comparison : -comparison;
});
// Calculate pagination
const total = filteredDrivers.length;
const totalPages = Math.ceil(total / limit);
const startIndex = (page - 1) * limit;
const endIndex = Math.min(startIndex + limit, total);
// Get paginated drivers
const paginatedDrivers = filteredDrivers.slice(startIndex, endIndex);
// Map to ranking entries with rank
const driverEntries: DriverRankingEntry[] = paginatedDrivers.map(
(driver, index): DriverRankingEntry => ({
rank: startIndex + index + 1,
id: driver.id,
name: driver.name,
rating: driver.rating,
...(driver.teamId !== undefined && { teamId: driver.teamId }),
...(driver.teamName !== undefined && { teamName: driver.teamName }),
raceCount: driver.raceCount,
}),
);
// Publish event
await this.ports.eventPublisher.publishDriverRankingsAccessed({
type: 'driver_rankings_accessed',
timestamp: new Date(),
});
return {
drivers: driverEntries,
pagination: {
total,
page,
limit,
totalPages,
},
};
} catch (error) {
// Publish error event
await this.ports.eventPublisher.publishLeaderboardsError({
type: 'leaderboards_error',
error: error instanceof Error ? error.message : String(error),
timestamp: new Date(),
});
throw error;
}
}
private validateQuery(query: DriverRankingsQuery): void {
if (query.page !== undefined && query.page < 1) {
throw new ValidationError('Page must be a positive integer');
}
if (query.limit !== undefined && query.limit < 1) {
throw new ValidationError('Limit must be a positive integer');
}
if (query.minRating !== undefined && query.minRating < 0) {
throw new ValidationError('Min rating must be a non-negative number');
}
if (query.sortBy && !['rating', 'name', 'rank', 'raceCount'].includes(query.sortBy)) {
throw new ValidationError('Invalid sort field');
}
if (query.sortOrder && !['asc', 'desc'].includes(query.sortOrder)) {
throw new ValidationError('Sort order must be "asc" or "desc"');
}
}
}

View File

@@ -1,95 +0,0 @@
/**
* Get Global Leaderboards Use Case
*
* Orchestrates the retrieval of global leaderboards data.
* Aggregates data from repositories and returns top drivers and teams.
*/
import { LeaderboardsRepository } from '../ports/LeaderboardsRepository';
import { LeaderboardsEventPublisher } from '../ports/LeaderboardsEventPublisher';
import {
GlobalLeaderboardsQuery,
GlobalLeaderboardsResult,
GlobalLeaderboardDriverEntry,
GlobalLeaderboardTeamEntry,
} from '../ports/GlobalLeaderboardsQuery';
export interface GetGlobalLeaderboardsUseCasePorts {
leaderboardsRepository: LeaderboardsRepository;
eventPublisher: LeaderboardsEventPublisher;
}
export class GetGlobalLeaderboardsUseCase {
constructor(private readonly ports: GetGlobalLeaderboardsUseCasePorts) {}
async execute(query: GlobalLeaderboardsQuery = {}): Promise<GlobalLeaderboardsResult> {
try {
const driverLimit = query.driverLimit ?? 10;
const teamLimit = query.teamLimit ?? 10;
// Fetch all drivers and teams in parallel
const [allDrivers, allTeams] = await Promise.all([
this.ports.leaderboardsRepository.findAllDrivers(),
this.ports.leaderboardsRepository.findAllTeams(),
]);
// Sort drivers by rating (highest first) and take top N
const topDrivers = allDrivers
.sort((a, b) => {
const ratingComparison = b.rating - a.rating;
if (ratingComparison === 0) {
return a.name.localeCompare(b.name);
}
return ratingComparison;
})
.slice(0, driverLimit)
.map((driver, index): GlobalLeaderboardDriverEntry => ({
rank: index + 1,
id: driver.id,
name: driver.name,
rating: driver.rating,
...(driver.teamId !== undefined && { teamId: driver.teamId }),
...(driver.teamName !== undefined && { teamName: driver.teamName }),
raceCount: driver.raceCount,
}));
// Sort teams by rating (highest first) and take top N
const topTeams = allTeams
.sort((a, b) => {
const ratingComparison = b.rating - a.rating;
if (ratingComparison === 0) {
return a.name.localeCompare(b.name);
}
return ratingComparison;
})
.slice(0, teamLimit)
.map((team, index): GlobalLeaderboardTeamEntry => ({
rank: index + 1,
id: team.id,
name: team.name,
rating: team.rating,
memberCount: team.memberCount,
raceCount: team.raceCount,
}));
// Publish event
await this.ports.eventPublisher.publishGlobalLeaderboardsAccessed({
type: 'global_leaderboards_accessed',
timestamp: new Date(),
});
return {
drivers: topDrivers,
teams: topTeams,
};
} catch (error) {
// Publish error event
await this.ports.eventPublisher.publishLeaderboardsError({
type: 'leaderboards_error',
error: error instanceof Error ? error.message : String(error),
timestamp: new Date(),
});
throw error;
}
}
}

View File

@@ -1,201 +0,0 @@
/**
* Get Team Rankings Use Case
*
* Orchestrates the retrieval of team rankings data.
* Aggregates data from repositories and returns teams with search, filter, and sort capabilities.
*/
import { LeaderboardsRepository } from '../ports/LeaderboardsRepository';
import { LeaderboardsEventPublisher } from '../ports/LeaderboardsEventPublisher';
import {
TeamRankingsQuery,
TeamRankingsResult,
TeamRankingEntry,
PaginationMetadata,
} from '../ports/TeamRankingsQuery';
import { ValidationError } from '../../../shared/errors/ValidationError';
export interface GetTeamRankingsUseCasePorts {
leaderboardsRepository: LeaderboardsRepository;
eventPublisher: LeaderboardsEventPublisher;
}
export class GetTeamRankingsUseCase {
constructor(private readonly ports: GetTeamRankingsUseCasePorts) {}
async execute(query: TeamRankingsQuery = {}): Promise<TeamRankingsResult> {
try {
// Validate query parameters
this.validateQuery(query);
const page = query.page ?? 1;
const limit = query.limit ?? 20;
// Fetch all teams and drivers for member count aggregation
const [allTeams, allDrivers] = await Promise.all([
this.ports.leaderboardsRepository.findAllTeams(),
this.ports.leaderboardsRepository.findAllDrivers(),
]);
// Count members from drivers
const driverCounts = new Map<string, number>();
allDrivers.forEach(driver => {
if (driver.teamId) {
driverCounts.set(driver.teamId, (driverCounts.get(driver.teamId) || 0) + 1);
}
});
// Map teams from repository
const teamsWithAggregatedData = allTeams.map(team => {
const countFromDrivers = driverCounts.get(team.id);
return {
...team,
// If drivers exist in repository for this team, use that count as source of truth.
// Otherwise, fall back to the memberCount property on the team itself.
memberCount: countFromDrivers !== undefined ? countFromDrivers : (team.memberCount || 0)
};
});
// Discover teams that only exist in the drivers repository
const discoveredTeams: any[] = [];
driverCounts.forEach((count, teamId) => {
if (!allTeams.some(t => t.id === teamId)) {
const driverWithTeam = allDrivers.find(d => d.teamId === teamId);
discoveredTeams.push({
id: teamId,
name: driverWithTeam?.teamName || `Team ${teamId}`,
rating: 0,
memberCount: count,
raceCount: 0
});
}
});
const finalTeams = [...teamsWithAggregatedData, ...discoveredTeams];
// Apply search filter
let filteredTeams = finalTeams;
if (query.search) {
const searchLower = query.search.toLowerCase();
filteredTeams = filteredTeams.filter((team) =>
team.name.toLowerCase().includes(searchLower),
);
}
// Apply rating filter
if (query.minRating !== undefined) {
filteredTeams = filteredTeams.filter(
(team) => team.rating >= query.minRating!,
);
}
// Apply member count filter
if (query.minMemberCount !== undefined) {
filteredTeams = filteredTeams.filter(
(team) => team.memberCount >= query.minMemberCount!,
);
}
// Sort teams
const sortBy = query.sortBy ?? 'rating';
const sortOrder = query.sortOrder ?? 'desc';
filteredTeams.sort((a, b) => {
let comparison = 0;
switch (sortBy) {
case 'rating':
comparison = a.rating - b.rating;
break;
case 'name':
comparison = a.name.localeCompare(b.name);
break;
case 'rank':
comparison = 0;
break;
case 'memberCount':
comparison = a.memberCount - b.memberCount;
break;
}
// If primary sort is equal, always use name ASC as secondary sort
if (comparison === 0 && sortBy !== 'name') {
return a.name.localeCompare(b.name);
}
return sortOrder === 'asc' ? comparison : -comparison;
});
// Calculate pagination
const total = filteredTeams.length;
const totalPages = Math.ceil(total / limit);
const startIndex = (page - 1) * limit;
const endIndex = Math.min(startIndex + limit, total);
// Get paginated teams
const paginatedTeams = filteredTeams.slice(startIndex, endIndex);
// Map to ranking entries with rank
const teamEntries: TeamRankingEntry[] = paginatedTeams.map(
(team, index): TeamRankingEntry => ({
rank: startIndex + index + 1,
id: team.id,
name: team.name,
rating: team.rating,
memberCount: team.memberCount,
raceCount: team.raceCount,
}),
);
// Publish event
await this.ports.eventPublisher.publishTeamRankingsAccessed({
type: 'team_rankings_accessed',
timestamp: new Date(),
});
return {
teams: teamEntries,
pagination: {
total,
page,
limit,
totalPages,
},
};
} catch (error) {
// Publish error event
await this.ports.eventPublisher.publishLeaderboardsError({
type: 'leaderboards_error',
error: error instanceof Error ? error.message : String(error),
timestamp: new Date(),
});
throw error;
}
}
private validateQuery(query: TeamRankingsQuery): void {
if (query.page !== undefined && query.page < 1) {
throw new ValidationError('Page must be a positive integer');
}
if (query.limit !== undefined && query.limit < 1) {
throw new ValidationError('Limit must be a positive integer');
}
if (query.minRating !== undefined && query.minRating < 0) {
throw new ValidationError('Min rating must be a non-negative number');
}
if (query.minMemberCount !== undefined && query.minMemberCount < 0) {
throw new ValidationError('Min member count must be a non-negative number');
}
if (query.sortBy && !['rating', 'name', 'rank', 'memberCount'].includes(query.sortBy)) {
throw new ValidationError('Invalid sort field');
}
if (query.sortOrder && !['asc', 'desc'].includes(query.sortOrder)) {
throw new ValidationError('Sort order must be "asc" or "desc"');
}
}
}

View File

@@ -1,4 +0,0 @@
export interface ApproveMembershipRequestCommand {
leagueId: string;
requestId: string;
}

View File

@@ -1,4 +0,0 @@
export interface DemoteAdminCommand {
leagueId: string;
targetDriverId: string;
}

View File

@@ -1,4 +0,0 @@
export interface JoinLeagueCommand {
leagueId: string;
driverId: string;
}

View File

@@ -1,33 +0,0 @@
export interface LeagueCreateCommand {
name: string;
description?: string;
visibility: 'public' | 'private';
ownerId: string;
// Structure
maxDrivers?: number;
approvalRequired: boolean;
lateJoinAllowed: boolean;
// Schedule
raceFrequency?: string;
raceDay?: string;
raceTime?: string;
tracks?: string[];
// Scoring
scoringSystem?: any;
bonusPointsEnabled: boolean;
penaltiesEnabled: boolean;
// Stewarding
protestsEnabled: boolean;
appealsEnabled: boolean;
stewardTeam?: string[];
// Tags
gameType?: string;
skillLevel?: string;
category?: string;
tags?: string[];
}

View File

@@ -1,48 +0,0 @@
export interface LeagueCreatedEvent {
type: 'LeagueCreatedEvent';
leagueId: string;
ownerId: string;
timestamp: Date;
}
export interface LeagueUpdatedEvent {
type: 'LeagueUpdatedEvent';
leagueId: string;
updates: Partial<any>;
timestamp: Date;
}
export interface LeagueDeletedEvent {
type: 'LeagueDeletedEvent';
leagueId: string;
timestamp: Date;
}
export interface LeagueAccessedEvent {
type: 'LeagueAccessedEvent';
leagueId: string;
driverId: string;
timestamp: Date;
}
export interface LeagueRosterAccessedEvent {
type: 'LeagueRosterAccessedEvent';
leagueId: string;
timestamp: Date;
}
export interface LeagueEventPublisher {
emitLeagueCreated(event: LeagueCreatedEvent): Promise<void>;
emitLeagueUpdated(event: LeagueUpdatedEvent): Promise<void>;
emitLeagueDeleted(event: LeagueDeletedEvent): Promise<void>;
emitLeagueAccessed(event: LeagueAccessedEvent): Promise<void>;
emitLeagueRosterAccessed(event: LeagueRosterAccessedEvent): Promise<void>;
getLeagueCreatedEventCount(): number;
getLeagueUpdatedEventCount(): number;
getLeagueDeletedEventCount(): number;
getLeagueAccessedEventCount(): number;
getLeagueRosterAccessedEventCount(): number;
clear(): void;
}

View File

@@ -1,191 +0,0 @@
export interface LeagueData {
id: string;
name: string;
description: string | null;
visibility: 'public' | 'private';
ownerId: string;
status: 'active' | 'pending' | 'archived';
createdAt: Date;
updatedAt: Date;
// Structure
maxDrivers: number | null;
approvalRequired: boolean;
lateJoinAllowed: boolean;
// Schedule
raceFrequency: string | null;
raceDay: string | null;
raceTime: string | null;
tracks: string[] | null;
// Scoring
scoringSystem: any | null;
bonusPointsEnabled: boolean;
penaltiesEnabled: boolean;
// Stewarding
protestsEnabled: boolean;
appealsEnabled: boolean;
stewardTeam: string[] | null;
// Tags
gameType: string | null;
skillLevel: string | null;
category: string | null;
tags: string[] | null;
}
export interface LeagueStats {
leagueId: string;
memberCount: number;
raceCount: number;
sponsorCount: number;
prizePool: number;
rating: number;
reviewCount: number;
}
export interface LeagueFinancials {
leagueId: string;
walletBalance: number;
totalRevenue: number;
totalFees: number;
pendingPayouts: number;
netBalance: number;
}
export interface LeagueStewardingMetrics {
leagueId: string;
averageResolutionTime: number;
averageProtestResolutionTime: number;
averagePenaltyAppealSuccessRate: number;
averageProtestSuccessRate: number;
averageStewardingActionSuccessRate: number;
}
export interface LeaguePerformanceMetrics {
leagueId: string;
averageLapTime: number;
averageFieldSize: number;
averageIncidentCount: number;
averagePenaltyCount: number;
averageProtestCount: number;
averageStewardingActionCount: number;
}
export interface LeagueRatingMetrics {
leagueId: string;
overallRating: number;
ratingTrend: number;
rankTrend: number;
pointsTrend: number;
winRateTrend: number;
podiumRateTrend: number;
dnfRateTrend: number;
}
export interface LeagueTrendMetrics {
leagueId: string;
incidentRateTrend: number;
penaltyRateTrend: number;
protestRateTrend: number;
stewardingActionRateTrend: number;
stewardingTimeTrend: number;
protestResolutionTimeTrend: number;
}
export interface LeagueSuccessRateMetrics {
leagueId: string;
penaltyAppealSuccessRate: number;
protestSuccessRate: number;
stewardingActionSuccessRate: number;
stewardingActionAppealSuccessRate: number;
stewardingActionPenaltySuccessRate: number;
stewardingActionProtestSuccessRate: number;
}
export interface LeagueResolutionTimeMetrics {
leagueId: string;
averageStewardingTime: number;
averageProtestResolutionTime: number;
averageStewardingActionAppealPenaltyProtestResolutionTime: number;
}
export interface LeagueComplexSuccessRateMetrics {
leagueId: string;
stewardingActionAppealPenaltyProtestSuccessRate: number;
stewardingActionAppealProtestSuccessRate: number;
stewardingActionPenaltyProtestSuccessRate: number;
stewardingActionAppealPenaltyProtestSuccessRate2: number;
}
export interface LeagueComplexResolutionTimeMetrics {
leagueId: string;
stewardingActionAppealPenaltyProtestResolutionTime: number;
stewardingActionAppealProtestResolutionTime: number;
stewardingActionPenaltyProtestResolutionTime: number;
stewardingActionAppealPenaltyProtestResolutionTime2: number;
}
export interface LeagueMember {
driverId: string;
name: string;
role: 'owner' | 'admin' | 'steward' | 'member';
joinDate: Date;
}
export interface LeaguePendingRequest {
id: string;
driverId: string;
name: string;
requestDate: Date;
}
export interface LeagueRepository {
create(league: LeagueData): Promise<LeagueData>;
findById(id: string): Promise<LeagueData | null>;
findByName(name: string): Promise<LeagueData | null>;
findByOwner(ownerId: string): Promise<LeagueData[]>;
search(query: string): Promise<LeagueData[]>;
update(id: string, updates: Partial<LeagueData>): Promise<LeagueData>;
delete(id: string): Promise<void>;
getStats(leagueId: string): Promise<LeagueStats>;
updateStats(leagueId: string, stats: LeagueStats): Promise<LeagueStats>;
getFinancials(leagueId: string): Promise<LeagueFinancials>;
updateFinancials(leagueId: string, financials: LeagueFinancials): Promise<LeagueFinancials>;
getStewardingMetrics(leagueId: string): Promise<LeagueStewardingMetrics>;
updateStewardingMetrics(leagueId: string, metrics: LeagueStewardingMetrics): Promise<LeagueStewardingMetrics>;
getPerformanceMetrics(leagueId: string): Promise<LeaguePerformanceMetrics>;
updatePerformanceMetrics(leagueId: string, metrics: LeaguePerformanceMetrics): Promise<LeaguePerformanceMetrics>;
getRatingMetrics(leagueId: string): Promise<LeagueRatingMetrics>;
updateRatingMetrics(leagueId: string, metrics: LeagueRatingMetrics): Promise<LeagueRatingMetrics>;
getTrendMetrics(leagueId: string): Promise<LeagueTrendMetrics>;
updateTrendMetrics(leagueId: string, metrics: LeagueTrendMetrics): Promise<LeagueTrendMetrics>;
getSuccessRateMetrics(leagueId: string): Promise<LeagueSuccessRateMetrics>;
updateSuccessRateMetrics(leagueId: string, metrics: LeagueSuccessRateMetrics): Promise<LeagueSuccessRateMetrics>;
getResolutionTimeMetrics(leagueId: string): Promise<LeagueResolutionTimeMetrics>;
updateResolutionTimeMetrics(leagueId: string, metrics: LeagueResolutionTimeMetrics): Promise<LeagueResolutionTimeMetrics>;
getComplexSuccessRateMetrics(leagueId: string): Promise<LeagueComplexSuccessRateMetrics>;
updateComplexSuccessRateMetrics(leagueId: string, metrics: LeagueComplexSuccessRateMetrics): Promise<LeagueComplexSuccessRateMetrics>;
getComplexResolutionTimeMetrics(leagueId: string): Promise<LeagueComplexResolutionTimeMetrics>;
updateComplexResolutionTimeMetrics(leagueId: string, metrics: LeagueComplexResolutionTimeMetrics): Promise<LeagueComplexResolutionTimeMetrics>;
getLeagueMembers(leagueId: string): Promise<LeagueMember[]>;
getPendingRequests(leagueId: string): Promise<LeaguePendingRequest[]>;
addLeagueMembers(leagueId: string, members: LeagueMember[]): Promise<void>;
updateLeagueMember(leagueId: string, driverId: string, updates: Partial<LeagueMember>): Promise<void>;
removeLeagueMember(leagueId: string, driverId: string): Promise<void>;
addPendingRequests(leagueId: string, requests: LeaguePendingRequest[]): Promise<void>;
removePendingRequest(leagueId: string, requestId: string): Promise<void>;
}

View File

@@ -1,3 +0,0 @@
export interface LeagueRosterQuery {
leagueId: string;
}

View File

@@ -1,4 +0,0 @@
export interface LeaveLeagueCommand {
leagueId: string;
driverId: string;
}

View File

@@ -1,4 +0,0 @@
export interface PromoteMemberCommand {
leagueId: string;
targetDriverId: string;
}

View File

@@ -1,4 +0,0 @@
export interface RejectMembershipRequestCommand {
leagueId: string;
requestId: string;
}

View File

@@ -1,4 +0,0 @@
export interface RemoveMemberCommand {
leagueId: string;
targetDriverId: string;
}

View File

@@ -1,36 +0,0 @@
import { LeagueRepository } from '../ports/LeagueRepository';
import { DriverRepository } from '../ports/DriverRepository';
import { EventPublisher } from '../ports/EventPublisher';
import { ApproveMembershipRequestCommand } from '../ports/ApproveMembershipRequestCommand';
export class ApproveMembershipRequestUseCase {
constructor(
private readonly leagueRepository: LeagueRepository,
private readonly driverRepository: DriverRepository,
private readonly eventPublisher: EventPublisher,
) {}
async execute(command: ApproveMembershipRequestCommand): Promise<void> {
const league = await this.leagueRepository.findById(command.leagueId);
if (!league) {
throw new Error('League not found');
}
const requests = await this.leagueRepository.getPendingRequests(command.leagueId);
const request = requests.find(r => r.id === command.requestId);
if (!request) {
throw new Error('Request not found');
}
await this.leagueRepository.addLeagueMembers(command.leagueId, [
{
driverId: request.driverId,
name: request.name,
role: 'member',
joinDate: new Date(),
},
]);
await this.leagueRepository.removePendingRequest(command.leagueId, command.requestId);
}
}

View File

@@ -1,187 +0,0 @@
import { LeagueRepository, LeagueData } from '../ports/LeagueRepository';
import { LeagueEventPublisher, LeagueCreatedEvent } from '../ports/LeagueEventPublisher';
import { LeagueCreateCommand } from '../ports/LeagueCreateCommand';
export class CreateLeagueUseCase {
constructor(
private readonly leagueRepository: LeagueRepository,
private readonly eventPublisher: LeagueEventPublisher,
) {}
async execute(command: LeagueCreateCommand): Promise<LeagueData> {
// Validate command
if (!command.name || command.name.trim() === '') {
throw new Error('League name is required');
}
if (command.name.length > 255) {
throw new Error('League name is too long');
}
if (!command.ownerId || command.ownerId.trim() === '') {
throw new Error('Owner ID is required');
}
if (command.maxDrivers !== undefined && command.maxDrivers < 1) {
throw new Error('Max drivers must be at least 1');
}
// Create league data
const leagueId = `league-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const now = new Date();
const leagueData: LeagueData = {
id: leagueId,
name: command.name,
description: command.description || null,
visibility: command.visibility,
ownerId: command.ownerId,
status: 'active',
createdAt: now,
updatedAt: now,
maxDrivers: command.maxDrivers || null,
approvalRequired: command.approvalRequired,
lateJoinAllowed: command.lateJoinAllowed,
raceFrequency: command.raceFrequency || null,
raceDay: command.raceDay || null,
raceTime: command.raceTime || null,
tracks: command.tracks || null,
scoringSystem: command.scoringSystem || null,
bonusPointsEnabled: command.bonusPointsEnabled,
penaltiesEnabled: command.penaltiesEnabled,
protestsEnabled: command.protestsEnabled,
appealsEnabled: command.appealsEnabled,
stewardTeam: command.stewardTeam || null,
gameType: command.gameType || null,
skillLevel: command.skillLevel || null,
category: command.category || null,
tags: command.tags || null,
};
// Save league to repository
const savedLeague = await this.leagueRepository.create(leagueData);
// Initialize league stats
const defaultStats = {
leagueId,
memberCount: 1,
raceCount: 0,
sponsorCount: 0,
prizePool: 0,
rating: 0,
reviewCount: 0,
};
await this.leagueRepository.updateStats(leagueId, defaultStats);
// Initialize league financials
const defaultFinancials = {
leagueId,
walletBalance: 0,
totalRevenue: 0,
totalFees: 0,
pendingPayouts: 0,
netBalance: 0,
};
await this.leagueRepository.updateFinancials(leagueId, defaultFinancials);
// Initialize stewarding metrics
const defaultStewardingMetrics = {
leagueId,
averageResolutionTime: 0,
averageProtestResolutionTime: 0,
averagePenaltyAppealSuccessRate: 0,
averageProtestSuccessRate: 0,
averageStewardingActionSuccessRate: 0,
};
await this.leagueRepository.updateStewardingMetrics(leagueId, defaultStewardingMetrics);
// Initialize performance metrics
const defaultPerformanceMetrics = {
leagueId,
averageLapTime: 0,
averageFieldSize: 0,
averageIncidentCount: 0,
averagePenaltyCount: 0,
averageProtestCount: 0,
averageStewardingActionCount: 0,
};
await this.leagueRepository.updatePerformanceMetrics(leagueId, defaultPerformanceMetrics);
// Initialize rating metrics
const defaultRatingMetrics = {
leagueId,
overallRating: 0,
ratingTrend: 0,
rankTrend: 0,
pointsTrend: 0,
winRateTrend: 0,
podiumRateTrend: 0,
dnfRateTrend: 0,
};
await this.leagueRepository.updateRatingMetrics(leagueId, defaultRatingMetrics);
// Initialize trend metrics
const defaultTrendMetrics = {
leagueId,
incidentRateTrend: 0,
penaltyRateTrend: 0,
protestRateTrend: 0,
stewardingActionRateTrend: 0,
stewardingTimeTrend: 0,
protestResolutionTimeTrend: 0,
};
await this.leagueRepository.updateTrendMetrics(leagueId, defaultTrendMetrics);
// Initialize success rate metrics
const defaultSuccessRateMetrics = {
leagueId,
penaltyAppealSuccessRate: 0,
protestSuccessRate: 0,
stewardingActionSuccessRate: 0,
stewardingActionAppealSuccessRate: 0,
stewardingActionPenaltySuccessRate: 0,
stewardingActionProtestSuccessRate: 0,
};
await this.leagueRepository.updateSuccessRateMetrics(leagueId, defaultSuccessRateMetrics);
// Initialize resolution time metrics
const defaultResolutionTimeMetrics = {
leagueId,
averageStewardingTime: 0,
averageProtestResolutionTime: 0,
averageStewardingActionAppealPenaltyProtestResolutionTime: 0,
};
await this.leagueRepository.updateResolutionTimeMetrics(leagueId, defaultResolutionTimeMetrics);
// Initialize complex success rate metrics
const defaultComplexSuccessRateMetrics = {
leagueId,
stewardingActionAppealPenaltyProtestSuccessRate: 0,
stewardingActionAppealProtestSuccessRate: 0,
stewardingActionPenaltyProtestSuccessRate: 0,
stewardingActionAppealPenaltyProtestSuccessRate2: 0,
};
await this.leagueRepository.updateComplexSuccessRateMetrics(leagueId, defaultComplexSuccessRateMetrics);
// Initialize complex resolution time metrics
const defaultComplexResolutionTimeMetrics = {
leagueId,
stewardingActionAppealPenaltyProtestResolutionTime: 0,
stewardingActionAppealProtestResolutionTime: 0,
stewardingActionPenaltyProtestResolutionTime: 0,
stewardingActionAppealPenaltyProtestResolutionTime2: 0,
};
await this.leagueRepository.updateComplexResolutionTimeMetrics(leagueId, defaultComplexResolutionTimeMetrics);
// Emit event
const event: LeagueCreatedEvent = {
type: 'LeagueCreatedEvent',
leagueId,
ownerId: command.ownerId,
timestamp: now,
};
await this.eventPublisher.emitLeagueCreated(event);
return savedLeague;
}
}

View File

@@ -1,16 +0,0 @@
import { LeagueRepository } from '../ports/LeagueRepository';
import { DriverRepository } from '../ports/DriverRepository';
import { EventPublisher } from '../ports/EventPublisher';
import { DemoteAdminCommand } from '../ports/DemoteAdminCommand';
export class DemoteAdminUseCase {
constructor(
private readonly leagueRepository: LeagueRepository,
private readonly driverRepository: DriverRepository,
private readonly eventPublisher: EventPublisher,
) {}
async execute(command: DemoteAdminCommand): Promise<void> {
await this.leagueRepository.updateLeagueMember(command.leagueId, command.targetDriverId, { role: 'member' });
}
}

View File

@@ -1,81 +0,0 @@
import { LeagueRepository } from '../ports/LeagueRepository';
import { LeagueRosterQuery } from '../ports/LeagueRosterQuery';
import { LeagueEventPublisher, LeagueRosterAccessedEvent } from '../ports/LeagueEventPublisher';
export interface LeagueRosterResult {
leagueId: string;
members: Array<{
driverId: string;
name: string;
role: 'owner' | 'admin' | 'steward' | 'member';
joinDate: Date;
}>;
pendingRequests: Array<{
requestId: string;
driverId: string;
name: string;
requestDate: Date;
}>;
stats: {
adminCount: number;
driverCount: number;
};
}
export class GetLeagueRosterUseCase {
constructor(
private readonly leagueRepository: LeagueRepository,
private readonly eventPublisher: LeagueEventPublisher,
) {}
async execute(query: LeagueRosterQuery): Promise<LeagueRosterResult> {
// Validate query
if (!query.leagueId || query.leagueId.trim() === '') {
throw new Error('League ID is required');
}
// Find league
const league = await this.leagueRepository.findById(query.leagueId);
if (!league) {
throw new Error(`League with id ${query.leagueId} not found`);
}
// Get league members (simplified - in real implementation would get from membership repository)
const members = await this.leagueRepository.getLeagueMembers(query.leagueId);
// Get pending requests (simplified)
const pendingRequests = await this.leagueRepository.getPendingRequests(query.leagueId);
// Calculate stats
const adminCount = members.filter(m => m.role === 'owner' || m.role === 'admin').length;
const driverCount = members.filter(m => m.role === 'member').length;
// Emit event
const event: LeagueRosterAccessedEvent = {
type: 'LeagueRosterAccessedEvent',
leagueId: query.leagueId,
timestamp: new Date(),
};
await this.eventPublisher.emitLeagueRosterAccessed(event);
return {
leagueId: query.leagueId,
members: members.map(m => ({
driverId: m.driverId,
name: m.name,
role: m.role,
joinDate: m.joinDate,
})),
pendingRequests: pendingRequests.map(r => ({
requestId: r.id,
driverId: r.driverId,
name: r.name,
requestDate: r.requestDate,
})),
stats: {
adminCount,
driverCount,
},
};
}
}

View File

@@ -1,40 +0,0 @@
import { LeagueRepository, LeagueData } from '../ports/LeagueRepository';
import { LeagueEventPublisher, LeagueAccessedEvent } from '../ports/LeagueEventPublisher';
export interface GetLeagueQuery {
leagueId: string;
driverId?: string;
}
export class GetLeagueUseCase {
constructor(
private readonly leagueRepository: LeagueRepository,
private readonly eventPublisher: LeagueEventPublisher,
) {}
async execute(query: GetLeagueQuery): Promise<LeagueData> {
// Validate query
if (!query.leagueId || query.leagueId.trim() === '') {
throw new Error('League ID is required');
}
// Find league
const league = await this.leagueRepository.findById(query.leagueId);
if (!league) {
throw new Error(`League with id ${query.leagueId} not found`);
}
// Emit event if driver ID is provided
if (query.driverId) {
const event: LeagueAccessedEvent = {
type: 'LeagueAccessedEvent',
leagueId: query.leagueId,
driverId: query.driverId,
timestamp: new Date(),
};
await this.eventPublisher.emitLeagueAccessed(event);
}
return league;
}
}

View File

@@ -1,44 +0,0 @@
import { LeagueRepository, LeagueData } from '../ports/LeagueRepository';
import { DriverRepository } from '../ports/DriverRepository';
import { EventPublisher } from '../ports/EventPublisher';
import { JoinLeagueCommand } from '../ports/JoinLeagueCommand';
export class JoinLeagueUseCase {
constructor(
private readonly leagueRepository: LeagueRepository,
private readonly driverRepository: DriverRepository,
private readonly eventPublisher: EventPublisher,
) {}
async execute(command: JoinLeagueCommand): Promise<void> {
const league = await this.leagueRepository.findById(command.leagueId);
if (!league) {
throw new Error('League not found');
}
const driver = await this.driverRepository.findDriverById(command.driverId);
if (!driver) {
throw new Error('Driver not found');
}
if (league.approvalRequired) {
await this.leagueRepository.addPendingRequests(command.leagueId, [
{
id: `request-${Date.now()}`,
driverId: command.driverId,
name: driver.name,
requestDate: new Date(),
},
]);
} else {
await this.leagueRepository.addLeagueMembers(command.leagueId, [
{
driverId: command.driverId,
name: driver.name,
role: 'member',
joinDate: new Date(),
},
]);
}
}
}

View File

@@ -1,16 +0,0 @@
import { LeagueRepository } from '../ports/LeagueRepository';
import { DriverRepository } from '../ports/DriverRepository';
import { EventPublisher } from '../ports/EventPublisher';
import { LeaveLeagueCommand } from '../ports/LeaveLeagueCommand';
export class LeaveLeagueUseCase {
constructor(
private readonly leagueRepository: LeagueRepository,
private readonly driverRepository: DriverRepository,
private readonly eventPublisher: EventPublisher,
) {}
async execute(command: LeaveLeagueCommand): Promise<void> {
await this.leagueRepository.removeLeagueMember(command.leagueId, command.driverId);
}
}

View File

@@ -1,16 +0,0 @@
import { LeagueRepository } from '../ports/LeagueRepository';
import { DriverRepository } from '../ports/DriverRepository';
import { EventPublisher } from '../ports/EventPublisher';
import { PromoteMemberCommand } from '../ports/PromoteMemberCommand';
export class PromoteMemberUseCase {
constructor(
private readonly leagueRepository: LeagueRepository,
private readonly driverRepository: DriverRepository,
private readonly eventPublisher: EventPublisher,
) {}
async execute(command: PromoteMemberCommand): Promise<void> {
await this.leagueRepository.updateLeagueMember(command.leagueId, command.targetDriverId, { role: 'admin' });
}
}

View File

@@ -1,16 +0,0 @@
import { LeagueRepository } from '../ports/LeagueRepository';
import { DriverRepository } from '../ports/DriverRepository';
import { EventPublisher } from '../ports/EventPublisher';
import { RejectMembershipRequestCommand } from '../ports/RejectMembershipRequestCommand';
export class RejectMembershipRequestUseCase {
constructor(
private readonly leagueRepository: LeagueRepository,
private readonly driverRepository: DriverRepository,
private readonly eventPublisher: EventPublisher,
) {}
async execute(command: RejectMembershipRequestCommand): Promise<void> {
await this.leagueRepository.removePendingRequest(command.leagueId, command.requestId);
}
}

View File

@@ -1,16 +0,0 @@
import { LeagueRepository } from '../ports/LeagueRepository';
import { DriverRepository } from '../ports/DriverRepository';
import { EventPublisher } from '../ports/EventPublisher';
import { RemoveMemberCommand } from '../ports/RemoveMemberCommand';
export class RemoveMemberUseCase {
constructor(
private readonly leagueRepository: LeagueRepository,
private readonly driverRepository: DriverRepository,
private readonly eventPublisher: EventPublisher,
) {}
async execute(command: RemoveMemberCommand): Promise<void> {
await this.leagueRepository.removeLeagueMember(command.leagueId, command.targetDriverId);
}
}

View File

@@ -1,27 +0,0 @@
import { LeagueRepository, LeagueData } from '../ports/LeagueRepository';
export interface SearchLeaguesQuery {
query: string;
limit?: number;
offset?: number;
}
export class SearchLeaguesUseCase {
constructor(private readonly leagueRepository: LeagueRepository) {}
async execute(query: SearchLeaguesQuery): Promise<LeagueData[]> {
// Validate query
if (!query.query || query.query.trim() === '') {
throw new Error('Search query is required');
}
// Search leagues
const results = await this.leagueRepository.search(query.query);
// Apply limit and offset
const limit = query.limit || 10;
const offset = query.offset || 0;
return results.slice(offset, offset + limit);
}
}

View File

@@ -4,7 +4,6 @@ import { Result } from '@core/shared/domain/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { PaymentStatus, PaymentType } from '../../domain/entities/Payment';
import type { PaymentRepository } from '../../domain/repositories/PaymentRepository';
import type { SponsorRepository } from '@core/racing/domain/repositories/SponsorRepository';
export interface SponsorBillingStats {
totalSpent: number;
@@ -56,7 +55,7 @@ export interface GetSponsorBillingResult {
stats: SponsorBillingStats;
}
export type GetSponsorBillingErrorCode = 'SPONSOR_NOT_FOUND';
export type GetSponsorBillingErrorCode = never;
export class GetSponsorBillingUseCase
implements UseCase<GetSponsorBillingInput, GetSponsorBillingResult, GetSponsorBillingErrorCode>
@@ -64,20 +63,11 @@ export class GetSponsorBillingUseCase
constructor(
private readonly paymentRepository: PaymentRepository,
private readonly seasonSponsorshipRepository: SeasonSponsorshipRepository,
private readonly sponsorRepository: SponsorRepository,
) {}
async execute(input: GetSponsorBillingInput): Promise<Result<GetSponsorBillingResult, ApplicationErrorCode<GetSponsorBillingErrorCode>>> {
const { sponsorId } = input;
const sponsor = await this.sponsorRepository.findById(sponsorId);
if (!sponsor) {
return Result.err({
code: 'SPONSOR_NOT_FOUND',
details: { message: 'Sponsor not found' },
});
}
// In this in-memory implementation we derive billing data from payments
// where the sponsor is the payer.
const payments = await this.paymentRepository.findByFilters({

View File

@@ -38,9 +38,4 @@ export class DriverStatsUseCase {
this._logger.debug(`Getting stats for driver ${driverId}`);
return this._driverStatsRepository.getDriverStats(driverId);
}
clear(): void {
this._logger.info('[DriverStatsUseCase] Clearing all stats');
// No data to clear as this use case generates data on-the-fly
}
}

View File

@@ -43,9 +43,4 @@ export class RankingUseCase {
return rankings;
}
clear(): void {
this._logger.info('[RankingUseCase] Clearing all rankings');
// No data to clear as this use case generates data on-the-fly
}
}

View File

@@ -88,29 +88,4 @@ export class Track extends Entity<string> {
gameId: TrackGameId.create(props.gameId),
});
}
update(props: Partial<{
name: string;
shortName: string;
country: string;
category: TrackCategory;
difficulty: TrackDifficulty;
lengthKm: number;
turns: number;
imageUrl: string;
gameId: string;
}>): Track {
return new Track({
id: this.id,
name: props.name ? TrackName.create(props.name) : this.name,
shortName: props.shortName ? TrackShortName.create(props.shortName) : this.shortName,
country: props.country ? TrackCountry.create(props.country) : this.country,
category: props.category ?? this.category,
difficulty: props.difficulty ?? this.difficulty,
lengthKm: props.lengthKm ? TrackLength.create(props.lengthKm) : this.lengthKm,
turns: props.turns ? TrackTurns.create(props.turns) : this.turns,
imageUrl: props.imageUrl ? TrackImageUrl.create(props.imageUrl) : this.imageUrl,
gameId: props.gameId ? TrackGameId.create(props.gameId) : this.gameId,
});
}
}
}

View File

@@ -1,16 +0,0 @@
/**
* Validation Error
*
* Thrown when input validation fails.
*/
export class ValidationError extends Error {
readonly type = 'domain';
readonly context = 'validation';
readonly kind = 'validation';
constructor(message: string) {
super(message);
this.name = 'ValidationError';
}
}

25
package-lock.json generated
View File

@@ -251,6 +251,25 @@
"undici-types": "~6.21.0"
}
},
"apps/companion/node_modules/@types/react": {
"version": "18.3.27",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"dev": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"apps/companion/node_modules/@types/react-dom": {
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
},
"apps/companion/node_modules/path-to-regexp": {
"version": "8.3.0",
"license": "MIT",
@@ -4717,6 +4736,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true
},
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",

View File

@@ -45,7 +45,6 @@
"glob": "^13.0.0",
"husky": "^9.1.7",
"jsdom": "^22.1.0",
"lint-staged": "^15.2.10",
"openapi-typescript": "^7.4.3",
"prettier": "^3.0.0",
"puppeteer": "^24.31.0",
@@ -129,7 +128,6 @@
"test:unit": "vitest run tests/unit",
"test:watch": "vitest watch",
"test:website:types": "vitest run --config vitest.website.config.ts apps/website/lib/types/contractConsumption.test.ts",
"verify": "npm run lint && npm run typecheck && npm run test:unit && npm run test:integration",
"typecheck": "npm run typecheck:targets",
"typecheck:grep": "npm run typescript",
"typecheck:root": "npx tsc --noEmit --project tsconfig.json",
@@ -141,19 +139,10 @@
"website:start": "npm run start --workspace=@gridpilot/website",
"website:type-check": "npm run type-check --workspace=@gridpilot/website"
},
"lint-staged": {
"*.{js,ts,tsx}": [
"eslint --fix",
"vitest related --run"
],
"*.{json,md,yml}": [
"prettier --write"
]
},
"version": "0.1.0",
"workspaces": [
"core/*",
"apps/*",
"testing/*"
]
}
}

View File

@@ -1,100 +0,0 @@
# CI/CD & Dev Experience Optimization Plan
## Current Situation
- **Husky `pre-commit`**: Runs `npm test` (Vitest) on every commit. This likely runs the entire test suite, which is slow and frustrating for developers.
- **Gitea Actions**: Currently only has `contract-testing.yml`.
- **Missing**: No automated linting or type-checking in CI, no tiered testing strategy.
## Proposed Strategy: The "Fast Feedback Loop"
We will implement a tiered approach to balance speed and safety.
### 1. Local Development (Husky + lint-staged)
**Goal**: Prevent obvious errors from entering the repo without slowing down the dev.
- **Trigger**: `pre-commit`
- **Action**: Only run on **staged files**.
- **Tasks**:
- `eslint --fix`
- `prettier --write`
- `vitest related` (only run tests related to changed files)
### 2. Pull Request (Gitea Actions)
**Goal**: Ensure the branch is stable and doesn't break the build or other modules.
- **Trigger**: PR creation and updates.
- **Tasks**:
- Full `lint`
- Full `typecheck` (crucial for monorepo integrity)
- Full `unit tests`
- `integration tests`
- `contract tests`
### 3. Merge to Main / Release (Gitea Actions)
**Goal**: Final verification before deployment.
- **Trigger**: Push to `main` or `develop`.
- **Tasks**:
- Everything from PR stage.
- `e2e tests` (Playwright) - these are the slowest and most expensive.
---
## Implementation Steps
### Step 1: Install and Configure `lint-staged`
We need to add `lint-staged` to [`package.json`](package.json) and update the Husky hook.
### Step 2: Optimize Husky Hook
Update [`.husky/pre-commit`](.husky/pre-commit) to run `npx lint-staged` instead of `npm test`.
### Step 3: Create Comprehensive CI Workflow
Create `.github/workflows/ci.yml` (Gitea Actions compatible) to handle the heavy lifting.
---
## Workflow Diagram
```mermaid
graph TD
A[Developer Commits] --> B{Husky pre-commit}
B -->|lint-staged| C[Lint/Format Changed Files]
C --> D[Run Related Tests]
D --> E[Commit Success]
E --> F[Push to PR]
F --> G{Gitea CI PR Job}
G --> H[Full Lint & Typecheck]
G --> I[Full Unit & Integration Tests]
G --> J[Contract Tests]
J --> K{Merge to Main}
K --> L{Gitea CI Main Job}
L --> M[All PR Checks]
L --> N[Full E2E Tests]
N --> O[Deploy/Release]
```
## Proposed `lint-staged` Configuration
```json
{
"*.{js,ts,tsx}": ["eslint --fix", "vitest related --run"],
"*.{json,md,yml}": ["prettier --write"]
}
```
---
## Questions for the User
1. Do you want to include `typecheck` in the `pre-commit` hook? (Note: `tsc` doesn't support linting only changed files easily, so it usually checks the whole project, which might be slow).
2. Should we run `integration tests` on every PR, or only on merge to `main`?
3. Are there specific directories that should be excluded from this automated flow?

View File

@@ -0,0 +1,923 @@
/**
* Contract Validation Tests for Admin Module
*
* These tests validate that the admin API DTOs and OpenAPI spec are consistent
* and that the generated types will be compatible with the website admin client.
*/
import * as fs from 'fs/promises';
import * as path from 'path';
import { describe, expect, it } from 'vitest';
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('Admin Module Contract Validation', () => {
const apiRoot = path.join(__dirname, '../..');
const openapiPath = path.join(apiRoot, 'apps/api/openapi.json');
const generatedTypesDir = path.join(apiRoot, 'apps/website/lib/types/generated');
const websiteTypesDir = path.join(apiRoot, 'apps/website/lib/types');
describe('OpenAPI Spec Integrity for Admin Endpoints', () => {
it('should have admin endpoints defined in OpenAPI spec', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
// Check for admin endpoints
expect(spec.paths['/admin/users']).toBeDefined();
expect(spec.paths['/admin/dashboard/stats']).toBeDefined();
// Verify GET methods exist
expect(spec.paths['/admin/users'].get).toBeDefined();
expect(spec.paths['/admin/dashboard/stats'].get).toBeDefined();
});
it('should have ListUsersRequestDto schema defined', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schema = spec.components.schemas['ListUsersRequestDto'];
expect(schema).toBeDefined();
expect(schema.type).toBe('object');
// Verify optional query parameters
expect(schema.properties?.role).toBeDefined();
expect(schema.properties?.status).toBeDefined();
expect(schema.properties?.email).toBeDefined();
expect(schema.properties?.search).toBeDefined();
expect(schema.properties?.page).toBeDefined();
expect(schema.properties?.limit).toBeDefined();
expect(schema.properties?.sortBy).toBeDefined();
expect(schema.properties?.sortDirection).toBeDefined();
});
it('should have UserResponseDto schema defined', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schema = spec.components.schemas['UserResponseDto'];
expect(schema).toBeDefined();
expect(schema.type).toBe('object');
// Verify required fields
expect(schema.required).toContain('id');
expect(schema.required).toContain('email');
expect(schema.required).toContain('displayName');
expect(schema.required).toContain('roles');
expect(schema.required).toContain('status');
expect(schema.required).toContain('isSystemAdmin');
expect(schema.required).toContain('createdAt');
expect(schema.required).toContain('updatedAt');
// Verify field types
expect(schema.properties?.id?.type).toBe('string');
expect(schema.properties?.email?.type).toBe('string');
expect(schema.properties?.displayName?.type).toBe('string');
expect(schema.properties?.roles?.type).toBe('array');
expect(schema.properties?.status?.type).toBe('string');
expect(schema.properties?.isSystemAdmin?.type).toBe('boolean');
expect(schema.properties?.createdAt?.type).toBe('string');
expect(schema.properties?.updatedAt?.type).toBe('string');
// Verify optional fields
expect(schema.properties?.lastLoginAt).toBeDefined();
expect(schema.properties?.lastLoginAt?.nullable).toBe(true);
expect(schema.properties?.primaryDriverId).toBeDefined();
expect(schema.properties?.primaryDriverId?.nullable).toBe(true);
});
it('should have UserListResponseDto schema defined', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schema = spec.components.schemas['UserListResponseDto'];
expect(schema).toBeDefined();
expect(schema.type).toBe('object');
// Verify required fields
expect(schema.required).toContain('users');
expect(schema.required).toContain('total');
expect(schema.required).toContain('page');
expect(schema.required).toContain('limit');
expect(schema.required).toContain('totalPages');
// Verify field types
expect(schema.properties?.users?.type).toBe('array');
expect(schema.properties?.users?.items?.$ref).toBe('#/components/schemas/UserResponseDto');
expect(schema.properties?.total?.type).toBe('number');
expect(schema.properties?.page?.type).toBe('number');
expect(schema.properties?.limit?.type).toBe('number');
expect(schema.properties?.totalPages?.type).toBe('number');
});
it('should have DashboardStatsResponseDto schema defined', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schema = spec.components.schemas['DashboardStatsResponseDto'];
expect(schema).toBeDefined();
expect(schema.type).toBe('object');
// Verify required fields
expect(schema.required).toContain('totalUsers');
expect(schema.required).toContain('activeUsers');
expect(schema.required).toContain('suspendedUsers');
expect(schema.required).toContain('deletedUsers');
expect(schema.required).toContain('systemAdmins');
expect(schema.required).toContain('recentLogins');
expect(schema.required).toContain('newUsersToday');
expect(schema.required).toContain('userGrowth');
expect(schema.required).toContain('roleDistribution');
expect(schema.required).toContain('statusDistribution');
expect(schema.required).toContain('activityTimeline');
// Verify field types
expect(schema.properties?.totalUsers?.type).toBe('number');
expect(schema.properties?.activeUsers?.type).toBe('number');
expect(schema.properties?.suspendedUsers?.type).toBe('number');
expect(schema.properties?.deletedUsers?.type).toBe('number');
expect(schema.properties?.systemAdmins?.type).toBe('number');
expect(schema.properties?.recentLogins?.type).toBe('number');
expect(schema.properties?.newUsersToday?.type).toBe('number');
// Verify nested objects
expect(schema.properties?.userGrowth?.type).toBe('array');
expect(schema.properties?.roleDistribution?.type).toBe('array');
expect(schema.properties?.statusDistribution?.type).toBe('object');
expect(schema.properties?.activityTimeline?.type).toBe('array');
});
it('should have proper query parameter validation in OpenAPI', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const listUsersPath = spec.paths['/admin/users']?.get;
expect(listUsersPath).toBeDefined();
// Verify query parameters are documented
const params = listUsersPath.parameters || [];
const paramNames = params.map((p: any) => p.name);
// These should be query parameters based on the DTO
expect(paramNames).toContain('role');
expect(paramNames).toContain('status');
expect(paramNames).toContain('email');
expect(paramNames).toContain('search');
expect(paramNames).toContain('page');
expect(paramNames).toContain('limit');
expect(paramNames).toContain('sortBy');
expect(paramNames).toContain('sortDirection');
});
});
describe('DTO Consistency', () => {
it('should have generated DTO files for admin 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', ''));
// Check for admin-related DTOs
const adminDTOs = [
'ListUsersRequestDto',
'UserResponseDto',
'UserListResponseDto',
'DashboardStatsResponseDto',
];
for (const dtoName of adminDTOs) {
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;
// Test ListUsersRequestDto
const listUsersSchema = schemas['ListUsersRequestDto'];
const listUsersDtoPath = path.join(generatedTypesDir, 'ListUsersRequestDto.ts');
const listUsersDtoExists = await fs.access(listUsersDtoPath).then(() => true).catch(() => false);
if (listUsersDtoExists) {
const listUsersDtoContent = await fs.readFile(listUsersDtoPath, 'utf-8');
// Check that all properties are present
if (listUsersSchema.properties) {
for (const propName of Object.keys(listUsersSchema.properties)) {
expect(listUsersDtoContent).toContain(propName);
}
}
}
// Test UserResponseDto
const userSchema = schemas['UserResponseDto'];
const userDtoPath = path.join(generatedTypesDir, 'UserResponseDto.ts');
const userDtoExists = await fs.access(userDtoPath).then(() => true).catch(() => false);
if (userDtoExists) {
const userDtoContent = await fs.readFile(userDtoPath, 'utf-8');
// Check that all required properties are present
if (userSchema.required) {
for (const requiredProp of userSchema.required) {
expect(userDtoContent).toContain(requiredProp);
}
}
// Check that all properties are present
if (userSchema.properties) {
for (const propName of Object.keys(userSchema.properties)) {
expect(userDtoContent).toContain(propName);
}
}
}
// Test UserListResponseDto
const userListSchema = schemas['UserListResponseDto'];
const userListDtoPath = path.join(generatedTypesDir, 'UserListResponseDto.ts');
const userListDtoExists = await fs.access(userListDtoPath).then(() => true).catch(() => false);
if (userListDtoExists) {
const userListDtoContent = await fs.readFile(userListDtoPath, 'utf-8');
// Check that all required properties are present
if (userListSchema.required) {
for (const requiredProp of userListSchema.required) {
expect(userListDtoContent).toContain(requiredProp);
}
}
}
// Test DashboardStatsResponseDto
const dashboardSchema = schemas['DashboardStatsResponseDto'];
const dashboardDtoPath = path.join(generatedTypesDir, 'DashboardStatsResponseDto.ts');
const dashboardDtoExists = await fs.access(dashboardDtoPath).then(() => true).catch(() => false);
if (dashboardDtoExists) {
const dashboardDtoContent = await fs.readFile(dashboardDtoPath, 'utf-8');
// Check that all required properties are present
if (dashboardSchema.required) {
for (const requiredProp of dashboardSchema.required) {
expect(dashboardDtoContent).toContain(requiredProp);
}
}
}
});
it('should have TBD admin types defined', async () => {
const adminTypesPath = path.join(websiteTypesDir, 'tbd', 'AdminUserDto.ts');
const adminTypesExists = await fs.access(adminTypesPath).then(() => true).catch(() => false);
expect(adminTypesExists).toBe(true);
const adminTypesContent = await fs.readFile(adminTypesPath, 'utf-8');
// Verify UserDto interface
expect(adminTypesContent).toContain('export interface AdminUserDto');
expect(adminTypesContent).toContain('id: string');
expect(adminTypesContent).toContain('email: string');
expect(adminTypesContent).toContain('displayName: string');
expect(adminTypesContent).toContain('roles: string[]');
expect(adminTypesContent).toContain('status: string');
expect(adminTypesContent).toContain('isSystemAdmin: boolean');
expect(adminTypesContent).toContain('createdAt: string');
expect(adminTypesContent).toContain('updatedAt: string');
expect(adminTypesContent).toContain('lastLoginAt?: string');
expect(adminTypesContent).toContain('primaryDriverId?: string');
// Verify UserListResponse interface
expect(adminTypesContent).toContain('export interface AdminUserListResponseDto');
expect(adminTypesContent).toContain('users: AdminUserDto[]');
expect(adminTypesContent).toContain('total: number');
expect(adminTypesContent).toContain('page: number');
expect(adminTypesContent).toContain('limit: number');
expect(adminTypesContent).toContain('totalPages: number');
// Verify DashboardStats interface
expect(adminTypesContent).toContain('export interface AdminDashboardStatsDto');
expect(adminTypesContent).toContain('totalUsers: number');
expect(adminTypesContent).toContain('activeUsers: number');
expect(adminTypesContent).toContain('suspendedUsers: number');
expect(adminTypesContent).toContain('deletedUsers: number');
expect(adminTypesContent).toContain('systemAdmins: number');
expect(adminTypesContent).toContain('recentLogins: number');
expect(adminTypesContent).toContain('newUsersToday: number');
// Verify ListUsersQuery interface
expect(adminTypesContent).toContain('export interface AdminListUsersQueryDto');
expect(adminTypesContent).toContain('role?: string');
expect(adminTypesContent).toContain('status?: string');
expect(adminTypesContent).toContain('email?: string');
expect(adminTypesContent).toContain('search?: string');
expect(adminTypesContent).toContain('page?: number');
expect(adminTypesContent).toContain('limit?: number');
expect(adminTypesContent).toContain("sortBy?: 'email' | 'displayName' | 'createdAt' | 'lastLoginAt' | 'status'");
expect(adminTypesContent).toContain("sortDirection?: 'asc' | 'desc'");
});
it('should have admin types re-exported from main types file', async () => {
const adminTypesPath = path.join(websiteTypesDir, 'admin.ts');
const adminTypesExists = await fs.access(adminTypesPath).then(() => true).catch(() => false);
expect(adminTypesExists).toBe(true);
const adminTypesContent = await fs.readFile(adminTypesPath, 'utf-8');
// Verify re-exports
expect(adminTypesContent).toContain('AdminUserDto as UserDto');
expect(adminTypesContent).toContain('AdminUserListResponseDto as UserListResponse');
expect(adminTypesContent).toContain('AdminListUsersQueryDto as ListUsersQuery');
expect(adminTypesContent).toContain('AdminDashboardStatsDto as DashboardStats');
});
});
describe('Admin API Client Contract', () => {
it('should have AdminApiClient defined', async () => {
const adminApiClientPath = path.join(apiRoot, 'apps/website/lib/api/admin/AdminApiClient.ts');
const adminApiClientExists = await fs.access(adminApiClientPath).then(() => true).catch(() => false);
expect(adminApiClientExists).toBe(true);
const adminApiClientContent = await fs.readFile(adminApiClientPath, 'utf-8');
// Verify class definition
expect(adminApiClientContent).toContain('export class AdminApiClient');
expect(adminApiClientContent).toContain('extends BaseApiClient');
// Verify methods exist
expect(adminApiClientContent).toContain('async listUsers');
expect(adminApiClientContent).toContain('async getUser');
expect(adminApiClientContent).toContain('async updateUserRoles');
expect(adminApiClientContent).toContain('async updateUserStatus');
expect(adminApiClientContent).toContain('async deleteUser');
expect(adminApiClientContent).toContain('async createUser');
expect(adminApiClientContent).toContain('async getDashboardStats');
// Verify method signatures
expect(adminApiClientContent).toContain('listUsers(query: ListUsersQuery = {})');
expect(adminApiClientContent).toContain('getUser(userId: string)');
expect(adminApiClientContent).toContain('updateUserRoles(userId: string, roles: string[])');
expect(adminApiClientContent).toContain('updateUserStatus(userId: string, status: string)');
expect(adminApiClientContent).toContain('deleteUser(userId: string)');
expect(adminApiClientContent).toContain('createUser(userData: {');
expect(adminApiClientContent).toContain('getDashboardStats()');
});
it('should have proper request construction in listUsers method', async () => {
const adminApiClientPath = path.join(apiRoot, 'apps/website/lib/api/admin/AdminApiClient.ts');
const adminApiClientContent = await fs.readFile(adminApiClientPath, 'utf-8');
// Verify query parameter construction
expect(adminApiClientContent).toContain('const params = new URLSearchParams()');
expect(adminApiClientContent).toContain("params.append('role', query.role)");
expect(adminApiClientContent).toContain("params.append('status', query.status)");
expect(adminApiClientContent).toContain("params.append('email', query.email)");
expect(adminApiClientContent).toContain("params.append('search', query.search)");
expect(adminApiClientContent).toContain("params.append('page', query.page.toString())");
expect(adminApiClientContent).toContain("params.append('limit', query.limit.toString())");
expect(adminApiClientContent).toContain("params.append('sortBy', query.sortBy)");
expect(adminApiClientContent).toContain("params.append('sortDirection', query.sortDirection)");
// Verify endpoint construction
expect(adminApiClientContent).toContain("return this.get<UserListResponse>(`/admin/users?${params.toString()}`)");
});
it('should have proper request construction in createUser method', async () => {
const adminApiClientPath = path.join(apiRoot, 'apps/website/lib/api/admin/AdminApiClient.ts');
const adminApiClientContent = await fs.readFile(adminApiClientPath, 'utf-8');
// Verify POST request with userData
expect(adminApiClientContent).toContain("return this.post<UserDto>(`/admin/users`, userData)");
});
it('should have proper request construction in getDashboardStats method', async () => {
const adminApiClientPath = path.join(apiRoot, 'apps/website/lib/api/admin/AdminApiClient.ts');
const adminApiClientContent = await fs.readFile(adminApiClientPath, 'utf-8');
// Verify GET request
expect(adminApiClientContent).toContain("return this.get<DashboardStats>(`/admin/dashboard/stats`)");
});
});
describe('Request Correctness Tests', () => {
it('should validate ListUsersRequestDto query parameters', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schema = spec.components.schemas['ListUsersRequestDto'];
// Verify all query parameters are optional (no required fields)
expect(schema.required).toBeUndefined();
// Verify enum values for role
expect(schema.properties?.role?.enum).toBeUndefined(); // No enum constraint in DTO
// Verify enum values for status
expect(schema.properties?.status?.enum).toBeUndefined(); // No enum constraint in DTO
// Verify enum values for sortBy
expect(schema.properties?.sortBy?.enum).toEqual([
'email',
'displayName',
'createdAt',
'lastLoginAt',
'status'
]);
// Verify enum values for sortDirection
expect(schema.properties?.sortDirection?.enum).toEqual(['asc', 'desc']);
expect(schema.properties?.sortDirection?.default).toBe('asc');
// Verify numeric constraints
expect(schema.properties?.page?.minimum).toBe(1);
expect(schema.properties?.page?.default).toBe(1);
expect(schema.properties?.limit?.minimum).toBe(1);
expect(schema.properties?.limit?.maximum).toBe(100);
expect(schema.properties?.limit?.default).toBe(10);
});
it('should validate UserResponseDto field constraints', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schema = spec.components.schemas['UserResponseDto'];
// Verify required fields
expect(schema.required).toContain('id');
expect(schema.required).toContain('email');
expect(schema.required).toContain('displayName');
expect(schema.required).toContain('roles');
expect(schema.required).toContain('status');
expect(schema.required).toContain('isSystemAdmin');
expect(schema.required).toContain('createdAt');
expect(schema.required).toContain('updatedAt');
// Verify optional fields are nullable
expect(schema.properties?.lastLoginAt?.nullable).toBe(true);
expect(schema.properties?.primaryDriverId?.nullable).toBe(true);
// Verify roles is an array
expect(schema.properties?.roles?.type).toBe('array');
expect(schema.properties?.roles?.items?.type).toBe('string');
});
it('should validate DashboardStatsResponseDto structure', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schema = spec.components.schemas['DashboardStatsResponseDto'];
// Verify all required fields
const requiredFields = [
'totalUsers',
'activeUsers',
'suspendedUsers',
'deletedUsers',
'systemAdmins',
'recentLogins',
'newUsersToday',
'userGrowth',
'roleDistribution',
'statusDistribution',
'activityTimeline'
];
for (const field of requiredFields) {
expect(schema.required).toContain(field);
}
// Verify nested object structures
expect(schema.properties?.userGrowth?.items?.$ref).toBe('#/components/schemas/UserGrowthDto');
expect(schema.properties?.roleDistribution?.items?.$ref).toBe('#/components/schemas/RoleDistributionDto');
expect(schema.properties?.statusDistribution?.$ref).toBe('#/components/schemas/StatusDistributionDto');
expect(schema.properties?.activityTimeline?.items?.$ref).toBe('#/components/schemas/ActivityTimelineDto');
});
});
describe('Response Handling Tests', () => {
it('should handle successful user list response', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const userListSchema = spec.components.schemas['UserListResponseDto'];
const userSchema = spec.components.schemas['UserResponseDto'];
// Verify response structure
expect(userListSchema.properties?.users).toBeDefined();
expect(userListSchema.properties?.users?.items?.$ref).toBe('#/components/schemas/UserResponseDto');
// Verify user object has all required fields
expect(userSchema.required).toContain('id');
expect(userSchema.required).toContain('email');
expect(userSchema.required).toContain('displayName');
expect(userSchema.required).toContain('roles');
expect(userSchema.required).toContain('status');
expect(userSchema.required).toContain('isSystemAdmin');
expect(userSchema.required).toContain('createdAt');
expect(userSchema.required).toContain('updatedAt');
// Verify optional fields
expect(userSchema.properties?.lastLoginAt).toBeDefined();
expect(userSchema.properties?.primaryDriverId).toBeDefined();
});
it('should handle successful dashboard stats response', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const dashboardSchema = spec.components.schemas['DashboardStatsResponseDto'];
// Verify all required fields are present
expect(dashboardSchema.required).toContain('totalUsers');
expect(dashboardSchema.required).toContain('activeUsers');
expect(dashboardSchema.required).toContain('suspendedUsers');
expect(dashboardSchema.required).toContain('deletedUsers');
expect(dashboardSchema.required).toContain('systemAdmins');
expect(dashboardSchema.required).toContain('recentLogins');
expect(dashboardSchema.required).toContain('newUsersToday');
expect(dashboardSchema.required).toContain('userGrowth');
expect(dashboardSchema.required).toContain('roleDistribution');
expect(dashboardSchema.required).toContain('statusDistribution');
expect(dashboardSchema.required).toContain('activityTimeline');
// Verify nested objects are properly typed
expect(dashboardSchema.properties?.userGrowth?.type).toBe('array');
expect(dashboardSchema.properties?.roleDistribution?.type).toBe('array');
expect(dashboardSchema.properties?.statusDistribution?.type).toBe('object');
expect(dashboardSchema.properties?.activityTimeline?.type).toBe('array');
});
it('should handle optional fields in user response', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const userSchema = spec.components.schemas['UserResponseDto'];
// Verify optional fields are nullable
expect(userSchema.properties?.lastLoginAt?.nullable).toBe(true);
expect(userSchema.properties?.primaryDriverId?.nullable).toBe(true);
// Verify optional fields are not in required array
expect(userSchema.required).not.toContain('lastLoginAt');
expect(userSchema.required).not.toContain('primaryDriverId');
});
it('should handle pagination fields correctly', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const userListSchema = spec.components.schemas['UserListResponseDto'];
// Verify pagination fields are required
expect(userListSchema.required).toContain('total');
expect(userListSchema.required).toContain('page');
expect(userListSchema.required).toContain('limit');
expect(userListSchema.required).toContain('totalPages');
// Verify pagination field types
expect(userListSchema.properties?.total?.type).toBe('number');
expect(userListSchema.properties?.page?.type).toBe('number');
expect(userListSchema.properties?.limit?.type).toBe('number');
expect(userListSchema.properties?.totalPages?.type).toBe('number');
});
});
describe('Error Handling Tests', () => {
it('should document 403 Forbidden response for admin endpoints', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const listUsersPath = spec.paths['/admin/users']?.get;
const dashboardStatsPath = spec.paths['/admin/dashboard/stats']?.get;
// Verify 403 response is documented
expect(listUsersPath.responses['403']).toBeDefined();
expect(dashboardStatsPath.responses['403']).toBeDefined();
});
it('should document 401 Unauthorized response for admin endpoints', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const listUsersPath = spec.paths['/admin/users']?.get;
const dashboardStatsPath = spec.paths['/admin/dashboard/stats']?.get;
// Verify 401 response is documented
expect(listUsersPath.responses['401']).toBeDefined();
expect(dashboardStatsPath.responses['401']).toBeDefined();
});
it('should document 400 Bad Request response for invalid query parameters', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const listUsersPath = spec.paths['/admin/users']?.get;
// Verify 400 response is documented
expect(listUsersPath.responses['400']).toBeDefined();
});
it('should document 500 Internal Server Error response', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const listUsersPath = spec.paths['/admin/users']?.get;
const dashboardStatsPath = spec.paths['/admin/dashboard/stats']?.get;
// Verify 500 response is documented
expect(listUsersPath.responses['500']).toBeDefined();
expect(dashboardStatsPath.responses['500']).toBeDefined();
});
it('should document 404 Not Found response for user operations', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
// Check for user-specific endpoints (if they exist)
const getUserPath = spec.paths['/admin/users/{userId}']?.get;
if (getUserPath) {
expect(getUserPath.responses['404']).toBeDefined();
}
});
it('should document 409 Conflict response for duplicate operations', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
// Check for create user endpoint (if it exists)
const createUserPath = spec.paths['/admin/users']?.post;
if (createUserPath) {
expect(createUserPath.responses['409']).toBeDefined();
}
});
});
describe('Semantic Guarantee Tests', () => {
it('should maintain ordering guarantees for user list', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const listUsersPath = spec.paths['/admin/users']?.get;
// Verify sortBy and sortDirection parameters are documented
const params = listUsersPath.parameters || [];
const sortByParam = params.find((p: any) => p.name === 'sortBy');
const sortDirectionParam = params.find((p: any) => p.name === 'sortDirection');
expect(sortByParam).toBeDefined();
expect(sortDirectionParam).toBeDefined();
// Verify sortDirection has default value
expect(sortDirectionParam?.schema?.default).toBe('asc');
});
it('should validate pagination consistency', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const userListSchema = spec.components.schemas['UserListResponseDto'];
// Verify pagination fields are all required
expect(userListSchema.required).toContain('page');
expect(userListSchema.required).toContain('limit');
expect(userListSchema.required).toContain('total');
expect(userListSchema.required).toContain('totalPages');
// Verify page and limit have constraints
const listUsersPath = spec.paths['/admin/users']?.get;
const params = listUsersPath.parameters || [];
const pageParam = params.find((p: any) => p.name === 'page');
const limitParam = params.find((p: any) => p.name === 'limit');
expect(pageParam?.schema?.minimum).toBe(1);
expect(pageParam?.schema?.default).toBe(1);
expect(limitParam?.schema?.minimum).toBe(1);
expect(limitParam?.schema?.maximum).toBe(100);
expect(limitParam?.schema?.default).toBe(10);
});
it('should validate idempotency for user status updates', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
// Check for user status update endpoint (if it exists)
const updateUserStatusPath = spec.paths['/admin/users/{userId}/status']?.patch;
if (updateUserStatusPath) {
// Verify it accepts a status parameter
const params = updateUserStatusPath.parameters || [];
const statusParam = params.find((p: any) => p.name === 'status');
expect(statusParam).toBeDefined();
}
});
it('should validate uniqueness constraints for user email', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const userSchema = spec.components.schemas['UserResponseDto'];
// Verify email is a required field
expect(userSchema.required).toContain('email');
expect(userSchema.properties?.email?.type).toBe('string');
// Check for create user endpoint (if it exists)
const createUserPath = spec.paths['/admin/users']?.post;
if (createUserPath) {
// Verify email is required in request body
const requestBody = createUserPath.requestBody;
if (requestBody && requestBody.content && requestBody.content['application/json']) {
const schema = requestBody.content['application/json'].schema;
if (schema && schema.$ref) {
// This would reference a CreateUserDto which should have email as required
expect(schema.$ref).toContain('CreateUserDto');
}
}
}
});
it('should validate consistency between request and response schemas', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const userSchema = spec.components.schemas['UserResponseDto'];
const userListSchema = spec.components.schemas['UserListResponseDto'];
// Verify UserListResponse contains array of UserResponse
expect(userListSchema.properties?.users?.items?.$ref).toBe('#/components/schemas/UserResponseDto');
// Verify UserResponse has consistent field types
expect(userSchema.properties?.id?.type).toBe('string');
expect(userSchema.properties?.email?.type).toBe('string');
expect(userSchema.properties?.displayName?.type).toBe('string');
expect(userSchema.properties?.roles?.type).toBe('array');
expect(userSchema.properties?.status?.type).toBe('string');
expect(userSchema.properties?.isSystemAdmin?.type).toBe('boolean');
});
it('should validate semantic consistency in dashboard stats', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const dashboardSchema = spec.components.schemas['DashboardStatsResponseDto'];
// Verify totalUsers >= activeUsers + suspendedUsers + deletedUsers
// (This is a semantic guarantee that should be enforced by the backend)
expect(dashboardSchema.properties?.totalUsers).toBeDefined();
expect(dashboardSchema.properties?.activeUsers).toBeDefined();
expect(dashboardSchema.properties?.suspendedUsers).toBeDefined();
expect(dashboardSchema.properties?.deletedUsers).toBeDefined();
// Verify systemAdmins is a subset of totalUsers
expect(dashboardSchema.properties?.systemAdmins).toBeDefined();
// Verify recentLogins and newUsersToday are non-negative
expect(dashboardSchema.properties?.recentLogins).toBeDefined();
expect(dashboardSchema.properties?.newUsersToday).toBeDefined();
});
it('should validate pagination metadata consistency', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const userListSchema = spec.components.schemas['UserListResponseDto'];
// Verify pagination metadata is always present
expect(userListSchema.required).toContain('page');
expect(userListSchema.required).toContain('limit');
expect(userListSchema.required).toContain('total');
expect(userListSchema.required).toContain('totalPages');
// Verify totalPages calculation is consistent
// totalPages should be >= 1 and should be calculated as Math.ceil(total / limit)
expect(userListSchema.properties?.totalPages?.type).toBe('number');
});
});
describe('Admin Module Integration Tests', () => {
it('should have consistent types between API DTOs and website types', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const adminTypesPath = path.join(websiteTypesDir, 'tbd', 'AdminUserDto.ts');
const adminTypesContent = await fs.readFile(adminTypesPath, 'utf-8');
// Verify UserDto interface matches UserResponseDto schema
const userSchema = spec.components.schemas['UserResponseDto'];
expect(adminTypesContent).toContain('export interface AdminUserDto');
// Check all required fields from schema are in interface
for (const field of userSchema.required || []) {
expect(adminTypesContent).toContain(`${field}:`);
}
// Check all properties from schema are in interface
if (userSchema.properties) {
for (const propName of Object.keys(userSchema.properties)) {
expect(adminTypesContent).toContain(propName);
}
}
});
it('should have consistent query types between API and website', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const adminTypesPath = path.join(websiteTypesDir, 'tbd', 'AdminUserDto.ts');
const adminTypesContent = await fs.readFile(adminTypesPath, 'utf-8');
// Verify ListUsersQuery interface matches ListUsersRequestDto schema
const listUsersSchema = spec.components.schemas['ListUsersRequestDto'];
expect(adminTypesContent).toContain('export interface AdminListUsersQueryDto');
// Check all properties from schema are in interface
if (listUsersSchema.properties) {
for (const propName of Object.keys(listUsersSchema.properties)) {
expect(adminTypesContent).toContain(propName);
}
}
});
it('should have consistent response types between API and website', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const adminTypesPath = path.join(websiteTypesDir, 'tbd', 'AdminUserDto.ts');
const adminTypesContent = await fs.readFile(adminTypesPath, 'utf-8');
// Verify UserListResponse interface matches UserListResponseDto schema
const userListSchema = spec.components.schemas['UserListResponseDto'];
expect(adminTypesContent).toContain('export interface AdminUserListResponseDto');
// Check all required fields from schema are in interface
for (const field of userListSchema.required || []) {
expect(adminTypesContent).toContain(`${field}:`);
}
// Verify DashboardStats interface matches DashboardStatsResponseDto schema
const dashboardSchema = spec.components.schemas['DashboardStatsResponseDto'];
expect(adminTypesContent).toContain('export interface AdminDashboardStatsDto');
// Check all required fields from schema are in interface
for (const field of dashboardSchema.required || []) {
expect(adminTypesContent).toContain(`${field}:`);
}
});
it('should have AdminApiClient methods matching API endpoints', async () => {
const adminApiClientPath = path.join(apiRoot, 'apps/website/lib/api/admin/AdminApiClient.ts');
const adminApiClientContent = await fs.readFile(adminApiClientPath, 'utf-8');
// Verify listUsers method exists and uses correct endpoint
expect(adminApiClientContent).toContain('async listUsers');
expect(adminApiClientContent).toContain("return this.get<UserListResponse>(`/admin/users?${params.toString()}`)");
// Verify getDashboardStats method exists and uses correct endpoint
expect(adminApiClientContent).toContain('async getDashboardStats');
expect(adminApiClientContent).toContain("return this.get<DashboardStats>(`/admin/dashboard/stats`)");
});
it('should have proper error handling in AdminApiClient', async () => {
const adminApiClientPath = path.join(apiRoot, 'apps/website/lib/api/admin/AdminApiClient.ts');
const adminApiClientContent = await fs.readFile(adminApiClientPath, 'utf-8');
// Verify BaseApiClient is extended (which provides error handling)
expect(adminApiClientContent).toContain('extends BaseApiClient');
// Verify methods use BaseApiClient methods (which handle errors)
expect(adminApiClientContent).toContain('this.get<');
expect(adminApiClientContent).toContain('this.post<');
expect(adminApiClientContent).toContain('this.patch<');
expect(adminApiClientContent).toContain('this.delete<');
});
});
});

View File

@@ -0,0 +1,897 @@
/**
* Contract Validation Tests for Analytics Module
*
* These tests validate that the analytics API DTOs and OpenAPI spec are consistent
* and that the generated types will be compatible with the website analytics client.
*/
import * as fs from 'fs/promises';
import * as path from 'path';
import { describe, expect, it } from 'vitest';
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('Analytics Module Contract Validation', () => {
const apiRoot = path.join(__dirname, '../..');
const openapiPath = path.join(apiRoot, 'apps/api/openapi.json');
const generatedTypesDir = path.join(apiRoot, 'apps/website/lib/types/generated');
const websiteTypesDir = path.join(apiRoot, 'apps/website/lib/types');
describe('OpenAPI Spec Integrity for Analytics Endpoints', () => {
it('should have analytics endpoints defined in OpenAPI spec', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
// Check for analytics endpoints
expect(spec.paths['/analytics/page-view']).toBeDefined();
expect(spec.paths['/analytics/engagement']).toBeDefined();
expect(spec.paths['/analytics/dashboard']).toBeDefined();
expect(spec.paths['/analytics/metrics']).toBeDefined();
// Verify POST methods exist for recording endpoints
expect(spec.paths['/analytics/page-view'].post).toBeDefined();
expect(spec.paths['/analytics/engagement'].post).toBeDefined();
// Verify GET methods exist for query endpoints
expect(spec.paths['/analytics/dashboard'].get).toBeDefined();
expect(spec.paths['/analytics/metrics'].get).toBeDefined();
});
it('should have RecordPageViewInputDTO schema defined', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schema = spec.components.schemas['RecordPageViewInputDTO'];
expect(schema).toBeDefined();
expect(schema.type).toBe('object');
// Verify required fields
expect(schema.required).toContain('entityType');
expect(schema.required).toContain('entityId');
expect(schema.required).toContain('visitorType');
expect(schema.required).toContain('sessionId');
// Verify field types
expect(schema.properties?.entityType?.type).toBe('string');
expect(schema.properties?.entityId?.type).toBe('string');
expect(schema.properties?.visitorType?.type).toBe('string');
expect(schema.properties?.sessionId?.type).toBe('string');
// Verify optional fields
expect(schema.properties?.visitorId).toBeDefined();
expect(schema.properties?.referrer).toBeDefined();
expect(schema.properties?.userAgent).toBeDefined();
expect(schema.properties?.country).toBeDefined();
});
it('should have RecordPageViewOutputDTO schema defined', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schema = spec.components.schemas['RecordPageViewOutputDTO'];
expect(schema).toBeDefined();
expect(schema.type).toBe('object');
// Verify required fields
expect(schema.required).toContain('pageViewId');
// Verify field types
expect(schema.properties?.pageViewId?.type).toBe('string');
});
it('should have RecordEngagementInputDTO schema defined', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schema = spec.components.schemas['RecordEngagementInputDTO'];
expect(schema).toBeDefined();
expect(schema.type).toBe('object');
// Verify required fields
expect(schema.required).toContain('action');
expect(schema.required).toContain('entityType');
expect(schema.required).toContain('entityId');
expect(schema.required).toContain('actorType');
expect(schema.required).toContain('sessionId');
// Verify field types
expect(schema.properties?.action?.type).toBe('string');
expect(schema.properties?.entityType?.type).toBe('string');
expect(schema.properties?.entityId?.type).toBe('string');
expect(schema.properties?.actorType?.type).toBe('string');
expect(schema.properties?.sessionId?.type).toBe('string');
// Verify optional fields
expect(schema.properties?.actorId).toBeDefined();
expect(schema.properties?.metadata).toBeDefined();
});
it('should have RecordEngagementOutputDTO schema defined', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schema = spec.components.schemas['RecordEngagementOutputDTO'];
expect(schema).toBeDefined();
expect(schema.type).toBe('object');
// Verify required fields
expect(schema.required).toContain('eventId');
expect(schema.required).toContain('engagementWeight');
// Verify field types
expect(schema.properties?.eventId?.type).toBe('string');
expect(schema.properties?.engagementWeight?.type).toBe('number');
});
it('should have GetAnalyticsMetricsOutputDTO schema defined', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schema = spec.components.schemas['GetAnalyticsMetricsOutputDTO'];
expect(schema).toBeDefined();
expect(schema.type).toBe('object');
// Verify required fields
expect(schema.required).toContain('pageViews');
expect(schema.required).toContain('uniqueVisitors');
expect(schema.required).toContain('averageSessionDuration');
expect(schema.required).toContain('bounceRate');
// Verify field types
expect(schema.properties?.pageViews?.type).toBe('number');
expect(schema.properties?.uniqueVisitors?.type).toBe('number');
expect(schema.properties?.averageSessionDuration?.type).toBe('number');
expect(schema.properties?.bounceRate?.type).toBe('number');
});
it('should have GetDashboardDataOutputDTO schema defined', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schema = spec.components.schemas['GetDashboardDataOutputDTO'];
expect(schema).toBeDefined();
expect(schema.type).toBe('object');
// Verify required fields
expect(schema.required).toContain('totalUsers');
expect(schema.required).toContain('activeUsers');
expect(schema.required).toContain('totalRaces');
expect(schema.required).toContain('totalLeagues');
// Verify field types
expect(schema.properties?.totalUsers?.type).toBe('number');
expect(schema.properties?.activeUsers?.type).toBe('number');
expect(schema.properties?.totalRaces?.type).toBe('number');
expect(schema.properties?.totalLeagues?.type).toBe('number');
});
it('should have proper request/response structure for page-view endpoint', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const pageViewPath = spec.paths['/analytics/page-view']?.post;
expect(pageViewPath).toBeDefined();
// Verify request body
const requestBody = pageViewPath.requestBody;
expect(requestBody).toBeDefined();
expect(requestBody.content['application/json']).toBeDefined();
expect(requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/RecordPageViewInputDTO');
// Verify response
const response201 = pageViewPath.responses['201'];
expect(response201).toBeDefined();
expect(response201.content['application/json'].schema.$ref).toBe('#/components/schemas/RecordPageViewOutputDTO');
});
it('should have proper request/response structure for engagement endpoint', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const engagementPath = spec.paths['/analytics/engagement']?.post;
expect(engagementPath).toBeDefined();
// Verify request body
const requestBody = engagementPath.requestBody;
expect(requestBody).toBeDefined();
expect(requestBody.content['application/json']).toBeDefined();
expect(requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/RecordEngagementInputDTO');
// Verify response
const response201 = engagementPath.responses['201'];
expect(response201).toBeDefined();
expect(response201.content['application/json'].schema.$ref).toBe('#/components/schemas/RecordEngagementOutputDTO');
});
it('should have proper response structure for metrics endpoint', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const metricsPath = spec.paths['/analytics/metrics']?.get;
expect(metricsPath).toBeDefined();
// Verify response
const response200 = metricsPath.responses['200'];
expect(response200).toBeDefined();
expect(response200.content['application/json'].schema.$ref).toBe('#/components/schemas/GetAnalyticsMetricsOutputDTO');
});
it('should have proper response structure for dashboard endpoint', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const dashboardPath = spec.paths['/analytics/dashboard']?.get;
expect(dashboardPath).toBeDefined();
// Verify response
const response200 = dashboardPath.responses['200'];
expect(response200).toBeDefined();
expect(response200.content['application/json'].schema.$ref).toBe('#/components/schemas/GetDashboardDataOutputDTO');
});
});
describe('DTO Consistency', () => {
it('should have generated DTO files for analytics 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', ''));
// Check for analytics-related DTOs
const analyticsDTOs = [
'RecordPageViewInputDTO',
'RecordPageViewOutputDTO',
'RecordEngagementInputDTO',
'RecordEngagementOutputDTO',
'GetAnalyticsMetricsOutputDTO',
'GetDashboardDataOutputDTO',
];
for (const dtoName of analyticsDTOs) {
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;
// Test RecordPageViewInputDTO
const pageViewSchema = schemas['RecordPageViewInputDTO'];
const pageViewDtoPath = path.join(generatedTypesDir, 'RecordPageViewInputDTO.ts');
const pageViewDtoExists = await fs.access(pageViewDtoPath).then(() => true).catch(() => false);
if (pageViewDtoExists) {
const pageViewDtoContent = await fs.readFile(pageViewDtoPath, 'utf-8');
// Check that all properties are present
if (pageViewSchema.properties) {
for (const propName of Object.keys(pageViewSchema.properties)) {
expect(pageViewDtoContent).toContain(propName);
}
}
}
// Test RecordEngagementInputDTO
const engagementSchema = schemas['RecordEngagementInputDTO'];
const engagementDtoPath = path.join(generatedTypesDir, 'RecordEngagementInputDTO.ts');
const engagementDtoExists = await fs.access(engagementDtoPath).then(() => true).catch(() => false);
if (engagementDtoExists) {
const engagementDtoContent = await fs.readFile(engagementDtoPath, 'utf-8');
// Check that all properties are present
if (engagementSchema.properties) {
for (const propName of Object.keys(engagementSchema.properties)) {
expect(engagementDtoContent).toContain(propName);
}
}
}
// Test GetAnalyticsMetricsOutputDTO
const metricsSchema = schemas['GetAnalyticsMetricsOutputDTO'];
const metricsDtoPath = path.join(generatedTypesDir, 'GetAnalyticsMetricsOutputDTO.ts');
const metricsDtoExists = await fs.access(metricsDtoPath).then(() => true).catch(() => false);
if (metricsDtoExists) {
const metricsDtoContent = await fs.readFile(metricsDtoPath, 'utf-8');
// Check that all required properties are present
if (metricsSchema.required) {
for (const requiredProp of metricsSchema.required) {
expect(metricsDtoContent).toContain(requiredProp);
}
}
}
// Test GetDashboardDataOutputDTO
const dashboardSchema = schemas['GetDashboardDataOutputDTO'];
const dashboardDtoPath = path.join(generatedTypesDir, 'GetDashboardDataOutputDTO.ts');
const dashboardDtoExists = await fs.access(dashboardDtoPath).then(() => true).catch(() => false);
if (dashboardDtoExists) {
const dashboardDtoContent = await fs.readFile(dashboardDtoPath, 'utf-8');
// Check that all required properties are present
if (dashboardSchema.required) {
for (const requiredProp of dashboardSchema.required) {
expect(dashboardDtoContent).toContain(requiredProp);
}
}
}
});
it('should have analytics types defined in tbd folder', async () => {
// Check if analytics types exist in tbd folder (similar to admin types)
const tbdDir = path.join(websiteTypesDir, 'tbd');
const tbdFiles = await fs.readdir(tbdDir).catch(() => []);
// Analytics types might be in a separate file or combined with existing types
// For now, we'll check if the generated types are properly available
const generatedFiles = await fs.readdir(generatedTypesDir);
const analyticsGenerated = generatedFiles.filter(f =>
f.includes('Analytics') ||
f.includes('Record') ||
f.includes('PageView') ||
f.includes('Engagement')
);
expect(analyticsGenerated.length).toBeGreaterThanOrEqual(6);
});
it('should have analytics types re-exported from main types file', async () => {
// Check if there's an analytics.ts file or if types are exported elsewhere
const analyticsTypesPath = path.join(websiteTypesDir, 'analytics.ts');
const analyticsTypesExists = await fs.access(analyticsTypesPath).then(() => true).catch(() => false);
if (analyticsTypesExists) {
const analyticsTypesContent = await fs.readFile(analyticsTypesPath, 'utf-8');
// Verify re-exports
expect(analyticsTypesContent).toContain('RecordPageViewInputDTO');
expect(analyticsTypesContent).toContain('RecordEngagementInputDTO');
expect(analyticsTypesContent).toContain('GetAnalyticsMetricsOutputDTO');
expect(analyticsTypesContent).toContain('GetDashboardDataOutputDTO');
}
});
});
describe('Analytics API Client Contract', () => {
it('should have AnalyticsApiClient defined', async () => {
const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts');
const analyticsApiClientExists = await fs.access(analyticsApiClientPath).then(() => true).catch(() => false);
expect(analyticsApiClientExists).toBe(true);
const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8');
// Verify class definition
expect(analyticsApiClientContent).toContain('export class AnalyticsApiClient');
expect(analyticsApiClientContent).toContain('extends BaseApiClient');
// Verify methods exist
expect(analyticsApiClientContent).toContain('recordPageView');
expect(analyticsApiClientContent).toContain('recordEngagement');
expect(analyticsApiClientContent).toContain('getDashboardData');
expect(analyticsApiClientContent).toContain('getAnalyticsMetrics');
// Verify method signatures
expect(analyticsApiClientContent).toContain('recordPageView(input: RecordPageViewInputDTO)');
expect(analyticsApiClientContent).toContain('recordEngagement(input: RecordEngagementInputDTO)');
expect(analyticsApiClientContent).toContain('getDashboardData()');
expect(analyticsApiClientContent).toContain('getAnalyticsMetrics()');
});
it('should have proper request construction in recordPageView method', async () => {
const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts');
const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8');
// Verify POST request with input
expect(analyticsApiClientContent).toContain("return this.post<RecordPageViewOutputDTO>('/analytics/page-view', input)");
});
it('should have proper request construction in recordEngagement method', async () => {
const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts');
const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8');
// Verify POST request with input
expect(analyticsApiClientContent).toContain("return this.post<RecordEngagementOutputDTO>('/analytics/engagement', input)");
});
it('should have proper request construction in getDashboardData method', async () => {
const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts');
const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8');
// Verify GET request
expect(analyticsApiClientContent).toContain("return this.get<GetDashboardDataOutputDTO>('/analytics/dashboard')");
});
it('should have proper request construction in getAnalyticsMetrics method', async () => {
const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts');
const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8');
// Verify GET request
expect(analyticsApiClientContent).toContain("return this.get<GetAnalyticsMetricsOutputDTO>('/analytics/metrics')");
});
});
describe('Request Correctness Tests', () => {
it('should validate RecordPageViewInputDTO required fields', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schema = spec.components.schemas['RecordPageViewInputDTO'];
// Verify all required fields are present
expect(schema.required).toContain('entityType');
expect(schema.required).toContain('entityId');
expect(schema.required).toContain('visitorType');
expect(schema.required).toContain('sessionId');
// Verify no extra required fields
expect(schema.required.length).toBe(4);
});
it('should validate RecordPageViewInputDTO optional fields', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schema = spec.components.schemas['RecordPageViewInputDTO'];
// Verify optional fields are not required
expect(schema.required).not.toContain('visitorId');
expect(schema.required).not.toContain('referrer');
expect(schema.required).not.toContain('userAgent');
expect(schema.required).not.toContain('country');
// Verify optional fields exist
expect(schema.properties?.visitorId).toBeDefined();
expect(schema.properties?.referrer).toBeDefined();
expect(schema.properties?.userAgent).toBeDefined();
expect(schema.properties?.country).toBeDefined();
});
it('should validate RecordEngagementInputDTO required fields', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schema = spec.components.schemas['RecordEngagementInputDTO'];
// Verify all required fields are present
expect(schema.required).toContain('action');
expect(schema.required).toContain('entityType');
expect(schema.required).toContain('entityId');
expect(schema.required).toContain('actorType');
expect(schema.required).toContain('sessionId');
// Verify no extra required fields
expect(schema.required.length).toBe(5);
});
it('should validate RecordEngagementInputDTO optional fields', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schema = spec.components.schemas['RecordEngagementInputDTO'];
// Verify optional fields are not required
expect(schema.required).not.toContain('actorId');
expect(schema.required).not.toContain('metadata');
// Verify optional fields exist
expect(schema.properties?.actorId).toBeDefined();
expect(schema.properties?.metadata).toBeDefined();
});
it('should validate GetAnalyticsMetricsOutputDTO structure', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schema = spec.components.schemas['GetAnalyticsMetricsOutputDTO'];
// Verify all required fields
expect(schema.required).toContain('pageViews');
expect(schema.required).toContain('uniqueVisitors');
expect(schema.required).toContain('averageSessionDuration');
expect(schema.required).toContain('bounceRate');
// Verify field types
expect(schema.properties?.pageViews?.type).toBe('number');
expect(schema.properties?.uniqueVisitors?.type).toBe('number');
expect(schema.properties?.averageSessionDuration?.type).toBe('number');
expect(schema.properties?.bounceRate?.type).toBe('number');
});
it('should validate GetDashboardDataOutputDTO structure', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schema = spec.components.schemas['GetDashboardDataOutputDTO'];
// Verify all required fields
expect(schema.required).toContain('totalUsers');
expect(schema.required).toContain('activeUsers');
expect(schema.required).toContain('totalRaces');
expect(schema.required).toContain('totalLeagues');
// Verify field types
expect(schema.properties?.totalUsers?.type).toBe('number');
expect(schema.properties?.activeUsers?.type).toBe('number');
expect(schema.properties?.totalRaces?.type).toBe('number');
expect(schema.properties?.totalLeagues?.type).toBe('number');
});
});
describe('Response Handling Tests', () => {
it('should handle successful page view recording response', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const pageViewSchema = spec.components.schemas['RecordPageViewOutputDTO'];
// Verify response structure
expect(pageViewSchema.properties?.pageViewId).toBeDefined();
expect(pageViewSchema.properties?.pageViewId?.type).toBe('string');
expect(pageViewSchema.required).toContain('pageViewId');
});
it('should handle successful engagement recording response', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const engagementSchema = spec.components.schemas['RecordEngagementOutputDTO'];
// Verify response structure
expect(engagementSchema.properties?.eventId).toBeDefined();
expect(engagementSchema.properties?.engagementWeight).toBeDefined();
expect(engagementSchema.properties?.eventId?.type).toBe('string');
expect(engagementSchema.properties?.engagementWeight?.type).toBe('number');
expect(engagementSchema.required).toContain('eventId');
expect(engagementSchema.required).toContain('engagementWeight');
});
it('should handle metrics response with all required fields', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const metricsSchema = spec.components.schemas['GetAnalyticsMetricsOutputDTO'];
// Verify all required fields are present
for (const field of ['pageViews', 'uniqueVisitors', 'averageSessionDuration', 'bounceRate']) {
expect(metricsSchema.required).toContain(field);
expect(metricsSchema.properties?.[field]).toBeDefined();
expect(metricsSchema.properties?.[field]?.type).toBe('number');
}
});
it('should handle dashboard data response with all required fields', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const dashboardSchema = spec.components.schemas['GetDashboardDataOutputDTO'];
// Verify all required fields are present
for (const field of ['totalUsers', 'activeUsers', 'totalRaces', 'totalLeagues']) {
expect(dashboardSchema.required).toContain(field);
expect(dashboardSchema.properties?.[field]).toBeDefined();
expect(dashboardSchema.properties?.[field]?.type).toBe('number');
}
});
it('should handle optional fields in page view input correctly', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schema = spec.components.schemas['RecordPageViewInputDTO'];
// Verify optional fields are nullable or optional
expect(schema.properties?.visitorId?.type).toBe('string');
expect(schema.properties?.referrer?.type).toBe('string');
expect(schema.properties?.userAgent?.type).toBe('string');
expect(schema.properties?.country?.type).toBe('string');
});
it('should handle optional fields in engagement input correctly', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schema = spec.components.schemas['RecordEngagementInputDTO'];
// Verify optional fields
expect(schema.properties?.actorId?.type).toBe('string');
expect(schema.properties?.metadata?.type).toBe('object');
});
});
describe('Error Handling Tests', () => {
it('should document 400 Bad Request response for invalid page view input', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const pageViewPath = spec.paths['/analytics/page-view']?.post;
// Check if 400 response is documented
if (pageViewPath.responses['400']) {
expect(pageViewPath.responses['400']).toBeDefined();
}
});
it('should document 400 Bad Request response for invalid engagement input', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const engagementPath = spec.paths['/analytics/engagement']?.post;
// Check if 400 response is documented
if (engagementPath.responses['400']) {
expect(engagementPath.responses['400']).toBeDefined();
}
});
it('should document 401 Unauthorized response for protected endpoints', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
// Dashboard and metrics endpoints should require authentication
const dashboardPath = spec.paths['/analytics/dashboard']?.get;
const metricsPath = spec.paths['/analytics/metrics']?.get;
// Check if 401 responses are documented
if (dashboardPath.responses['401']) {
expect(dashboardPath.responses['401']).toBeDefined();
}
if (metricsPath.responses['401']) {
expect(metricsPath.responses['401']).toBeDefined();
}
});
it('should document 500 Internal Server Error response', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const pageViewPath = spec.paths['/analytics/page-view']?.post;
const engagementPath = spec.paths['/analytics/engagement']?.post;
// Check if 500 response is documented for recording endpoints
if (pageViewPath.responses['500']) {
expect(pageViewPath.responses['500']).toBeDefined();
}
if (engagementPath.responses['500']) {
expect(engagementPath.responses['500']).toBeDefined();
}
});
it('should have proper error handling in AnalyticsApiClient', async () => {
const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts');
const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8');
// Verify BaseApiClient is extended (which provides error handling)
expect(analyticsApiClientContent).toContain('extends BaseApiClient');
// Verify methods use BaseApiClient methods (which handle errors)
expect(analyticsApiClientContent).toContain('this.post<');
expect(analyticsApiClientContent).toContain('this.get<');
});
});
describe('Semantic Guarantee Tests', () => {
it('should maintain consistency between request and response schemas', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
// Verify page view request/response consistency
const pageViewInputSchema = spec.components.schemas['RecordPageViewInputDTO'];
const pageViewOutputSchema = spec.components.schemas['RecordPageViewOutputDTO'];
// Output should contain a reference to the input (pageViewId relates to the recorded page view)
expect(pageViewOutputSchema.properties?.pageViewId).toBeDefined();
expect(pageViewOutputSchema.properties?.pageViewId?.type).toBe('string');
// Verify engagement request/response consistency
const engagementInputSchema = spec.components.schemas['RecordEngagementInputDTO'];
const engagementOutputSchema = spec.components.schemas['RecordEngagementOutputDTO'];
// Output should contain event reference and engagement weight
expect(engagementOutputSchema.properties?.eventId).toBeDefined();
expect(engagementOutputSchema.properties?.engagementWeight).toBeDefined();
});
it('should validate semantic consistency in analytics metrics', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const metricsSchema = spec.components.schemas['GetAnalyticsMetricsOutputDTO'];
// Verify metrics are non-negative numbers
expect(metricsSchema.properties?.pageViews?.type).toBe('number');
expect(metricsSchema.properties?.uniqueVisitors?.type).toBe('number');
expect(metricsSchema.properties?.averageSessionDuration?.type).toBe('number');
expect(metricsSchema.properties?.bounceRate?.type).toBe('number');
// Verify bounce rate is a percentage (0-1 range or 0-100)
// This is a semantic guarantee that should be documented
expect(metricsSchema.properties?.bounceRate).toBeDefined();
});
it('should validate semantic consistency in dashboard data', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const dashboardSchema = spec.components.schemas['GetDashboardDataOutputDTO'];
// Verify dashboard metrics are non-negative numbers
expect(dashboardSchema.properties?.totalUsers?.type).toBe('number');
expect(dashboardSchema.properties?.activeUsers?.type).toBe('number');
expect(dashboardSchema.properties?.totalRaces?.type).toBe('number');
expect(dashboardSchema.properties?.totalLeagues?.type).toBe('number');
// Semantic guarantee: activeUsers <= totalUsers
// This should be enforced by the backend
expect(dashboardSchema.properties?.activeUsers).toBeDefined();
expect(dashboardSchema.properties?.totalUsers).toBeDefined();
});
it('should validate idempotency for analytics recording', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
// Check if recording endpoints support idempotency
const pageViewPath = spec.paths['/analytics/page-view']?.post;
const engagementPath = spec.paths['/analytics/engagement']?.post;
// Verify session-based deduplication is possible
const pageViewSchema = spec.components.schemas['RecordPageViewInputDTO'];
const engagementSchema = spec.components.schemas['RecordEngagementInputDTO'];
// Both should have sessionId for deduplication
expect(pageViewSchema.properties?.sessionId).toBeDefined();
expect(engagementSchema.properties?.sessionId).toBeDefined();
});
it('should validate uniqueness constraints for analytics entities', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const pageViewSchema = spec.components.schemas['RecordPageViewInputDTO'];
const engagementSchema = spec.components.schemas['RecordEngagementInputDTO'];
// Verify entity identification fields are required
expect(pageViewSchema.required).toContain('entityType');
expect(pageViewSchema.required).toContain('entityId');
expect(pageViewSchema.required).toContain('sessionId');
expect(engagementSchema.required).toContain('entityType');
expect(engagementSchema.required).toContain('entityId');
expect(engagementSchema.required).toContain('sessionId');
});
it('should validate consistency between request and response types', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
// Verify all DTOs have consistent type definitions
const dtos = [
'RecordPageViewInputDTO',
'RecordPageViewOutputDTO',
'RecordEngagementInputDTO',
'RecordEngagementOutputDTO',
'GetAnalyticsMetricsOutputDTO',
'GetDashboardDataOutputDTO',
];
for (const dtoName of dtos) {
const schema = spec.components.schemas[dtoName];
expect(schema).toBeDefined();
expect(schema.type).toBe('object');
// All should have properties defined
expect(schema.properties).toBeDefined();
// All should have required fields (even if empty array)
expect(schema.required).toBeDefined();
}
});
});
describe('Analytics Module Integration Tests', () => {
it('should have consistent types between API DTOs and website types', 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', ''));
// Check all analytics DTOs exist in generated types
const analyticsDTOs = [
'RecordPageViewInputDTO',
'RecordPageViewOutputDTO',
'RecordEngagementInputDTO',
'RecordEngagementOutputDTO',
'GetAnalyticsMetricsOutputDTO',
'GetDashboardDataOutputDTO',
];
for (const dtoName of analyticsDTOs) {
expect(spec.components.schemas[dtoName]).toBeDefined();
expect(generatedDTOs).toContain(dtoName);
}
});
it('should have AnalyticsApiClient methods matching API endpoints', async () => {
const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts');
const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8');
// Verify recordPageView method exists and uses correct endpoint
expect(analyticsApiClientContent).toContain('async recordPageView');
expect(analyticsApiClientContent).toContain("return this.post<RecordPageViewOutputDTO>('/analytics/page-view', input)");
// Verify recordEngagement method exists and uses correct endpoint
expect(analyticsApiClientContent).toContain('async recordEngagement');
expect(analyticsApiClientContent).toContain("return this.post<RecordEngagementOutputDTO>('/analytics/engagement', input)");
// Verify getDashboardData method exists and uses correct endpoint
expect(analyticsApiClientContent).toContain('async getDashboardData');
expect(analyticsApiClientContent).toContain("return this.get<GetDashboardDataOutputDTO>('/analytics/dashboard')");
// Verify getAnalyticsMetrics method exists and uses correct endpoint
expect(analyticsApiClientContent).toContain('async getAnalyticsMetrics');
expect(analyticsApiClientContent).toContain("return this.get<GetAnalyticsMetricsOutputDTO>('/analytics/metrics')");
});
it('should have proper error handling in AnalyticsApiClient', async () => {
const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts');
const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8');
// Verify BaseApiClient is extended (which provides error handling)
expect(analyticsApiClientContent).toContain('extends BaseApiClient');
// Verify methods use BaseApiClient methods (which handle errors)
expect(analyticsApiClientContent).toContain('this.post<');
expect(analyticsApiClientContent).toContain('this.get<');
});
it('should have consistent type imports in AnalyticsApiClient', async () => {
const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts');
const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8');
// Verify all required types are imported
expect(analyticsApiClientContent).toContain('RecordPageViewOutputDTO');
expect(analyticsApiClientContent).toContain('RecordEngagementOutputDTO');
expect(analyticsApiClientContent).toContain('GetDashboardDataOutputDTO');
expect(analyticsApiClientContent).toContain('GetAnalyticsMetricsOutputDTO');
expect(analyticsApiClientContent).toContain('RecordPageViewInputDTO');
expect(analyticsApiClientContent).toContain('RecordEngagementInputDTO');
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,313 @@
/**
* Contract Validation Tests for Bootstrap Module
*
* These tests validate that the bootstrap module is properly configured and that
* the initialization process follows expected patterns. The bootstrap module is
* an internal initialization module that runs during application startup and
* does not expose HTTP endpoints.
*
* Key Findings:
* - Bootstrap module is an internal initialization module (not an API endpoint)
* - It runs during application startup via OnModuleInit lifecycle hook
* - It seeds the database with initial data (admin users, achievements, racing data)
* - It does not expose any HTTP controllers or endpoints
* - No API client exists in the website app for bootstrap operations
* - No bootstrap-related endpoints are defined in the OpenAPI spec
*/
import * as fs from 'fs/promises';
import * as path from 'path';
import { describe, expect, it } from 'vitest';
interface OpenAPISpec {
openapi: string;
info: {
title: string;
description: string;
version: string;
};
paths: Record<string, any>;
components: {
schemas: Record<string, any>;
};
}
describe('Bootstrap Module Contract Validation', () => {
const apiRoot = path.join(__dirname, '../..');
const openapiPath = path.join(apiRoot, 'apps/api/openapi.json');
const bootstrapModulePath = path.join(apiRoot, 'apps/api/src/domain/bootstrap/BootstrapModule.ts');
const bootstrapAdaptersPath = path.join(apiRoot, 'adapters/bootstrap');
describe('OpenAPI Spec Integrity for Bootstrap', () => {
it('should NOT have bootstrap endpoints defined in OpenAPI spec', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
// Bootstrap is an internal module, not an API endpoint
// Verify no bootstrap-related paths exist
const bootstrapPaths = Object.keys(spec.paths).filter(p => p.includes('bootstrap'));
expect(bootstrapPaths.length).toBe(0);
});
it('should NOT have bootstrap-related DTOs in OpenAPI spec', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
// Bootstrap module doesn't expose DTOs for API consumption
// It uses internal DTOs for seeding data
const bootstrapSchemas = Object.keys(spec.components.schemas).filter(s =>
s.toLowerCase().includes('bootstrap') ||
s.toLowerCase().includes('seed')
);
expect(bootstrapSchemas.length).toBe(0);
});
});
describe('Bootstrap Module Structure', () => {
it('should have BootstrapModule defined', async () => {
const bootstrapModuleExists = await fs.access(bootstrapModulePath).then(() => true).catch(() => false);
expect(bootstrapModuleExists).toBe(true);
});
it('should have BootstrapModule implement OnModuleInit', async () => {
const bootstrapModuleContent = await fs.readFile(bootstrapModulePath, 'utf-8');
// Verify it implements OnModuleInit lifecycle hook
expect(bootstrapModuleContent).toContain('implements OnModuleInit');
expect(bootstrapModuleContent).toContain('async onModuleInit()');
});
it('should have BootstrapModule with proper dependencies', async () => {
const bootstrapModuleContent = await fs.readFile(bootstrapModulePath, 'utf-8');
// Verify required dependencies are injected
expect(bootstrapModuleContent).toContain('@Inject(ENSURE_INITIAL_DATA_TOKEN)');
expect(bootstrapModuleContent).toContain('@Inject(SEED_DEMO_USERS_TOKEN)');
expect(bootstrapModuleContent).toContain('@Inject(\'Logger\')');
expect(bootstrapModuleContent).toContain('@Inject(\'RacingSeedDependencies\')');
});
it('should have BootstrapModule with proper imports', async () => {
const bootstrapModuleContent = await fs.readFile(bootstrapModulePath, 'utf-8');
// Verify persistence modules are imported
expect(bootstrapModuleContent).toContain('RacingPersistenceModule');
expect(bootstrapModuleContent).toContain('SocialPersistenceModule');
expect(bootstrapModuleContent).toContain('AchievementPersistenceModule');
expect(bootstrapModuleContent).toContain('IdentityPersistenceModule');
expect(bootstrapModuleContent).toContain('AdminPersistenceModule');
});
});
describe('Bootstrap Adapters Structure', () => {
it('should have EnsureInitialData adapter', async () => {
const ensureInitialDataPath = path.join(bootstrapAdaptersPath, 'EnsureInitialData.ts');
const ensureInitialDataExists = await fs.access(ensureInitialDataPath).then(() => true).catch(() => false);
expect(ensureInitialDataExists).toBe(true);
});
it('should have SeedDemoUsers adapter', async () => {
const seedDemoUsersPath = path.join(bootstrapAdaptersPath, 'SeedDemoUsers.ts');
const seedDemoUsersExists = await fs.access(seedDemoUsersPath).then(() => true).catch(() => false);
expect(seedDemoUsersExists).toBe(true);
});
it('should have SeedRacingData adapter', async () => {
const seedRacingDataPath = path.join(bootstrapAdaptersPath, 'SeedRacingData.ts');
const seedRacingDataExists = await fs.access(seedRacingDataPath).then(() => true).catch(() => false);
expect(seedRacingDataExists).toBe(true);
});
it('should have racing seed factories', async () => {
const racingDir = path.join(bootstrapAdaptersPath, 'racing');
const racingDirExists = await fs.access(racingDir).then(() => true).catch(() => false);
expect(racingDirExists).toBe(true);
// Verify key factory files exist
const racingFiles = await fs.readdir(racingDir);
expect(racingFiles).toContain('RacingDriverFactory.ts');
expect(racingFiles).toContain('RacingTeamFactory.ts');
expect(racingFiles).toContain('RacingLeagueFactory.ts');
expect(racingFiles).toContain('RacingRaceFactory.ts');
});
});
describe('Bootstrap Configuration', () => {
it('should have bootstrap configuration in environment', async () => {
const envPath = path.join(apiRoot, 'apps/api/src/env.ts');
const envContent = await fs.readFile(envPath, 'utf-8');
// Verify bootstrap configuration functions exist
expect(envContent).toContain('getEnableBootstrap');
expect(envContent).toContain('getForceReseed');
});
it('should have bootstrap enabled by default', async () => {
const envPath = path.join(apiRoot, 'apps/api/src/env.ts');
const envContent = await fs.readFile(envPath, 'utf-8');
// Verify bootstrap is enabled by default (for dev/test)
expect(envContent).toContain('GRIDPILOT_API_BOOTSTRAP');
expect(envContent).toContain('true'); // Default value
});
});
describe('Bootstrap Initialization Logic', () => {
it('should have proper initialization sequence', async () => {
const bootstrapModuleContent = await fs.readFile(bootstrapModulePath, 'utf-8');
// Verify initialization sequence
expect(bootstrapModuleContent).toContain('await this.ensureInitialData.execute()');
expect(bootstrapModuleContent).toContain('await this.shouldSeedRacingData()');
expect(bootstrapModuleContent).toContain('await this.shouldSeedDemoUsers()');
});
it('should have environment-aware seeding logic', async () => {
const bootstrapModuleContent = await fs.readFile(bootstrapModulePath, 'utf-8');
// Verify environment checks
expect(bootstrapModuleContent).toContain('process.env.NODE_ENV');
expect(bootstrapModuleContent).toContain('production');
expect(bootstrapModuleContent).toContain('inmemory');
expect(bootstrapModuleContent).toContain('postgres');
});
it('should have force reseed capability', async () => {
const bootstrapModuleContent = await fs.readFile(bootstrapModulePath, 'utf-8');
// Verify force reseed logic
expect(bootstrapModuleContent).toContain('getForceReseed()');
expect(bootstrapModuleContent).toContain('Force reseed enabled');
});
});
describe('Bootstrap Data Seeding', () => {
it('should seed initial admin user', async () => {
const ensureInitialDataPath = path.join(bootstrapAdaptersPath, 'EnsureInitialData.ts');
const ensureInitialDataContent = await fs.readFile(ensureInitialDataPath, 'utf-8');
// Verify admin user seeding
expect(ensureInitialDataContent).toContain('admin@gridpilot.local');
expect(ensureInitialDataContent).toContain('Admin');
expect(ensureInitialDataContent).toContain('signupUseCase');
});
it('should seed achievements', async () => {
const ensureInitialDataPath = path.join(bootstrapAdaptersPath, 'EnsureInitialData.ts');
const ensureInitialDataContent = await fs.readFile(ensureInitialDataPath, 'utf-8');
// Verify achievement seeding
expect(ensureInitialDataContent).toContain('DRIVER_ACHIEVEMENTS');
expect(ensureInitialDataContent).toContain('STEWARD_ACHIEVEMENTS');
expect(ensureInitialDataContent).toContain('ADMIN_ACHIEVEMENTS');
expect(ensureInitialDataContent).toContain('COMMUNITY_ACHIEVEMENTS');
expect(ensureInitialDataContent).toContain('createAchievementUseCase');
});
it('should seed demo users', async () => {
const seedDemoUsersPath = path.join(bootstrapAdaptersPath, 'SeedDemoUsers.ts');
const seedDemoUsersContent = await fs.readFile(seedDemoUsersPath, 'utf-8');
// Verify demo user seeding
expect(seedDemoUsersContent).toContain('SeedDemoUsers');
expect(seedDemoUsersContent).toContain('execute');
});
it('should seed racing data', async () => {
const seedRacingDataPath = path.join(bootstrapAdaptersPath, 'SeedRacingData.ts');
const seedRacingDataContent = await fs.readFile(seedRacingDataPath, 'utf-8');
// Verify racing data seeding
expect(seedRacingDataContent).toContain('SeedRacingData');
expect(seedRacingDataContent).toContain('execute');
expect(seedRacingDataContent).toContain('RacingSeedDependencies');
});
});
describe('Bootstrap Providers', () => {
it('should have BootstrapProviders defined', async () => {
const bootstrapProvidersPath = path.join(apiRoot, 'apps/api/src/domain/bootstrap/BootstrapProviders.ts');
const bootstrapProvidersExists = await fs.access(bootstrapProvidersPath).then(() => true).catch(() => false);
expect(bootstrapProvidersExists).toBe(true);
});
it('should have proper provider tokens', async () => {
const bootstrapProvidersContent = await fs.readFile(
path.join(apiRoot, 'apps/api/src/domain/bootstrap/BootstrapProviders.ts'),
'utf-8'
);
// Verify provider tokens are defined
expect(bootstrapProvidersContent).toContain('ENSURE_INITIAL_DATA_TOKEN');
expect(bootstrapProvidersContent).toContain('SEED_DEMO_USERS_TOKEN');
});
});
describe('Bootstrap Module Integration', () => {
it('should be imported in main app module', async () => {
const appModulePath = path.join(apiRoot, 'apps/api/src/app.module.ts');
const appModuleContent = await fs.readFile(appModulePath, 'utf-8');
// Verify BootstrapModule is imported
expect(appModuleContent).toContain('BootstrapModule');
expect(appModuleContent).toContain('./domain/bootstrap/BootstrapModule');
});
it('should be included in app module imports', async () => {
const appModulePath = path.join(apiRoot, 'apps/api/src/app.module.ts');
const appModuleContent = await fs.readFile(appModulePath, 'utf-8');
// Verify BootstrapModule is in imports array
expect(appModuleContent).toMatch(/imports:\s*\[[^\]]*BootstrapModule[^\]]*\]/s);
});
});
describe('Bootstrap Module Tests', () => {
it('should have unit tests for BootstrapModule', async () => {
const bootstrapModuleTestPath = path.join(apiRoot, 'apps/api/src/domain/bootstrap/BootstrapModule.test.ts');
const bootstrapModuleTestExists = await fs.access(bootstrapModuleTestPath).then(() => true).catch(() => false);
expect(bootstrapModuleTestExists).toBe(true);
});
it('should have postgres seed tests', async () => {
const postgresSeedTestPath = path.join(apiRoot, 'apps/api/src/domain/bootstrap/BootstrapModule.postgres-seed.test.ts');
const postgresSeedTestExists = await fs.access(postgresSeedTestPath).then(() => true).catch(() => false);
expect(postgresSeedTestExists).toBe(true);
});
it('should have racing seed tests', async () => {
const racingSeedTestPath = path.join(apiRoot, 'apps/api/src/domain/bootstrap/RacingSeed.test.ts');
const racingSeedTestExists = await fs.access(racingSeedTestPath).then(() => true).catch(() => false);
expect(racingSeedTestExists).toBe(true);
});
});
describe('Bootstrap Module Contract Summary', () => {
it('should document that bootstrap is an internal module', async () => {
const bootstrapModuleContent = await fs.readFile(bootstrapModulePath, 'utf-8');
// Verify bootstrap is documented as internal initialization
expect(bootstrapModuleContent).toContain('Initializing application data');
expect(bootstrapModuleContent).toContain('Bootstrap disabled');
});
it('should have no API client in website app', async () => {
const websiteApiDir = path.join(apiRoot, 'apps/website/lib/api');
const apiFiles = await fs.readdir(websiteApiDir);
// Verify no bootstrap API client exists
const bootstrapFiles = apiFiles.filter(f => f.toLowerCase().includes('bootstrap'));
expect(bootstrapFiles.length).toBe(0);
});
it('should have no bootstrap endpoints in OpenAPI', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
// Verify no bootstrap paths exist
const allPaths = Object.keys(spec.paths);
const bootstrapPaths = allPaths.filter(p => p.toLowerCase().includes('bootstrap'));
expect(bootstrapPaths.length).toBe(0);
});
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,57 +0,0 @@
import { vi } from 'vitest';
import { InMemoryDriverRepository } from '../../../adapters/drivers/persistence/inmemory/InMemoryDriverRepository';
import { InMemoryRaceRepository } from '../../../adapters/races/persistence/inmemory/InMemoryRaceRepository';
import { InMemoryLeagueRepository } from '../../../adapters/leagues/persistence/inmemory/InMemoryLeagueRepository';
import { InMemoryActivityRepository } from '../../../adapters/activity/persistence/inmemory/InMemoryActivityRepository';
import { InMemoryEventPublisher } from '../../../adapters/events/InMemoryEventPublisher';
import { GetDashboardUseCase } from '../../../core/dashboard/application/use-cases/GetDashboardUseCase';
import { DashboardPresenter } from '../../../core/dashboard/application/presenters/DashboardPresenter';
import { DashboardRepository } from '../../../core/dashboard/application/ports/DashboardRepository';
export class DashboardTestContext {
public readonly driverRepository: InMemoryDriverRepository;
public readonly raceRepository: InMemoryRaceRepository;
public readonly leagueRepository: InMemoryLeagueRepository;
public readonly activityRepository: InMemoryActivityRepository;
public readonly eventPublisher: InMemoryEventPublisher;
public readonly getDashboardUseCase: GetDashboardUseCase;
public readonly dashboardPresenter: DashboardPresenter;
public readonly loggerMock: any;
constructor() {
this.driverRepository = new InMemoryDriverRepository();
this.raceRepository = new InMemoryRaceRepository();
this.leagueRepository = new InMemoryLeagueRepository();
this.activityRepository = new InMemoryActivityRepository();
this.eventPublisher = new InMemoryEventPublisher();
this.dashboardPresenter = new DashboardPresenter();
this.loggerMock = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
this.getDashboardUseCase = new GetDashboardUseCase({
driverRepository: this.driverRepository,
raceRepository: this.raceRepository as unknown as DashboardRepository,
leagueRepository: this.leagueRepository as unknown as DashboardRepository,
activityRepository: this.activityRepository as unknown as DashboardRepository,
eventPublisher: this.eventPublisher,
logger: this.loggerMock,
});
}
public clear(): void {
this.driverRepository.clear();
this.raceRepository.clear();
this.leagueRepository.clear();
this.activityRepository.clear();
this.eventPublisher.clear();
vi.clearAllMocks();
}
public static create(): DashboardTestContext {
return new DashboardTestContext();
}
}

View File

@@ -0,0 +1,261 @@
/**
* Integration Test: Dashboard Data Flow
*
* Tests the complete data flow for dashboard functionality:
* 1. Repository queries return correct data
* 2. Use case processes and orchestrates data correctly
* 3. Presenter transforms data to DTOs
* 4. API returns correct response structure
*
* Focus: Data transformation and flow, NOT UI rendering
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { InMemoryDriverRepository } from '../../../adapters/drivers/persistence/inmemory/InMemoryDriverRepository';
import { InMemoryRaceRepository } from '../../../adapters/races/persistence/inmemory/InMemoryRaceRepository';
import { InMemoryLeagueRepository } from '../../../adapters/leagues/persistence/inmemory/InMemoryLeagueRepository';
import { InMemoryActivityRepository } from '../../../adapters/activity/persistence/inmemory/InMemoryActivityRepository';
import { InMemoryEventPublisher } from '../../../adapters/events/InMemoryEventPublisher';
import { GetDashboardUseCase } from '../../../core/dashboard/use-cases/GetDashboardUseCase';
import { DashboardPresenter } from '../../../core/dashboard/presenters/DashboardPresenter';
import { DashboardDTO } from '../../../core/dashboard/dto/DashboardDTO';
describe('Dashboard Data Flow Integration', () => {
let driverRepository: InMemoryDriverRepository;
let raceRepository: InMemoryRaceRepository;
let leagueRepository: InMemoryLeagueRepository;
let activityRepository: InMemoryActivityRepository;
let eventPublisher: InMemoryEventPublisher;
let getDashboardUseCase: GetDashboardUseCase;
let dashboardPresenter: DashboardPresenter;
beforeAll(() => {
// TODO: Initialize In-Memory repositories, event publisher, use case, and presenter
// driverRepository = new InMemoryDriverRepository();
// raceRepository = new InMemoryRaceRepository();
// leagueRepository = new InMemoryLeagueRepository();
// activityRepository = new InMemoryActivityRepository();
// eventPublisher = new InMemoryEventPublisher();
// getDashboardUseCase = new GetDashboardUseCase({
// driverRepository,
// raceRepository,
// leagueRepository,
// activityRepository,
// eventPublisher,
// });
// dashboardPresenter = new DashboardPresenter();
});
beforeEach(() => {
// TODO: Clear all In-Memory repositories before each test
// driverRepository.clear();
// raceRepository.clear();
// leagueRepository.clear();
// activityRepository.clear();
// eventPublisher.clear();
});
describe('Repository to Use Case Data Flow', () => {
it('should correctly flow driver data from repository to use case', async () => {
// TODO: Implement test
// Scenario: Driver data flow
// Given: A driver exists in the repository with specific statistics
// And: The driver has rating 1500, rank 123, 10 starts, 3 wins, 5 podiums
// When: GetDashboardUseCase.execute() is called
// Then: The use case should retrieve driver data from repository
// And: The use case should calculate derived statistics
// And: The result should contain all driver statistics
});
it('should correctly flow race data from repository to use case', async () => {
// TODO: Implement test
// Scenario: Race data flow
// Given: Multiple races exist in the repository
// And: Some races are scheduled for the future
// And: Some races are completed
// When: GetDashboardUseCase.execute() is called
// Then: The use case should retrieve upcoming races from repository
// And: The use case should limit results to 3 races
// And: The use case should sort races by scheduled date
});
it('should correctly flow league data from repository to use case', async () => {
// TODO: Implement test
// Scenario: League data flow
// Given: Multiple leagues exist in the repository
// And: The driver is participating in some leagues
// When: GetDashboardUseCase.execute() is called
// Then: The use case should retrieve league memberships from repository
// And: The use case should calculate standings for each league
// And: The result should contain league name, position, points, and driver count
});
it('should correctly flow activity data from repository to use case', async () => {
// TODO: Implement test
// Scenario: Activity data flow
// Given: Multiple activities exist in the repository
// And: Activities include race results and other events
// When: GetDashboardUseCase.execute() is called
// Then: The use case should retrieve recent activities from repository
// And: The use case should sort activities by timestamp (newest first)
// And: The result should contain activity type, description, and timestamp
});
});
describe('Use Case to Presenter Data Flow', () => {
it('should correctly transform use case result to DTO', async () => {
// TODO: Implement test
// Scenario: Use case result transformation
// Given: A driver exists with complete data
// And: GetDashboardUseCase.execute() returns a DashboardResult
// When: DashboardPresenter.present() is called with the result
// Then: The presenter should transform the result to DashboardDTO
// And: The DTO should have correct structure and types
// And: All fields should be properly formatted
});
it('should correctly handle empty data in DTO transformation', async () => {
// TODO: Implement test
// Scenario: Empty data transformation
// Given: A driver exists with no data
// And: GetDashboardUseCase.execute() returns a DashboardResult with empty sections
// When: DashboardPresenter.present() is called
// Then: The DTO should have empty arrays for sections
// And: The DTO should have default values for statistics
// And: The DTO structure should remain valid
});
it('should correctly format dates and times in DTO', async () => {
// TODO: Implement test
// Scenario: Date formatting in DTO
// Given: A driver exists with upcoming races
// And: Races have scheduled dates in the future
// When: DashboardPresenter.present() is called
// Then: The DTO should have formatted date strings
// And: The DTO should have time-until-race strings
// And: The DTO should have activity timestamps
});
});
describe('Complete Data Flow: Repository -> Use Case -> Presenter', () => {
it('should complete full data flow for driver with all data', async () => {
// TODO: Implement test
// Scenario: Complete data flow
// Given: A driver exists with complete data in repositories
// When: GetDashboardUseCase.execute() is called
// And: DashboardPresenter.present() is called with the result
// Then: The final DTO should contain:
// - Driver statistics (rating, rank, starts, wins, podiums, leagues)
// - Upcoming races (up to 3, sorted by date)
// - Championship standings (league name, position, points, driver count)
// - Recent activity (type, description, timestamp, status)
// And: All data should be correctly transformed and formatted
});
it('should complete full data flow for new driver with no data', async () => {
// TODO: Implement test
// Scenario: Complete data flow for new driver
// Given: A newly registered driver exists with no data
// When: GetDashboardUseCase.execute() is called
// And: DashboardPresenter.present() is called with the result
// Then: The final DTO should contain:
// - Basic driver statistics (rating, rank, starts, wins, podiums, leagues)
// - Empty upcoming races array
// - Empty championship standings array
// - Empty recent activity array
// And: All fields should have appropriate default values
});
it('should maintain data consistency across multiple data flows', async () => {
// TODO: Implement test
// Scenario: Data consistency
// Given: A driver exists with data
// When: GetDashboardUseCase.execute() is called multiple times
// And: DashboardPresenter.present() is called for each result
// Then: All DTOs should be identical
// And: Data should remain consistent across calls
});
});
describe('Data Transformation Edge Cases', () => {
it('should handle driver with maximum upcoming races', async () => {
// TODO: Implement test
// Scenario: Maximum upcoming races
// Given: A driver exists
// And: The driver has 10 upcoming races scheduled
// When: GetDashboardUseCase.execute() is called
// And: DashboardPresenter.present() is called
// Then: The DTO should contain exactly 3 upcoming races
// And: The races should be the 3 earliest scheduled races
});
it('should handle driver with many championship standings', async () => {
// TODO: Implement test
// Scenario: Many championship standings
// Given: A driver exists
// And: The driver is participating in 5 championships
// When: GetDashboardUseCase.execute() is called
// And: DashboardPresenter.present() is called
// Then: The DTO should contain standings for all 5 championships
// And: Each standing should have correct data
});
it('should handle driver with many recent activities', async () => {
// TODO: Implement test
// Scenario: Many recent activities
// Given: A driver exists
// And: The driver has 20 recent activities
// When: GetDashboardUseCase.execute() is called
// And: DashboardPresenter.present() is called
// Then: The DTO should contain all 20 activities
// And: Activities should be sorted by timestamp (newest first)
});
it('should handle driver with mixed race statuses', async () => {
// TODO: Implement test
// Scenario: Mixed race statuses
// Given: A driver exists
// And: The driver has completed races, scheduled races, and cancelled races
// When: GetDashboardUseCase.execute() is called
// And: DashboardPresenter.present() is called
// Then: Driver statistics should only count completed races
// And: Upcoming races should only include scheduled races
// And: Cancelled races should not appear in any section
});
});
describe('DTO Structure Validation', () => {
it('should validate DTO structure for complete dashboard', async () => {
// TODO: Implement test
// Scenario: DTO structure validation
// Given: A driver exists with complete data
// When: GetDashboardUseCase.execute() is called
// And: DashboardPresenter.present() is called
// Then: The DTO should have all required properties
// And: Each property should have correct type
// And: Nested objects should have correct structure
});
it('should validate DTO structure for empty dashboard', async () => {
// TODO: Implement test
// Scenario: Empty DTO structure validation
// Given: A driver exists with no data
// When: GetDashboardUseCase.execute() is called
// And: DashboardPresenter.present() is called
// Then: The DTO should have all required properties
// And: Array properties should be empty arrays
// And: Object properties should have default values
});
it('should validate DTO structure for partial data', async () => {
// TODO: Implement test
// Scenario: Partial DTO structure validation
// Given: A driver exists with some data but not all
// When: GetDashboardUseCase.execute() is called
// And: DashboardPresenter.present() is called
// Then: The DTO should have all required properties
// And: Properties with data should have correct values
// And: Properties without data should have appropriate defaults
});
});
});

View File

@@ -0,0 +1,350 @@
/**
* Integration Test: Dashboard Error Handling
*
* Tests error handling and edge cases at the Use Case level:
* - Repository errors (driver not found, data access errors)
* - Validation errors (invalid driver ID, invalid parameters)
* - Business logic errors (permission denied, data inconsistencies)
*
* Focus: Error orchestration and handling, NOT UI error messages
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { InMemoryDriverRepository } from '../../../adapters/drivers/persistence/inmemory/InMemoryDriverRepository';
import { InMemoryRaceRepository } from '../../../adapters/races/persistence/inmemory/InMemoryRaceRepository';
import { InMemoryLeagueRepository } from '../../../adapters/leagues/persistence/inmemory/InMemoryLeagueRepository';
import { InMemoryActivityRepository } from '../../../adapters/activity/persistence/inmemory/InMemoryActivityRepository';
import { InMemoryEventPublisher } from '../../../adapters/events/InMemoryEventPublisher';
import { GetDashboardUseCase } from '../../../core/dashboard/use-cases/GetDashboardUseCase';
import { DriverNotFoundError } from '../../../core/dashboard/errors/DriverNotFoundError';
import { ValidationError } from '../../../core/shared/errors/ValidationError';
describe('Dashboard Error Handling Integration', () => {
let driverRepository: InMemoryDriverRepository;
let raceRepository: InMemoryRaceRepository;
let leagueRepository: InMemoryLeagueRepository;
let activityRepository: InMemoryActivityRepository;
let eventPublisher: InMemoryEventPublisher;
let getDashboardUseCase: GetDashboardUseCase;
beforeAll(() => {
// TODO: Initialize In-Memory repositories, event publisher, and use case
// driverRepository = new InMemoryDriverRepository();
// raceRepository = new InMemoryRaceRepository();
// leagueRepository = new InMemoryLeagueRepository();
// activityRepository = new InMemoryActivityRepository();
// eventPublisher = new InMemoryEventPublisher();
// getDashboardUseCase = new GetDashboardUseCase({
// driverRepository,
// raceRepository,
// leagueRepository,
// activityRepository,
// eventPublisher,
// });
});
beforeEach(() => {
// TODO: Clear all In-Memory repositories before each test
// driverRepository.clear();
// raceRepository.clear();
// leagueRepository.clear();
// activityRepository.clear();
// eventPublisher.clear();
});
describe('Driver Not Found Errors', () => {
it('should throw DriverNotFoundError when driver does not exist', async () => {
// TODO: Implement test
// Scenario: Non-existent driver
// Given: No driver exists with ID "non-existent-driver-id"
// When: GetDashboardUseCase.execute() is called with "non-existent-driver-id"
// Then: Should throw DriverNotFoundError
// And: Error message should indicate driver not found
// And: EventPublisher should NOT emit any events
});
it('should throw DriverNotFoundError when driver ID is valid but not found', async () => {
// TODO: Implement test
// Scenario: Valid ID but no driver
// Given: A valid UUID format driver ID
// And: No driver exists with that ID
// When: GetDashboardUseCase.execute() is called with the ID
// Then: Should throw DriverNotFoundError
// And: EventPublisher should NOT emit any events
});
it('should not throw error when driver exists', async () => {
// TODO: Implement test
// Scenario: Existing driver
// Given: A driver exists with ID "existing-driver-id"
// When: GetDashboardUseCase.execute() is called with "existing-driver-id"
// Then: Should NOT throw DriverNotFoundError
// And: Should return dashboard data successfully
});
});
describe('Validation Errors', () => {
it('should throw ValidationError when driver ID is empty string', async () => {
// TODO: Implement test
// Scenario: Empty driver ID
// Given: An empty string as driver ID
// When: GetDashboardUseCase.execute() is called with empty string
// Then: Should throw ValidationError
// And: Error should indicate invalid driver ID
// And: EventPublisher should NOT emit any events
});
it('should throw ValidationError when driver ID is null', async () => {
// TODO: Implement test
// Scenario: Null driver ID
// Given: null as driver ID
// When: GetDashboardUseCase.execute() is called with null
// Then: Should throw ValidationError
// And: Error should indicate invalid driver ID
// And: EventPublisher should NOT emit any events
});
it('should throw ValidationError when driver ID is undefined', async () => {
// TODO: Implement test
// Scenario: Undefined driver ID
// Given: undefined as driver ID
// When: GetDashboardUseCase.execute() is called with undefined
// Then: Should throw ValidationError
// And: Error should indicate invalid driver ID
// And: EventPublisher should NOT emit any events
});
it('should throw ValidationError when driver ID is not a string', async () => {
// TODO: Implement test
// Scenario: Invalid type driver ID
// Given: A number as driver ID
// When: GetDashboardUseCase.execute() is called with number
// Then: Should throw ValidationError
// And: Error should indicate invalid driver ID type
// And: EventPublisher should NOT emit any events
});
it('should throw ValidationError when driver ID is malformed', async () => {
// TODO: Implement test
// Scenario: Malformed driver ID
// Given: A malformed string as driver ID (e.g., "invalid-id-format")
// When: GetDashboardUseCase.execute() is called with malformed ID
// Then: Should throw ValidationError
// And: Error should indicate invalid driver ID format
// And: EventPublisher should NOT emit any events
});
});
describe('Repository Error Handling', () => {
it('should handle driver repository query error', async () => {
// TODO: Implement test
// Scenario: Driver repository error
// Given: A driver exists
// And: DriverRepository throws an error during query
// When: GetDashboardUseCase.execute() is called
// Then: Should propagate the error appropriately
// And: EventPublisher should NOT emit any events
});
it('should handle race repository query error', async () => {
// TODO: Implement test
// Scenario: Race repository error
// Given: A driver exists
// And: RaceRepository throws an error during query
// When: GetDashboardUseCase.execute() is called
// Then: Should propagate the error appropriately
// And: EventPublisher should NOT emit any events
});
it('should handle league repository query error', async () => {
// TODO: Implement test
// Scenario: League repository error
// Given: A driver exists
// And: LeagueRepository throws an error during query
// When: GetDashboardUseCase.execute() is called
// Then: Should propagate the error appropriately
// And: EventPublisher should NOT emit any events
});
it('should handle activity repository query error', async () => {
// TODO: Implement test
// Scenario: Activity repository error
// Given: A driver exists
// And: ActivityRepository throws an error during query
// When: GetDashboardUseCase.execute() is called
// Then: Should propagate the error appropriately
// And: EventPublisher should NOT emit any events
});
it('should handle multiple repository errors gracefully', async () => {
// TODO: Implement test
// Scenario: Multiple repository errors
// Given: A driver exists
// And: Multiple repositories throw errors
// When: GetDashboardUseCase.execute() is called
// Then: Should handle errors appropriately
// And: Should not crash the application
// And: EventPublisher should NOT emit any events
});
});
describe('Event Publisher Error Handling', () => {
it('should handle event publisher error gracefully', async () => {
// TODO: Implement test
// Scenario: Event publisher error
// Given: A driver exists with data
// And: EventPublisher throws an error during emit
// When: GetDashboardUseCase.execute() is called
// Then: Should complete the use case execution
// And: Should not propagate the event publisher error
// And: Dashboard data should still be returned
});
it('should not fail when event publisher is unavailable', async () => {
// TODO: Implement test
// Scenario: Event publisher unavailable
// Given: A driver exists with data
// And: EventPublisher is configured to fail
// When: GetDashboardUseCase.execute() is called
// Then: Should complete the use case execution
// And: Dashboard data should still be returned
// And: Should not throw error
});
});
describe('Business Logic Error Handling', () => {
it('should handle driver with corrupted data gracefully', async () => {
// TODO: Implement test
// Scenario: Corrupted driver data
// Given: A driver exists with corrupted/invalid data
// When: GetDashboardUseCase.execute() is called
// Then: Should handle the corrupted data gracefully
// And: Should not crash the application
// And: Should return valid dashboard data where possible
});
it('should handle race data inconsistencies', async () => {
// TODO: Implement test
// Scenario: Race data inconsistencies
// Given: A driver exists
// And: Race data has inconsistencies (e.g., scheduled date in past)
// When: GetDashboardUseCase.execute() is called
// Then: Should handle inconsistencies gracefully
// And: Should filter out invalid races
// And: Should return valid dashboard data
});
it('should handle league data inconsistencies', async () => {
// TODO: Implement test
// Scenario: League data inconsistencies
// Given: A driver exists
// And: League data has inconsistencies (e.g., missing required fields)
// When: GetDashboardUseCase.execute() is called
// Then: Should handle inconsistencies gracefully
// And: Should filter out invalid leagues
// And: Should return valid dashboard data
});
it('should handle activity data inconsistencies', async () => {
// TODO: Implement test
// Scenario: Activity data inconsistencies
// Given: A driver exists
// And: Activity data has inconsistencies (e.g., missing timestamp)
// When: GetDashboardUseCase.execute() is called
// Then: Should handle inconsistencies gracefully
// And: Should filter out invalid activities
// And: Should return valid dashboard data
});
});
describe('Error Recovery and Fallbacks', () => {
it('should return partial data when one repository fails', async () => {
// TODO: Implement test
// Scenario: Partial data recovery
// Given: A driver exists
// And: RaceRepository fails but other repositories succeed
// When: GetDashboardUseCase.execute() is called
// Then: Should return dashboard data with available sections
// And: Should not include failed section
// And: Should not throw error
});
it('should return empty sections when data is unavailable', async () => {
// TODO: Implement test
// Scenario: Empty sections fallback
// Given: A driver exists
// And: All repositories return empty results
// When: GetDashboardUseCase.execute() is called
// Then: Should return dashboard with empty sections
// And: Should include basic driver statistics
// And: Should not throw error
});
it('should handle timeout scenarios gracefully', async () => {
// TODO: Implement test
// Scenario: Timeout handling
// Given: A driver exists
// And: Repository queries take too long
// When: GetDashboardUseCase.execute() is called
// Then: Should handle timeout gracefully
// And: Should not crash the application
// And: Should return appropriate error or timeout response
});
});
describe('Error Propagation', () => {
it('should propagate DriverNotFoundError to caller', async () => {
// TODO: Implement test
// Scenario: Error propagation
// Given: No driver exists
// When: GetDashboardUseCase.execute() is called
// Then: DriverNotFoundError should be thrown
// And: Error should be catchable by caller
// And: Error should have appropriate message
});
it('should propagate ValidationError to caller', async () => {
// TODO: Implement test
// Scenario: Validation error propagation
// Given: Invalid driver ID
// When: GetDashboardUseCase.execute() is called
// Then: ValidationError should be thrown
// And: Error should be catchable by caller
// And: Error should have appropriate message
});
it('should propagate repository errors to caller', async () => {
// TODO: Implement test
// Scenario: Repository error propagation
// Given: A driver exists
// And: Repository throws error
// When: GetDashboardUseCase.execute() is called
// Then: Repository error should be propagated
// And: Error should be catchable by caller
});
});
describe('Error Logging and Observability', () => {
it('should log errors appropriately', async () => {
// TODO: Implement test
// Scenario: Error logging
// Given: A driver exists
// And: An error occurs during execution
// When: GetDashboardUseCase.execute() is called
// Then: Error should be logged appropriately
// And: Log should include error details
// And: Log should include context information
});
it('should include context in error messages', async () => {
// TODO: Implement test
// Scenario: Error context
// Given: A driver exists
// And: An error occurs during execution
// When: GetDashboardUseCase.execute() is called
// Then: Error message should include driver ID
// And: Error message should include operation details
// And: Error message should be informative
});
});
});

View File

@@ -0,0 +1,270 @@
/**
* Integration Test: Dashboard Use Case Orchestration
*
* Tests the orchestration logic of dashboard-related Use Cases:
* - GetDashboardUseCase: Retrieves driver statistics, upcoming races, standings, and activity
* - 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 { InMemoryDriverRepository } from '../../../adapters/drivers/persistence/inmemory/InMemoryDriverRepository';
import { InMemoryRaceRepository } from '../../../adapters/races/persistence/inmemory/InMemoryRaceRepository';
import { InMemoryLeagueRepository } from '../../../adapters/leagues/persistence/inmemory/InMemoryLeagueRepository';
import { InMemoryActivityRepository } from '../../../adapters/activity/persistence/inmemory/InMemoryActivityRepository';
import { InMemoryEventPublisher } from '../../../adapters/events/InMemoryEventPublisher';
import { GetDashboardUseCase } from '../../../core/dashboard/use-cases/GetDashboardUseCase';
import { DashboardQuery } from '../../../core/dashboard/ports/DashboardQuery';
describe('Dashboard Use Case Orchestration', () => {
let driverRepository: InMemoryDriverRepository;
let raceRepository: InMemoryRaceRepository;
let leagueRepository: InMemoryLeagueRepository;
let activityRepository: InMemoryActivityRepository;
let eventPublisher: InMemoryEventPublisher;
let getDashboardUseCase: GetDashboardUseCase;
beforeAll(() => {
// TODO: Initialize In-Memory repositories and event publisher
// driverRepository = new InMemoryDriverRepository();
// raceRepository = new InMemoryRaceRepository();
// leagueRepository = new InMemoryLeagueRepository();
// activityRepository = new InMemoryActivityRepository();
// eventPublisher = new InMemoryEventPublisher();
// getDashboardUseCase = new GetDashboardUseCase({
// driverRepository,
// raceRepository,
// leagueRepository,
// activityRepository,
// eventPublisher,
// });
});
beforeEach(() => {
// TODO: Clear all In-Memory repositories before each test
// driverRepository.clear();
// raceRepository.clear();
// leagueRepository.clear();
// activityRepository.clear();
// eventPublisher.clear();
});
describe('GetDashboardUseCase - Success Path', () => {
it('should retrieve complete dashboard data for a driver with all data', async () => {
// TODO: Implement test
// Scenario: Driver with complete data
// Given: A driver exists with statistics (rating, rank, starts, wins, podiums)
// And: The driver has upcoming races scheduled
// And: The driver is participating in active championships
// And: The driver has recent activity (race results, events)
// When: GetDashboardUseCase.execute() is called with driver ID
// Then: The result should contain all dashboard sections
// And: Driver statistics should be correctly calculated
// And: Upcoming races should be limited to 3
// And: Championship standings should include league info
// And: Recent activity should be sorted by timestamp
// And: EventPublisher should emit DashboardAccessedEvent
});
it('should retrieve dashboard data for a new driver with no history', async () => {
// TODO: Implement test
// Scenario: New driver with minimal data
// Given: A newly registered driver exists
// And: The driver has no race history
// And: The driver has no upcoming races
// And: The driver is not in any championships
// And: The driver has no recent activity
// When: GetDashboardUseCase.execute() is called with driver ID
// Then: The result should contain basic driver statistics
// And: Upcoming races section should be empty
// And: Championship standings section should be empty
// And: Recent activity section should be empty
// And: EventPublisher should emit DashboardAccessedEvent
});
it('should retrieve dashboard data with upcoming races limited to 3', async () => {
// TODO: Implement test
// Scenario: Driver with many upcoming races
// Given: A driver exists
// And: The driver has 5 upcoming races scheduled
// When: GetDashboardUseCase.execute() is called with driver ID
// Then: The result should contain only 3 upcoming races
// And: The races should be sorted by scheduled date (earliest first)
// And: EventPublisher should emit DashboardAccessedEvent
});
it('should retrieve dashboard data with championship standings for multiple leagues', async () => {
// TODO: Implement test
// Scenario: Driver in multiple championships
// Given: A driver exists
// And: The driver is participating in 3 active championships
// When: GetDashboardUseCase.execute() is called with driver ID
// Then: The result should contain standings for all 3 leagues
// And: Each league should show position, points, and total drivers
// And: EventPublisher should emit DashboardAccessedEvent
});
it('should retrieve dashboard data with recent activity sorted by timestamp', async () => {
// TODO: Implement test
// Scenario: Driver with multiple recent activities
// Given: A driver exists
// And: The driver has 5 recent activities (race results, events)
// When: GetDashboardUseCase.execute() is called with driver ID
// Then: The result should contain all activities
// And: Activities should be sorted by timestamp (newest first)
// And: EventPublisher should emit DashboardAccessedEvent
});
});
describe('GetDashboardUseCase - Edge Cases', () => {
it('should handle driver with no upcoming races but has completed races', async () => {
// TODO: Implement test
// Scenario: Driver with completed races but no upcoming races
// Given: A driver exists
// And: The driver has completed races in the past
// And: The driver has no upcoming races scheduled
// When: GetDashboardUseCase.execute() is called with driver ID
// Then: The result should contain driver statistics from completed races
// And: Upcoming races section should be empty
// And: EventPublisher should emit DashboardAccessedEvent
});
it('should handle driver with upcoming races but no completed races', async () => {
// TODO: Implement test
// Scenario: Driver with upcoming races but no completed races
// Given: A driver exists
// And: The driver has upcoming races scheduled
// And: The driver has no completed races
// When: GetDashboardUseCase.execute() is called with driver ID
// Then: The result should contain upcoming races
// And: Driver statistics should show zeros for wins, podiums, etc.
// And: EventPublisher should emit DashboardAccessedEvent
});
it('should handle driver with championship standings but no recent activity', async () => {
// TODO: Implement test
// Scenario: Driver in championships but no recent activity
// Given: A driver exists
// And: The driver is participating in active championships
// And: The driver has no recent activity
// When: GetDashboardUseCase.execute() is called with driver ID
// Then: The result should contain championship standings
// And: Recent activity section should be empty
// And: EventPublisher should emit DashboardAccessedEvent
});
it('should handle driver with recent activity but no championship standings', async () => {
// TODO: Implement test
// Scenario: Driver with recent activity but not in championships
// Given: A driver exists
// And: The driver has recent activity
// And: The driver is not participating in any championships
// When: GetDashboardUseCase.execute() is called with driver ID
// Then: The result should contain recent activity
// And: Championship standings section should be empty
// And: EventPublisher should emit DashboardAccessedEvent
});
it('should handle driver with no data at all', async () => {
// TODO: Implement test
// Scenario: Driver with absolutely no data
// Given: A driver exists
// And: The driver has no statistics
// And: The driver has no upcoming races
// And: The driver has no championship standings
// And: The driver has no recent activity
// When: GetDashboardUseCase.execute() is called with driver ID
// Then: The result should contain basic driver info
// And: All sections should be empty or show default values
// And: EventPublisher should emit DashboardAccessedEvent
});
});
describe('GetDashboardUseCase - Error Handling', () => {
it('should throw error when driver does not exist', async () => {
// TODO: Implement test
// Scenario: Non-existent driver
// Given: No driver exists with the given ID
// When: GetDashboardUseCase.execute() is called with non-existent driver ID
// Then: Should throw DriverNotFoundError
// And: EventPublisher should NOT emit any events
});
it('should throw error when driver ID is invalid', async () => {
// TODO: Implement test
// Scenario: Invalid driver ID
// Given: An invalid driver ID (e.g., empty string, null, undefined)
// When: GetDashboardUseCase.execute() is called with invalid driver 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 driver exists
// And: DriverRepository throws an error during query
// When: GetDashboardUseCase.execute() is called
// Then: Should propagate the error appropriately
// And: EventPublisher should NOT emit any events
});
});
describe('Dashboard Data Orchestration', () => {
it('should correctly calculate driver statistics from race results', async () => {
// TODO: Implement test
// Scenario: Driver statistics calculation
// Given: A driver exists
// And: The driver has 10 completed races
// And: The driver has 3 wins
// And: The driver has 5 podiums
// When: GetDashboardUseCase.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 upcoming race time information', async () => {
// TODO: Implement test
// Scenario: Upcoming race time formatting
// Given: A driver exists
// And: The driver has an upcoming race scheduled in 2 days 4 hours
// When: GetDashboardUseCase.execute() is called
// Then: The upcoming race should include:
// - Track name
// - Car type
// - Scheduled date and time
// - Time until race (formatted as "2 days 4 hours")
});
it('should correctly aggregate championship standings across leagues', async () => {
// TODO: Implement test
// Scenario: Championship standings aggregation
// Given: A driver exists
// And: The 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: GetDashboardUseCase.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 recent activity with proper status', async () => {
// TODO: Implement test
// Scenario: Recent activity formatting
// Given: A driver exists
// And: The driver has a race result (finished 3rd)
// And: The driver has a league invitation event
// When: GetDashboardUseCase.execute() is called
// Then: Recent activity should show:
// - Race result: Type "race_result", Status "success", Description "Finished 3rd at Monza"
// - League invitation: Type "league_invitation", Status "info", Description "Invited to League XYZ"
});
});
});

View File

@@ -1,71 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { DashboardTestContext } from '../DashboardTestContext';
describe('Dashboard Data Flow Integration', () => {
const context = DashboardTestContext.create();
beforeEach(() => {
context.clear();
});
describe('Repository to Use Case Data Flow', () => {
it('should correctly flow driver data from repository to use case', async () => {
const driverId = 'driver-flow';
context.driverRepository.addDriver({
id: driverId,
name: 'Flow Driver',
rating: 1500,
rank: 123,
starts: 10,
wins: 3,
podiums: 5,
leagues: 1,
});
const result = await context.getDashboardUseCase.execute({ driverId });
expect(result.driver.id).toBe(driverId);
expect(result.driver.name).toBe('Flow Driver');
expect(result.statistics.rating).toBe(1500);
expect(result.statistics.rank).toBe(123);
expect(result.statistics.starts).toBe(10);
expect(result.statistics.wins).toBe(3);
expect(result.statistics.podiums).toBe(5);
});
});
describe('Complete Data Flow: Repository -> Use Case -> Presenter', () => {
it('should complete full data flow for driver with all data', async () => {
const driverId = 'driver-complete-flow';
context.driverRepository.addDriver({
id: driverId,
name: 'Complete Flow Driver',
avatar: 'https://example.com/avatar.jpg',
rating: 1600,
rank: 85,
starts: 25,
wins: 8,
podiums: 15,
leagues: 2,
});
context.raceRepository.addUpcomingRaces(driverId, [
{
id: 'race-1',
trackName: 'Monza',
carType: 'GT3',
scheduledDate: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000),
},
]);
const result = await context.getDashboardUseCase.execute({ driverId });
const dto = context.dashboardPresenter.present(result);
expect(dto.driver.id).toBe(driverId);
expect(dto.driver.name).toBe('Complete Flow Driver');
expect(dto.statistics.rating).toBe(1600);
expect(dto.upcomingRaces).toHaveLength(1);
expect(dto.upcomingRaces[0].trackName).toBe('Monza');
});
});
});

View File

@@ -1,77 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { DashboardTestContext } from '../DashboardTestContext';
import { DriverNotFoundError } from '../../../../core/dashboard/domain/errors/DriverNotFoundError';
import { ValidationError } from '../../../../core/shared/errors/ValidationError';
describe('Dashboard Error Handling Integration', () => {
const context = DashboardTestContext.create();
beforeEach(() => {
context.clear();
});
describe('Driver Not Found Errors', () => {
it('should throw DriverNotFoundError when driver does not exist', async () => {
const driverId = 'non-existent-driver-id';
await expect(context.getDashboardUseCase.execute({ driverId }))
.rejects.toThrow(DriverNotFoundError);
expect(context.eventPublisher.getDashboardAccessedEventCount()).toBe(0);
});
});
describe('Validation Errors', () => {
it('should throw ValidationError when driver ID is empty string', async () => {
const driverId = '';
await expect(context.getDashboardUseCase.execute({ driverId }))
.rejects.toThrow(ValidationError);
expect(context.eventPublisher.getDashboardAccessedEventCount()).toBe(0);
});
});
describe('Repository Error Handling', () => {
it('should handle driver repository query error', async () => {
const driverId = 'driver-repo-error';
const spy = vi.spyOn(context.driverRepository, 'findDriverById').mockRejectedValue(new Error('Driver repo failed'));
await expect(context.getDashboardUseCase.execute({ driverId }))
.rejects.toThrow('Driver repo failed');
expect(context.eventPublisher.getDashboardAccessedEventCount()).toBe(0);
spy.mockRestore();
});
});
describe('Event Publisher Error Handling', () => {
it('should handle event publisher error gracefully', async () => {
const driverId = 'driver-pub-error';
context.driverRepository.addDriver({
id: driverId,
name: 'Pub Error Driver',
rating: 1000,
rank: 1,
starts: 0,
wins: 0,
podiums: 0,
leagues: 0,
});
const spy = vi.spyOn(context.eventPublisher, 'publishDashboardAccessed').mockRejectedValue(new Error('Publisher failed'));
const result = await context.getDashboardUseCase.execute({ driverId });
expect(result).toBeDefined();
expect(result.driver.id).toBe(driverId);
expect(context.loggerMock.error).toHaveBeenCalledWith(
'Failed to publish dashboard accessed event',
expect.any(Error),
expect.objectContaining({ driverId })
);
spy.mockRestore();
});
});
});

Some files were not shown because too many files have changed in this diff Show More