Compare commits
12 Commits
tests/cont
...
9bb6b228f1
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bb6b228f1 | |||
| 95276df5af | |||
| 34eae53184 | |||
| a00ca4edfd | |||
| 6df38a462a | |||
| a0f41f242f | |||
| eaf51712a7 | |||
| 853ec7b0ce | |||
| 2fba80da57 | |||
| cf7a551117 | |||
| 5612df2e33 | |||
| 597bb48248 |
186
.github/workflows/ci.yml
vendored
Normal file
186
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
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
110
.github/workflows/contract-testing.yml
vendored
@@ -1,110 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1 +1 @@
|
|||||||
npm test
|
npx lint-staged
|
||||||
24
README.md
24
README.md
@@ -64,12 +64,28 @@ Individual applications support hot reload and watch mode during development:
|
|||||||
GridPilot follows strict BDD (Behavior-Driven Development) with comprehensive test coverage.
|
GridPilot follows strict BDD (Behavior-Driven Development) with comprehensive test coverage.
|
||||||
|
|
||||||
### Local Verification Pipeline
|
### Local Verification Pipeline
|
||||||
Run this sequence before pushing to ensure correctness:
|
|
||||||
```bash
|
GridPilot uses **lint-staged** to automatically validate only changed files on commit:
|
||||||
npm run lint && npm run typecheck && npm run test:unit && npm run test:integration
|
|
||||||
```
|
- `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.
|
||||||
|
|
||||||
### Individual Commands
|
### Individual Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run all tests
|
# Run all tests
|
||||||
npm test
|
npm test
|
||||||
|
|||||||
@@ -3,10 +3,23 @@ import {
|
|||||||
DashboardAccessedEvent,
|
DashboardAccessedEvent,
|
||||||
DashboardErrorEvent,
|
DashboardErrorEvent,
|
||||||
} from '../../core/dashboard/application/ports/DashboardEventPublisher';
|
} from '../../core/dashboard/application/ports/DashboardEventPublisher';
|
||||||
|
import {
|
||||||
|
LeagueEventPublisher,
|
||||||
|
LeagueCreatedEvent,
|
||||||
|
LeagueUpdatedEvent,
|
||||||
|
LeagueDeletedEvent,
|
||||||
|
LeagueAccessedEvent,
|
||||||
|
LeagueRosterAccessedEvent,
|
||||||
|
} from '../../core/leagues/application/ports/LeagueEventPublisher';
|
||||||
|
|
||||||
export class InMemoryEventPublisher implements DashboardEventPublisher {
|
export class InMemoryEventPublisher implements DashboardEventPublisher, LeagueEventPublisher {
|
||||||
private dashboardAccessedEvents: DashboardAccessedEvent[] = [];
|
private dashboardAccessedEvents: DashboardAccessedEvent[] = [];
|
||||||
private dashboardErrorEvents: DashboardErrorEvent[] = [];
|
private dashboardErrorEvents: DashboardErrorEvent[] = [];
|
||||||
|
private leagueCreatedEvents: LeagueCreatedEvent[] = [];
|
||||||
|
private leagueUpdatedEvents: LeagueUpdatedEvent[] = [];
|
||||||
|
private leagueDeletedEvents: LeagueDeletedEvent[] = [];
|
||||||
|
private leagueAccessedEvents: LeagueAccessedEvent[] = [];
|
||||||
|
private leagueRosterAccessedEvents: LeagueRosterAccessedEvent[] = [];
|
||||||
private shouldFail: boolean = false;
|
private shouldFail: boolean = false;
|
||||||
|
|
||||||
async publishDashboardAccessed(event: DashboardAccessedEvent): Promise<void> {
|
async publishDashboardAccessed(event: DashboardAccessedEvent): Promise<void> {
|
||||||
@@ -19,6 +32,31 @@ export class InMemoryEventPublisher implements DashboardEventPublisher {
|
|||||||
this.dashboardErrorEvents.push(event);
|
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 {
|
getDashboardAccessedEventCount(): number {
|
||||||
return this.dashboardAccessedEvents.length;
|
return this.dashboardAccessedEvents.length;
|
||||||
}
|
}
|
||||||
@@ -27,9 +65,42 @@ export class InMemoryEventPublisher implements DashboardEventPublisher {
|
|||||||
return this.dashboardErrorEvents.length;
|
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 {
|
clear(): void {
|
||||||
this.dashboardAccessedEvents = [];
|
this.dashboardAccessedEvents = [];
|
||||||
this.dashboardErrorEvents = [];
|
this.dashboardErrorEvents = [];
|
||||||
|
this.leagueCreatedEvents = [];
|
||||||
|
this.leagueUpdatedEvents = [];
|
||||||
|
this.leagueDeletedEvents = [];
|
||||||
|
this.leagueAccessedEvents = [];
|
||||||
|
this.leagueRosterAccessedEvents = [];
|
||||||
this.shouldFail = false;
|
this.shouldFail = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
175
adapters/events/InMemoryHealthEventPublisher.ts
Normal file
175
adapters/events/InMemoryHealthEventPublisher.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
84
adapters/leagues/events/InMemoryLeagueEventPublisher.ts
Normal file
84
adapters/leagues/events/InMemoryLeagueEventPublisher.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,64 +1,364 @@
|
|||||||
import {
|
import {
|
||||||
DashboardRepository,
|
LeagueRepository,
|
||||||
DriverData,
|
LeagueData,
|
||||||
RaceData,
|
LeagueStats,
|
||||||
LeagueStandingData,
|
LeagueFinancials,
|
||||||
ActivityData,
|
LeagueStewardingMetrics,
|
||||||
FriendData,
|
LeaguePerformanceMetrics,
|
||||||
} from '../../../../core/dashboard/application/ports/DashboardRepository';
|
LeagueRatingMetrics,
|
||||||
|
LeagueTrendMetrics,
|
||||||
|
LeagueSuccessRateMetrics,
|
||||||
|
LeagueResolutionTimeMetrics,
|
||||||
|
LeagueComplexSuccessRateMetrics,
|
||||||
|
LeagueComplexResolutionTimeMetrics,
|
||||||
|
LeagueMember,
|
||||||
|
LeaguePendingRequest,
|
||||||
|
} from '../../../../core/leagues/application/ports/LeagueRepository';
|
||||||
|
import { LeagueStandingData } from '../../../../core/dashboard/application/ports/DashboardRepository';
|
||||||
|
|
||||||
export class InMemoryLeagueRepository implements DashboardRepository {
|
export class InMemoryLeagueRepository implements LeagueRepository {
|
||||||
private drivers: Map<string, DriverData> = new Map();
|
private leagues: Map<string, LeagueData> = new Map();
|
||||||
private upcomingRaces: Map<string, RaceData[]> = 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();
|
||||||
private leagueStandings: Map<string, LeagueStandingData[]> = new Map();
|
private leagueStandings: Map<string, LeagueStandingData[]> = new Map();
|
||||||
private recentActivity: Map<string, ActivityData[]> = new Map();
|
private leagueMembers: Map<string, LeagueMember[]> = new Map();
|
||||||
private friends: Map<string, FriendData[]> = new Map();
|
private leaguePendingRequests: Map<string, LeaguePendingRequest[]> = new Map();
|
||||||
|
|
||||||
async findDriverById(driverId: string): Promise<DriverData | null> {
|
async create(league: LeagueData): Promise<LeagueData> {
|
||||||
return this.drivers.get(driverId) || null;
|
this.leagues.set(league.id, league);
|
||||||
|
return league;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUpcomingRaces(driverId: string): Promise<RaceData[]> {
|
async findById(id: string): Promise<LeagueData | null> {
|
||||||
return this.upcomingRaces.get(driverId) || [];
|
return this.leagues.get(id) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLeagueStandings(driverId: string): Promise<LeagueStandingData[]> {
|
async findByName(name: string): Promise<LeagueData | null> {
|
||||||
return this.leagueStandings.get(driverId) || [];
|
for (const league of Array.from(this.leagues.values())) {
|
||||||
|
if (league.name === name) {
|
||||||
|
return league;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRecentActivity(driverId: string): Promise<ActivityData[]> {
|
async findByOwner(ownerId: string): Promise<LeagueData[]> {
|
||||||
return this.recentActivity.get(driverId) || [];
|
const leagues: LeagueData[] = [];
|
||||||
|
for (const league of Array.from(this.leagues.values())) {
|
||||||
|
if (league.ownerId === ownerId) {
|
||||||
|
leagues.push(league);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return leagues;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFriends(driverId: string): Promise<FriendData[]> {
|
async search(query: string): Promise<LeagueData[]> {
|
||||||
return this.friends.get(driverId) || [];
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
addDriver(driver: DriverData): void {
|
async update(id: string, updates: Partial<LeagueData>): Promise<LeagueData> {
|
||||||
this.drivers.set(driver.id, driver);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
addUpcomingRaces(driverId: string, races: RaceData[]): void {
|
async delete(id: string): Promise<void> {
|
||||||
this.upcomingRaces.set(driverId, races);
|
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 {
|
addLeagueStandings(driverId: string, standings: LeagueStandingData[]): void {
|
||||||
this.leagueStandings.set(driverId, standings);
|
this.leagueStandings.set(driverId, standings);
|
||||||
}
|
}
|
||||||
|
|
||||||
addRecentActivity(driverId: string, activities: ActivityData[]): void {
|
async getLeagueStandings(driverId: string): Promise<LeagueStandingData[]> {
|
||||||
this.recentActivity.set(driverId, activities);
|
return this.leagueStandings.get(driverId) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
addFriends(driverId: string, friends: FriendData[]): void {
|
async addLeagueMembers(leagueId: string, members: LeagueMember[]): Promise<void> {
|
||||||
this.friends.set(driverId, friends);
|
const current = this.leagueMembers.get(leagueId) || [];
|
||||||
|
this.leagueMembers.set(leagueId, [...current, ...members]);
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
async getLeagueMembers(leagueId: string): Promise<LeagueMember[]> {
|
||||||
this.drivers.clear();
|
return this.leagueMembers.get(leagueId) || [];
|
||||||
this.upcomingRaces.clear();
|
}
|
||||||
this.leagueStandings.clear();
|
|
||||||
this.recentActivity.clear();
|
async updateLeagueMember(leagueId: string, driverId: string, updates: Partial<LeagueMember>): Promise<void> {
|
||||||
this.friends.clear();
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeLeagueMember(leagueId: string, driverId: string): Promise<void> {
|
||||||
|
const members = this.leagueMembers.get(leagueId) || [];
|
||||||
|
this.leagueMembers.set(leagueId, members.filter(m => m.driverId !== driverId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async addPendingRequests(leagueId: string, requests: LeaguePendingRequest[]): Promise<void> {
|
||||||
|
const current = this.leaguePendingRequests.get(leagueId) || [];
|
||||||
|
this.leaguePendingRequests.set(leagueId, [...current, ...requests]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPendingRequests(leagueId: string): Promise<LeaguePendingRequest[]> {
|
||||||
|
return this.leaguePendingRequests.get(leagueId) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePendingRequest(leagueId: string, requestId: string): Promise<void> {
|
||||||
|
const current = this.leaguePendingRequests.get(leagueId) || [];
|
||||||
|
this.leaguePendingRequests.set(leagueId, current.filter(r => r.id !== requestId));
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
93
adapters/media/events/InMemoryMediaEventPublisher.ts
Normal file
93
adapters/media/events/InMemoryMediaEventPublisher.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,12 @@ export class InMemoryAvatarGenerationRepository implements AvatarGenerationRepos
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.requests.clear();
|
||||||
|
this.userRequests.clear();
|
||||||
|
this.logger.info('InMemoryAvatarGenerationRepository cleared.');
|
||||||
|
}
|
||||||
|
|
||||||
async save(request: AvatarGenerationRequest): Promise<void> {
|
async save(request: AvatarGenerationRequest): Promise<void> {
|
||||||
this.logger.debug(`[InMemoryAvatarGenerationRepository] Saving avatar generation request: ${request.id} for user ${request.userId}.`);
|
this.logger.debug(`[InMemoryAvatarGenerationRepository] Saving avatar generation request: ${request.id} for user ${request.userId}.`);
|
||||||
this.requests.set(request.id, request);
|
this.requests.set(request.id, request);
|
||||||
|
|||||||
121
adapters/media/persistence/inmemory/InMemoryAvatarRepository.ts
Normal file
121
adapters/media/persistence/inmemory/InMemoryAvatarRepository.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
106
adapters/media/persistence/inmemory/InMemoryMediaRepository.ts
Normal file
106
adapters/media/persistence/inmemory/InMemoryMediaRepository.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
adapters/media/ports/InMemoryAvatarGenerationAdapter.ts
Normal file
22
adapters/media/ports/InMemoryAvatarGenerationAdapter.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
109
adapters/media/ports/InMemoryMediaStorageAdapter.ts
Normal file
109
adapters/media/ports/InMemoryMediaStorageAdapter.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,34 +6,33 @@ import type { Payment, PaymentType } from '@core/payments/domain/entities/Paymen
|
|||||||
import type { PaymentRepository } from '@core/payments/domain/repositories/PaymentRepository';
|
import type { PaymentRepository } from '@core/payments/domain/repositories/PaymentRepository';
|
||||||
import type { Logger } from '@core/shared/domain/Logger';
|
import type { Logger } from '@core/shared/domain/Logger';
|
||||||
|
|
||||||
const payments: Map<string, Payment> = new Map();
|
|
||||||
|
|
||||||
export class InMemoryPaymentRepository implements PaymentRepository {
|
export class InMemoryPaymentRepository implements PaymentRepository {
|
||||||
|
private payments: Map<string, Payment> = new Map();
|
||||||
constructor(private readonly logger: Logger) {}
|
constructor(private readonly logger: Logger) {}
|
||||||
|
|
||||||
async findById(id: string): Promise<Payment | null> {
|
async findById(id: string): Promise<Payment | null> {
|
||||||
this.logger.debug('[InMemoryPaymentRepository] findById', { id });
|
this.logger.debug('[InMemoryPaymentRepository] findById', { id });
|
||||||
return payments.get(id) || null;
|
return this.payments.get(id) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByLeagueId(leagueId: string): Promise<Payment[]> {
|
async findByLeagueId(leagueId: string): Promise<Payment[]> {
|
||||||
this.logger.debug('[InMemoryPaymentRepository] findByLeagueId', { leagueId });
|
this.logger.debug('[InMemoryPaymentRepository] findByLeagueId', { leagueId });
|
||||||
return Array.from(payments.values()).filter(p => p.leagueId === leagueId);
|
return Array.from(this.payments.values()).filter(p => p.leagueId === leagueId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByPayerId(payerId: string): Promise<Payment[]> {
|
async findByPayerId(payerId: string): Promise<Payment[]> {
|
||||||
this.logger.debug('[InMemoryPaymentRepository] findByPayerId', { payerId });
|
this.logger.debug('[InMemoryPaymentRepository] findByPayerId', { payerId });
|
||||||
return Array.from(payments.values()).filter(p => p.payerId === payerId);
|
return Array.from(this.payments.values()).filter(p => p.payerId === payerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByType(type: PaymentType): Promise<Payment[]> {
|
async findByType(type: PaymentType): Promise<Payment[]> {
|
||||||
this.logger.debug('[InMemoryPaymentRepository] findByType', { type });
|
this.logger.debug('[InMemoryPaymentRepository] findByType', { type });
|
||||||
return Array.from(payments.values()).filter(p => p.type === type);
|
return Array.from(this.payments.values()).filter(p => p.type === type);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByFilters(filters: { leagueId?: string; payerId?: string; type?: PaymentType }): Promise<Payment[]> {
|
async findByFilters(filters: { leagueId?: string; payerId?: string; type?: PaymentType }): Promise<Payment[]> {
|
||||||
this.logger.debug('[InMemoryPaymentRepository] findByFilters', { filters });
|
this.logger.debug('[InMemoryPaymentRepository] findByFilters', { filters });
|
||||||
let results = Array.from(payments.values());
|
let results = Array.from(this.payments.values());
|
||||||
|
|
||||||
if (filters.leagueId) {
|
if (filters.leagueId) {
|
||||||
results = results.filter(p => p.leagueId === filters.leagueId);
|
results = results.filter(p => p.leagueId === filters.leagueId);
|
||||||
@@ -50,13 +49,17 @@ export class InMemoryPaymentRepository implements PaymentRepository {
|
|||||||
|
|
||||||
async create(payment: Payment): Promise<Payment> {
|
async create(payment: Payment): Promise<Payment> {
|
||||||
this.logger.debug('[InMemoryPaymentRepository] create', { payment });
|
this.logger.debug('[InMemoryPaymentRepository] create', { payment });
|
||||||
payments.set(payment.id, payment);
|
this.payments.set(payment.id, payment);
|
||||||
return payment;
|
return payment;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(payment: Payment): Promise<Payment> {
|
async update(payment: Payment): Promise<Payment> {
|
||||||
this.logger.debug('[InMemoryPaymentRepository] update', { payment });
|
this.logger.debug('[InMemoryPaymentRepository] update', { payment });
|
||||||
payments.set(payment.id, payment);
|
this.payments.set(payment.id, payment);
|
||||||
return payment;
|
return payment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.payments.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,52 +5,86 @@
|
|||||||
import type { Transaction, Wallet } from '@core/payments/domain/entities/Wallet';
|
import type { Transaction, Wallet } from '@core/payments/domain/entities/Wallet';
|
||||||
import type { WalletRepository, TransactionRepository } from '@core/payments/domain/repositories/WalletRepository';
|
import type { WalletRepository, TransactionRepository } from '@core/payments/domain/repositories/WalletRepository';
|
||||||
import type { Logger } from '@core/shared/domain/Logger';
|
import type { Logger } from '@core/shared/domain/Logger';
|
||||||
|
import type { LeagueWalletRepository } from '@core/racing/domain/repositories/LeagueWalletRepository';
|
||||||
|
|
||||||
const wallets: Map<string, Wallet> = new Map();
|
const wallets: Map<string, any> = new Map();
|
||||||
const transactions: Map<string, Transaction> = new Map();
|
const transactions: Map<string, any> = new Map();
|
||||||
|
|
||||||
export class InMemoryWalletRepository implements WalletRepository {
|
export class InMemoryWalletRepository implements WalletRepository, LeagueWalletRepository {
|
||||||
constructor(private readonly logger: Logger) {}
|
constructor(private readonly logger: Logger) {}
|
||||||
|
|
||||||
async findById(id: string): Promise<Wallet | null> {
|
async findById(id: string): Promise<any | null> {
|
||||||
this.logger.debug('[InMemoryWalletRepository] findById', { id });
|
this.logger.debug('[InMemoryWalletRepository] findById', { id });
|
||||||
return wallets.get(id) || null;
|
return wallets.get(id) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByLeagueId(leagueId: string): Promise<Wallet | null> {
|
async findByLeagueId(leagueId: string): Promise<any | null> {
|
||||||
this.logger.debug('[InMemoryWalletRepository] findByLeagueId', { leagueId });
|
this.logger.debug('[InMemoryWalletRepository] findByLeagueId', { leagueId });
|
||||||
return Array.from(wallets.values()).find(w => w.leagueId === leagueId) || null;
|
return Array.from(wallets.values()).find(w => w.leagueId.toString() === leagueId) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(wallet: Wallet): Promise<Wallet> {
|
async create(wallet: any): Promise<any> {
|
||||||
this.logger.debug('[InMemoryWalletRepository] create', { wallet });
|
this.logger.debug('[InMemoryWalletRepository] create', { wallet });
|
||||||
wallets.set(wallet.id, wallet);
|
wallets.set(wallet.id.toString(), wallet);
|
||||||
return wallet;
|
return wallet;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(wallet: Wallet): Promise<Wallet> {
|
async update(wallet: any): Promise<any> {
|
||||||
this.logger.debug('[InMemoryWalletRepository] update', { wallet });
|
this.logger.debug('[InMemoryWalletRepository] update', { wallet });
|
||||||
wallets.set(wallet.id, wallet);
|
wallets.set(wallet.id.toString(), wallet);
|
||||||
return wallet;
|
return wallet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
wallets.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async exists(id: string): Promise<boolean> {
|
||||||
|
return wallets.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
wallets.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InMemoryTransactionRepository implements TransactionRepository {
|
export class InMemoryTransactionRepository implements TransactionRepository {
|
||||||
constructor(private readonly logger: Logger) {}
|
constructor(private readonly logger: Logger) {}
|
||||||
|
|
||||||
async findById(id: string): Promise<Transaction | null> {
|
async findById(id: string): Promise<any | null> {
|
||||||
this.logger.debug('[InMemoryTransactionRepository] findById', { id });
|
this.logger.debug('[InMemoryTransactionRepository] findById', { id });
|
||||||
return transactions.get(id) || null;
|
return transactions.get(id) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByWalletId(walletId: string): Promise<Transaction[]> {
|
async findByWalletId(walletId: string): Promise<any[]> {
|
||||||
this.logger.debug('[InMemoryTransactionRepository] findByWalletId', { walletId });
|
this.logger.debug('[InMemoryTransactionRepository] findByWalletId', { walletId });
|
||||||
return Array.from(transactions.values()).filter(t => t.walletId === walletId);
|
return Array.from(transactions.values()).filter(t => t.walletId.toString() === walletId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(transaction: Transaction): Promise<Transaction> {
|
async create(transaction: any): Promise<any> {
|
||||||
this.logger.debug('[InMemoryTransactionRepository] create', { transaction });
|
this.logger.debug('[InMemoryTransactionRepository] create', { transaction });
|
||||||
transactions.set(transaction.id, transaction);
|
transactions.set(transaction.id.toString(), transaction);
|
||||||
return transaction;
|
return transaction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async update(transaction: any): Promise<any> {
|
||||||
|
transactions.set(transaction.id.toString(), transaction);
|
||||||
|
return transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
transactions.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async exists(id: string): Promise<boolean> {
|
||||||
|
return transactions.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
findByType(type: any): Promise<any[]> {
|
||||||
|
return Promise.resolve(Array.from(transactions.values()).filter(t => t.type === type));
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
transactions.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -93,6 +93,12 @@ export class InMemoryDriverRepository implements DriverRepository {
|
|||||||
return Promise.resolve(this.iracingIdIndex.has(iracingId));
|
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
|
// Serialization methods for persistence
|
||||||
serialize(driver: Driver): Record<string, unknown> {
|
serialize(driver: Driver): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -92,4 +92,9 @@ export class InMemoryLeagueMembershipRepository implements LeagueMembershipRepos
|
|||||||
}
|
}
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.memberships.clear();
|
||||||
|
this.joinRequests.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export class InMemoryLeagueRepository implements LeagueRepository {
|
|||||||
this.logger.info('InMemoryLeagueRepository initialized');
|
this.logger.info('InMemoryLeagueRepository initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.leagues.clear();
|
||||||
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<League | null> {
|
async findById(id: string): Promise<League | null> {
|
||||||
this.logger.debug(`Attempting to find league with ID: ${id}.`);
|
this.logger.debug(`Attempting to find league with ID: ${id}.`);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -105,4 +105,8 @@ export class InMemoryRaceRepository implements RaceRepository {
|
|||||||
this.logger.debug(`[InMemoryRaceRepository] Checking existence of race with ID: ${id}.`);
|
this.logger.debug(`[InMemoryRaceRepository] Checking existence of race with ID: ${id}.`);
|
||||||
return Promise.resolve(this.races.has(id));
|
return Promise.resolve(this.races.has(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.races.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,6 +218,11 @@ 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
|
* Utility method to generate a new UUID
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -83,4 +83,8 @@ export class InMemorySeasonRepository implements SeasonRepository {
|
|||||||
);
|
);
|
||||||
return Promise.resolve(activeSeasons);
|
return Promise.resolve(activeSeasons);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.seasons.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,4 +95,9 @@ export class InMemorySponsorRepository implements SponsorRepository {
|
|||||||
this.logger.debug(`[InMemorySponsorRepository] Checking existence of sponsor with ID: ${id}`);
|
this.logger.debug(`[InMemorySponsorRepository] Checking existence of sponsor with ID: ${id}`);
|
||||||
return Promise.resolve(this.sponsors.has(id));
|
return Promise.resolve(this.sponsors.has(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.sponsors.clear();
|
||||||
|
this.emailIndex.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,4 +99,12 @@ export class InMemorySponsorshipPricingRepository implements SponsorshipPricingR
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async create(pricing: any): Promise<void> {
|
||||||
|
await this.save(pricing.entityType, pricing.entityId, pricing);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.pricings.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -109,4 +109,8 @@ export class InMemorySponsorshipRequestRepository implements SponsorshipRequestR
|
|||||||
this.logger.debug(`[InMemorySponsorshipRequestRepository] Checking existence of request with ID: ${id}.`);
|
this.logger.debug(`[InMemorySponsorshipRequestRepository] Checking existence of request with ID: ${id}.`);
|
||||||
return Promise.resolve(this.requests.has(id));
|
return Promise.resolve(this.requests.has(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.requests.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,6 +166,11 @@ export class InMemoryStandingRepository implements StandingRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
this.logger.debug('Clearing all standings.');
|
||||||
|
this.standings.clear();
|
||||||
|
}
|
||||||
|
|
||||||
async recalculate(leagueId: string): Promise<Standing[]> {
|
async recalculate(leagueId: string): Promise<Standing[]> {
|
||||||
this.logger.debug(`Recalculating standings for league id: ${leagueId}`);
|
this.logger.debug(`Recalculating standings for league id: ${leagueId}`);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -212,4 +212,10 @@ async getMembership(teamId: string, driverId: string): Promise<TeamMembership |
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
this.logger.info('[InMemoryTeamMembershipRepository] Clearing all memberships and join requests');
|
||||||
|
this.membershipsByTeam.clear();
|
||||||
|
this.joinRequestsByTeam.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -124,6 +124,11 @@ export class InMemoryTeamRepository implements TeamRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
this.logger.info('[InMemoryTeamRepository] Clearing all teams');
|
||||||
|
this.teams.clear();
|
||||||
|
}
|
||||||
|
|
||||||
// Serialization methods for persistence
|
// Serialization methods for persistence
|
||||||
serialize(team: Team): Record<string, unknown> {
|
serialize(team: Team): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -104,4 +104,9 @@ export class InMemoryDriverExtendedProfileProvider implements DriverExtendedProf
|
|||||||
openToRequests: hash % 2 === 0,
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -32,4 +32,9 @@ export class InMemoryDriverRatingProvider implements DriverRatingProvider {
|
|||||||
}
|
}
|
||||||
return ratingsMap;
|
return ratingsMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.logger.info('[InMemoryDriverRatingProvider] Clearing all data');
|
||||||
|
// No data to clear as this provider generates data on-the-fly
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,4 +153,10 @@ export class InMemorySocialGraphRepository implements SocialGraphRepository {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async clear(): Promise<void> {
|
||||||
|
this.logger.info('[InMemorySocialGraphRepository] Clearing all friendships and drivers');
|
||||||
|
this.friendships = [];
|
||||||
|
this.driversById.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@ import { Provider } from '@nestjs/common';
|
|||||||
import {
|
import {
|
||||||
ANALYTICS_ENGAGEMENT_REPOSITORY_TOKEN,
|
ANALYTICS_ENGAGEMENT_REPOSITORY_TOKEN,
|
||||||
ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN,
|
ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN,
|
||||||
} from '../../../../persistence/analytics/AnalyticsPersistenceTokens';
|
} from '../../persistence/analytics/AnalyticsPersistenceTokens';
|
||||||
|
|
||||||
const LOGGER_TOKEN = 'Logger';
|
const LOGGER_TOKEN = 'Logger';
|
||||||
|
|
||||||
|
|||||||
@@ -140,10 +140,9 @@ export const SponsorProviders: Provider[] = [
|
|||||||
useFactory: (
|
useFactory: (
|
||||||
paymentRepo: PaymentRepository,
|
paymentRepo: PaymentRepository,
|
||||||
seasonSponsorshipRepo: SeasonSponsorshipRepository,
|
seasonSponsorshipRepo: SeasonSponsorshipRepository,
|
||||||
) => {
|
sponsorRepo: SponsorRepository,
|
||||||
return new GetSponsorBillingUseCase(paymentRepo, seasonSponsorshipRepo);
|
) => new GetSponsorBillingUseCase(paymentRepo, seasonSponsorshipRepo, sponsorRepo),
|
||||||
},
|
inject: [PAYMENT_REPOSITORY_TOKEN, SEASON_SPONSORSHIP_REPOSITORY_TOKEN, SPONSOR_REPOSITORY_TOKEN],
|
||||||
inject: [PAYMENT_REPOSITORY_TOKEN, SEASON_SPONSORSHIP_REPOSITORY_TOKEN],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: GET_ENTITY_SPONSORSHIP_PRICING_USE_CASE_TOKEN,
|
provide: GET_ENTITY_SPONSORSHIP_PRICING_USE_CASE_TOKEN,
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
|
|||||||
*/
|
*/
|
||||||
export class LeaguesViewDataBuilder {
|
export class LeaguesViewDataBuilder {
|
||||||
static build(apiDto: AllLeaguesWithCapacityAndScoringDTO): LeaguesViewData {
|
static build(apiDto: AllLeaguesWithCapacityAndScoringDTO): LeaguesViewData {
|
||||||
|
if (!apiDto || !Array.isArray(apiDto.leagues)) {
|
||||||
|
return { leagues: [] };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
leagues: apiDto.leagues.map((league) => ({
|
leagues: apiDto.leagues.map((league) => ({
|
||||||
id: league.id,
|
id: league.id,
|
||||||
|
|||||||
@@ -2,14 +2,16 @@ import { PageQuery } from '@/lib/contracts/page-queries/PageQuery';
|
|||||||
import { Result } from '@/lib/contracts/Result';
|
import { Result } from '@/lib/contracts/Result';
|
||||||
import { LeagueService, type LeagueDetailData } from '@/lib/services/leagues/LeagueService';
|
import { LeagueService, type LeagueDetailData } from '@/lib/services/leagues/LeagueService';
|
||||||
import { type PresentationError, mapToPresentationError } from '@/lib/contracts/page-queries/PresentationError';
|
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
|
* LeagueDetail page query
|
||||||
* Returns the raw API DTO for the league detail page
|
* Returns the raw API DTO for the league detail page
|
||||||
* No DI container usage - constructs dependencies explicitly
|
* No DI container usage - constructs dependencies explicitly
|
||||||
*/
|
*/
|
||||||
export class LeagueDetailPageQuery implements PageQuery<LeagueDetailData, string, PresentationError> {
|
export class LeagueDetailPageQuery implements PageQuery<LeagueDetailViewData, string, PresentationError> {
|
||||||
async execute(leagueId: string): Promise<Result<LeagueDetailData, PresentationError>> {
|
async execute(leagueId: string): Promise<Result<LeagueDetailViewData, PresentationError>> {
|
||||||
const service = new LeagueService();
|
const service = new LeagueService();
|
||||||
const result = await service.getLeagueDetailData(leagueId);
|
const result = await service.getLeagueDetailData(leagueId);
|
||||||
|
|
||||||
@@ -17,11 +19,12 @@ export class LeagueDetailPageQuery implements PageQuery<LeagueDetailData, string
|
|||||||
return Result.err(mapToPresentationError(result.getError()));
|
return Result.err(mapToPresentationError(result.getError()));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.ok(result.unwrap());
|
const viewData = LeagueDetailViewDataBuilder.build(result.unwrap());
|
||||||
|
return Result.ok(viewData);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static method to avoid object construction in server code
|
// Static method to avoid object construction in server code
|
||||||
static async execute(leagueId: string): Promise<Result<LeagueDetailData, PresentationError>> {
|
static async execute(leagueId: string): Promise<Result<LeagueDetailViewData, PresentationError>> {
|
||||||
const query = new LeagueDetailPageQuery();
|
const query = new LeagueDetailPageQuery();
|
||||||
return query.execute(leagueId);
|
return query.execute(leagueId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ export class LeaguesPageQuery implements PageQuery<LeaguesViewData, void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Transform to ViewData using builder
|
// Transform to ViewData using builder
|
||||||
const viewData = LeaguesViewDataBuilder.build(result.unwrap());
|
const apiDto = result.unwrap();
|
||||||
|
if (!apiDto || !apiDto.leagues) {
|
||||||
|
return Result.err('UNKNOWN_ERROR');
|
||||||
|
}
|
||||||
|
const viewData = LeaguesViewDataBuilder.build(apiDto);
|
||||||
return Result.ok(viewData);
|
return Result.ok(viewData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -169,27 +169,28 @@ export class LeagueService implements Service {
|
|||||||
this.racesApiClient.getPageData(leagueId),
|
this.racesApiClient.getPageData(leagueId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') {
|
||||||
const membershipCount = Array.isArray(memberships?.members) ? memberships.members.length : 0;
|
const membershipCount = Array.isArray(memberships?.members) ? memberships.members.length : 0;
|
||||||
const racesCount = Array.isArray(racesPageData?.races) ? racesPageData.races.length : 0;
|
const racesCount = Array.isArray(racesPageData?.races) ? racesPageData.races.length : 0;
|
||||||
const race0 = racesCount > 0 ? racesPageData.races[0] : null;
|
const race0 = racesCount > 0 ? racesPageData.races[0] : null;
|
||||||
|
|
||||||
console.info(
|
console.info(
|
||||||
'[LeagueService.getLeagueDetailData] baseUrl=%s leagueId=%s memberships=%d races=%d race0=%o',
|
'[LeagueService.getLeagueDetailData] baseUrl=%s leagueId=%s memberships=%d races=%d race0=%o apiDto=%o',
|
||||||
this.baseUrl,
|
this.baseUrl,
|
||||||
leagueId,
|
leagueId,
|
||||||
membershipCount,
|
membershipCount,
|
||||||
racesCount,
|
racesCount,
|
||||||
race0,
|
race0,
|
||||||
|
apiDto
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!apiDto || !apiDto.leagues) {
|
if (!apiDto || !apiDto.leagues) {
|
||||||
return Result.err({ type: 'notFound', message: 'Leagues not found' });
|
return Result.err({ type: 'notFound', message: 'Leagues not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const league = apiDto.leagues.find(l => l.id === leagueId);
|
const leagues = Array.isArray(apiDto.leagues) ? apiDto.leagues : [];
|
||||||
|
const league = leagues.find(l => l.id === leagueId);
|
||||||
if (!league) {
|
if (!league) {
|
||||||
return Result.err({ type: 'notFound', message: 'League not found' });
|
return Result.err({ type: 'notFound', message: 'League not found' });
|
||||||
}
|
}
|
||||||
@@ -220,7 +221,7 @@ export class LeagueService implements Service {
|
|||||||
console.warn('Failed to fetch league scoring config', e);
|
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,
|
id: r.id,
|
||||||
name: `${r.track} - ${r.car}`,
|
name: `${r.track} - ${r.car}`,
|
||||||
date: r.scheduledAt,
|
date: r.scheduledAt,
|
||||||
|
|||||||
64
core/dashboard/application/dto/DashboardDTO.ts
Normal file
64
core/dashboard/application/dto/DashboardDTO.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* 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[];
|
||||||
|
}
|
||||||
43
core/dashboard/application/ports/DashboardEventPublisher.ts
Normal file
43
core/dashboard/application/ports/DashboardEventPublisher.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* 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>;
|
||||||
|
}
|
||||||
9
core/dashboard/application/ports/DashboardQuery.ts
Normal file
9
core/dashboard/application/ports/DashboardQuery.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Dashboard Query
|
||||||
|
*
|
||||||
|
* Query object for fetching dashboard data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DashboardQuery {
|
||||||
|
driverId: string;
|
||||||
|
}
|
||||||
107
core/dashboard/application/ports/DashboardRepository.ts
Normal file
107
core/dashboard/application/ports/DashboardRepository.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* 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[]>;
|
||||||
|
}
|
||||||
18
core/dashboard/application/presenters/DashboardPresenter.ts
Normal file
18
core/dashboard/application/presenters/DashboardPresenter.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
194
core/dashboard/application/use-cases/GetDashboardUseCase.ts
Normal file
194
core/dashboard/application/use-cases/GetDashboardUseCase.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* 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' : ''}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
core/dashboard/domain/errors/DriverNotFoundError.ts
Normal file
16
core/dashboard/domain/errors/DriverNotFoundError.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
54
core/health/ports/HealthCheckQuery.ts
Normal file
54
core/health/ports/HealthCheckQuery.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
80
core/health/ports/HealthEventPublisher.ts
Normal file
80
core/health/ports/HealthEventPublisher.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
62
core/health/use-cases/CheckApiHealthUseCase.ts
Normal file
62
core/health/use-cases/CheckApiHealthUseCase.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
core/health/use-cases/GetConnectionStatusUseCase.ts
Normal file
52
core/health/use-cases/GetConnectionStatusUseCase.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
77
core/leaderboards/application/ports/DriverRankingsQuery.ts
Normal file
77
core/leaderboards/application/ports/DriverRankingsQuery.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* 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[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* 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>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* 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[]>;
|
||||||
|
}
|
||||||
76
core/leaderboards/application/ports/TeamRankingsQuery.ts
Normal file
76
core/leaderboards/application/ports/TeamRankingsQuery.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* 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"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* 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"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface ApproveMembershipRequestCommand {
|
||||||
|
leagueId: string;
|
||||||
|
requestId: string;
|
||||||
|
}
|
||||||
4
core/leagues/application/ports/DemoteAdminCommand.ts
Normal file
4
core/leagues/application/ports/DemoteAdminCommand.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface DemoteAdminCommand {
|
||||||
|
leagueId: string;
|
||||||
|
targetDriverId: string;
|
||||||
|
}
|
||||||
4
core/leagues/application/ports/JoinLeagueCommand.ts
Normal file
4
core/leagues/application/ports/JoinLeagueCommand.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface JoinLeagueCommand {
|
||||||
|
leagueId: string;
|
||||||
|
driverId: string;
|
||||||
|
}
|
||||||
33
core/leagues/application/ports/LeagueCreateCommand.ts
Normal file
33
core/leagues/application/ports/LeagueCreateCommand.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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[];
|
||||||
|
}
|
||||||
48
core/leagues/application/ports/LeagueEventPublisher.ts
Normal file
48
core/leagues/application/ports/LeagueEventPublisher.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
191
core/leagues/application/ports/LeagueRepository.ts
Normal file
191
core/leagues/application/ports/LeagueRepository.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
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>;
|
||||||
|
}
|
||||||
3
core/leagues/application/ports/LeagueRosterQuery.ts
Normal file
3
core/leagues/application/ports/LeagueRosterQuery.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export interface LeagueRosterQuery {
|
||||||
|
leagueId: string;
|
||||||
|
}
|
||||||
4
core/leagues/application/ports/LeaveLeagueCommand.ts
Normal file
4
core/leagues/application/ports/LeaveLeagueCommand.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface LeaveLeagueCommand {
|
||||||
|
leagueId: string;
|
||||||
|
driverId: string;
|
||||||
|
}
|
||||||
4
core/leagues/application/ports/PromoteMemberCommand.ts
Normal file
4
core/leagues/application/ports/PromoteMemberCommand.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface PromoteMemberCommand {
|
||||||
|
leagueId: string;
|
||||||
|
targetDriverId: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface RejectMembershipRequestCommand {
|
||||||
|
leagueId: string;
|
||||||
|
requestId: string;
|
||||||
|
}
|
||||||
4
core/leagues/application/ports/RemoveMemberCommand.ts
Normal file
4
core/leagues/application/ports/RemoveMemberCommand.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export interface RemoveMemberCommand {
|
||||||
|
leagueId: string;
|
||||||
|
targetDriverId: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
187
core/leagues/application/use-cases/CreateLeagueUseCase.ts
Normal file
187
core/leagues/application/use-cases/CreateLeagueUseCase.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
core/leagues/application/use-cases/DemoteAdminUseCase.ts
Normal file
16
core/leagues/application/use-cases/DemoteAdminUseCase.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
81
core/leagues/application/use-cases/GetLeagueRosterUseCase.ts
Normal file
81
core/leagues/application/use-cases/GetLeagueRosterUseCase.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
40
core/leagues/application/use-cases/GetLeagueUseCase.ts
Normal file
40
core/leagues/application/use-cases/GetLeagueUseCase.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
core/leagues/application/use-cases/JoinLeagueUseCase.ts
Normal file
44
core/leagues/application/use-cases/JoinLeagueUseCase.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
core/leagues/application/use-cases/LeaveLeagueUseCase.ts
Normal file
16
core/leagues/application/use-cases/LeaveLeagueUseCase.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
core/leagues/application/use-cases/PromoteMemberUseCase.ts
Normal file
16
core/leagues/application/use-cases/PromoteMemberUseCase.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
core/leagues/application/use-cases/RemoveMemberUseCase.ts
Normal file
16
core/leagues/application/use-cases/RemoveMemberUseCase.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
core/leagues/application/use-cases/SearchLeaguesUseCase.ts
Normal file
27
core/leagues/application/use-cases/SearchLeaguesUseCase.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Result } from '@core/shared/domain/Result';
|
|||||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||||
import { PaymentStatus, PaymentType } from '../../domain/entities/Payment';
|
import { PaymentStatus, PaymentType } from '../../domain/entities/Payment';
|
||||||
import type { PaymentRepository } from '../../domain/repositories/PaymentRepository';
|
import type { PaymentRepository } from '../../domain/repositories/PaymentRepository';
|
||||||
|
import type { SponsorRepository } from '@core/racing/domain/repositories/SponsorRepository';
|
||||||
|
|
||||||
export interface SponsorBillingStats {
|
export interface SponsorBillingStats {
|
||||||
totalSpent: number;
|
totalSpent: number;
|
||||||
@@ -55,7 +56,7 @@ export interface GetSponsorBillingResult {
|
|||||||
stats: SponsorBillingStats;
|
stats: SponsorBillingStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GetSponsorBillingErrorCode = never;
|
export type GetSponsorBillingErrorCode = 'SPONSOR_NOT_FOUND';
|
||||||
|
|
||||||
export class GetSponsorBillingUseCase
|
export class GetSponsorBillingUseCase
|
||||||
implements UseCase<GetSponsorBillingInput, GetSponsorBillingResult, GetSponsorBillingErrorCode>
|
implements UseCase<GetSponsorBillingInput, GetSponsorBillingResult, GetSponsorBillingErrorCode>
|
||||||
@@ -63,11 +64,20 @@ export class GetSponsorBillingUseCase
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly paymentRepository: PaymentRepository,
|
private readonly paymentRepository: PaymentRepository,
|
||||||
private readonly seasonSponsorshipRepository: SeasonSponsorshipRepository,
|
private readonly seasonSponsorshipRepository: SeasonSponsorshipRepository,
|
||||||
|
private readonly sponsorRepository: SponsorRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(input: GetSponsorBillingInput): Promise<Result<GetSponsorBillingResult, ApplicationErrorCode<GetSponsorBillingErrorCode>>> {
|
async execute(input: GetSponsorBillingInput): Promise<Result<GetSponsorBillingResult, ApplicationErrorCode<GetSponsorBillingErrorCode>>> {
|
||||||
const { sponsorId } = input;
|
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
|
// In this in-memory implementation we derive billing data from payments
|
||||||
// where the sponsor is the payer.
|
// where the sponsor is the payer.
|
||||||
const payments = await this.paymentRepository.findByFilters({
|
const payments = await this.paymentRepository.findByFilters({
|
||||||
|
|||||||
@@ -38,4 +38,9 @@ export class DriverStatsUseCase {
|
|||||||
this._logger.debug(`Getting stats for driver ${driverId}`);
|
this._logger.debug(`Getting stats for driver ${driverId}`);
|
||||||
return this._driverStatsRepository.getDriverStats(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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -43,4 +43,9 @@ export class RankingUseCase {
|
|||||||
|
|
||||||
return rankings;
|
return rankings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this._logger.info('[RankingUseCase] Clearing all rankings');
|
||||||
|
// No data to clear as this use case generates data on-the-fly
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -88,4 +88,29 @@ export class Track extends Entity<string> {
|
|||||||
gameId: TrackGameId.create(props.gameId),
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
16
core/shared/errors/ValidationError.ts
Normal file
16
core/shared/errors/ValidationError.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
11
package.json
11
package.json
@@ -45,6 +45,7 @@
|
|||||||
"glob": "^13.0.0",
|
"glob": "^13.0.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^22.1.0",
|
"jsdom": "^22.1.0",
|
||||||
|
"lint-staged": "^15.2.10",
|
||||||
"openapi-typescript": "^7.4.3",
|
"openapi-typescript": "^7.4.3",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.0.0",
|
||||||
"puppeteer": "^24.31.0",
|
"puppeteer": "^24.31.0",
|
||||||
@@ -128,6 +129,7 @@
|
|||||||
"test:unit": "vitest run tests/unit",
|
"test:unit": "vitest run tests/unit",
|
||||||
"test:watch": "vitest watch",
|
"test:watch": "vitest watch",
|
||||||
"test:website:types": "vitest run --config vitest.website.config.ts apps/website/lib/types/contractConsumption.test.ts",
|
"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": "npm run typecheck:targets",
|
||||||
"typecheck:grep": "npm run typescript",
|
"typecheck:grep": "npm run typescript",
|
||||||
"typecheck:root": "npx tsc --noEmit --project tsconfig.json",
|
"typecheck:root": "npx tsc --noEmit --project tsconfig.json",
|
||||||
@@ -139,6 +141,15 @@
|
|||||||
"website:start": "npm run start --workspace=@gridpilot/website",
|
"website:start": "npm run start --workspace=@gridpilot/website",
|
||||||
"website:type-check": "npm run type-check --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",
|
"version": "0.1.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"core/*",
|
"core/*",
|
||||||
|
|||||||
100
plans/ci-optimization.md
Normal file
100
plans/ci-optimization.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# 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?
|
||||||
52
plans/test_gap_analysis.md
Normal file
52
plans/test_gap_analysis.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Test Coverage Analysis & Gap Report
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
We have compared the existing E2E and Integration tests against the core concepts defined in [`docs/concept/`](docs/concept) and the testing principles in [`docs/TESTING_LAYERS.md`](docs/TESTING_LAYERS.md).
|
||||||
|
|
||||||
|
While the functional coverage is high, there are critical gaps in **Integration Testing** specifically regarding external boundaries (iRacing API) and specific infrastructure-heavy business logic (Rating Engine).
|
||||||
|
|
||||||
|
## 2. Concept vs. Test Mapping
|
||||||
|
|
||||||
|
| Concept Area | E2E Coverage | Integration Coverage | Status |
|
||||||
|
|--------------|--------------|----------------------|--------|
|
||||||
|
| **League Management** | [`leagues/`](tests/e2e/leagues) | [`leagues/`](tests/integration/leagues) | ✅ Covered |
|
||||||
|
| **Season/Schedule** | [`leagues/league-schedule.spec.ts`](tests/e2e/leagues/league-schedule.spec.ts) | [`leagues/schedule/`](tests/integration/leagues/schedule) | ✅ Covered |
|
||||||
|
| **Results Import** | [`races/race-results.spec.ts`](tests/e2e/races/race-results.spec.ts) | [`races/results/`](tests/integration/races/results) | ⚠️ Missing iRacing API Integration |
|
||||||
|
| **Complaints/Penalties** | [`leagues/league-stewarding.spec.ts`](tests/e2e/leagues/league-stewarding.spec.ts) | [`races/stewarding/`](tests/integration/races/stewarding) | ✅ Covered |
|
||||||
|
| **Team Competition** | [`teams/`](tests/e2e/teams) | [`teams/`](tests/integration/teams) | ✅ Covered |
|
||||||
|
| **Driver Profile/Stats** | [`drivers/`](tests/e2e/drivers) | [`drivers/profile/`](tests/integration/drivers/profile) | ✅ Covered |
|
||||||
|
| **Rating System** | None | None | ❌ Missing |
|
||||||
|
| **Social/Messaging** | None | None | ❌ Missing |
|
||||||
|
|
||||||
|
## 3. Identified Gaps in Integration Tests
|
||||||
|
|
||||||
|
According to [`docs/TESTING_LAYERS.md`](docs/TESTING_LAYERS.md), integration tests should protect **environmental correctness** (DB, external APIs, Auth).
|
||||||
|
|
||||||
|
### 🚨 Critical Gaps (Infrastructure/Boundaries)
|
||||||
|
1. **iRacing API Integration**:
|
||||||
|
- *Concept*: [`docs/concept/ADMINS.md`](docs/concept/ADMINS.md:83) (Automatic Results Import).
|
||||||
|
- *Gap*: We have tests for *displaying* results, but no integration tests verifying the actual handshake and parsing logic with the iRacing API boundary.
|
||||||
|
2. **Rating Engine Persistence**:
|
||||||
|
- *Concept*: [`docs/concept/RATING.md`](docs/concept/RATING.md) (GridPilot Rating).
|
||||||
|
- *Gap*: The rating system involves complex calculations that must be persisted correctly. We lack integration tests for the `RatingService` interacting with the DB.
|
||||||
|
3. **Auth/Identity Provider**:
|
||||||
|
- *Concept*: [`docs/concept/CONCEPT.md`](docs/concept/CONCEPT.md:172) (Safety, Security & Trust).
|
||||||
|
- *Gap*: No integration tests for the Auth boundary (e.g., JWT validation, session persistence).
|
||||||
|
|
||||||
|
### 🛠 Functional Gaps (Business Logic Integration)
|
||||||
|
1. **Social/Messaging**:
|
||||||
|
- *Concept*: [`docs/concept/SOCIAL.md`](docs/concept/SOCIAL.md) (Messaging, Notifications).
|
||||||
|
- *Gap*: No integration tests for message persistence or notification delivery (queues).
|
||||||
|
2. **Constructors-Style Scoring**:
|
||||||
|
- *Concept*: [`docs/concept/RACING.md`](docs/concept/RACING.md:47) (Constructors-Style Points).
|
||||||
|
- *Gap*: While we have `StandingsCalculation.test.ts`, we need specific integration tests for complex multi-driver team scoring scenarios against the DB.
|
||||||
|
|
||||||
|
## 4. Proposed Action Plan
|
||||||
|
|
||||||
|
1. **Implement iRacing API Contract/Integration Tests**: Verify the parsing of iRacing result payloads.
|
||||||
|
2. **Add Rating Persistence Tests**: Ensure `GridPilot Rating` updates correctly in the DB after race results are processed.
|
||||||
|
3. **Add Social/Notification Integration**: Test the persistence of messages and the triggering of notifications.
|
||||||
|
4. **Auth Integration**: Verify the system-level Auth flow as per the "Trust" requirement.
|
||||||
|
|
||||||
|
---
|
||||||
|
*Uncle Bob's Note: Remember, the closer a test is to the code, the more of them you should have. But for the system to be robust, the boundaries must be ironclad.*
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
/**
|
|
||||||
* Contract Validation Tests for API
|
|
||||||
*
|
|
||||||
* These tests validate that the API DTOs and OpenAPI spec are consistent
|
|
||||||
* and that the generated types will be compatible with the website.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import * as fs from 'fs/promises';
|
|
||||||
import * as path from 'path';
|
|
||||||
import * as os from 'os';
|
|
||||||
import { execFile } from 'node:child_process';
|
|
||||||
import { promisify } from 'node:util';
|
|
||||||
|
|
||||||
interface OpenAPISchema {
|
|
||||||
type?: string;
|
|
||||||
format?: string;
|
|
||||||
$ref?: string;
|
|
||||||
items?: OpenAPISchema;
|
|
||||||
properties?: Record<string, OpenAPISchema>;
|
|
||||||
required?: string[];
|
|
||||||
enum?: string[];
|
|
||||||
nullable?: boolean;
|
|
||||||
description?: string;
|
|
||||||
default?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OpenAPISpec {
|
|
||||||
openapi: string;
|
|
||||||
info: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
version: string;
|
|
||||||
};
|
|
||||||
paths: Record<string, any>;
|
|
||||||
components: {
|
|
||||||
schemas: Record<string, OpenAPISchema>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('API Contract Validation', () => {
|
|
||||||
const apiRoot = path.join(__dirname, '../..'); // /Users/marcmintel/Projects/gridpilot
|
|
||||||
const openapiPath = path.join(apiRoot, 'apps/api/openapi.json');
|
|
||||||
const generatedTypesDir = path.join(apiRoot, 'apps/website/lib/types/generated');
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
|
|
||||||
describe('OpenAPI Spec Integrity', () => {
|
|
||||||
it('should have a valid OpenAPI spec file', async () => {
|
|
||||||
const specExists = await fs.access(openapiPath).then(() => true).catch(() => false);
|
|
||||||
expect(specExists).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have a valid JSON structure', async () => {
|
|
||||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
||||||
expect(() => JSON.parse(content)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have required OpenAPI fields', async () => {
|
|
||||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
||||||
const spec: OpenAPISpec = JSON.parse(content);
|
|
||||||
|
|
||||||
expect(spec.openapi).toMatch(/^3\.\d+\.\d+$/);
|
|
||||||
expect(spec.info).toBeDefined();
|
|
||||||
expect(spec.info.title).toBeDefined();
|
|
||||||
expect(spec.info.version).toBeDefined();
|
|
||||||
expect(spec.components).toBeDefined();
|
|
||||||
expect(spec.components.schemas).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('committed openapi.json should match generator output', async () => {
|
|
||||||
const repoRoot = apiRoot; // Already at the repo root
|
|
||||||
|
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gridpilot-openapi-'));
|
|
||||||
const generatedOpenapiPath = path.join(tmpDir, 'openapi.json');
|
|
||||||
|
|
||||||
await execFileAsync(
|
|
||||||
'npx',
|
|
||||||
['--no-install', 'tsx', 'scripts/generate-openapi-spec.ts', '--output', generatedOpenapiPath],
|
|
||||||
{ cwd: repoRoot, maxBuffer: 20 * 1024 * 1024 },
|
|
||||||
);
|
|
||||||
|
|
||||||
const committed: OpenAPISpec = JSON.parse(await fs.readFile(openapiPath, 'utf-8'));
|
|
||||||
const generated: OpenAPISpec = JSON.parse(await fs.readFile(generatedOpenapiPath, 'utf-8'));
|
|
||||||
|
|
||||||
expect(generated).toEqual(committed);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include real HTTP paths for known routes', async () => {
|
|
||||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
||||||
const spec: OpenAPISpec = JSON.parse(content);
|
|
||||||
|
|
||||||
const pathKeys = Object.keys(spec.paths ?? {});
|
|
||||||
expect(pathKeys.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// A couple of stable routes to detect "empty/stale" specs.
|
|
||||||
expect(spec.paths['/drivers/leaderboard']).toBeDefined();
|
|
||||||
expect(spec.paths['/dashboard/overview']).toBeDefined();
|
|
||||||
|
|
||||||
// Sanity-check the operation objects exist (method keys are lowercase in OpenAPI).
|
|
||||||
expect(spec.paths['/drivers/leaderboard'].get).toBeDefined();
|
|
||||||
expect(spec.paths['/dashboard/overview'].get).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include league schedule publish/unpublish endpoints and published state', async () => {
|
|
||||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
||||||
const spec: OpenAPISpec = JSON.parse(content);
|
|
||||||
|
|
||||||
expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/publish']).toBeDefined();
|
|
||||||
expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/publish'].post).toBeDefined();
|
|
||||||
|
|
||||||
expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/unpublish']).toBeDefined();
|
|
||||||
expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/unpublish'].post).toBeDefined();
|
|
||||||
|
|
||||||
const scheduleSchema = spec.components.schemas['LeagueScheduleDTO'];
|
|
||||||
if (!scheduleSchema) {
|
|
||||||
throw new Error('Expected LeagueScheduleDTO schema to be present in OpenAPI spec');
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(scheduleSchema.properties?.published).toBeDefined();
|
|
||||||
expect(scheduleSchema.properties?.published?.type).toBe('boolean');
|
|
||||||
expect(scheduleSchema.required ?? []).toContain('published');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include league roster admin read endpoints and schemas', async () => {
|
|
||||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
||||||
const spec: OpenAPISpec = JSON.parse(content);
|
|
||||||
|
|
||||||
expect(spec.paths['/leagues/{leagueId}/admin/roster/members']).toBeDefined();
|
|
||||||
expect(spec.paths['/leagues/{leagueId}/admin/roster/members'].get).toBeDefined();
|
|
||||||
|
|
||||||
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests']).toBeDefined();
|
|
||||||
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests'].get).toBeDefined();
|
|
||||||
|
|
||||||
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/approve']).toBeDefined();
|
|
||||||
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/approve'].post).toBeDefined();
|
|
||||||
|
|
||||||
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/reject']).toBeDefined();
|
|
||||||
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/reject'].post).toBeDefined();
|
|
||||||
|
|
||||||
const memberSchema = spec.components.schemas['LeagueRosterMemberDTO'];
|
|
||||||
if (!memberSchema) {
|
|
||||||
throw new Error('Expected LeagueRosterMemberDTO schema to be present in OpenAPI spec');
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(memberSchema.properties?.driverId).toBeDefined();
|
|
||||||
expect(memberSchema.properties?.role).toBeDefined();
|
|
||||||
expect(memberSchema.properties?.joinedAt).toBeDefined();
|
|
||||||
expect(memberSchema.required ?? []).toContain('driverId');
|
|
||||||
expect(memberSchema.required ?? []).toContain('role');
|
|
||||||
expect(memberSchema.required ?? []).toContain('joinedAt');
|
|
||||||
expect(memberSchema.required ?? []).toContain('driver');
|
|
||||||
|
|
||||||
const joinRequestSchema = spec.components.schemas['LeagueRosterJoinRequestDTO'];
|
|
||||||
if (!joinRequestSchema) {
|
|
||||||
throw new Error('Expected LeagueRosterJoinRequestDTO schema to be present in OpenAPI spec');
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(joinRequestSchema.properties?.id).toBeDefined();
|
|
||||||
expect(joinRequestSchema.properties?.leagueId).toBeDefined();
|
|
||||||
expect(joinRequestSchema.properties?.driverId).toBeDefined();
|
|
||||||
expect(joinRequestSchema.properties?.requestedAt).toBeDefined();
|
|
||||||
expect(joinRequestSchema.required ?? []).toContain('id');
|
|
||||||
expect(joinRequestSchema.required ?? []).toContain('leagueId');
|
|
||||||
expect(joinRequestSchema.required ?? []).toContain('driverId');
|
|
||||||
expect(joinRequestSchema.required ?? []).toContain('requestedAt');
|
|
||||||
expect(joinRequestSchema.required ?? []).toContain('driver');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have no circular references in schemas', async () => {
|
|
||||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
||||||
const spec: OpenAPISpec = JSON.parse(content);
|
|
||||||
const schemas = spec.components.schemas;
|
|
||||||
|
|
||||||
const visited = new Set<string>();
|
|
||||||
const visiting = new Set<string>();
|
|
||||||
|
|
||||||
function detectCircular(schemaName: string): boolean {
|
|
||||||
if (visiting.has(schemaName)) return true;
|
|
||||||
if (visited.has(schemaName)) return false;
|
|
||||||
|
|
||||||
visiting.add(schemaName);
|
|
||||||
const schema = schemas[schemaName];
|
|
||||||
|
|
||||||
if (!schema) {
|
|
||||||
visiting.delete(schemaName);
|
|
||||||
visited.add(schemaName);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check properties for references
|
|
||||||
if (schema.properties) {
|
|
||||||
for (const prop of Object.values(schema.properties)) {
|
|
||||||
if (prop.$ref) {
|
|
||||||
const refName = prop.$ref.split('/').pop();
|
|
||||||
if (refName && detectCircular(refName)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (prop.items?.$ref) {
|
|
||||||
const refName = prop.items.$ref.split('/').pop();
|
|
||||||
if (refName && detectCircular(refName)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
visiting.delete(schemaName);
|
|
||||||
visited.add(schemaName);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const schemaName of Object.keys(schemas)) {
|
|
||||||
expect(detectCircular(schemaName)).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DTO Consistency', () => {
|
|
||||||
it('should have generated DTO files for critical schemas', async () => {
|
|
||||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
||||||
const spec: OpenAPISpec = JSON.parse(content);
|
|
||||||
|
|
||||||
const generatedFiles = await fs.readdir(generatedTypesDir);
|
|
||||||
const generatedDTOs = generatedFiles
|
|
||||||
.filter(f => f.endsWith('.ts'))
|
|
||||||
.map(f => f.replace('.ts', ''));
|
|
||||||
|
|
||||||
// We intentionally do NOT require a 1:1 mapping for *all* schemas here.
|
|
||||||
// OpenAPI generation and type generation can be run as separate steps,
|
|
||||||
// and new schemas should not break API contract validation by themselves.
|
|
||||||
const criticalDTOs = [
|
|
||||||
'RequestAvatarGenerationInputDTO',
|
|
||||||
'RequestAvatarGenerationOutputDTO',
|
|
||||||
'UploadMediaInputDTO',
|
|
||||||
'UploadMediaOutputDTO',
|
|
||||||
'RaceDTO',
|
|
||||||
'DriverDTO',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const dtoName of criticalDTOs) {
|
|
||||||
expect(spec.components.schemas[dtoName]).toBeDefined();
|
|
||||||
expect(generatedDTOs).toContain(dtoName);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have consistent property types between DTOs and schemas', async () => {
|
|
||||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
||||||
const spec: OpenAPISpec = JSON.parse(content);
|
|
||||||
const schemas = spec.components.schemas;
|
|
||||||
|
|
||||||
for (const [schemaName, schema] of Object.entries(schemas)) {
|
|
||||||
const dtoPath = path.join(generatedTypesDir, `${schemaName}.ts`);
|
|
||||||
const dtoExists = await fs.access(dtoPath).then(() => true).catch(() => false);
|
|
||||||
|
|
||||||
if (!dtoExists) continue;
|
|
||||||
|
|
||||||
const dtoContent = await fs.readFile(dtoPath, 'utf-8');
|
|
||||||
|
|
||||||
// Check that all required properties are present
|
|
||||||
if (schema.required) {
|
|
||||||
for (const requiredProp of schema.required) {
|
|
||||||
expect(dtoContent).toContain(requiredProp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check that all properties are present
|
|
||||||
if (schema.properties) {
|
|
||||||
for (const propName of Object.keys(schema.properties)) {
|
|
||||||
expect(dtoContent).toContain(propName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Type Generation Integrity', () => {
|
|
||||||
it('should have valid TypeScript syntax in generated files', async () => {
|
|
||||||
const files = await fs.readdir(generatedTypesDir);
|
|
||||||
const dtos = files.filter(f => f.endsWith('.ts') && !f.endsWith('.test.ts'));
|
|
||||||
|
|
||||||
for (const file of dtos) {
|
|
||||||
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
|
|
||||||
|
|
||||||
// `index.ts` is a generated barrel file (no interfaces).
|
|
||||||
if (file === 'index.ts') {
|
|
||||||
expect(content).toContain('export type {');
|
|
||||||
expect(content).toContain("from './");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Basic TypeScript syntax checks (DTO interfaces)
|
|
||||||
expect(content).toContain('export interface');
|
|
||||||
expect(content).toContain('{');
|
|
||||||
expect(content).toContain('}');
|
|
||||||
|
|
||||||
// Should not have syntax errors (basic check)
|
|
||||||
expect(content).not.toContain('undefined;');
|
|
||||||
expect(content).not.toContain('any;');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have proper imports for dependencies', async () => {
|
|
||||||
const files = await fs.readdir(generatedTypesDir);
|
|
||||||
const dtos = files.filter(f => f.endsWith('.ts') && !f.endsWith('.test.ts'));
|
|
||||||
|
|
||||||
for (const file of dtos) {
|
|
||||||
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
|
|
||||||
const importMatches = content.match(/import type \{ (\w+) \} from '\.\/(\w+)';/g) || [];
|
|
||||||
|
|
||||||
for (const importLine of importMatches) {
|
|
||||||
const match = importLine.match(/import type \{ (\w+) \} from '\.\/(\w+)';/);
|
|
||||||
if (match) {
|
|
||||||
const [, importedType, fromFile] = match;
|
|
||||||
expect(importedType).toBe(fromFile);
|
|
||||||
|
|
||||||
// Check that the imported file exists
|
|
||||||
const importedPath = path.join(generatedTypesDir, `${fromFile}.ts`);
|
|
||||||
const exists = await fs.access(importedPath).then(() => true).catch(() => false);
|
|
||||||
expect(exists).toBe(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Contract Compatibility', () => {
|
|
||||||
it('should maintain backward compatibility for existing DTOs', async () => {
|
|
||||||
// This test ensures that when regenerating types, existing properties aren't removed
|
|
||||||
// unless explicitly intended
|
|
||||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
||||||
const spec: OpenAPISpec = JSON.parse(content);
|
|
||||||
|
|
||||||
// Check critical DTOs that are likely used in production
|
|
||||||
const criticalDTOs = [
|
|
||||||
'RequestAvatarGenerationInputDTO',
|
|
||||||
'RequestAvatarGenerationOutputDTO',
|
|
||||||
'UploadMediaInputDTO',
|
|
||||||
'UploadMediaOutputDTO',
|
|
||||||
'RaceDTO',
|
|
||||||
'DriverDTO'
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const dtoName of criticalDTOs) {
|
|
||||||
if (spec.components.schemas[dtoName]) {
|
|
||||||
const dtoPath = path.join(generatedTypesDir, `${dtoName}.ts`);
|
|
||||||
const exists = await fs.access(dtoPath).then(() => true).catch(() => false);
|
|
||||||
expect(exists).toBe(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle nullable fields correctly', async () => {
|
|
||||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
||||||
const spec: OpenAPISpec = JSON.parse(content);
|
|
||||||
const schemas = spec.components.schemas;
|
|
||||||
|
|
||||||
for (const [, schema] of Object.entries(schemas)) {
|
|
||||||
const required = new Set(schema.required ?? []);
|
|
||||||
if (!schema.properties) continue;
|
|
||||||
|
|
||||||
for (const [propName, propSchema] of Object.entries(schema.properties)) {
|
|
||||||
if (!propSchema.nullable) continue;
|
|
||||||
|
|
||||||
// In OpenAPI 3.0, a `nullable: true` property should not be listed as required,
|
|
||||||
// otherwise downstream generators can't represent it safely.
|
|
||||||
expect(required.has(propName)).toBe(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have no empty string defaults for avatar/logo URLs', async () => {
|
|
||||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
||||||
const spec: OpenAPISpec = JSON.parse(content);
|
|
||||||
const schemas = spec.components.schemas;
|
|
||||||
|
|
||||||
// Check DTOs that should use URL|null pattern
|
|
||||||
const mediaRelatedDTOs = [
|
|
||||||
'GetAvatarOutputDTO',
|
|
||||||
'UpdateAvatarInputDTO',
|
|
||||||
'DashboardDriverSummaryDTO',
|
|
||||||
'DriverProfileDriverSummaryDTO',
|
|
||||||
'DriverLeaderboardItemDTO',
|
|
||||||
'TeamListItemDTO',
|
|
||||||
'LeagueSummaryDTO',
|
|
||||||
'SponsorDTO',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const dtoName of mediaRelatedDTOs) {
|
|
||||||
const schema = schemas[dtoName];
|
|
||||||
if (!schema || !schema.properties) continue;
|
|
||||||
|
|
||||||
// Check for avatarUrl, logoUrl properties
|
|
||||||
for (const [propName, propSchema] of Object.entries(schema.properties)) {
|
|
||||||
if (propName === 'avatarUrl' || propName === 'logoUrl') {
|
|
||||||
// Should be string type, nullable (no empty string defaults)
|
|
||||||
expect(propSchema.type).toBe('string');
|
|
||||||
expect(propSchema.nullable).toBe(true);
|
|
||||||
// Should not have default value of empty string
|
|
||||||
if (propSchema.default !== undefined) {
|
|
||||||
expect(propSchema.default).not.toBe('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"cookies": [
|
|
||||||
{
|
|
||||||
"name": "gp_session",
|
|
||||||
"value": "gp_9f9c4115-2a02-4be7-9aec-72ddb3c7cdbf",
|
|
||||||
"domain": "localhost",
|
|
||||||
"path": "/",
|
|
||||||
"expires": -1,
|
|
||||||
"httpOnly": true,
|
|
||||||
"secure": false,
|
|
||||||
"sameSite": "Lax"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"userId": "68fd953d-4f4a-47b6-83b9-ec361238e4f1",
|
|
||||||
"email": "smoke-test-1767897520573@example.com",
|
|
||||||
"password": "Password123"
|
|
||||||
}
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
# API Smoke Tests
|
|
||||||
|
|
||||||
This directory contains true end-to-end API smoke tests that make direct HTTP requests to the running API server to validate endpoint functionality and detect issues like "presenter not presented" errors.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The API smoke tests are designed to:
|
|
||||||
|
|
||||||
1. **Test all public API endpoints** - Make requests to discover and validate endpoints
|
|
||||||
2. **Detect presenter errors** - Identify use cases that return errors without calling `this.output.present()`
|
|
||||||
3. **Validate response formats** - Ensure endpoints return proper data structures
|
|
||||||
4. **Test error handling** - Verify graceful handling of invalid inputs
|
|
||||||
5. **Generate detailed reports** - Create JSON and Markdown reports of findings
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
- `api-smoke.test.ts` - Main Playwright test file
|
|
||||||
- `README.md` - This documentation
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Local Testing
|
|
||||||
|
|
||||||
Run the API smoke tests against a locally running API:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start the API server (in one terminal)
|
|
||||||
npm run docker:dev:up
|
|
||||||
|
|
||||||
# Run smoke tests (in another terminal)
|
|
||||||
npm run test:api:smoke
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker Testing (Recommended)
|
|
||||||
|
|
||||||
Run the tests in the full Docker e2e environment:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start the complete e2e environment
|
|
||||||
npm run docker:e2e:up
|
|
||||||
|
|
||||||
# Run smoke tests in Docker
|
|
||||||
npm run test:api:smoke:docker
|
|
||||||
|
|
||||||
# Or use the unified command
|
|
||||||
npm run test:e2e:website # This runs all e2e tests including API smoke
|
|
||||||
```
|
|
||||||
|
|
||||||
### CI/CD Integration
|
|
||||||
|
|
||||||
Add to your CI pipeline:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# GitHub Actions example
|
|
||||||
- name: Start E2E Environment
|
|
||||||
run: npm run docker:e2e:up
|
|
||||||
|
|
||||||
- name: Run API Smoke Tests
|
|
||||||
run: npm run test:api:smoke:docker
|
|
||||||
|
|
||||||
- name: Upload Test Reports
|
|
||||||
uses: actions/upload-artifact@v3
|
|
||||||
with:
|
|
||||||
name: api-smoke-reports
|
|
||||||
path: |
|
|
||||||
api-smoke-report.json
|
|
||||||
api-smoke-report.md
|
|
||||||
playwright-report/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Test Coverage
|
|
||||||
|
|
||||||
The smoke tests cover:
|
|
||||||
|
|
||||||
### Race Endpoints
|
|
||||||
- `/races/all` - Get all races
|
|
||||||
- `/races/total-races` - Get total count
|
|
||||||
- `/races/page-data` - Get paginated data
|
|
||||||
- `/races/reference/penalty-types` - Reference data
|
|
||||||
- `/races/{id}` - Race details (with invalid IDs)
|
|
||||||
- `/races/{id}/results` - Race results
|
|
||||||
- `/races/{id}/sof` - Strength of field
|
|
||||||
- `/races/{id}/protests` - Protests
|
|
||||||
- `/races/{id}/penalties` - Penalties
|
|
||||||
|
|
||||||
### League Endpoints
|
|
||||||
- `/leagues/all` - All leagues
|
|
||||||
- `/leagues/available` - Available leagues
|
|
||||||
- `/leagues/{id}` - League details
|
|
||||||
- `/leagues/{id}/standings` - Standings
|
|
||||||
- `/leagues/{id}/schedule` - Schedule
|
|
||||||
|
|
||||||
### Team Endpoints
|
|
||||||
- `/teams/all` - All teams
|
|
||||||
- `/teams/{id}` - Team details
|
|
||||||
- `/teams/{id}/members` - Team members
|
|
||||||
|
|
||||||
### Driver Endpoints
|
|
||||||
- `/drivers/leaderboard` - Leaderboard
|
|
||||||
- `/drivers/total-drivers` - Total count
|
|
||||||
- `/drivers/{id}` - Driver details
|
|
||||||
|
|
||||||
### Media Endpoints
|
|
||||||
- `/media/avatar/{id}` - Avatar retrieval
|
|
||||||
- `/media/{id}` - Media retrieval
|
|
||||||
|
|
||||||
### Sponsor Endpoints
|
|
||||||
- `/sponsors/pricing` - Sponsorship pricing
|
|
||||||
- `/sponsors/dashboard` - Sponsor dashboard
|
|
||||||
- `/sponsors/{id}` - Sponsor details
|
|
||||||
|
|
||||||
### Auth Endpoints
|
|
||||||
- `/auth/login` - Login
|
|
||||||
- `/auth/signup` - Signup
|
|
||||||
- `/auth/session` - Session info
|
|
||||||
|
|
||||||
### Dashboard Endpoints
|
|
||||||
- `/dashboard/overview` - Overview
|
|
||||||
- `/dashboard/feed` - Activity feed
|
|
||||||
|
|
||||||
### Analytics Endpoints
|
|
||||||
- `/analytics/metrics` - Metrics
|
|
||||||
- `/analytics/dashboard` - Dashboard data
|
|
||||||
|
|
||||||
### Admin Endpoints
|
|
||||||
- `/admin/users` - User management
|
|
||||||
|
|
||||||
### Protest Endpoints
|
|
||||||
- `/protests/race/{id}` - Race protests
|
|
||||||
|
|
||||||
### Payment Endpoints
|
|
||||||
- `/payments/wallet` - Wallet info
|
|
||||||
|
|
||||||
### Notification Endpoints
|
|
||||||
- `/notifications/unread` - Unread notifications
|
|
||||||
|
|
||||||
### Feature Flags
|
|
||||||
- `/features` - Feature flag configuration
|
|
||||||
|
|
||||||
## Reports
|
|
||||||
|
|
||||||
After running tests, three reports are generated:
|
|
||||||
|
|
||||||
1. **`api-smoke-report.json`** - Detailed JSON report with all test results
|
|
||||||
2. **`api-smoke-report.md`** - Human-readable Markdown report
|
|
||||||
3. **Playwright HTML report** - Interactive test report (in `playwright-report/`)
|
|
||||||
|
|
||||||
### Report Structure
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2024-01-07T22:00:00Z",
|
|
||||||
"summary": {
|
|
||||||
"total": 50,
|
|
||||||
"success": 45,
|
|
||||||
"failed": 5,
|
|
||||||
"presenterErrors": 3,
|
|
||||||
"avgResponseTime": 45.2
|
|
||||||
},
|
|
||||||
"results": [...],
|
|
||||||
"failures": [...]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Detecting Presenter Errors
|
|
||||||
|
|
||||||
The test specifically looks for the "Presenter not presented" error pattern:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Detects these patterns:
|
|
||||||
- "Presenter not presented"
|
|
||||||
- "presenter not presented"
|
|
||||||
- Error messages containing these phrases
|
|
||||||
```
|
|
||||||
|
|
||||||
When found, these are flagged as **presenter errors** and require immediate attention.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### API Not Ready
|
|
||||||
If tests fail because API isn't ready:
|
|
||||||
```bash
|
|
||||||
# Check API health
|
|
||||||
curl http://localhost:3101/health
|
|
||||||
|
|
||||||
# Wait longer in test setup (increase timeout in test file)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Port Conflicts
|
|
||||||
```bash
|
|
||||||
# Stop conflicting services
|
|
||||||
npm run docker:e2e:down
|
|
||||||
|
|
||||||
# Check what's running
|
|
||||||
docker-compose -f docker-compose.e2e.yml ps
|
|
||||||
```
|
|
||||||
|
|
||||||
### Missing Data
|
|
||||||
The tests expect seeded data. If you see 404s:
|
|
||||||
```bash
|
|
||||||
# Ensure bootstrap is enabled
|
|
||||||
export GRIDPILOT_API_BOOTSTRAP=1
|
|
||||||
|
|
||||||
# Restart services
|
|
||||||
npm run docker:e2e:clean && npm run docker:e2e:up
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integration with Existing Tests
|
|
||||||
|
|
||||||
This smoke test complements the existing test suite:
|
|
||||||
|
|
||||||
- **Unit tests** (`apps/api/src/**/*Service.test.ts`) - Test individual services
|
|
||||||
- **Integration tests** (`tests/integration/`) - Test component interactions
|
|
||||||
- **E2E website tests** (`tests/e2e/website/`) - Test website functionality
|
|
||||||
- **API smoke tests** (this) - Test API endpoints directly
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Run before deployments** - Catch presenter errors before they reach production
|
|
||||||
2. **Run in CI/CD** - Automated regression testing
|
|
||||||
3. **Review reports** - Always check the generated reports
|
|
||||||
4. **Fix presenter errors immediately** - They indicate missing `.present()` calls
|
|
||||||
5. **Keep tests updated** - Add new endpoints as they're created
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
- Typical runtime: 30-60 seconds
|
|
||||||
- Parallel execution: Playwright runs tests in parallel by default
|
|
||||||
- Response time tracking: All requests are timed
|
|
||||||
- Average response time tracked in reports
|
|
||||||
|
|
||||||
## Maintenance
|
|
||||||
|
|
||||||
When adding new endpoints:
|
|
||||||
1. Add them to the test arrays in `api-smoke.test.ts`
|
|
||||||
2. Test locally first: `npm run test:api:smoke`
|
|
||||||
3. Verify reports show expected results
|
|
||||||
4. Commit updated test file
|
|
||||||
|
|
||||||
When fixing presenter errors:
|
|
||||||
1. Run smoke test to identify failing endpoints
|
|
||||||
2. Check the specific error messages
|
|
||||||
3. Fix the use case to call `this.output.present()` before returning
|
|
||||||
4. Re-run smoke test to verify fix
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
/**
|
|
||||||
* API Authentication Setup for E2E Tests
|
|
||||||
*
|
|
||||||
* This setup creates authentication sessions for both regular and admin users
|
|
||||||
* that are persisted across all tests in the suite.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test as setup } from '@playwright/test';
|
|
||||||
import * as fs from 'fs/promises';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL ?? process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
// Define auth file paths
|
|
||||||
const USER_AUTH_FILE = path.join(__dirname, '.auth/user-session.json');
|
|
||||||
const ADMIN_AUTH_FILE = path.join(__dirname, '.auth/admin-session.json');
|
|
||||||
|
|
||||||
setup('Authenticate regular user', async ({ request }) => {
|
|
||||||
console.log(`[AUTH SETUP] Creating regular user session at: ${API_BASE_URL}`);
|
|
||||||
|
|
||||||
// Wait for API to be ready
|
|
||||||
const maxAttempts = 30;
|
|
||||||
let apiReady = false;
|
|
||||||
|
|
||||||
for (let i = 0; i < maxAttempts; i++) {
|
|
||||||
try {
|
|
||||||
const response = await request.get(`${API_BASE_URL}/health`);
|
|
||||||
if (response.ok()) {
|
|
||||||
apiReady = true;
|
|
||||||
console.log(`[AUTH SETUP] API is ready after ${i + 1} attempts`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Continue trying
|
|
||||||
}
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!apiReady) {
|
|
||||||
throw new Error('API failed to become ready');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create test user and establish cookie-based session
|
|
||||||
const testEmail = `smoke-test-${Date.now()}@example.com`;
|
|
||||||
const testPassword = 'Password123';
|
|
||||||
|
|
||||||
// Signup
|
|
||||||
const signupResponse = await request.post(`${API_BASE_URL}/auth/signup`, {
|
|
||||||
data: {
|
|
||||||
email: testEmail,
|
|
||||||
password: testPassword,
|
|
||||||
displayName: 'Smoke Tester',
|
|
||||||
username: `smokeuser${Date.now()}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!signupResponse.ok()) {
|
|
||||||
throw new Error(`Signup failed: ${signupResponse.status()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const signupData = await signupResponse.json();
|
|
||||||
const testUserId = signupData?.user?.userId ?? null;
|
|
||||||
console.log('[AUTH SETUP] Test user created:', testUserId);
|
|
||||||
|
|
||||||
// Login to establish cookie session
|
|
||||||
const loginResponse = await request.post(`${API_BASE_URL}/auth/login`, {
|
|
||||||
data: {
|
|
||||||
email: testEmail,
|
|
||||||
password: testPassword
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!loginResponse.ok()) {
|
|
||||||
throw new Error(`Login failed: ${loginResponse.status()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[AUTH SETUP] Regular user session established');
|
|
||||||
|
|
||||||
// Get cookies and save to auth file
|
|
||||||
const context = request.context();
|
|
||||||
const cookies = context.cookies();
|
|
||||||
|
|
||||||
// Ensure auth directory exists
|
|
||||||
await fs.mkdir(path.dirname(USER_AUTH_FILE), { recursive: true });
|
|
||||||
|
|
||||||
// Save cookies to file
|
|
||||||
await fs.writeFile(USER_AUTH_FILE, JSON.stringify({ cookies }, null, 2));
|
|
||||||
console.log(`[AUTH SETUP] Saved user session to: ${USER_AUTH_FILE}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
setup('Authenticate admin user', async ({ request }) => {
|
|
||||||
console.log(`[AUTH SETUP] Creating admin user session at: ${API_BASE_URL}`);
|
|
||||||
|
|
||||||
// Use seeded admin credentials
|
|
||||||
const adminEmail = 'demo.admin@example.com';
|
|
||||||
const adminPassword = 'Demo1234!';
|
|
||||||
|
|
||||||
// Login as admin
|
|
||||||
const loginResponse = await request.post(`${API_BASE_URL}/auth/login`, {
|
|
||||||
data: {
|
|
||||||
email: adminEmail,
|
|
||||||
password: adminPassword
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!loginResponse.ok()) {
|
|
||||||
throw new Error(`Admin login failed: ${loginResponse.status()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[AUTH SETUP] Admin user session established');
|
|
||||||
|
|
||||||
// Get cookies and save to auth file
|
|
||||||
const context = request.context();
|
|
||||||
const cookies = context.cookies();
|
|
||||||
|
|
||||||
// Ensure auth directory exists
|
|
||||||
await fs.mkdir(path.dirname(ADMIN_AUTH_FILE), { recursive: true });
|
|
||||||
|
|
||||||
// Save cookies to file
|
|
||||||
await fs.writeFile(ADMIN_AUTH_FILE, JSON.stringify({ cookies }, null, 2));
|
|
||||||
console.log(`[AUTH SETUP] Saved admin session to: ${ADMIN_AUTH_FILE}`);
|
|
||||||
});
|
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
/**
|
|
||||||
* API Smoke Test
|
|
||||||
*
|
|
||||||
* This test performs true e2e testing of all API endpoints by making direct HTTP requests
|
|
||||||
* to the running API server. It tests for:
|
|
||||||
* - Basic connectivity and response codes
|
|
||||||
* - Presenter errors ("Presenter not presented")
|
|
||||||
* - Response format validation
|
|
||||||
* - Error handling
|
|
||||||
*
|
|
||||||
* This test is designed to run in the Docker e2e environment and can be executed with:
|
|
||||||
* npm run test:e2e:website (which runs everything in Docker)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect, request } from '@playwright/test';
|
|
||||||
import * as fs from 'fs/promises';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
interface EndpointTestResult {
|
|
||||||
endpoint: string;
|
|
||||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
|
||||||
status: number;
|
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
response?: unknown;
|
|
||||||
hasPresenterError: boolean;
|
|
||||||
responseTime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TestReport {
|
|
||||||
timestamp: string;
|
|
||||||
summary: {
|
|
||||||
total: number;
|
|
||||||
success: number;
|
|
||||||
failed: number;
|
|
||||||
presenterErrors: number;
|
|
||||||
avgResponseTime: number;
|
|
||||||
};
|
|
||||||
results: EndpointTestResult[];
|
|
||||||
failures: EndpointTestResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL ?? process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
// Auth file paths
|
|
||||||
const USER_AUTH_FILE = path.join(__dirname, '.auth/user-session.json');
|
|
||||||
const ADMIN_AUTH_FILE = path.join(__dirname, '.auth/admin-session.json');
|
|
||||||
|
|
||||||
test.describe('API Smoke Tests', () => {
|
|
||||||
// Aggregate across the whole suite (used for final report).
|
|
||||||
const allResults: EndpointTestResult[] = [];
|
|
||||||
|
|
||||||
let testResults: EndpointTestResult[] = [];
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
console.log(`[API SMOKE] Testing API at: ${API_BASE_URL}`);
|
|
||||||
|
|
||||||
// Verify auth files exist
|
|
||||||
const userAuthExists = await fs.access(USER_AUTH_FILE).then(() => true).catch(() => false);
|
|
||||||
const adminAuthExists = await fs.access(ADMIN_AUTH_FILE).then(() => true).catch(() => false);
|
|
||||||
|
|
||||||
if (!userAuthExists || !adminAuthExists) {
|
|
||||||
throw new Error('Auth files not found. Run global setup first.');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[API SMOKE] Auth files verified');
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async () => {
|
|
||||||
await generateReport();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('all public GET endpoints respond correctly', async ({ request }) => {
|
|
||||||
testResults = [];
|
|
||||||
const endpoints = [
|
|
||||||
// Race endpoints
|
|
||||||
{ method: 'GET' as const, path: '/races/all', name: 'Get all races' },
|
|
||||||
{ method: 'GET' as const, path: '/races/total-races', name: 'Get total races count' },
|
|
||||||
{ method: 'GET' as const, path: '/races/page-data', name: 'Get races page data' },
|
|
||||||
{ method: 'GET' as const, path: '/races/all/page-data', name: 'Get all races page data' },
|
|
||||||
{ method: 'GET' as const, path: '/races/reference/penalty-types', name: 'Get penalty types reference' },
|
|
||||||
|
|
||||||
// League endpoints
|
|
||||||
{ method: 'GET' as const, path: '/leagues/all-with-capacity', name: 'Get all leagues' },
|
|
||||||
{ method: 'GET' as const, path: '/leagues/available', name: 'Get available leagues' },
|
|
||||||
|
|
||||||
// Team endpoints
|
|
||||||
{ method: 'GET' as const, path: '/teams/all', name: 'Get all teams' },
|
|
||||||
|
|
||||||
// Driver endpoints
|
|
||||||
{ method: 'GET' as const, path: '/drivers/leaderboard', name: 'Get driver leaderboard' },
|
|
||||||
{ method: 'GET' as const, path: '/drivers/total-drivers', name: 'Get total drivers count' },
|
|
||||||
|
|
||||||
// Sponsor endpoints
|
|
||||||
{ method: 'GET' as const, path: '/sponsors/pricing', name: 'Get sponsorship pricing' },
|
|
||||||
|
|
||||||
// Features endpoint
|
|
||||||
{ method: 'GET' as const, path: '/features', name: 'Get feature flags' },
|
|
||||||
|
|
||||||
// Hello endpoint
|
|
||||||
{ method: 'GET' as const, path: '/hello', name: 'Hello World' },
|
|
||||||
|
|
||||||
// Media endpoints
|
|
||||||
{ method: 'GET' as const, path: '/media/avatar/non-existent-id', name: 'Get non-existent avatar' },
|
|
||||||
|
|
||||||
// Driver by ID
|
|
||||||
{ method: 'GET' as const, path: '/drivers/non-existent-id', name: 'Get non-existent driver' },
|
|
||||||
];
|
|
||||||
|
|
||||||
console.log(`\n[API SMOKE] Testing ${endpoints.length} public GET endpoints...`);
|
|
||||||
|
|
||||||
for (const endpoint of endpoints) {
|
|
||||||
await testEndpoint(request, endpoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for failures
|
|
||||||
const failures = testResults.filter(r => !r.success);
|
|
||||||
if (failures.length > 0) {
|
|
||||||
console.log('\n❌ FAILURES FOUND:');
|
|
||||||
failures.forEach(r => {
|
|
||||||
console.log(` ${r.method} ${r.endpoint} - ${r.status} - ${r.error || r.response}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert all endpoints succeeded
|
|
||||||
expect(failures.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('POST endpoints handle requests gracefully', async ({ request }) => {
|
|
||||||
testResults = [];
|
|
||||||
const endpoints = [
|
|
||||||
// Auth endpoints (no auth required)
|
|
||||||
{ method: 'POST' as const, path: '/auth/signup', name: 'Signup', requiresAuth: false, body: { email: `test-smoke-${Date.now()}@example.com`, password: 'Password123', displayName: 'Smoke Test', username: 'smoketest' } },
|
|
||||||
{ method: 'POST' as const, path: '/auth/login', name: 'Login', requiresAuth: false, body: { email: 'demo.driver@example.com', password: 'Demo1234!' } },
|
|
||||||
|
|
||||||
// Protected endpoints (require auth)
|
|
||||||
{ method: 'POST' as const, path: '/races/123/register', name: 'Register for race', requiresAuth: true, body: { driverId: 'test-driver' } },
|
|
||||||
{ method: 'POST' as const, path: '/races/protests/file', name: 'File protest', requiresAuth: true, body: { raceId: '123', protestingDriverId: 'driver-1', accusedDriverId: 'driver-2', incident: { lap: 1, description: 'Test protest' } } },
|
|
||||||
{ method: 'POST' as const, path: '/leagues/league-1/join', name: 'Join league', requiresAuth: true, body: {} },
|
|
||||||
{ method: 'POST' as const, path: '/teams/123/join', name: 'Join team', requiresAuth: true, body: { teamId: '123' } },
|
|
||||||
];
|
|
||||||
|
|
||||||
console.log(`\n[API SMOKE] Testing ${endpoints.length} POST endpoints...`);
|
|
||||||
|
|
||||||
for (const endpoint of endpoints) {
|
|
||||||
await testEndpoint(request, endpoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for presenter errors
|
|
||||||
const presenterErrors = testResults.filter(r => r.hasPresenterError);
|
|
||||||
expect(presenterErrors.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('parameterized endpoints handle missing IDs gracefully', async ({ request }) => {
|
|
||||||
testResults = [];
|
|
||||||
const endpoints = [
|
|
||||||
{ method: 'GET' as const, path: '/races/non-existent-id', name: 'Get non-existent race', requiresAuth: false },
|
|
||||||
{ method: 'GET' as const, path: '/races/non-existent-id/results', name: 'Get non-existent race results', requiresAuth: false },
|
|
||||||
{ method: 'GET' as const, path: '/leagues/non-existent-id', name: 'Get non-existent league', requiresAuth: false },
|
|
||||||
{ method: 'GET' as const, path: '/teams/non-existent-id', name: 'Get non-existent team', requiresAuth: false },
|
|
||||||
{ method: 'GET' as const, path: '/drivers/non-existent-id', name: 'Get non-existent driver', requiresAuth: false },
|
|
||||||
{ method: 'GET' as const, path: '/media/avatar/non-existent-id', name: 'Get non-existent avatar', requiresAuth: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
console.log(`\n[API SMOKE] Testing ${endpoints.length} parameterized endpoints with invalid IDs...`);
|
|
||||||
|
|
||||||
for (const endpoint of endpoints) {
|
|
||||||
await testEndpoint(request, endpoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for failures
|
|
||||||
const failures = testResults.filter(r => !r.success);
|
|
||||||
expect(failures.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('authenticated endpoints respond correctly', async () => {
|
|
||||||
testResults = [];
|
|
||||||
|
|
||||||
// Load user auth cookies
|
|
||||||
const userAuthData = await fs.readFile(USER_AUTH_FILE, 'utf-8');
|
|
||||||
const userCookies = JSON.parse(userAuthData).cookies;
|
|
||||||
|
|
||||||
// Create new API request context with user auth
|
|
||||||
const userContext = await request.newContext({
|
|
||||||
storageState: {
|
|
||||||
cookies: userCookies,
|
|
||||||
origins: [{ origin: API_BASE_URL, localStorage: [] }]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const endpoints = [
|
|
||||||
// Dashboard
|
|
||||||
{ method: 'GET' as const, path: '/dashboard/overview', name: 'Dashboard Overview' },
|
|
||||||
|
|
||||||
// Analytics
|
|
||||||
{ method: 'GET' as const, path: '/analytics/metrics', name: 'Analytics Metrics' },
|
|
||||||
|
|
||||||
// Notifications
|
|
||||||
{ method: 'GET' as const, path: '/notifications/unread', name: 'Unread Notifications' },
|
|
||||||
];
|
|
||||||
|
|
||||||
console.log(`\n[API SMOKE] Testing ${endpoints.length} authenticated endpoints...`);
|
|
||||||
|
|
||||||
for (const endpoint of endpoints) {
|
|
||||||
await testEndpoint(userContext, endpoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for presenter errors
|
|
||||||
const presenterErrors = testResults.filter(r => r.hasPresenterError);
|
|
||||||
expect(presenterErrors.length).toBe(0);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
await userContext.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('admin endpoints respond correctly', async () => {
|
|
||||||
testResults = [];
|
|
||||||
|
|
||||||
// Load admin auth cookies
|
|
||||||
const adminAuthData = await fs.readFile(ADMIN_AUTH_FILE, 'utf-8');
|
|
||||||
const adminCookies = JSON.parse(adminAuthData).cookies;
|
|
||||||
|
|
||||||
// Create new API request context with admin auth
|
|
||||||
const adminContext = await request.newContext({
|
|
||||||
storageState: {
|
|
||||||
cookies: adminCookies,
|
|
||||||
origins: [{ origin: API_BASE_URL, localStorage: [] }]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const endpoints = [
|
|
||||||
// Payments (requires admin capability)
|
|
||||||
{ method: 'GET' as const, path: '/payments/wallets?leagueId=league-1', name: 'Wallets' },
|
|
||||||
];
|
|
||||||
|
|
||||||
console.log(`\n[API SMOKE] Testing ${endpoints.length} admin endpoints...`);
|
|
||||||
|
|
||||||
for (const endpoint of endpoints) {
|
|
||||||
await testEndpoint(adminContext, endpoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for presenter errors
|
|
||||||
const presenterErrors = testResults.filter(r => r.hasPresenterError);
|
|
||||||
expect(presenterErrors.length).toBe(0);
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
await adminContext.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function testEndpoint(
|
|
||||||
request: import('@playwright/test').APIRequestContext,
|
|
||||||
endpoint: { method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; path: string; name?: string; body?: unknown; requiresAuth?: boolean }
|
|
||||||
): Promise<void> {
|
|
||||||
const startTime = Date.now();
|
|
||||||
const fullUrl = `${API_BASE_URL}${endpoint.path}`;
|
|
||||||
|
|
||||||
console.log(`\n[TEST] ${endpoint.method} ${endpoint.path} (${endpoint.name || 'Unknown'})`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
let response;
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
|
|
||||||
// Playwright's request context handles cookies automatically
|
|
||||||
// No need to set Authorization header for cookie-based auth
|
|
||||||
|
|
||||||
switch (endpoint.method) {
|
|
||||||
case 'GET':
|
|
||||||
response = await request.get(fullUrl, { headers });
|
|
||||||
break;
|
|
||||||
case 'POST':
|
|
||||||
response = await request.post(fullUrl, { data: endpoint.body || {}, headers });
|
|
||||||
break;
|
|
||||||
case 'PUT':
|
|
||||||
response = await request.put(fullUrl, { data: endpoint.body || {}, headers });
|
|
||||||
break;
|
|
||||||
case 'DELETE':
|
|
||||||
response = await request.delete(fullUrl, { headers });
|
|
||||||
break;
|
|
||||||
case 'PATCH':
|
|
||||||
response = await request.patch(fullUrl, { data: endpoint.body || {}, headers });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseTime = Date.now() - startTime;
|
|
||||||
const status = response.status();
|
|
||||||
const body = await response.json().catch(() => null);
|
|
||||||
const bodyText = await response.text().catch(() => '');
|
|
||||||
|
|
||||||
// Check for presenter errors
|
|
||||||
const hasPresenterError =
|
|
||||||
bodyText.includes('Presenter not presented') ||
|
|
||||||
bodyText.includes('presenter not presented') ||
|
|
||||||
(body && body.message && body.message.includes('Presenter not presented')) ||
|
|
||||||
(body && body.error && body.error.includes('Presenter not presented'));
|
|
||||||
|
|
||||||
// Success is 200-299 status, or 404 for non-existent resources, and no presenter error
|
|
||||||
const isNotFound = status === 404;
|
|
||||||
const success = (status >= 200 && status < 300 || isNotFound) && !hasPresenterError;
|
|
||||||
|
|
||||||
const result: EndpointTestResult = {
|
|
||||||
endpoint: endpoint.path,
|
|
||||||
method: endpoint.method,
|
|
||||||
status,
|
|
||||||
success,
|
|
||||||
hasPresenterError,
|
|
||||||
responseTime,
|
|
||||||
response: body || bodyText.substring(0, 200),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
result.error = body?.message || bodyText.substring(0, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
testResults.push(result);
|
|
||||||
allResults.push(result);
|
|
||||||
|
|
||||||
if (hasPresenterError) {
|
|
||||||
console.log(` ❌ PRESENTER ERROR: ${status} - ${body?.message || bodyText.substring(0, 100)}`);
|
|
||||||
} else if (success) {
|
|
||||||
console.log(` ✅ ${status} (${responseTime}ms)`);
|
|
||||||
} else {
|
|
||||||
console.log(` ⚠️ ${status} (${responseTime}ms) - ${body?.message || 'Error'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const responseTime = Date.now() - startTime;
|
|
||||||
const errorString = error instanceof Error ? error.message : String(error);
|
|
||||||
|
|
||||||
const result: EndpointTestResult = {
|
|
||||||
endpoint: endpoint.path,
|
|
||||||
method: endpoint.method,
|
|
||||||
status: 0,
|
|
||||||
success: false,
|
|
||||||
hasPresenterError: false,
|
|
||||||
responseTime,
|
|
||||||
error: errorString,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if it's a presenter error
|
|
||||||
if (errorString.includes('Presenter not presented')) {
|
|
||||||
result.hasPresenterError = true;
|
|
||||||
console.log(` ❌ PRESENTER ERROR (exception): ${errorString}`);
|
|
||||||
} else {
|
|
||||||
console.log(` ❌ EXCEPTION: ${errorString}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
testResults.push(result);
|
|
||||||
allResults.push(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateReport(): Promise<void> {
|
|
||||||
const summary = {
|
|
||||||
total: allResults.length,
|
|
||||||
success: allResults.filter(r => r.success).length,
|
|
||||||
failed: allResults.filter(r => !r.success).length,
|
|
||||||
presenterErrors: allResults.filter(r => r.hasPresenterError).length,
|
|
||||||
avgResponseTime: allResults.reduce((sum, r) => sum + r.responseTime, 0) / allResults.length || 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const report: TestReport = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
summary,
|
|
||||||
results: allResults,
|
|
||||||
failures: allResults.filter(r => !r.success),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Write JSON report
|
|
||||||
const jsonPath = path.join(__dirname, '../../../api-smoke-report.json');
|
|
||||||
await fs.writeFile(jsonPath, JSON.stringify(report, null, 2));
|
|
||||||
|
|
||||||
// Write Markdown report
|
|
||||||
const mdPath = path.join(__dirname, '../../../api-smoke-report.md');
|
|
||||||
let md = `# API Smoke Test Report\n\n`;
|
|
||||||
md += `**Generated:** ${new Date().toISOString()}\n`;
|
|
||||||
md += `**API Base URL:** ${API_BASE_URL}\n\n`;
|
|
||||||
|
|
||||||
md += `## Summary\n\n`;
|
|
||||||
md += `- **Total Endpoints:** ${summary.total}\n`;
|
|
||||||
md += `- **✅ Success:** ${summary.success}\n`;
|
|
||||||
md += `- **❌ Failed:** ${summary.failed}\n`;
|
|
||||||
md += `- **⚠️ Presenter Errors:** ${summary.presenterErrors}\n`;
|
|
||||||
md += `- **Avg Response Time:** ${summary.avgResponseTime.toFixed(2)}ms\n\n`;
|
|
||||||
|
|
||||||
if (summary.presenterErrors > 0) {
|
|
||||||
md += `## Presenter Errors\n\n`;
|
|
||||||
const presenterFailures = allResults.filter(r => r.hasPresenterError);
|
|
||||||
presenterFailures.forEach((r, i) => {
|
|
||||||
md += `${i + 1}. **${r.method} ${r.endpoint}**\n`;
|
|
||||||
md += ` - Status: ${r.status}\n`;
|
|
||||||
md += ` - Error: ${r.error || 'No error message'}\n\n`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (summary.failed > 0 && summary.presenterErrors < summary.failed) {
|
|
||||||
md += `## Other Failures\n\n`;
|
|
||||||
const otherFailures = allResults.filter(r => !r.success && !r.hasPresenterError);
|
|
||||||
otherFailures.forEach((r, i) => {
|
|
||||||
md += `${i + 1}. **${r.method} ${r.endpoint}**\n`;
|
|
||||||
md += ` - Status: ${r.status}\n`;
|
|
||||||
md += ` - Error: ${r.error || 'No error message'}\n\n`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.writeFile(mdPath, md);
|
|
||||||
|
|
||||||
console.log(`\n📊 Reports generated:`);
|
|
||||||
console.log(` JSON: ${jsonPath}`);
|
|
||||||
console.log(` Markdown: ${mdPath}`);
|
|
||||||
console.log(`\nSummary: ${summary.success}/${summary.total} passed, ${summary.presenterErrors} presenter errors`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,782 +0,0 @@
|
|||||||
/**
|
|
||||||
* League API Tests
|
|
||||||
*
|
|
||||||
* This test suite performs comprehensive API testing for league-related endpoints.
|
|
||||||
* It validates:
|
|
||||||
* - Response structure matches expected DTO
|
|
||||||
* - Required fields are present
|
|
||||||
* - Data types are correct
|
|
||||||
* - Edge cases (empty results, missing data)
|
|
||||||
* - Business logic (sorting, filtering, calculations)
|
|
||||||
*
|
|
||||||
* This test is designed to run in the Docker e2e environment and can be executed with:
|
|
||||||
* npm run test:e2e:website (which runs everything in Docker)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect, request } from '@playwright/test';
|
|
||||||
import * as fs from 'fs/promises';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
interface TestResult {
|
|
||||||
endpoint: string;
|
|
||||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
|
||||||
status: number;
|
|
||||||
success: boolean;
|
|
||||||
error?: string;
|
|
||||||
response?: unknown;
|
|
||||||
hasPresenterError: boolean;
|
|
||||||
responseTime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const API_BASE_URL = process.env.API_BASE_URL ?? process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3101';
|
|
||||||
|
|
||||||
// Auth file paths
|
|
||||||
const USER_AUTH_FILE = path.join(__dirname, '.auth/user-session.json');
|
|
||||||
const ADMIN_AUTH_FILE = path.join(__dirname, '.auth/admin-session.json');
|
|
||||||
|
|
||||||
test.describe('League API Tests', () => {
|
|
||||||
const allResults: TestResult[] = [];
|
|
||||||
let testResults: TestResult[] = [];
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
console.log(`[LEAGUE API] Testing API at: ${API_BASE_URL}`);
|
|
||||||
|
|
||||||
// Verify auth files exist
|
|
||||||
const userAuthExists = await fs.access(USER_AUTH_FILE).then(() => true).catch(() => false);
|
|
||||||
const adminAuthExists = await fs.access(ADMIN_AUTH_FILE).then(() => true).catch(() => false);
|
|
||||||
|
|
||||||
if (!userAuthExists || !adminAuthExists) {
|
|
||||||
throw new Error('Auth files not found. Run global setup first.');
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[LEAGUE API] Auth files verified');
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async () => {
|
|
||||||
await generateReport();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('League Discovery Endpoints - Public endpoints', async ({ request }) => {
|
|
||||||
testResults = [];
|
|
||||||
const endpoints = [
|
|
||||||
{ method: 'GET' as const, path: '/leagues/all-with-capacity', name: 'Get all leagues with capacity' },
|
|
||||||
{ method: 'GET' as const, path: '/leagues/all-with-capacity-and-scoring', name: 'Get all leagues with capacity and scoring' },
|
|
||||||
{ method: 'GET' as const, path: '/leagues/total-leagues', name: 'Get total leagues count' },
|
|
||||||
{ method: 'GET' as const, path: '/leagues/all', name: 'Get all leagues (alias)' },
|
|
||||||
{ method: 'GET' as const, path: '/leagues/available', name: 'Get available leagues (alias)' },
|
|
||||||
];
|
|
||||||
|
|
||||||
console.log(`\n[LEAGUE API] Testing ${endpoints.length} league discovery endpoints...`);
|
|
||||||
|
|
||||||
for (const endpoint of endpoints) {
|
|
||||||
await testEndpoint(request, endpoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for failures
|
|
||||||
const failures = testResults.filter(r => !r.success);
|
|
||||||
if (failures.length > 0) {
|
|
||||||
console.log('\n❌ FAILURES FOUND:');
|
|
||||||
failures.forEach(r => {
|
|
||||||
console.log(` ${r.method} ${r.endpoint} - ${r.status} - ${r.error || r.response}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert all endpoints succeeded
|
|
||||||
expect(failures.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('League Discovery - Response structure validation', async ({ request }) => {
|
|
||||||
testResults = [];
|
|
||||||
|
|
||||||
// Test /leagues/all-with-capacity
|
|
||||||
const allLeaguesResponse = await request.get(`${API_BASE_URL}/leagues/all-with-capacity`);
|
|
||||||
expect(allLeaguesResponse.ok()).toBe(true);
|
|
||||||
|
|
||||||
const allLeaguesData = await allLeaguesResponse.json();
|
|
||||||
expect(allLeaguesData).toHaveProperty('leagues');
|
|
||||||
expect(allLeaguesData).toHaveProperty('totalCount');
|
|
||||||
expect(Array.isArray(allLeaguesData.leagues)).toBe(true);
|
|
||||||
expect(typeof allLeaguesData.totalCount).toBe('number');
|
|
||||||
|
|
||||||
// Validate league structure if leagues exist
|
|
||||||
if (allLeaguesData.leagues.length > 0) {
|
|
||||||
const league = allLeaguesData.leagues[0];
|
|
||||||
expect(league).toHaveProperty('id');
|
|
||||||
expect(league).toHaveProperty('name');
|
|
||||||
expect(league).toHaveProperty('description');
|
|
||||||
expect(league).toHaveProperty('ownerId');
|
|
||||||
expect(league).toHaveProperty('createdAt');
|
|
||||||
expect(league).toHaveProperty('settings');
|
|
||||||
expect(league.settings).toHaveProperty('maxDrivers');
|
|
||||||
expect(league).toHaveProperty('usedSlots');
|
|
||||||
|
|
||||||
// Validate data types
|
|
||||||
expect(typeof league.id).toBe('string');
|
|
||||||
expect(typeof league.name).toBe('string');
|
|
||||||
expect(typeof league.description).toBe('string');
|
|
||||||
expect(typeof league.ownerId).toBe('string');
|
|
||||||
expect(typeof league.createdAt).toBe('string');
|
|
||||||
expect(typeof league.settings.maxDrivers).toBe('number');
|
|
||||||
expect(typeof league.usedSlots).toBe('number');
|
|
||||||
|
|
||||||
// Validate business logic: usedSlots <= maxDrivers
|
|
||||||
expect(league.usedSlots).toBeLessThanOrEqual(league.settings.maxDrivers);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test /leagues/all-with-capacity-and-scoring
|
|
||||||
const scoredLeaguesResponse = await request.get(`${API_BASE_URL}/leagues/all-with-capacity-and-scoring`);
|
|
||||||
expect(scoredLeaguesResponse.ok()).toBe(true);
|
|
||||||
|
|
||||||
const scoredLeaguesData = await scoredLeaguesResponse.json();
|
|
||||||
expect(scoredLeaguesData).toHaveProperty('leagues');
|
|
||||||
expect(scoredLeaguesData).toHaveProperty('totalCount');
|
|
||||||
expect(Array.isArray(scoredLeaguesData.leagues)).toBe(true);
|
|
||||||
|
|
||||||
// Validate scoring structure if leagues exist
|
|
||||||
if (scoredLeaguesData.leagues.length > 0) {
|
|
||||||
const league = scoredLeaguesData.leagues[0];
|
|
||||||
expect(league).toHaveProperty('scoring');
|
|
||||||
expect(league.scoring).toHaveProperty('gameId');
|
|
||||||
expect(league.scoring).toHaveProperty('scoringPresetId');
|
|
||||||
|
|
||||||
// Validate data types
|
|
||||||
expect(typeof league.scoring.gameId).toBe('string');
|
|
||||||
expect(typeof league.scoring.scoringPresetId).toBe('string');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test /leagues/total-leagues
|
|
||||||
const totalResponse = await request.get(`${API_BASE_URL}/leagues/total-leagues`);
|
|
||||||
expect(totalResponse.ok()).toBe(true);
|
|
||||||
|
|
||||||
const totalData = await totalResponse.json();
|
|
||||||
expect(totalData).toHaveProperty('totalLeagues');
|
|
||||||
expect(typeof totalData.totalLeagues).toBe('number');
|
|
||||||
expect(totalData.totalLeagues).toBeGreaterThanOrEqual(0);
|
|
||||||
|
|
||||||
// Validate consistency: totalCount from all-with-capacity should match totalLeagues
|
|
||||||
expect(allLeaguesData.totalCount).toBe(totalData.totalLeagues);
|
|
||||||
|
|
||||||
testResults.push({
|
|
||||||
endpoint: '/leagues/all-with-capacity',
|
|
||||||
method: 'GET',
|
|
||||||
status: allLeaguesResponse.status(),
|
|
||||||
success: true,
|
|
||||||
hasPresenterError: false,
|
|
||||||
responseTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
testResults.push({
|
|
||||||
endpoint: '/leagues/all-with-capacity-and-scoring',
|
|
||||||
method: 'GET',
|
|
||||||
status: scoredLeaguesResponse.status(),
|
|
||||||
success: true,
|
|
||||||
hasPresenterError: false,
|
|
||||||
responseTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
testResults.push({
|
|
||||||
endpoint: '/leagues/total-leagues',
|
|
||||||
method: 'GET',
|
|
||||||
status: totalResponse.status(),
|
|
||||||
success: true,
|
|
||||||
hasPresenterError: false,
|
|
||||||
responseTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
allResults.push(...testResults);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('League Detail Endpoints - Public endpoints', async ({ request }) => {
|
|
||||||
testResults = [];
|
|
||||||
|
|
||||||
// First, get a valid league ID from the discovery endpoint
|
|
||||||
const discoveryResponse = await request.get(`${API_BASE_URL}/leagues/all-with-capacity`);
|
|
||||||
const discoveryData = await discoveryResponse.json();
|
|
||||||
|
|
||||||
if (discoveryData.leagues.length === 0) {
|
|
||||||
console.log('[LEAGUE API] No leagues found, skipping detail endpoint tests');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const leagueId = discoveryData.leagues[0].id;
|
|
||||||
|
|
||||||
const endpoints = [
|
|
||||||
{ method: 'GET' as const, path: `/leagues/${leagueId}`, name: 'Get league details' },
|
|
||||||
{ method: 'GET' as const, path: `/leagues/${leagueId}/seasons`, name: 'Get league seasons' },
|
|
||||||
{ method: 'GET' as const, path: `/leagues/${leagueId}/stats`, name: 'Get league stats' },
|
|
||||||
{ method: 'GET' as const, path: `/leagues/${leagueId}/memberships`, name: 'Get league memberships' },
|
|
||||||
];
|
|
||||||
|
|
||||||
console.log(`\n[LEAGUE API] Testing ${endpoints.length} league detail endpoints for league ${leagueId}...`);
|
|
||||||
|
|
||||||
for (const endpoint of endpoints) {
|
|
||||||
await testEndpoint(request, endpoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for failures
|
|
||||||
const failures = testResults.filter(r => !r.success);
|
|
||||||
if (failures.length > 0) {
|
|
||||||
console.log('\n❌ FAILURES FOUND:');
|
|
||||||
failures.forEach(r => {
|
|
||||||
console.log(` ${r.method} ${r.endpoint} - ${r.status} - ${r.error || r.response}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert all endpoints succeeded
|
|
||||||
expect(failures.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('League Detail - Response structure validation', async ({ request }) => {
|
|
||||||
testResults = [];
|
|
||||||
|
|
||||||
// First, get a valid league ID from the discovery endpoint
|
|
||||||
const discoveryResponse = await request.get(`${API_BASE_URL}/leagues/all-with-capacity`);
|
|
||||||
const discoveryData = await discoveryResponse.json();
|
|
||||||
|
|
||||||
if (discoveryData.leagues.length === 0) {
|
|
||||||
console.log('[LEAGUE API] No leagues found, skipping detail validation tests');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const leagueId = discoveryData.leagues[0].id;
|
|
||||||
|
|
||||||
// Test /leagues/{id}
|
|
||||||
const leagueResponse = await request.get(`${API_BASE_URL}/leagues/${leagueId}`);
|
|
||||||
expect(leagueResponse.ok()).toBe(true);
|
|
||||||
|
|
||||||
const leagueData = await leagueResponse.json();
|
|
||||||
expect(leagueData).toHaveProperty('id');
|
|
||||||
expect(leagueData).toHaveProperty('name');
|
|
||||||
expect(leagueData).toHaveProperty('description');
|
|
||||||
expect(leagueData).toHaveProperty('ownerId');
|
|
||||||
expect(leagueData).toHaveProperty('createdAt');
|
|
||||||
|
|
||||||
// Validate data types
|
|
||||||
expect(typeof leagueData.id).toBe('string');
|
|
||||||
expect(typeof leagueData.name).toBe('string');
|
|
||||||
expect(typeof leagueData.description).toBe('string');
|
|
||||||
expect(typeof leagueData.ownerId).toBe('string');
|
|
||||||
expect(typeof leagueData.createdAt).toBe('string');
|
|
||||||
|
|
||||||
// Validate ID matches requested ID
|
|
||||||
expect(leagueData.id).toBe(leagueId);
|
|
||||||
|
|
||||||
// Test /leagues/{id}/seasons
|
|
||||||
const seasonsResponse = await request.get(`${API_BASE_URL}/leagues/${leagueId}/seasons`);
|
|
||||||
expect(seasonsResponse.ok()).toBe(true);
|
|
||||||
|
|
||||||
const seasonsData = await seasonsResponse.json();
|
|
||||||
expect(Array.isArray(seasonsData)).toBe(true);
|
|
||||||
|
|
||||||
// Validate season structure if seasons exist
|
|
||||||
if (seasonsData.length > 0) {
|
|
||||||
const season = seasonsData[0];
|
|
||||||
expect(season).toHaveProperty('id');
|
|
||||||
expect(season).toHaveProperty('name');
|
|
||||||
expect(season).toHaveProperty('status');
|
|
||||||
|
|
||||||
// Validate data types
|
|
||||||
expect(typeof season.id).toBe('string');
|
|
||||||
expect(typeof season.name).toBe('string');
|
|
||||||
expect(typeof season.status).toBe('string');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test /leagues/{id}/stats
|
|
||||||
const statsResponse = await request.get(`${API_BASE_URL}/leagues/${leagueId}/stats`);
|
|
||||||
expect(statsResponse.ok()).toBe(true);
|
|
||||||
|
|
||||||
const statsData = await statsResponse.json();
|
|
||||||
expect(statsData).toHaveProperty('memberCount');
|
|
||||||
expect(statsData).toHaveProperty('raceCount');
|
|
||||||
expect(statsData).toHaveProperty('avgSOF');
|
|
||||||
|
|
||||||
// Validate data types
|
|
||||||
expect(typeof statsData.memberCount).toBe('number');
|
|
||||||
expect(typeof statsData.raceCount).toBe('number');
|
|
||||||
expect(typeof statsData.avgSOF).toBe('number');
|
|
||||||
|
|
||||||
// Validate business logic: counts should be non-negative
|
|
||||||
expect(statsData.memberCount).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(statsData.raceCount).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(statsData.avgSOF).toBeGreaterThanOrEqual(0);
|
|
||||||
|
|
||||||
// Test /leagues/{id}/memberships
|
|
||||||
const membershipsResponse = await request.get(`${API_BASE_URL}/leagues/${leagueId}/memberships`);
|
|
||||||
expect(membershipsResponse.ok()).toBe(true);
|
|
||||||
|
|
||||||
const membershipsData = await membershipsResponse.json();
|
|
||||||
expect(membershipsData).toHaveProperty('members');
|
|
||||||
expect(Array.isArray(membershipsData.members)).toBe(true);
|
|
||||||
|
|
||||||
// Validate membership structure if members exist
|
|
||||||
if (membershipsData.members.length > 0) {
|
|
||||||
const member = membershipsData.members[0];
|
|
||||||
expect(member).toHaveProperty('driverId');
|
|
||||||
expect(member).toHaveProperty('role');
|
|
||||||
expect(member).toHaveProperty('joinedAt');
|
|
||||||
|
|
||||||
// Validate data types
|
|
||||||
expect(typeof member.driverId).toBe('string');
|
|
||||||
expect(typeof member.role).toBe('string');
|
|
||||||
expect(typeof member.joinedAt).toBe('string');
|
|
||||||
|
|
||||||
// Validate business logic: at least one owner must exist
|
|
||||||
const hasOwner = membershipsData.members.some((m: any) => m.role === 'owner');
|
|
||||||
expect(hasOwner).toBe(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
testResults.push({
|
|
||||||
endpoint: `/leagues/${leagueId}`,
|
|
||||||
method: 'GET',
|
|
||||||
status: leagueResponse.status(),
|
|
||||||
success: true,
|
|
||||||
hasPresenterError: false,
|
|
||||||
responseTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
testResults.push({
|
|
||||||
endpoint: `/leagues/${leagueId}/seasons`,
|
|
||||||
method: 'GET',
|
|
||||||
status: seasonsResponse.status(),
|
|
||||||
success: true,
|
|
||||||
hasPresenterError: false,
|
|
||||||
responseTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
testResults.push({
|
|
||||||
endpoint: `/leagues/${leagueId}/stats`,
|
|
||||||
method: 'GET',
|
|
||||||
status: statsResponse.status(),
|
|
||||||
success: true,
|
|
||||||
hasPresenterError: false,
|
|
||||||
responseTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
testResults.push({
|
|
||||||
endpoint: `/leagues/${leagueId}/memberships`,
|
|
||||||
method: 'GET',
|
|
||||||
status: membershipsResponse.status(),
|
|
||||||
success: true,
|
|
||||||
hasPresenterError: false,
|
|
||||||
responseTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
allResults.push(...testResults);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('League Schedule Endpoints - Public endpoints', async ({ request }) => {
|
|
||||||
testResults = [];
|
|
||||||
|
|
||||||
// First, get a valid league ID from the discovery endpoint
|
|
||||||
const discoveryResponse = await request.get(`${API_BASE_URL}/leagues/all-with-capacity`);
|
|
||||||
const discoveryData = await discoveryResponse.json();
|
|
||||||
|
|
||||||
if (discoveryData.leagues.length === 0) {
|
|
||||||
console.log('[LEAGUE API] No leagues found, skipping schedule endpoint tests');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const leagueId = discoveryData.leagues[0].id;
|
|
||||||
|
|
||||||
const endpoints = [
|
|
||||||
{ method: 'GET' as const, path: `/leagues/${leagueId}/schedule`, name: 'Get league schedule' },
|
|
||||||
];
|
|
||||||
|
|
||||||
console.log(`\n[LEAGUE API] Testing ${endpoints.length} league schedule endpoints for league ${leagueId}...`);
|
|
||||||
|
|
||||||
for (const endpoint of endpoints) {
|
|
||||||
await testEndpoint(request, endpoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for failures
|
|
||||||
const failures = testResults.filter(r => !r.success);
|
|
||||||
if (failures.length > 0) {
|
|
||||||
console.log('\n❌ FAILURES FOUND:');
|
|
||||||
failures.forEach(r => {
|
|
||||||
console.log(` ${r.method} ${r.endpoint} - ${r.status} - ${r.error || r.response}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert all endpoints succeeded
|
|
||||||
expect(failures.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('League Schedule - Response structure validation', async ({ request }) => {
|
|
||||||
testResults = [];
|
|
||||||
|
|
||||||
// First, get a valid league ID from the discovery endpoint
|
|
||||||
const discoveryResponse = await request.get(`${API_BASE_URL}/leagues/all-with-capacity`);
|
|
||||||
const discoveryData = await discoveryResponse.json();
|
|
||||||
|
|
||||||
if (discoveryData.leagues.length === 0) {
|
|
||||||
console.log('[LEAGUE API] No leagues found, skipping schedule validation tests');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const leagueId = discoveryData.leagues[0].id;
|
|
||||||
|
|
||||||
// Test /leagues/{id}/schedule
|
|
||||||
const scheduleResponse = await request.get(`${API_BASE_URL}/leagues/${leagueId}/schedule`);
|
|
||||||
expect(scheduleResponse.ok()).toBe(true);
|
|
||||||
|
|
||||||
const scheduleData = await scheduleResponse.json();
|
|
||||||
expect(scheduleData).toHaveProperty('seasonId');
|
|
||||||
expect(scheduleData).toHaveProperty('races');
|
|
||||||
expect(Array.isArray(scheduleData.races)).toBe(true);
|
|
||||||
|
|
||||||
// Validate data types
|
|
||||||
expect(typeof scheduleData.seasonId).toBe('string');
|
|
||||||
|
|
||||||
// Validate race structure if races exist
|
|
||||||
if (scheduleData.races.length > 0) {
|
|
||||||
const race = scheduleData.races[0];
|
|
||||||
expect(race).toHaveProperty('id');
|
|
||||||
expect(race).toHaveProperty('track');
|
|
||||||
expect(race).toHaveProperty('car');
|
|
||||||
expect(race).toHaveProperty('scheduledAt');
|
|
||||||
|
|
||||||
// Validate data types
|
|
||||||
expect(typeof race.id).toBe('string');
|
|
||||||
expect(typeof race.track).toBe('string');
|
|
||||||
expect(typeof race.car).toBe('string');
|
|
||||||
expect(typeof race.scheduledAt).toBe('string');
|
|
||||||
|
|
||||||
// Validate business logic: races should be sorted by scheduledAt
|
|
||||||
const scheduledTimes = scheduleData.races.map((r: any) => new Date(r.scheduledAt).getTime());
|
|
||||||
const sortedTimes = [...scheduledTimes].sort((a, b) => a - b);
|
|
||||||
expect(scheduledTimes).toEqual(sortedTimes);
|
|
||||||
}
|
|
||||||
|
|
||||||
testResults.push({
|
|
||||||
endpoint: `/leagues/${leagueId}/schedule`,
|
|
||||||
method: 'GET',
|
|
||||||
status: scheduleResponse.status(),
|
|
||||||
success: true,
|
|
||||||
hasPresenterError: false,
|
|
||||||
responseTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
allResults.push(...testResults);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('League Standings Endpoints - Public endpoints', async ({ request }) => {
|
|
||||||
testResults = [];
|
|
||||||
|
|
||||||
// First, get a valid league ID from the discovery endpoint
|
|
||||||
const discoveryResponse = await request.get(`${API_BASE_URL}/leagues/all-with-capacity`);
|
|
||||||
const discoveryData = await discoveryResponse.json();
|
|
||||||
|
|
||||||
if (discoveryData.leagues.length === 0) {
|
|
||||||
console.log('[LEAGUE API] No leagues found, skipping standings endpoint tests');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const leagueId = discoveryData.leagues[0].id;
|
|
||||||
|
|
||||||
const endpoints = [
|
|
||||||
{ method: 'GET' as const, path: `/leagues/${leagueId}/standings`, name: 'Get league standings' },
|
|
||||||
];
|
|
||||||
|
|
||||||
console.log(`\n[LEAGUE API] Testing ${endpoints.length} league standings endpoints for league ${leagueId}...`);
|
|
||||||
|
|
||||||
for (const endpoint of endpoints) {
|
|
||||||
await testEndpoint(request, endpoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for failures
|
|
||||||
const failures = testResults.filter(r => !r.success);
|
|
||||||
if (failures.length > 0) {
|
|
||||||
console.log('\n❌ FAILURES FOUND:');
|
|
||||||
failures.forEach(r => {
|
|
||||||
console.log(` ${r.method} ${r.endpoint} - ${r.status} - ${r.error || r.response}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert all endpoints succeeded
|
|
||||||
expect(failures.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('League Standings - Response structure validation', async ({ request }) => {
|
|
||||||
testResults = [];
|
|
||||||
|
|
||||||
// First, get a valid league ID from the discovery endpoint
|
|
||||||
const discoveryResponse = await request.get(`${API_BASE_URL}/leagues/all-with-capacity`);
|
|
||||||
const discoveryData = await discoveryResponse.json();
|
|
||||||
|
|
||||||
if (discoveryData.leagues.length === 0) {
|
|
||||||
console.log('[LEAGUE API] No leagues found, skipping standings validation tests');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const leagueId = discoveryData.leagues[0].id;
|
|
||||||
|
|
||||||
// Test /leagues/{id}/standings
|
|
||||||
const standingsResponse = await request.get(`${API_BASE_URL}/leagues/${leagueId}/standings`);
|
|
||||||
expect(standingsResponse.ok()).toBe(true);
|
|
||||||
|
|
||||||
const standingsData = await standingsResponse.json();
|
|
||||||
expect(standingsData).toHaveProperty('standings');
|
|
||||||
expect(Array.isArray(standingsData.standings)).toBe(true);
|
|
||||||
|
|
||||||
// Validate standing structure if standings exist
|
|
||||||
if (standingsData.standings.length > 0) {
|
|
||||||
const standing = standingsData.standings[0];
|
|
||||||
expect(standing).toHaveProperty('position');
|
|
||||||
expect(standing).toHaveProperty('driverId');
|
|
||||||
expect(standing).toHaveProperty('points');
|
|
||||||
expect(standing).toHaveProperty('races');
|
|
||||||
|
|
||||||
// Validate data types
|
|
||||||
expect(typeof standing.position).toBe('number');
|
|
||||||
expect(typeof standing.driverId).toBe('string');
|
|
||||||
expect(typeof standing.points).toBe('number');
|
|
||||||
expect(typeof standing.races).toBe('number');
|
|
||||||
|
|
||||||
// Validate business logic: position must be sequential starting from 1
|
|
||||||
const positions = standingsData.standings.map((s: any) => s.position);
|
|
||||||
const expectedPositions = Array.from({ length: positions.length }, (_, i) => i + 1);
|
|
||||||
expect(positions).toEqual(expectedPositions);
|
|
||||||
|
|
||||||
// Validate business logic: points must be non-negative
|
|
||||||
expect(standing.points).toBeGreaterThanOrEqual(0);
|
|
||||||
|
|
||||||
// Validate business logic: races count must be non-negative
|
|
||||||
expect(standing.races).toBeGreaterThanOrEqual(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
testResults.push({
|
|
||||||
endpoint: `/leagues/${leagueId}/standings`,
|
|
||||||
method: 'GET',
|
|
||||||
status: standingsResponse.status(),
|
|
||||||
success: true,
|
|
||||||
hasPresenterError: false,
|
|
||||||
responseTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
allResults.push(...testResults);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Edge Cases - Invalid league IDs', async ({ request }) => {
|
|
||||||
testResults = [];
|
|
||||||
|
|
||||||
const endpoints = [
|
|
||||||
{ method: 'GET' as const, path: '/leagues/non-existent-league-id', name: 'Get non-existent league' },
|
|
||||||
{ method: 'GET' as const, path: '/leagues/non-existent-league-id/seasons', name: 'Get seasons for non-existent league' },
|
|
||||||
{ method: 'GET' as const, path: '/leagues/non-existent-league-id/stats', name: 'Get stats for non-existent league' },
|
|
||||||
{ method: 'GET' as const, path: '/leagues/non-existent-league-id/schedule', name: 'Get schedule for non-existent league' },
|
|
||||||
{ method: 'GET' as const, path: '/leagues/non-existent-league-id/standings', name: 'Get standings for non-existent league' },
|
|
||||||
{ method: 'GET' as const, path: '/leagues/non-existent-league-id/memberships', name: 'Get memberships for non-existent league' },
|
|
||||||
];
|
|
||||||
|
|
||||||
console.log(`\n[LEAGUE API] Testing ${endpoints.length} edge case endpoints with invalid IDs...`);
|
|
||||||
|
|
||||||
for (const endpoint of endpoints) {
|
|
||||||
await testEndpoint(request, endpoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for failures
|
|
||||||
const failures = testResults.filter(r => !r.success);
|
|
||||||
if (failures.length > 0) {
|
|
||||||
console.log('\n❌ FAILURES FOUND:');
|
|
||||||
failures.forEach(r => {
|
|
||||||
console.log(` ${r.method} ${r.endpoint} - ${r.status} - ${r.error || r.response}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert all endpoints succeeded (404 is acceptable for non-existent resources)
|
|
||||||
expect(failures.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Edge Cases - Empty results', async ({ request }) => {
|
|
||||||
testResults = [];
|
|
||||||
|
|
||||||
// Test discovery endpoints with filters (if available)
|
|
||||||
// Note: The current API doesn't seem to have filter parameters, but we test the base endpoints
|
|
||||||
|
|
||||||
const endpoints = [
|
|
||||||
{ method: 'GET' as const, path: '/leagues/all-with-capacity', name: 'Get all leagues (empty check)' },
|
|
||||||
{ method: 'GET' as const, path: '/leagues/all-with-capacity-and-scoring', name: 'Get all leagues with scoring (empty check)' },
|
|
||||||
];
|
|
||||||
|
|
||||||
console.log(`\n[LEAGUE API] Testing ${endpoints.length} endpoints for empty result handling...`);
|
|
||||||
|
|
||||||
for (const endpoint of endpoints) {
|
|
||||||
await testEndpoint(request, endpoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for failures
|
|
||||||
const failures = testResults.filter(r => !r.success);
|
|
||||||
if (failures.length > 0) {
|
|
||||||
console.log('\n❌ FAILURES FOUND:');
|
|
||||||
failures.forEach(r => {
|
|
||||||
console.log(` ${r.method} ${r.endpoint} - ${r.status} - ${r.error || r.response}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Assert all endpoints succeeded
|
|
||||||
expect(failures.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function testEndpoint(
|
|
||||||
request: import('@playwright/test').APIRequestContext,
|
|
||||||
endpoint: { method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; path: string; name?: string; body?: unknown; requiresAuth?: boolean }
|
|
||||||
): Promise<void> {
|
|
||||||
const startTime = Date.now();
|
|
||||||
const fullUrl = `${API_BASE_URL}${endpoint.path}`;
|
|
||||||
|
|
||||||
console.log(`\n[TEST] ${endpoint.method} ${endpoint.path} (${endpoint.name || 'Unknown'})`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
let response;
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
|
|
||||||
// Playwright's request context handles cookies automatically
|
|
||||||
// No need to set Authorization header for cookie-based auth
|
|
||||||
|
|
||||||
switch (endpoint.method) {
|
|
||||||
case 'GET':
|
|
||||||
response = await request.get(fullUrl, { headers });
|
|
||||||
break;
|
|
||||||
case 'POST':
|
|
||||||
response = await request.post(fullUrl, { data: endpoint.body || {}, headers });
|
|
||||||
break;
|
|
||||||
case 'PUT':
|
|
||||||
response = await request.put(fullUrl, { data: endpoint.body || {}, headers });
|
|
||||||
break;
|
|
||||||
case 'DELETE':
|
|
||||||
response = await request.delete(fullUrl, { headers });
|
|
||||||
break;
|
|
||||||
case 'PATCH':
|
|
||||||
response = await request.patch(fullUrl, { data: endpoint.body || {}, headers });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseTime = Date.now() - startTime;
|
|
||||||
const status = response.status();
|
|
||||||
const body = await response.json().catch(() => null);
|
|
||||||
const bodyText = await response.text().catch(() => '');
|
|
||||||
|
|
||||||
// Check for presenter errors
|
|
||||||
const hasPresenterError =
|
|
||||||
bodyText.includes('Presenter not presented') ||
|
|
||||||
bodyText.includes('presenter not presented') ||
|
|
||||||
(body && body.message && body.message.includes('Presenter not presented')) ||
|
|
||||||
(body && body.error && body.error.includes('Presenter not presented'));
|
|
||||||
|
|
||||||
// Success is 200-299 status, or 404 for non-existent resources, and no presenter error
|
|
||||||
const isNotFound = status === 404;
|
|
||||||
const success = (status >= 200 && status < 300 || isNotFound) && !hasPresenterError;
|
|
||||||
|
|
||||||
const result: TestResult = {
|
|
||||||
endpoint: endpoint.path,
|
|
||||||
method: endpoint.method,
|
|
||||||
status,
|
|
||||||
success,
|
|
||||||
hasPresenterError,
|
|
||||||
responseTime,
|
|
||||||
response: body || bodyText.substring(0, 200),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
result.error = body?.message || bodyText.substring(0, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
testResults.push(result);
|
|
||||||
allResults.push(result);
|
|
||||||
|
|
||||||
if (hasPresenterError) {
|
|
||||||
console.log(` ❌ PRESENTER ERROR: ${status} - ${body?.message || bodyText.substring(0, 100)}`);
|
|
||||||
} else if (success) {
|
|
||||||
console.log(` ✅ ${status} (${responseTime}ms)`);
|
|
||||||
} else {
|
|
||||||
console.log(` ⚠️ ${status} (${responseTime}ms) - ${body?.message || 'Error'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const responseTime = Date.now() - startTime;
|
|
||||||
const errorString = error instanceof Error ? error.message : String(error);
|
|
||||||
|
|
||||||
const result: TestResult = {
|
|
||||||
endpoint: endpoint.path,
|
|
||||||
method: endpoint.method,
|
|
||||||
status: 0,
|
|
||||||
success: false,
|
|
||||||
hasPresenterError: false,
|
|
||||||
responseTime,
|
|
||||||
error: errorString,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if it's a presenter error
|
|
||||||
if (errorString.includes('Presenter not presented')) {
|
|
||||||
result.hasPresenterError = true;
|
|
||||||
console.log(` ❌ PRESENTER ERROR (exception): ${errorString}`);
|
|
||||||
} else {
|
|
||||||
console.log(` ❌ EXCEPTION: ${errorString}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
testResults.push(result);
|
|
||||||
allResults.push(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateReport(): Promise<void> {
|
|
||||||
const summary = {
|
|
||||||
total: allResults.length,
|
|
||||||
success: allResults.filter(r => r.success).length,
|
|
||||||
failed: allResults.filter(r => !r.success).length,
|
|
||||||
presenterErrors: allResults.filter(r => r.hasPresenterError).length,
|
|
||||||
avgResponseTime: allResults.reduce((sum, r) => sum + r.responseTime, 0) / allResults.length || 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const report = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
summary,
|
|
||||||
results: allResults,
|
|
||||||
failures: allResults.filter(r => !r.success),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Write JSON report
|
|
||||||
const jsonPath = path.join(__dirname, '../../../league-api-test-report.json');
|
|
||||||
await fs.writeFile(jsonPath, JSON.stringify(report, null, 2));
|
|
||||||
|
|
||||||
// Write Markdown report
|
|
||||||
const mdPath = path.join(__dirname, '../../../league-api-test-report.md');
|
|
||||||
let md = `# League API Test Report\n\n`;
|
|
||||||
md += `**Generated:** ${new Date().toISOString()}\n`;
|
|
||||||
md += `**API Base URL:** ${API_BASE_URL}\n\n`;
|
|
||||||
|
|
||||||
md += `## Summary\n\n`;
|
|
||||||
md += `- **Total Endpoints:** ${summary.total}\n`;
|
|
||||||
md += `- **✅ Success:** ${summary.success}\n`;
|
|
||||||
md += `- **❌ Failed:** ${summary.failed}\n`;
|
|
||||||
md += `- **⚠️ Presenter Errors:** ${summary.presenterErrors}\n`;
|
|
||||||
md += `- **Avg Response Time:** ${summary.avgResponseTime.toFixed(2)}ms\n\n`;
|
|
||||||
|
|
||||||
if (summary.presenterErrors > 0) {
|
|
||||||
md += `## Presenter Errors\n\n`;
|
|
||||||
const presenterFailures = allResults.filter(r => r.hasPresenterError);
|
|
||||||
presenterFailures.forEach((r, i) => {
|
|
||||||
md += `${i + 1}. **${r.method} ${r.endpoint}**\n`;
|
|
||||||
md += ` - Status: ${r.status}\n`;
|
|
||||||
md += ` - Error: ${r.error || 'No error message'}\n\n`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (summary.failed > 0 && summary.presenterErrors < summary.failed) {
|
|
||||||
md += `## Other Failures\n\n`;
|
|
||||||
const otherFailures = allResults.filter(r => !r.success && !r.hasPresenterError);
|
|
||||||
otherFailures.forEach((r, i) => {
|
|
||||||
md += `${i + 1}. **${r.method} ${r.endpoint}**\n`;
|
|
||||||
md += ` - Status: ${r.status}\n`;
|
|
||||||
md += ` - Error: ${r.error || 'No error message'}\n\n`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await fs.writeFile(mdPath, md);
|
|
||||||
|
|
||||||
console.log(`\n📊 Reports generated:`);
|
|
||||||
console.log(` JSON: ${jsonPath}`);
|
|
||||||
console.log(` Markdown: ${mdPath}`);
|
|
||||||
console.log(`\nSummary: ${summary.success}/${summary.total} passed, ${summary.presenterErrors} presenter errors`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user