Compare commits
5 Commits
tests/core
...
853ec7b0ce
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||||
28
README.md
28
README.md
@@ -56,7 +56,7 @@ npm test
|
|||||||
Individual applications support hot reload and watch mode during development:
|
Individual applications support hot reload and watch mode during development:
|
||||||
|
|
||||||
- **web-api**: Backend REST API server
|
- **web-api**: Backend REST API server
|
||||||
- **web-client**: Frontend React application
|
- **web-client**: Frontend React application
|
||||||
- **companion**: Desktop companion application
|
- **companion**: Desktop companion application
|
||||||
|
|
||||||
## Testing Commands
|
## Testing Commands
|
||||||
@@ -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
|
||||||
@@ -147,4 +163,4 @@ Comprehensive documentation is available in the [`/docs`](docs/) directory:
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License - see [LICENSE](LICENSE) file for details.
|
MIT License - see [LICENSE](LICENSE) file for details.
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
69
adapters/leagues/events/InMemoryLeagueEventPublisher.ts
Normal file
69
adapters/leagues/events/InMemoryLeagueEventPublisher.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import {
|
||||||
|
LeagueEventPublisher,
|
||||||
|
LeagueCreatedEvent,
|
||||||
|
LeagueUpdatedEvent,
|
||||||
|
LeagueDeletedEvent,
|
||||||
|
LeagueAccessedEvent,
|
||||||
|
} from '../../../core/leagues/application/ports/LeagueEventPublisher';
|
||||||
|
|
||||||
|
export class InMemoryLeagueEventPublisher implements LeagueEventPublisher {
|
||||||
|
private leagueCreatedEvents: LeagueCreatedEvent[] = [];
|
||||||
|
private leagueUpdatedEvents: LeagueUpdatedEvent[] = [];
|
||||||
|
private leagueDeletedEvents: LeagueDeletedEvent[] = [];
|
||||||
|
private leagueAccessedEvents: LeagueAccessedEvent[] = [];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
getLeagueCreatedEventCount(): number {
|
||||||
|
return this.leagueCreatedEvents.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLeagueUpdatedEventCount(): number {
|
||||||
|
return this.leagueUpdatedEvents.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLeagueDeletedEventCount(): number {
|
||||||
|
return this.leagueDeletedEvents.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLeagueAccessedEventCount(): number {
|
||||||
|
return this.leagueAccessedEvents.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.leagueCreatedEvents = [];
|
||||||
|
this.leagueUpdatedEvents = [];
|
||||||
|
this.leagueDeletedEvents = [];
|
||||||
|
this.leagueAccessedEvents = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getLeagueCreatedEvents(): LeagueCreatedEvent[] {
|
||||||
|
return [...this.leagueCreatedEvents];
|
||||||
|
}
|
||||||
|
|
||||||
|
getLeagueUpdatedEvents(): LeagueUpdatedEvent[] {
|
||||||
|
return [...this.leagueUpdatedEvents];
|
||||||
|
}
|
||||||
|
|
||||||
|
getLeagueDeletedEvents(): LeagueDeletedEvent[] {
|
||||||
|
return [...this.leagueDeletedEvents];
|
||||||
|
}
|
||||||
|
|
||||||
|
getLeagueAccessedEvents(): LeagueAccessedEvent[] {
|
||||||
|
return [...this.leagueAccessedEvents];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,64 +1,310 @@
|
|||||||
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,
|
||||||
|
} from '../../../../core/leagues/application/ports/LeagueRepository';
|
||||||
|
|
||||||
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 leagueStandings: Map<string, LeagueStandingData[]> = new Map();
|
private leagueFinancials: Map<string, LeagueFinancials> = new Map();
|
||||||
private recentActivity: Map<string, ActivityData[]> = new Map();
|
private leagueStewardingMetrics: Map<string, LeagueStewardingMetrics> = new Map();
|
||||||
private friends: Map<string, FriendData[]> = 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();
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
addLeagueStandings(driverId: string, standings: LeagueStandingData[]): void {
|
async getStats(leagueId: string): Promise<LeagueStats> {
|
||||||
this.leagueStandings.set(driverId, standings);
|
return this.leagueStats.get(leagueId) || this.createDefaultStats(leagueId);
|
||||||
}
|
}
|
||||||
|
|
||||||
addRecentActivity(driverId: string, activities: ActivityData[]): void {
|
async updateStats(leagueId: string, stats: LeagueStats): Promise<LeagueStats> {
|
||||||
this.recentActivity.set(driverId, activities);
|
this.leagueStats.set(leagueId, stats);
|
||||||
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
addFriends(driverId: string, friends: FriendData[]): void {
|
async getFinancials(leagueId: string): Promise<LeagueFinancials> {
|
||||||
this.friends.set(driverId, friends);
|
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 {
|
clear(): void {
|
||||||
this.drivers.clear();
|
this.leagues.clear();
|
||||||
this.upcomingRaces.clear();
|
this.leagueStats.clear();
|
||||||
this.leagueStandings.clear();
|
this.leagueFinancials.clear();
|
||||||
this.recentActivity.clear();
|
this.leagueStewardingMetrics.clear();
|
||||||
this.friends.clear();
|
this.leaguePerformanceMetrics.clear();
|
||||||
|
this.leagueRatingMetrics.clear();
|
||||||
|
this.leagueTrendMetrics.clear();
|
||||||
|
this.leagueSuccessRateMetrics.clear();
|
||||||
|
this.leagueResolutionTimeMetrics.clear();
|
||||||
|
this.leagueComplexSuccessRateMetrics.clear();
|
||||||
|
this.leagueComplexResolutionTimeMetrics.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
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 = `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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { AdminUser } from '@core/admin/domain/entities/AdminUser';
|
|
||||||
import { AdminUserOrmEntity } from '../entities/AdminUserOrmEntity';
|
|
||||||
import { AdminUserOrmMapper } from './AdminUserOrmMapper';
|
|
||||||
import { TypeOrmAdminSchemaError } from '../errors/TypeOrmAdminSchemaError';
|
|
||||||
|
|
||||||
describe('AdminUserOrmMapper', () => {
|
|
||||||
describe('TDD - Test First', () => {
|
|
||||||
describe('toDomain', () => {
|
|
||||||
it('should map valid ORM entity to domain entity', () => {
|
|
||||||
// Arrange
|
|
||||||
const entity = new AdminUserOrmEntity();
|
|
||||||
entity.id = 'user-123';
|
|
||||||
entity.email = 'test@example.com';
|
|
||||||
entity.displayName = 'Test User';
|
|
||||||
entity.roles = ['owner'];
|
|
||||||
entity.status = 'active';
|
|
||||||
entity.createdAt = new Date('2024-01-01');
|
|
||||||
entity.updatedAt = new Date('2024-01-02');
|
|
||||||
|
|
||||||
const mapper = new AdminUserOrmMapper();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const domain = mapper.toDomain(entity);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(domain.id.value).toBe('user-123');
|
|
||||||
expect(domain.email.value).toBe('test@example.com');
|
|
||||||
expect(domain.displayName).toBe('Test User');
|
|
||||||
expect(domain.roles).toHaveLength(1);
|
|
||||||
expect(domain.roles[0]!.value).toBe('owner');
|
|
||||||
expect(domain.status.value).toBe('active');
|
|
||||||
expect(domain.createdAt).toEqual(new Date('2024-01-01'));
|
|
||||||
expect(domain.updatedAt).toEqual(new Date('2024-01-02'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should map entity with optional fields', () => {
|
|
||||||
// Arrange
|
|
||||||
const entity = new AdminUserOrmEntity();
|
|
||||||
entity.id = 'user-123';
|
|
||||||
entity.email = 'test@example.com';
|
|
||||||
entity.displayName = 'Test User';
|
|
||||||
entity.roles = ['user'];
|
|
||||||
entity.status = 'active';
|
|
||||||
entity.createdAt = new Date('2024-01-01');
|
|
||||||
entity.updatedAt = new Date('2024-01-02');
|
|
||||||
entity.primaryDriverId = 'driver-456';
|
|
||||||
entity.lastLoginAt = new Date('2024-01-03');
|
|
||||||
|
|
||||||
const mapper = new AdminUserOrmMapper();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const domain = mapper.toDomain(entity);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(domain.primaryDriverId).toBe('driver-456');
|
|
||||||
expect(domain.lastLoginAt).toEqual(new Date('2024-01-03'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle null optional fields', () => {
|
|
||||||
// Arrange
|
|
||||||
const entity = new AdminUserOrmEntity();
|
|
||||||
entity.id = 'user-123';
|
|
||||||
entity.email = 'test@example.com';
|
|
||||||
entity.displayName = 'Test User';
|
|
||||||
entity.roles = ['user'];
|
|
||||||
entity.status = 'active';
|
|
||||||
entity.createdAt = new Date('2024-01-01');
|
|
||||||
entity.updatedAt = new Date('2024-01-02');
|
|
||||||
entity.primaryDriverId = null;
|
|
||||||
entity.lastLoginAt = null;
|
|
||||||
|
|
||||||
const mapper = new AdminUserOrmMapper();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const domain = mapper.toDomain(entity);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(domain.primaryDriverId).toBeUndefined();
|
|
||||||
expect(domain.lastLoginAt).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error for missing id', () => {
|
|
||||||
// Arrange
|
|
||||||
const entity = new AdminUserOrmEntity();
|
|
||||||
entity.id = '';
|
|
||||||
entity.email = 'test@example.com';
|
|
||||||
entity.displayName = 'Test User';
|
|
||||||
entity.roles = ['user'];
|
|
||||||
entity.status = 'active';
|
|
||||||
entity.createdAt = new Date('2024-01-01');
|
|
||||||
entity.updatedAt = new Date('2024-01-02');
|
|
||||||
|
|
||||||
const mapper = new AdminUserOrmMapper();
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => mapper.toDomain(entity)).toThrow('Field id must be a non-empty string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error for missing email', () => {
|
|
||||||
// Arrange
|
|
||||||
const entity = new AdminUserOrmEntity();
|
|
||||||
entity.id = 'user-123';
|
|
||||||
entity.email = '';
|
|
||||||
entity.displayName = 'Test User';
|
|
||||||
entity.roles = ['user'];
|
|
||||||
entity.status = 'active';
|
|
||||||
entity.createdAt = new Date('2024-01-01');
|
|
||||||
entity.updatedAt = new Date('2024-01-02');
|
|
||||||
|
|
||||||
const mapper = new AdminUserOrmMapper();
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => mapper.toDomain(entity)).toThrow('Field email must be a non-empty string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error for missing displayName', () => {
|
|
||||||
// Arrange
|
|
||||||
const entity = new AdminUserOrmEntity();
|
|
||||||
entity.id = 'user-123';
|
|
||||||
entity.email = 'test@example.com';
|
|
||||||
entity.displayName = '';
|
|
||||||
entity.roles = ['user'];
|
|
||||||
entity.status = 'active';
|
|
||||||
entity.createdAt = new Date('2024-01-01');
|
|
||||||
entity.updatedAt = new Date('2024-01-02');
|
|
||||||
|
|
||||||
const mapper = new AdminUserOrmMapper();
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => mapper.toDomain(entity)).toThrow('Field displayName must be a non-empty string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error for invalid roles array', () => {
|
|
||||||
// Arrange
|
|
||||||
const entity = new AdminUserOrmEntity();
|
|
||||||
entity.id = 'user-123';
|
|
||||||
entity.email = 'test@example.com';
|
|
||||||
entity.displayName = 'Test User';
|
|
||||||
entity.roles = null as unknown as string[];
|
|
||||||
entity.status = 'active';
|
|
||||||
entity.createdAt = new Date('2024-01-01');
|
|
||||||
entity.updatedAt = new Date('2024-01-02');
|
|
||||||
|
|
||||||
const mapper = new AdminUserOrmMapper();
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => mapper.toDomain(entity)).toThrow('Field roles must be an array of strings');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error for invalid roles array items', () => {
|
|
||||||
// Arrange
|
|
||||||
const entity = new AdminUserOrmEntity();
|
|
||||||
entity.id = 'user-123';
|
|
||||||
entity.email = 'test@example.com';
|
|
||||||
entity.displayName = 'Test User';
|
|
||||||
entity.roles = ['user', 123 as unknown as string];
|
|
||||||
entity.status = 'active';
|
|
||||||
entity.createdAt = new Date('2024-01-01');
|
|
||||||
entity.updatedAt = new Date('2024-01-02');
|
|
||||||
|
|
||||||
const mapper = new AdminUserOrmMapper();
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => mapper.toDomain(entity)).toThrow('Field roles must be an array of strings');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error for missing status', () => {
|
|
||||||
// Arrange
|
|
||||||
const entity = new AdminUserOrmEntity();
|
|
||||||
entity.id = 'user-123';
|
|
||||||
entity.email = 'test@example.com';
|
|
||||||
entity.displayName = 'Test User';
|
|
||||||
entity.roles = ['user'];
|
|
||||||
entity.status = '';
|
|
||||||
entity.createdAt = new Date('2024-01-01');
|
|
||||||
entity.updatedAt = new Date('2024-01-02');
|
|
||||||
|
|
||||||
const mapper = new AdminUserOrmMapper();
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => mapper.toDomain(entity)).toThrow('Field status must be a non-empty string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error for invalid createdAt', () => {
|
|
||||||
// Arrange
|
|
||||||
const entity = new AdminUserOrmEntity();
|
|
||||||
entity.id = 'user-123';
|
|
||||||
entity.email = 'test@example.com';
|
|
||||||
entity.displayName = 'Test User';
|
|
||||||
entity.roles = ['user'];
|
|
||||||
entity.status = 'active';
|
|
||||||
entity.createdAt = new Date('invalid') as unknown as Date;
|
|
||||||
entity.updatedAt = new Date('2024-01-02');
|
|
||||||
|
|
||||||
const mapper = new AdminUserOrmMapper();
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => mapper.toDomain(entity)).toThrow('Field createdAt must be a valid Date');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error for invalid updatedAt', () => {
|
|
||||||
// Arrange
|
|
||||||
const entity = new AdminUserOrmEntity();
|
|
||||||
entity.id = 'user-123';
|
|
||||||
entity.email = 'test@example.com';
|
|
||||||
entity.displayName = 'Test User';
|
|
||||||
entity.roles = ['user'];
|
|
||||||
entity.status = 'active';
|
|
||||||
entity.createdAt = new Date('2024-01-01');
|
|
||||||
entity.updatedAt = new Date('invalid') as unknown as Date;
|
|
||||||
|
|
||||||
const mapper = new AdminUserOrmMapper();
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => mapper.toDomain(entity)).toThrow('Field updatedAt must be a valid Date');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error for invalid primaryDriverId type', () => {
|
|
||||||
// Arrange
|
|
||||||
const entity = new AdminUserOrmEntity();
|
|
||||||
entity.id = 'user-123';
|
|
||||||
entity.email = 'test@example.com';
|
|
||||||
entity.displayName = 'Test User';
|
|
||||||
entity.roles = ['user'];
|
|
||||||
entity.status = 'active';
|
|
||||||
entity.createdAt = new Date('2024-01-01');
|
|
||||||
entity.updatedAt = new Date('2024-01-02');
|
|
||||||
entity.primaryDriverId = 123 as unknown as string;
|
|
||||||
|
|
||||||
const mapper = new AdminUserOrmMapper();
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => mapper.toDomain(entity)).toThrow('Field primaryDriverId must be a string or undefined');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error for invalid lastLoginAt type', () => {
|
|
||||||
// Arrange
|
|
||||||
const entity = new AdminUserOrmEntity();
|
|
||||||
entity.id = 'user-123';
|
|
||||||
entity.email = 'test@example.com';
|
|
||||||
entity.displayName = 'Test User';
|
|
||||||
entity.roles = ['user'];
|
|
||||||
entity.status = 'active';
|
|
||||||
entity.createdAt = new Date('2024-01-01');
|
|
||||||
entity.updatedAt = new Date('2024-01-02');
|
|
||||||
entity.lastLoginAt = 'invalid' as unknown as Date;
|
|
||||||
|
|
||||||
const mapper = new AdminUserOrmMapper();
|
|
||||||
|
|
||||||
// Act & Assert
|
|
||||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => mapper.toDomain(entity)).toThrow('Field lastLoginAt must be a valid Date');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle multiple roles', () => {
|
|
||||||
// Arrange
|
|
||||||
const entity = new AdminUserOrmEntity();
|
|
||||||
entity.id = 'user-123';
|
|
||||||
entity.email = 'test@example.com';
|
|
||||||
entity.displayName = 'Test User';
|
|
||||||
entity.roles = ['owner', 'admin'];
|
|
||||||
entity.status = 'active';
|
|
||||||
entity.createdAt = new Date('2024-01-01');
|
|
||||||
entity.updatedAt = new Date('2024-01-02');
|
|
||||||
|
|
||||||
const mapper = new AdminUserOrmMapper();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const domain = mapper.toDomain(entity);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(domain.roles).toHaveLength(2);
|
|
||||||
expect(domain.roles.map(r => r.value)).toContain('owner');
|
|
||||||
expect(domain.roles.map(r => r.value)).toContain('admin');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('toOrmEntity', () => {
|
|
||||||
it('should map domain entity to ORM entity', () => {
|
|
||||||
// Arrange
|
|
||||||
const domain = AdminUser.create({
|
|
||||||
id: 'user-123',
|
|
||||||
email: 'test@example.com',
|
|
||||||
displayName: 'Test User',
|
|
||||||
roles: ['owner'],
|
|
||||||
status: 'active',
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
updatedAt: new Date('2024-01-02'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapper = new AdminUserOrmMapper();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const entity = mapper.toOrmEntity(domain);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(entity.id).toBe('user-123');
|
|
||||||
expect(entity.email).toBe('test@example.com');
|
|
||||||
expect(entity.displayName).toBe('Test User');
|
|
||||||
expect(entity.roles).toEqual(['owner']);
|
|
||||||
expect(entity.status).toBe('active');
|
|
||||||
expect(entity.createdAt).toEqual(new Date('2024-01-01'));
|
|
||||||
expect(entity.updatedAt).toEqual(new Date('2024-01-02'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should map domain entity with optional fields', () => {
|
|
||||||
// Arrange
|
|
||||||
const domain = AdminUser.create({
|
|
||||||
id: 'user-123',
|
|
||||||
email: 'test@example.com',
|
|
||||||
displayName: 'Test User',
|
|
||||||
roles: ['user'],
|
|
||||||
status: 'active',
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
updatedAt: new Date('2024-01-02'),
|
|
||||||
primaryDriverId: 'driver-456',
|
|
||||||
lastLoginAt: new Date('2024-01-03'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapper = new AdminUserOrmMapper();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const entity = mapper.toOrmEntity(domain);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(entity.primaryDriverId).toBe('driver-456');
|
|
||||||
expect(entity.lastLoginAt).toEqual(new Date('2024-01-03'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle domain entity without optional fields', () => {
|
|
||||||
// Arrange
|
|
||||||
const domain = AdminUser.create({
|
|
||||||
id: 'user-123',
|
|
||||||
email: 'test@example.com',
|
|
||||||
displayName: 'Test User',
|
|
||||||
roles: ['user'],
|
|
||||||
status: 'active',
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
updatedAt: new Date('2024-01-02'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapper = new AdminUserOrmMapper();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const entity = mapper.toOrmEntity(domain);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(entity.primaryDriverId).toBeUndefined();
|
|
||||||
expect(entity.lastLoginAt).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should map domain entity with multiple roles', () => {
|
|
||||||
// Arrange
|
|
||||||
const domain = AdminUser.create({
|
|
||||||
id: 'user-123',
|
|
||||||
email: 'test@example.com',
|
|
||||||
displayName: 'Test User',
|
|
||||||
roles: ['owner', 'admin'],
|
|
||||||
status: 'active',
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
updatedAt: new Date('2024-01-02'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapper = new AdminUserOrmMapper();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const entity = mapper.toOrmEntity(domain);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(entity.roles).toEqual(['owner', 'admin']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('toStored', () => {
|
|
||||||
it('should call toDomain for stored entity', () => {
|
|
||||||
// Arrange
|
|
||||||
const entity = new AdminUserOrmEntity();
|
|
||||||
entity.id = 'user-123';
|
|
||||||
entity.email = 'test@example.com';
|
|
||||||
entity.displayName = 'Test User';
|
|
||||||
entity.roles = ['owner'];
|
|
||||||
entity.status = 'active';
|
|
||||||
entity.createdAt = new Date('2024-01-01');
|
|
||||||
entity.updatedAt = new Date('2024-01-02');
|
|
||||||
|
|
||||||
const mapper = new AdminUserOrmMapper();
|
|
||||||
|
|
||||||
// Act
|
|
||||||
const domain = mapper.toStored(entity);
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
expect(domain.id.value).toBe('user-123');
|
|
||||||
expect(domain.email.value).toBe('test@example.com');
|
|
||||||
expect(domain.displayName).toBe('Test User');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import {
|
|
||||||
assertNonEmptyString,
|
|
||||||
assertStringArray,
|
|
||||||
assertDate,
|
|
||||||
assertOptionalDate,
|
|
||||||
assertOptionalString,
|
|
||||||
} from './TypeOrmAdminSchemaGuards';
|
|
||||||
import { TypeOrmAdminSchemaError } from '../errors/TypeOrmAdminSchemaError';
|
|
||||||
|
|
||||||
describe('TypeOrmAdminSchemaGuards', () => {
|
|
||||||
describe('TDD - Test First', () => {
|
|
||||||
describe('assertNonEmptyString', () => {
|
|
||||||
it('should not throw for valid non-empty string', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', 'valid string')).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for empty string', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', '')).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', '')).toThrow('Field fieldName must be a non-empty string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for string with only whitespace', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', ' ')).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', ' ')).toThrow('Field fieldName must be a non-empty string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for null', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', null)).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', null)).toThrow('Field fieldName must be a non-empty string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for undefined', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', undefined)).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', undefined)).toThrow('Field fieldName must be a non-empty string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for number', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', 123)).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', 123)).toThrow('Field fieldName must be a non-empty string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for object', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', {})).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', {})).toThrow('Field fieldName must be a non-empty string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for array', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', [])).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => assertNonEmptyString('TestEntity', 'fieldName', [])).toThrow('Field fieldName must be a non-empty string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include entity name in error message', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertNonEmptyString('AdminUser', 'email', '')).toThrow('[TypeOrmAdminSchemaError] AdminUser.email: INVALID_STRING - Field email must be a non-empty string');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('assertStringArray', () => {
|
|
||||||
it('should not throw for valid string array', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertStringArray('TestEntity', 'fieldName', ['a', 'b', 'c'])).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not throw for empty array', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertStringArray('TestEntity', 'fieldName', [])).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for non-array', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertStringArray('TestEntity', 'fieldName', 'not an array')).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => assertStringArray('TestEntity', 'fieldName', 'not an array')).toThrow('Field fieldName must be an array of strings');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for null', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertStringArray('TestEntity', 'fieldName', null)).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => assertStringArray('TestEntity', 'fieldName', null)).toThrow('Field fieldName must be an array of strings');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for undefined', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertStringArray('TestEntity', 'fieldName', undefined)).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => assertStringArray('TestEntity', 'fieldName', undefined)).toThrow('Field fieldName must be an array of strings');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for array with non-string items', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertStringArray('TestEntity', 'fieldName', ['a', 123, 'c'])).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => assertStringArray('TestEntity', 'fieldName', ['a', 123, 'c'])).toThrow('Field fieldName must be an array of strings');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for array with null items', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertStringArray('TestEntity', 'fieldName', ['a', null, 'c'])).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => assertStringArray('TestEntity', 'fieldName', ['a', null, 'c'])).toThrow('Field fieldName must be an array of strings');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for array with undefined items', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertStringArray('TestEntity', 'fieldName', ['a', undefined, 'c'])).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => assertStringArray('TestEntity', 'fieldName', ['a', undefined, 'c'])).toThrow('Field fieldName must be an array of strings');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for array with object items', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertStringArray('TestEntity', 'fieldName', ['a', {}, 'c'])).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => assertStringArray('TestEntity', 'fieldName', ['a', {}, 'c'])).toThrow('Field fieldName must be an array of strings');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include entity name in error message', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertStringArray('AdminUser', 'roles', null)).toThrow('[TypeOrmAdminSchemaError] AdminUser.roles: INVALID_STRING_ARRAY - Field roles must be an array of strings');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('assertDate', () => {
|
|
||||||
it('should not throw for valid Date', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertDate('TestEntity', 'fieldName', new Date())).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not throw for Date with valid timestamp', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertDate('TestEntity', 'fieldName', new Date('2024-01-01'))).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for null', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertDate('TestEntity', 'fieldName', null)).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => assertDate('TestEntity', 'fieldName', null)).toThrow('Field fieldName must be a valid Date');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for undefined', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertDate('TestEntity', 'fieldName', undefined)).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => assertDate('TestEntity', 'fieldName', undefined)).toThrow('Field fieldName must be a valid Date');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for string', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertDate('TestEntity', 'fieldName', '2024-01-01')).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => assertDate('TestEntity', 'fieldName', '2024-01-01')).toThrow('Field fieldName must be a valid Date');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for number', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertDate('TestEntity', 'fieldName', 1234567890)).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => assertDate('TestEntity', 'fieldName', 1234567890)).toThrow('Field fieldName must be a valid Date');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for object', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertDate('TestEntity', 'fieldName', {})).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => assertDate('TestEntity', 'fieldName', {})).toThrow('Field fieldName must be a valid Date');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for invalid Date (NaN)', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertDate('TestEntity', 'fieldName', new Date('invalid'))).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => assertDate('TestEntity', 'fieldName', new Date('invalid'))).toThrow('Field fieldName must be a valid Date');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include entity name in error message', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertDate('AdminUser', 'createdAt', null)).toThrow('[TypeOrmAdminSchemaError] AdminUser.createdAt: INVALID_DATE - Field createdAt must be a valid Date');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('assertOptionalDate', () => {
|
|
||||||
it('should not throw for valid Date', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertOptionalDate('TestEntity', 'fieldName', new Date())).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not throw for null', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertOptionalDate('TestEntity', 'fieldName', null)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not throw for undefined', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertOptionalDate('TestEntity', 'fieldName', undefined)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for invalid Date', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertOptionalDate('TestEntity', 'fieldName', new Date('invalid'))).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => assertOptionalDate('TestEntity', 'fieldName', new Date('invalid'))).toThrow('Field fieldName must be a valid Date');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for string', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertOptionalDate('TestEntity', 'fieldName', '2024-01-01')).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => assertOptionalDate('TestEntity', 'fieldName', '2024-01-01')).toThrow('Field fieldName must be a valid Date');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include entity name in error message', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertOptionalDate('AdminUser', 'lastLoginAt', new Date('invalid'))).toThrow('[TypeOrmAdminSchemaError] AdminUser.lastLoginAt: INVALID_DATE - Field lastLoginAt must be a valid Date');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('assertOptionalString', () => {
|
|
||||||
it('should not throw for valid string', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertOptionalString('TestEntity', 'fieldName', 'valid string')).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not throw for null', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertOptionalString('TestEntity', 'fieldName', null)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not throw for undefined', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertOptionalString('TestEntity', 'fieldName', undefined)).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for number', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertOptionalString('TestEntity', 'fieldName', 123)).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => assertOptionalString('TestEntity', 'fieldName', 123)).toThrow('Field fieldName must be a string or undefined');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for object', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertOptionalString('TestEntity', 'fieldName', {})).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => assertOptionalString('TestEntity', 'fieldName', {})).toThrow('Field fieldName must be a string or undefined');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw for array', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertOptionalString('TestEntity', 'fieldName', [])).toThrow(TypeOrmAdminSchemaError);
|
|
||||||
expect(() => assertOptionalString('TestEntity', 'fieldName', [])).toThrow('Field fieldName must be a string or undefined');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include entity name in error message', () => {
|
|
||||||
// Arrange & Act & Assert
|
|
||||||
expect(() => assertOptionalString('AdminUser', 'primaryDriverId', 123)).toThrow('[TypeOrmAdminSchemaError] AdminUser.primaryDriverId: INVALID_OPTIONAL_STRING - Field primaryDriverId must be a string or undefined');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
130
core/dashboard/application/use-cases/GetDashboardUseCase.ts
Normal file
130
core/dashboard/application/use-cases/GetDashboardUseCase.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* 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 } 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';
|
||||||
|
|
||||||
|
export interface GetDashboardUseCasePorts {
|
||||||
|
driverRepository: DashboardRepository;
|
||||||
|
raceRepository: DashboardRepository;
|
||||||
|
leagueRepository: DashboardRepository;
|
||||||
|
activityRepository: DashboardRepository;
|
||||||
|
eventPublisher: DashboardEventPublisher;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
const [upcomingRaces, leagueStandings, recentActivity] = await Promise.all([
|
||||||
|
this.ports.raceRepository.getUpcomingRaces(query.driverId),
|
||||||
|
this.ports.leagueRepository.getLeagueStandings(query.driverId),
|
||||||
|
this.ports.activityRepository.getRecentActivity(query.driverId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Limit upcoming races to 3
|
||||||
|
const limitedRaces = upcomingRaces
|
||||||
|
.sort((a, b) => a.scheduledDate.getTime() - b.scheduledDate.getTime())
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
// Sort recent activity by timestamp (newest first)
|
||||||
|
const sortedActivity = recentActivity
|
||||||
|
.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: leagueStandings.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
|
||||||
|
await this.ports.eventPublisher.publishDashboardAccessed({
|
||||||
|
type: 'dashboard_accessed',
|
||||||
|
driverId: query.driverId,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private validateQuery(query: DashboardQuery): void {
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
const { RuleTester } = require('eslint');
|
|
||||||
const rule = require('./domain-no-application');
|
|
||||||
|
|
||||||
const ruleTester = new RuleTester({
|
|
||||||
parser: require.resolve('@typescript-eslint/parser'),
|
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
sourceType: 'module',
|
|
||||||
ecmaFeatures: {
|
|
||||||
jsx: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
ruleTester.run('domain-no-application', rule, {
|
|
||||||
valid: [
|
|
||||||
// Domain file importing from domain
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/User.ts',
|
|
||||||
code: "import { UserId } from './UserId';",
|
|
||||||
},
|
|
||||||
// Domain file importing from shared
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/User.ts',
|
|
||||||
code: "import { ValueObject } from '../shared/ValueObject';",
|
|
||||||
},
|
|
||||||
// Domain file importing from ports
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/User.ts',
|
|
||||||
code: "import { UserRepository } from '../ports/UserRepository';",
|
|
||||||
},
|
|
||||||
// Non-domain file importing from application
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/application/user/CreateUser.ts',
|
|
||||||
code: "import { CreateUserCommand } from './CreateUserCommand';",
|
|
||||||
},
|
|
||||||
// Non-domain file importing from application
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/application/user/CreateUser.ts',
|
|
||||||
code: "import { UserService } from '../services/UserService';",
|
|
||||||
},
|
|
||||||
// Domain file with no imports
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/User.ts',
|
|
||||||
code: "export class User {}",
|
|
||||||
},
|
|
||||||
// Domain file with multiple imports, none from application
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/User.ts',
|
|
||||||
code: `
|
|
||||||
import { UserId } from './UserId';
|
|
||||||
import { UserName } from './UserName';
|
|
||||||
import { ValueObject } from '../shared/ValueObject';
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
invalid: [
|
|
||||||
// Domain file importing from application
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/User.ts',
|
|
||||||
code: "import { CreateUserCommand } from '../application/user/CreateUserCommand';",
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
messageId: 'forbiddenImport',
|
|
||||||
data: {
|
|
||||||
source: '../application/user/CreateUserCommand',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// Domain file importing from application with different path
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/User.ts',
|
|
||||||
code: "import { UserService } from '../../application/services/UserService';",
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
messageId: 'forbiddenImport',
|
|
||||||
data: {
|
|
||||||
source: '../../application/services/UserService',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// Domain file importing from application with absolute path
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/User.ts',
|
|
||||||
code: "import { CreateUserCommand } from 'core/application/user/CreateUserCommand';",
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
messageId: 'forbiddenImport',
|
|
||||||
data: {
|
|
||||||
source: 'core/application/user/CreateUserCommand',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// Domain file with multiple imports, one from application
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/User.ts',
|
|
||||||
code: `
|
|
||||||
import { UserId } from './UserId';
|
|
||||||
import { CreateUserCommand } from '../application/user/CreateUserCommand';
|
|
||||||
import { UserName } from './UserName';
|
|
||||||
`,
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
messageId: 'forbiddenImport',
|
|
||||||
data: {
|
|
||||||
source: '../application/user/CreateUserCommand',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
const index = require('./index');
|
|
||||||
|
|
||||||
describe('eslint-rules index', () => {
|
|
||||||
describe('rules', () => {
|
|
||||||
it('should export no-index-files rule', () => {
|
|
||||||
expect(index.rules['no-index-files']).toBeDefined();
|
|
||||||
expect(index.rules['no-index-files'].meta).toBeDefined();
|
|
||||||
expect(index.rules['no-index-files'].create).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should export no-framework-imports rule', () => {
|
|
||||||
expect(index.rules['no-framework-imports']).toBeDefined();
|
|
||||||
expect(index.rules['no-framework-imports'].meta).toBeDefined();
|
|
||||||
expect(index.rules['no-framework-imports'].create).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should export domain-no-application rule', () => {
|
|
||||||
expect(index.rules['domain-no-application']).toBeDefined();
|
|
||||||
expect(index.rules['domain-no-application'].meta).toBeDefined();
|
|
||||||
expect(index.rules['domain-no-application'].create).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have exactly 3 rules', () => {
|
|
||||||
expect(Object.keys(index.rules)).toHaveLength(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('configs', () => {
|
|
||||||
it('should export recommended config', () => {
|
|
||||||
expect(index.configs.recommended).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('recommended config should have gridpilot-core-rules plugin', () => {
|
|
||||||
expect(index.configs.recommended.plugins).toContain('gridpilot-core-rules');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('recommended config should enable all rules', () => {
|
|
||||||
expect(index.configs.recommended.rules['gridpilot-core-rules/no-index-files']).toBe('error');
|
|
||||||
expect(index.configs.recommended.rules['gridpilot-core-rules/no-framework-imports']).toBe('error');
|
|
||||||
expect(index.configs.recommended.rules['gridpilot-core-rules/domain-no-application']).toBe('error');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('recommended config should have exactly 3 rules', () => {
|
|
||||||
expect(Object.keys(index.configs.recommended.rules)).toHaveLength(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('rule metadata', () => {
|
|
||||||
it('no-index-files should have correct metadata', () => {
|
|
||||||
const rule = index.rules['no-index-files'];
|
|
||||||
expect(rule.meta.type).toBe('problem');
|
|
||||||
expect(rule.meta.docs.category).toBe('Best Practices');
|
|
||||||
expect(rule.meta.docs.recommended).toBe(true);
|
|
||||||
expect(rule.meta.fixable).toBe(null);
|
|
||||||
expect(rule.meta.schema).toEqual([]);
|
|
||||||
expect(rule.meta.messages.indexFile).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('no-framework-imports should have correct metadata', () => {
|
|
||||||
const rule = index.rules['no-framework-imports'];
|
|
||||||
expect(rule.meta.type).toBe('problem');
|
|
||||||
expect(rule.meta.docs.category).toBe('Architecture');
|
|
||||||
expect(rule.meta.docs.recommended).toBe(true);
|
|
||||||
expect(rule.meta.fixable).toBe(null);
|
|
||||||
expect(rule.meta.schema).toEqual([]);
|
|
||||||
expect(rule.meta.messages.frameworkImport).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('domain-no-application should have correct metadata', () => {
|
|
||||||
const rule = index.rules['domain-no-application'];
|
|
||||||
expect(rule.meta.type).toBe('problem');
|
|
||||||
expect(rule.meta.docs.category).toBe('Architecture');
|
|
||||||
expect(rule.meta.docs.recommended).toBe(true);
|
|
||||||
expect(rule.meta.fixable).toBe(null);
|
|
||||||
expect(rule.meta.schema).toEqual([]);
|
|
||||||
expect(rule.meta.messages.forbiddenImport).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
const { RuleTester } = require('eslint');
|
|
||||||
const rule = require('./no-framework-imports');
|
|
||||||
|
|
||||||
const ruleTester = new RuleTester({
|
|
||||||
parser: require.resolve('@typescript-eslint/parser'),
|
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
sourceType: 'module',
|
|
||||||
ecmaFeatures: {
|
|
||||||
jsx: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
ruleTester.run('no-framework-imports', rule, {
|
|
||||||
valid: [
|
|
||||||
// Import from domain
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/User.ts',
|
|
||||||
code: "import { UserId } from './UserId';",
|
|
||||||
},
|
|
||||||
// Import from application
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/application/user/CreateUser.ts',
|
|
||||||
code: "import { CreateUserCommand } from './CreateUserCommand';",
|
|
||||||
},
|
|
||||||
// Import from shared
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/shared/ValueObject.ts',
|
|
||||||
code: "import { ValueObject } from './ValueObject';",
|
|
||||||
},
|
|
||||||
// Import from ports
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/ports/UserRepository.ts',
|
|
||||||
code: "import { User } from '../domain/user/User';",
|
|
||||||
},
|
|
||||||
// Import from external packages (not frameworks)
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/User.ts',
|
|
||||||
code: "import { v4 as uuidv4 } from 'uuid';",
|
|
||||||
},
|
|
||||||
// Import from internal packages
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/User.ts',
|
|
||||||
code: "import { SomeUtil } from '@core/shared/SomeUtil';",
|
|
||||||
},
|
|
||||||
// No imports
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/User.ts',
|
|
||||||
code: "export class User {}",
|
|
||||||
},
|
|
||||||
// Multiple valid imports
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/User.ts',
|
|
||||||
code: `
|
|
||||||
import { UserId } from './UserId';
|
|
||||||
import { UserName } from './UserName';
|
|
||||||
import { ValueObject } from '../shared/ValueObject';
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
invalid: [
|
|
||||||
// Import from @nestjs
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/User.ts',
|
|
||||||
code: "import { Injectable } from '@nestjs/common';",
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
messageId: 'frameworkImport',
|
|
||||||
data: {
|
|
||||||
source: '@nestjs/common',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// Import from @nestjs/core
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/User.ts',
|
|
||||||
code: "import { Module } from '@nestjs/core';",
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
messageId: 'frameworkImport',
|
|
||||||
data: {
|
|
||||||
source: '@nestjs/core',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// Import from express
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/User.ts',
|
|
||||||
code: "import express from 'express';",
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
messageId: 'frameworkImport',
|
|
||||||
data: {
|
|
||||||
source: 'express',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// Import from react
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/User.ts',
|
|
||||||
code: "import React from 'react';",
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
messageId: 'frameworkImport',
|
|
||||||
data: {
|
|
||||||
source: 'react',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// Import from next
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/User.ts',
|
|
||||||
code: "import { useRouter } from 'next/router';",
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
messageId: 'frameworkImport',
|
|
||||||
data: {
|
|
||||||
source: 'next/router',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// Import from @nestjs with subpath
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/User.ts',
|
|
||||||
code: "import { Controller } from '@nestjs/common';",
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
messageId: 'frameworkImport',
|
|
||||||
data: {
|
|
||||||
source: '@nestjs/common',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// Multiple framework imports
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/User.ts',
|
|
||||||
code: `
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { UserId } from './UserId';
|
|
||||||
import React from 'react';
|
|
||||||
`,
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
messageId: 'frameworkImport',
|
|
||||||
data: {
|
|
||||||
source: '@nestjs/common',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
messageId: 'frameworkImport',
|
|
||||||
data: {
|
|
||||||
source: 'react',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
const { RuleTester } = require('eslint');
|
|
||||||
const rule = require('./no-index-files');
|
|
||||||
|
|
||||||
const ruleTester = new RuleTester({
|
|
||||||
parser: require.resolve('@typescript-eslint/parser'),
|
|
||||||
parserOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
sourceType: 'module',
|
|
||||||
ecmaFeatures: {
|
|
||||||
jsx: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
ruleTester.run('no-index-files', rule, {
|
|
||||||
valid: [
|
|
||||||
// Regular file in domain
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/User.ts',
|
|
||||||
code: "export class User {}",
|
|
||||||
},
|
|
||||||
// Regular file in application
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/application/user/CreateUser.ts',
|
|
||||||
code: "export class CreateUser {}",
|
|
||||||
},
|
|
||||||
// Regular file in shared
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/shared/ValueObject.ts',
|
|
||||||
code: "export class ValueObject {}",
|
|
||||||
},
|
|
||||||
// Regular file in ports
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/ports/UserRepository.ts',
|
|
||||||
code: "export interface UserRepository {}",
|
|
||||||
},
|
|
||||||
// File with index in the middle of the path
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/index/User.ts',
|
|
||||||
code: "export class User {}",
|
|
||||||
},
|
|
||||||
// File with index in the name but not at the end
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/indexHelper.ts',
|
|
||||||
code: "export class IndexHelper {}",
|
|
||||||
},
|
|
||||||
// Root index.ts is allowed
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/index.ts',
|
|
||||||
code: "export * from './domain';",
|
|
||||||
},
|
|
||||||
// File with index.ts in the middle of the path
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/index/User.ts',
|
|
||||||
code: "export class User {}",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
invalid: [
|
|
||||||
// index.ts in domain
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/index.ts',
|
|
||||||
code: "export * from './User';",
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
messageId: 'indexFile',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// index.ts in application
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/application/user/index.ts',
|
|
||||||
code: "export * from './CreateUser';",
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
messageId: 'indexFile',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// index.ts in shared
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/shared/index.ts',
|
|
||||||
code: "export * from './ValueObject';",
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
messageId: 'indexFile',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// index.ts in ports
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/ports/index.ts',
|
|
||||||
code: "export * from './UserRepository';",
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
messageId: 'indexFile',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// index.ts with Windows path separator
|
|
||||||
{
|
|
||||||
filename: 'C:\\path\\to\\core\\domain\\user\\index.ts',
|
|
||||||
code: "export * from './User';",
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
messageId: 'indexFile',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// index.ts at the start of path
|
|
||||||
{
|
|
||||||
filename: 'index.ts',
|
|
||||||
code: "export * from './domain';",
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
messageId: 'indexFile',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// index.ts in nested directory
|
|
||||||
{
|
|
||||||
filename: '/path/to/core/domain/user/profile/index.ts',
|
|
||||||
code: "export * from './Profile';",
|
|
||||||
errors: [
|
|
||||||
{
|
|
||||||
messageId: 'indexFile',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
/**
|
|
||||||
* Application Query Tests: GetUserRatingLedgerQuery
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { GetUserRatingLedgerQueryHandler } from './GetUserRatingLedgerQuery';
|
|
||||||
import { RatingEventRepository } from '../../domain/repositories/RatingEventRepository';
|
|
||||||
|
|
||||||
// Mock repository
|
|
||||||
const createMockRepository = () => ({
|
|
||||||
save: vi.fn(),
|
|
||||||
findByUserId: vi.fn(),
|
|
||||||
findByIds: vi.fn(),
|
|
||||||
getAllByUserId: vi.fn(),
|
|
||||||
findEventsPaginated: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GetUserRatingLedgerQueryHandler', () => {
|
|
||||||
let handler: GetUserRatingLedgerQueryHandler;
|
|
||||||
let mockRepository: ReturnType<typeof createMockRepository>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockRepository = createMockRepository();
|
|
||||||
handler = new GetUserRatingLedgerQueryHandler(mockRepository as unknown as RatingEventRepository);
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should query repository with default pagination', async () => {
|
|
||||||
mockRepository.findEventsPaginated.mockResolvedValue({
|
|
||||||
items: [],
|
|
||||||
total: 0,
|
|
||||||
limit: 20,
|
|
||||||
offset: 0,
|
|
||||||
hasMore: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await handler.execute({ userId: 'user-1' });
|
|
||||||
|
|
||||||
expect(mockRepository.findEventsPaginated).toHaveBeenCalledWith('user-1', {
|
|
||||||
limit: 20,
|
|
||||||
offset: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should query repository with custom pagination', async () => {
|
|
||||||
mockRepository.findEventsPaginated.mockResolvedValue({
|
|
||||||
items: [],
|
|
||||||
total: 0,
|
|
||||||
limit: 50,
|
|
||||||
offset: 100,
|
|
||||||
hasMore: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await handler.execute({
|
|
||||||
userId: 'user-1',
|
|
||||||
limit: 50,
|
|
||||||
offset: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockRepository.findEventsPaginated).toHaveBeenCalledWith('user-1', {
|
|
||||||
limit: 50,
|
|
||||||
offset: 100,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should query repository with filters', async () => {
|
|
||||||
mockRepository.findEventsPaginated.mockResolvedValue({
|
|
||||||
items: [],
|
|
||||||
total: 0,
|
|
||||||
limit: 20,
|
|
||||||
offset: 0,
|
|
||||||
hasMore: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const filter: any = {
|
|
||||||
dimensions: ['trust'],
|
|
||||||
sourceTypes: ['vote'],
|
|
||||||
from: '2026-01-01T00:00:00Z',
|
|
||||||
to: '2026-01-31T23:59:59Z',
|
|
||||||
reasonCodes: ['VOTE_POSITIVE'],
|
|
||||||
};
|
|
||||||
|
|
||||||
await handler.execute({
|
|
||||||
userId: 'user-1',
|
|
||||||
filter,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockRepository.findEventsPaginated).toHaveBeenCalledWith('user-1', {
|
|
||||||
limit: 20,
|
|
||||||
offset: 0,
|
|
||||||
filter: {
|
|
||||||
dimensions: ['trust'],
|
|
||||||
sourceTypes: ['vote'],
|
|
||||||
from: new Date('2026-01-01T00:00:00Z'),
|
|
||||||
to: new Date('2026-01-31T23:59:59Z'),
|
|
||||||
reasonCodes: ['VOTE_POSITIVE'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should map domain entities to DTOs', async () => {
|
|
||||||
const mockEvent = {
|
|
||||||
id: { value: 'event-1' },
|
|
||||||
userId: 'user-1',
|
|
||||||
dimension: { value: 'trust' },
|
|
||||||
delta: { value: 5 },
|
|
||||||
occurredAt: new Date('2026-01-15T12:00:00Z'),
|
|
||||||
createdAt: new Date('2026-01-15T12:00:00Z'),
|
|
||||||
source: 'admin_vote',
|
|
||||||
reason: 'VOTE_POSITIVE',
|
|
||||||
visibility: 'public',
|
|
||||||
weight: 1.0,
|
|
||||||
};
|
|
||||||
|
|
||||||
mockRepository.findEventsPaginated.mockResolvedValue({
|
|
||||||
items: [mockEvent],
|
|
||||||
total: 1,
|
|
||||||
limit: 20,
|
|
||||||
offset: 0,
|
|
||||||
hasMore: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await handler.execute({ userId: 'user-1' });
|
|
||||||
|
|
||||||
expect(result.entries).toHaveLength(1);
|
|
||||||
expect(result.entries[0]).toEqual({
|
|
||||||
id: 'event-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
dimension: 'trust',
|
|
||||||
delta: 5,
|
|
||||||
occurredAt: '2026-01-15T12:00:00.000Z',
|
|
||||||
createdAt: '2026-01-15T12:00:00.000Z',
|
|
||||||
source: 'admin_vote',
|
|
||||||
reason: 'VOTE_POSITIVE',
|
|
||||||
visibility: 'public',
|
|
||||||
weight: 1.0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle pagination metadata in result', async () => {
|
|
||||||
mockRepository.findEventsPaginated.mockResolvedValue({
|
|
||||||
items: [],
|
|
||||||
total: 100,
|
|
||||||
limit: 20,
|
|
||||||
offset: 20,
|
|
||||||
hasMore: true,
|
|
||||||
nextOffset: 40,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await handler.execute({ userId: 'user-1', limit: 20, offset: 20 });
|
|
||||||
|
|
||||||
expect(result.pagination).toEqual({
|
|
||||||
total: 100,
|
|
||||||
limit: 20,
|
|
||||||
offset: 20,
|
|
||||||
hasMore: true,
|
|
||||||
nextOffset: 40,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
/**
|
|
||||||
* Application Use Case Tests: CastAdminVoteUseCase
|
|
||||||
*
|
|
||||||
* Tests for casting votes in admin vote sessions
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { CastAdminVoteUseCase } from './CastAdminVoteUseCase';
|
|
||||||
import { AdminVoteSessionRepository } from '../../domain/repositories/AdminVoteSessionRepository';
|
|
||||||
import { AdminVoteSession } from '../../domain/entities/AdminVoteSession';
|
|
||||||
|
|
||||||
// Mock repository
|
|
||||||
const createMockRepository = () => ({
|
|
||||||
save: vi.fn(),
|
|
||||||
findById: vi.fn(),
|
|
||||||
findActiveForAdmin: vi.fn(),
|
|
||||||
findByAdminAndLeague: vi.fn(),
|
|
||||||
findByLeague: vi.fn(),
|
|
||||||
findClosedUnprocessed: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('CastAdminVoteUseCase', () => {
|
|
||||||
let useCase: CastAdminVoteUseCase;
|
|
||||||
let mockRepository: ReturnType<typeof createMockRepository>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockRepository = createMockRepository();
|
|
||||||
useCase = new CastAdminVoteUseCase(mockRepository);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Input validation', () => {
|
|
||||||
it('should reject when voteSessionId is missing', async () => {
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: '',
|
|
||||||
voterId: 'voter-123',
|
|
||||||
positive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors).toContain('voteSessionId is required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject when voterId is missing', async () => {
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-123',
|
|
||||||
voterId: '',
|
|
||||||
positive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors).toContain('voterId is required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject when positive is not a boolean', async () => {
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-123',
|
|
||||||
voterId: 'voter-123',
|
|
||||||
positive: 'true' as any,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors).toContain('positive must be a boolean value');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject when votedAt is not a valid date', async () => {
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-123',
|
|
||||||
voterId: 'voter-123',
|
|
||||||
positive: true,
|
|
||||||
votedAt: 'invalid-date',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors).toContain('votedAt must be a valid date if provided');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept valid input with all fields', async () => {
|
|
||||||
mockRepository.findById.mockResolvedValue({
|
|
||||||
id: 'session-123',
|
|
||||||
isVotingWindowOpen: vi.fn().mockReturnValue(true),
|
|
||||||
castVote: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-123',
|
|
||||||
voterId: 'voter-123',
|
|
||||||
positive: true,
|
|
||||||
votedAt: '2024-01-01T00:00:00Z',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.errors).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept valid input without optional votedAt', async () => {
|
|
||||||
mockRepository.findById.mockResolvedValue({
|
|
||||||
id: 'session-123',
|
|
||||||
isVotingWindowOpen: vi.fn().mockReturnValue(true),
|
|
||||||
castVote: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-123',
|
|
||||||
voterId: 'voter-123',
|
|
||||||
positive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.errors).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Session lookup', () => {
|
|
||||||
it('should reject when vote session is not found', async () => {
|
|
||||||
mockRepository.findById.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'non-existent-session',
|
|
||||||
voterId: 'voter-123',
|
|
||||||
positive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors).toContain('Vote session not found');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should find session by ID when provided', async () => {
|
|
||||||
const mockSession = {
|
|
||||||
id: 'session-123',
|
|
||||||
isVotingWindowOpen: vi.fn().mockReturnValue(true),
|
|
||||||
castVote: vi.fn(),
|
|
||||||
};
|
|
||||||
mockRepository.findById.mockResolvedValue(mockSession);
|
|
||||||
|
|
||||||
await useCase.execute({
|
|
||||||
voteSessionId: 'session-123',
|
|
||||||
voterId: 'voter-123',
|
|
||||||
positive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockRepository.findById).toHaveBeenCalledWith('session-123');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Voting window validation', () => {
|
|
||||||
it('should reject when voting window is not open', async () => {
|
|
||||||
const mockSession = {
|
|
||||||
id: 'session-123',
|
|
||||||
isVotingWindowOpen: vi.fn().mockReturnValue(false),
|
|
||||||
castVote: vi.fn(),
|
|
||||||
};
|
|
||||||
mockRepository.findById.mockResolvedValue(mockSession);
|
|
||||||
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-123',
|
|
||||||
voterId: 'voter-123',
|
|
||||||
positive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors).toContain('Vote session is not open for voting');
|
|
||||||
expect(mockSession.isVotingWindowOpen).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept when voting window is open', async () => {
|
|
||||||
const mockSession = {
|
|
||||||
id: 'session-123',
|
|
||||||
isVotingWindowOpen: vi.fn().mockReturnValue(true),
|
|
||||||
castVote: vi.fn(),
|
|
||||||
};
|
|
||||||
mockRepository.findById.mockResolvedValue(mockSession);
|
|
||||||
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-123',
|
|
||||||
voterId: 'voter-123',
|
|
||||||
positive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(mockSession.isVotingWindowOpen).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use current time when votedAt is not provided', async () => {
|
|
||||||
const mockSession = {
|
|
||||||
id: 'session-123',
|
|
||||||
isVotingWindowOpen: vi.fn().mockReturnValue(true),
|
|
||||||
castVote: vi.fn(),
|
|
||||||
};
|
|
||||||
mockRepository.findById.mockResolvedValue(mockSession);
|
|
||||||
|
|
||||||
await useCase.execute({
|
|
||||||
voteSessionId: 'session-123',
|
|
||||||
voterId: 'voter-123',
|
|
||||||
positive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockSession.isVotingWindowOpen).toHaveBeenCalledWith(expect.any(Date));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use provided votedAt when available', async () => {
|
|
||||||
const mockSession = {
|
|
||||||
id: 'session-123',
|
|
||||||
isVotingWindowOpen: vi.fn().mockReturnValue(true),
|
|
||||||
castVote: vi.fn(),
|
|
||||||
};
|
|
||||||
mockRepository.findById.mockResolvedValue(mockSession);
|
|
||||||
|
|
||||||
const votedAt = new Date('2024-01-01T12:00:00Z');
|
|
||||||
await useCase.execute({
|
|
||||||
voteSessionId: 'session-123',
|
|
||||||
voterId: 'voter-123',
|
|
||||||
positive: true,
|
|
||||||
votedAt: votedAt.toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockSession.isVotingWindowOpen).toHaveBeenCalledWith(votedAt);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Vote casting', () => {
|
|
||||||
it('should cast positive vote when session is open', async () => {
|
|
||||||
const mockSession = {
|
|
||||||
id: 'session-123',
|
|
||||||
isVotingWindowOpen: vi.fn().mockReturnValue(true),
|
|
||||||
castVote: vi.fn(),
|
|
||||||
};
|
|
||||||
mockRepository.findById.mockResolvedValue(mockSession);
|
|
||||||
|
|
||||||
await useCase.execute({
|
|
||||||
voteSessionId: 'session-123',
|
|
||||||
voterId: 'voter-123',
|
|
||||||
positive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockSession.castVote).toHaveBeenCalledWith('voter-123', true, expect.any(Date));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should cast negative vote when session is open', async () => {
|
|
||||||
const mockSession = {
|
|
||||||
id: 'session-123',
|
|
||||||
isVotingWindowOpen: vi.fn().mockReturnValue(true),
|
|
||||||
castVote: vi.fn(),
|
|
||||||
};
|
|
||||||
mockRepository.findById.mockResolvedValue(mockSession);
|
|
||||||
|
|
||||||
await useCase.execute({
|
|
||||||
voteSessionId: 'session-123',
|
|
||||||
voterId: 'voter-123',
|
|
||||||
positive: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockSession.castVote).toHaveBeenCalledWith('voter-123', false, expect.any(Date));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should save updated session after casting vote', async () => {
|
|
||||||
const mockSession = {
|
|
||||||
id: 'session-123',
|
|
||||||
isVotingWindowOpen: vi.fn().mockReturnValue(true),
|
|
||||||
castVote: vi.fn(),
|
|
||||||
};
|
|
||||||
mockRepository.findById.mockResolvedValue(mockSession);
|
|
||||||
|
|
||||||
await useCase.execute({
|
|
||||||
voteSessionId: 'session-123',
|
|
||||||
voterId: 'voter-123',
|
|
||||||
positive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockRepository.save).toHaveBeenCalledWith(mockSession);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return success when vote is cast', async () => {
|
|
||||||
const mockSession = {
|
|
||||||
id: 'session-123',
|
|
||||||
isVotingWindowOpen: vi.fn().mockReturnValue(true),
|
|
||||||
castVote: vi.fn(),
|
|
||||||
};
|
|
||||||
mockRepository.findById.mockResolvedValue(mockSession);
|
|
||||||
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-123',
|
|
||||||
voterId: 'voter-123',
|
|
||||||
positive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.voteSessionId).toBe('session-123');
|
|
||||||
expect(result.voterId).toBe('voter-123');
|
|
||||||
expect(result.errors).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Error handling', () => {
|
|
||||||
it('should handle repository errors gracefully', async () => {
|
|
||||||
mockRepository.findById.mockRejectedValue(new Error('Database error'));
|
|
||||||
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-123',
|
|
||||||
voterId: 'voter-123',
|
|
||||||
positive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors).toContain('Failed to cast vote: Database error');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle unexpected errors gracefully', async () => {
|
|
||||||
mockRepository.findById.mockRejectedValue('Unknown error');
|
|
||||||
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-123',
|
|
||||||
voterId: 'voter-123',
|
|
||||||
positive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors).toContain('Failed to cast vote: Unknown error');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle save errors gracefully', async () => {
|
|
||||||
const mockSession = {
|
|
||||||
id: 'session-123',
|
|
||||||
isVotingWindowOpen: vi.fn().mockReturnValue(true),
|
|
||||||
castVote: vi.fn(),
|
|
||||||
};
|
|
||||||
mockRepository.findById.mockResolvedValue(mockSession);
|
|
||||||
mockRepository.save.mockRejectedValue(new Error('Save failed'));
|
|
||||||
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-123',
|
|
||||||
voterId: 'voter-123',
|
|
||||||
positive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors).toContain('Failed to cast vote: Save failed');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Return values', () => {
|
|
||||||
it('should return voteSessionId in success response', async () => {
|
|
||||||
const mockSession = {
|
|
||||||
id: 'session-123',
|
|
||||||
isVotingWindowOpen: vi.fn().mockReturnValue(true),
|
|
||||||
castVote: vi.fn(),
|
|
||||||
};
|
|
||||||
mockRepository.findById.mockResolvedValue(mockSession);
|
|
||||||
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-123',
|
|
||||||
voterId: 'voter-123',
|
|
||||||
positive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.voteSessionId).toBe('session-123');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return voterId in success response', async () => {
|
|
||||||
const mockSession = {
|
|
||||||
id: 'session-123',
|
|
||||||
isVotingWindowOpen: vi.fn().mockReturnValue(true),
|
|
||||||
castVote: vi.fn(),
|
|
||||||
};
|
|
||||||
mockRepository.findById.mockResolvedValue(mockSession);
|
|
||||||
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-123',
|
|
||||||
voterId: 'voter-123',
|
|
||||||
positive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.voterId).toBe('voter-123');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return voteSessionId in error response', async () => {
|
|
||||||
mockRepository.findById.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-123',
|
|
||||||
voterId: 'voter-123',
|
|
||||||
positive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.voteSessionId).toBe('session-123');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return voterId in error response', async () => {
|
|
||||||
mockRepository.findById.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-123',
|
|
||||||
voterId: 'voter-123',
|
|
||||||
positive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.voterId).toBe('voter-123');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,251 +0,0 @@
|
|||||||
/**
|
|
||||||
* Application Use Case Tests: OpenAdminVoteSessionUseCase
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
||||||
import { OpenAdminVoteSessionUseCase } from './OpenAdminVoteSessionUseCase';
|
|
||||||
import { AdminVoteSessionRepository } from '../../domain/repositories/AdminVoteSessionRepository';
|
|
||||||
import { AdminVoteSession } from '../../domain/entities/AdminVoteSession';
|
|
||||||
|
|
||||||
// Mock repository
|
|
||||||
const createMockRepository = () => ({
|
|
||||||
save: vi.fn(),
|
|
||||||
findById: vi.fn(),
|
|
||||||
findActiveForAdmin: vi.fn(),
|
|
||||||
findByAdminAndLeague: vi.fn(),
|
|
||||||
findByLeague: vi.fn(),
|
|
||||||
findClosedUnprocessed: vi.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('OpenAdminVoteSessionUseCase', () => {
|
|
||||||
let useCase: OpenAdminVoteSessionUseCase;
|
|
||||||
let mockRepository: ReturnType<typeof createMockRepository>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockRepository = createMockRepository();
|
|
||||||
useCase = new OpenAdminVoteSessionUseCase(mockRepository as unknown as AdminVoteSessionRepository);
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Input validation', () => {
|
|
||||||
it('should reject when voteSessionId is missing', async () => {
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: '',
|
|
||||||
leagueId: 'league-1',
|
|
||||||
adminId: 'admin-1',
|
|
||||||
startDate: '2026-01-01',
|
|
||||||
endDate: '2026-01-07',
|
|
||||||
eligibleVoters: ['voter-1'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors).toContain('voteSessionId is required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject when leagueId is missing', async () => {
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-1',
|
|
||||||
leagueId: '',
|
|
||||||
adminId: 'admin-1',
|
|
||||||
startDate: '2026-01-01',
|
|
||||||
endDate: '2026-01-07',
|
|
||||||
eligibleVoters: ['voter-1'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors).toContain('leagueId is required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject when adminId is missing', async () => {
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-1',
|
|
||||||
leagueId: 'league-1',
|
|
||||||
adminId: '',
|
|
||||||
startDate: '2026-01-01',
|
|
||||||
endDate: '2026-01-07',
|
|
||||||
eligibleVoters: ['voter-1'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors).toContain('adminId is required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject when startDate is missing', async () => {
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-1',
|
|
||||||
leagueId: 'league-1',
|
|
||||||
adminId: 'admin-1',
|
|
||||||
startDate: '',
|
|
||||||
endDate: '2026-01-07',
|
|
||||||
eligibleVoters: ['voter-1'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors).toContain('startDate is required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject when endDate is missing', async () => {
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-1',
|
|
||||||
leagueId: 'league-1',
|
|
||||||
adminId: 'admin-1',
|
|
||||||
startDate: '2026-01-01',
|
|
||||||
endDate: '',
|
|
||||||
eligibleVoters: ['voter-1'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors).toContain('endDate is required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject when startDate is invalid', async () => {
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-1',
|
|
||||||
leagueId: 'league-1',
|
|
||||||
adminId: 'admin-1',
|
|
||||||
startDate: 'invalid-date',
|
|
||||||
endDate: '2026-01-07',
|
|
||||||
eligibleVoters: ['voter-1'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors).toContain('startDate must be a valid date');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject when endDate is invalid', async () => {
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-1',
|
|
||||||
leagueId: 'league-1',
|
|
||||||
adminId: 'admin-1',
|
|
||||||
startDate: '2026-01-01',
|
|
||||||
endDate: 'invalid-date',
|
|
||||||
eligibleVoters: ['voter-1'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors).toContain('endDate must be a valid date');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject when startDate is after endDate', async () => {
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-1',
|
|
||||||
leagueId: 'league-1',
|
|
||||||
adminId: 'admin-1',
|
|
||||||
startDate: '2026-01-07',
|
|
||||||
endDate: '2026-01-01',
|
|
||||||
eligibleVoters: ['voter-1'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors).toContain('startDate must be before endDate');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject when eligibleVoters is empty', async () => {
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-1',
|
|
||||||
leagueId: 'league-1',
|
|
||||||
adminId: 'admin-1',
|
|
||||||
startDate: '2026-01-01',
|
|
||||||
endDate: '2026-01-07',
|
|
||||||
eligibleVoters: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors).toContain('At least one eligible voter is required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject when eligibleVoters has duplicates', async () => {
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-1',
|
|
||||||
leagueId: 'league-1',
|
|
||||||
adminId: 'admin-1',
|
|
||||||
startDate: '2026-01-01',
|
|
||||||
endDate: '2026-01-07',
|
|
||||||
eligibleVoters: ['voter-1', 'voter-1'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors).toContain('Duplicate eligible voters are not allowed');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Business rules', () => {
|
|
||||||
it('should reject when session ID already exists', async () => {
|
|
||||||
mockRepository.findById.mockResolvedValue({ id: 'session-1' } as any);
|
|
||||||
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-1',
|
|
||||||
leagueId: 'league-1',
|
|
||||||
adminId: 'admin-1',
|
|
||||||
startDate: '2026-01-01',
|
|
||||||
endDate: '2026-01-07',
|
|
||||||
eligibleVoters: ['voter-1'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors).toContain('Vote session with this ID already exists');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject when there is an overlapping active session', async () => {
|
|
||||||
mockRepository.findById.mockResolvedValue(null);
|
|
||||||
mockRepository.findActiveForAdmin.mockResolvedValue([
|
|
||||||
{
|
|
||||||
startDate: new Date('2026-01-05'),
|
|
||||||
endDate: new Date('2026-01-10'),
|
|
||||||
}
|
|
||||||
] as any);
|
|
||||||
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-1',
|
|
||||||
leagueId: 'league-1',
|
|
||||||
adminId: 'admin-1',
|
|
||||||
startDate: '2026-01-01',
|
|
||||||
endDate: '2026-01-07',
|
|
||||||
eligibleVoters: ['voter-1'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors).toContain('Active vote session already exists for this admin in this league with overlapping dates');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create and save a new session when valid', async () => {
|
|
||||||
mockRepository.findById.mockResolvedValue(null);
|
|
||||||
mockRepository.findActiveForAdmin.mockResolvedValue([]);
|
|
||||||
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-1',
|
|
||||||
leagueId: 'league-1',
|
|
||||||
adminId: 'admin-1',
|
|
||||||
startDate: '2026-01-01',
|
|
||||||
endDate: '2026-01-07',
|
|
||||||
eligibleVoters: ['voter-1', 'voter-2'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(mockRepository.save).toHaveBeenCalled();
|
|
||||||
const savedSession = mockRepository.save.mock.calls[0][0];
|
|
||||||
expect(savedSession).toBeInstanceOf(AdminVoteSession);
|
|
||||||
expect(savedSession.id).toBe('session-1');
|
|
||||||
expect(savedSession.leagueId).toBe('league-1');
|
|
||||||
expect(savedSession.adminId).toBe('admin-1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Error handling', () => {
|
|
||||||
it('should handle repository errors gracefully', async () => {
|
|
||||||
mockRepository.findById.mockRejectedValue(new Error('Database error'));
|
|
||||||
|
|
||||||
const result = await useCase.execute({
|
|
||||||
voteSessionId: 'session-1',
|
|
||||||
leagueId: 'league-1',
|
|
||||||
adminId: 'admin-1',
|
|
||||||
startDate: '2026-01-01',
|
|
||||||
endDate: '2026-01-07',
|
|
||||||
eligibleVoters: ['voter-1'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors?.[0]).toContain('Failed to open vote session: Database error');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
/**
|
|
||||||
* Domain Entity Tests: Company
|
|
||||||
*
|
|
||||||
* Tests for Company entity business rules and invariants
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { Company } from './Company';
|
|
||||||
import { UserId } from '../value-objects/UserId';
|
|
||||||
|
|
||||||
describe('Company', () => {
|
|
||||||
describe('Creation', () => {
|
|
||||||
it('should create a company with valid properties', () => {
|
|
||||||
const userId = UserId.fromString('user-123');
|
|
||||||
const company = Company.create({
|
|
||||||
name: 'Acme Racing Team',
|
|
||||||
ownerUserId: userId,
|
|
||||||
contactEmail: 'contact@acme.com',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(company.getName()).toBe('Acme Racing Team');
|
|
||||||
expect(company.getOwnerUserId()).toEqual(userId);
|
|
||||||
expect(company.getContactEmail()).toBe('contact@acme.com');
|
|
||||||
expect(company.getId()).toBeDefined();
|
|
||||||
expect(company.getCreatedAt()).toBeInstanceOf(Date);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create a company without optional contact email', () => {
|
|
||||||
const userId = UserId.fromString('user-123');
|
|
||||||
const company = Company.create({
|
|
||||||
name: 'Acme Racing Team',
|
|
||||||
ownerUserId: userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(company.getContactEmail()).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should generate unique IDs for different companies', () => {
|
|
||||||
const userId = UserId.fromString('user-123');
|
|
||||||
const company1 = Company.create({
|
|
||||||
name: 'Team A',
|
|
||||||
ownerUserId: userId,
|
|
||||||
});
|
|
||||||
const company2 = Company.create({
|
|
||||||
name: 'Team B',
|
|
||||||
ownerUserId: userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(company1.getId()).not.toBe(company2.getId());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Rehydration', () => {
|
|
||||||
it('should rehydrate company from stored data', () => {
|
|
||||||
const userId = UserId.fromString('user-123');
|
|
||||||
const createdAt = new Date('2024-01-01');
|
|
||||||
|
|
||||||
const company = Company.rehydrate({
|
|
||||||
id: 'comp-123',
|
|
||||||
name: 'Acme Racing Team',
|
|
||||||
ownerUserId: 'user-123',
|
|
||||||
contactEmail: 'contact@acme.com',
|
|
||||||
createdAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(company.getId()).toBe('comp-123');
|
|
||||||
expect(company.getName()).toBe('Acme Racing Team');
|
|
||||||
expect(company.getOwnerUserId()).toEqual(userId);
|
|
||||||
expect(company.getContactEmail()).toBe('contact@acme.com');
|
|
||||||
expect(company.getCreatedAt()).toEqual(createdAt);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should rehydrate company without contact email', () => {
|
|
||||||
const createdAt = new Date('2024-01-01');
|
|
||||||
|
|
||||||
const company = Company.rehydrate({
|
|
||||||
id: 'comp-123',
|
|
||||||
name: 'Acme Racing Team',
|
|
||||||
ownerUserId: 'user-123',
|
|
||||||
createdAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(company.getContactEmail()).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Validation', () => {
|
|
||||||
it('should throw error when company name is empty', () => {
|
|
||||||
const userId = UserId.fromString('user-123');
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
Company.create({
|
|
||||||
name: '',
|
|
||||||
ownerUserId: userId,
|
|
||||||
});
|
|
||||||
}).toThrow('Company name cannot be empty');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error when company name is only whitespace', () => {
|
|
||||||
const userId = UserId.fromString('user-123');
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
Company.create({
|
|
||||||
name: ' ',
|
|
||||||
ownerUserId: userId,
|
|
||||||
});
|
|
||||||
}).toThrow('Company name cannot be empty');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error when company name is too short', () => {
|
|
||||||
const userId = UserId.fromString('user-123');
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
Company.create({
|
|
||||||
name: 'A',
|
|
||||||
ownerUserId: userId,
|
|
||||||
});
|
|
||||||
}).toThrow('Company name must be at least 2 characters long');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error when company name is too long', () => {
|
|
||||||
const userId = UserId.fromString('user-123');
|
|
||||||
const longName = 'A'.repeat(101);
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
Company.create({
|
|
||||||
name: longName,
|
|
||||||
ownerUserId: userId,
|
|
||||||
});
|
|
||||||
}).toThrow('Company name must be no more than 100 characters');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept company name with exactly 2 characters', () => {
|
|
||||||
const userId = UserId.fromString('user-123');
|
|
||||||
|
|
||||||
const company = Company.create({
|
|
||||||
name: 'AB',
|
|
||||||
ownerUserId: userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(company.getName()).toBe('AB');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept company name with exactly 100 characters', () => {
|
|
||||||
const userId = UserId.fromString('user-123');
|
|
||||||
const longName = 'A'.repeat(100);
|
|
||||||
|
|
||||||
const company = Company.create({
|
|
||||||
name: longName,
|
|
||||||
ownerUserId: userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(company.getName()).toBe(longName);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should trim whitespace from company name during validation', () => {
|
|
||||||
const userId = UserId.fromString('user-123');
|
|
||||||
|
|
||||||
const company = Company.create({
|
|
||||||
name: ' Acme Racing Team ',
|
|
||||||
ownerUserId: userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Note: The current implementation doesn't trim, it just validates
|
|
||||||
// So this test documents the current behavior
|
|
||||||
expect(company.getName()).toBe(' Acme Racing Team ');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Business Rules', () => {
|
|
||||||
it('should maintain immutability of properties', () => {
|
|
||||||
const userId = UserId.fromString('user-123');
|
|
||||||
const company = Company.create({
|
|
||||||
name: 'Acme Racing Team',
|
|
||||||
ownerUserId: userId,
|
|
||||||
contactEmail: 'contact@acme.com',
|
|
||||||
});
|
|
||||||
|
|
||||||
const originalName = company.getName();
|
|
||||||
const originalEmail = company.getContactEmail();
|
|
||||||
|
|
||||||
// Try to modify (should not work due to readonly properties)
|
|
||||||
// This is more of a TypeScript compile-time check, but we can verify runtime behavior
|
|
||||||
expect(company.getName()).toBe(originalName);
|
|
||||||
expect(company.getContactEmail()).toBe(originalEmail);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle special characters in company name', () => {
|
|
||||||
const userId = UserId.fromString('user-123');
|
|
||||||
|
|
||||||
const company = Company.create({
|
|
||||||
name: 'Acme & Sons Racing, LLC',
|
|
||||||
ownerUserId: userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(company.getName()).toBe('Acme & Sons Racing, LLC');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle unicode characters in company name', () => {
|
|
||||||
const userId = UserId.fromString('user-123');
|
|
||||||
|
|
||||||
const company = Company.create({
|
|
||||||
name: 'Räcing Tëam Ñumber Øne',
|
|
||||||
ownerUserId: userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(company.getName()).toBe('Räcing Tëam Ñumber Øne');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Edge Cases', () => {
|
|
||||||
it('should handle rehydration with null contact email', () => {
|
|
||||||
const createdAt = new Date('2024-01-01');
|
|
||||||
|
|
||||||
const company = Company.rehydrate({
|
|
||||||
id: 'comp-123',
|
|
||||||
name: 'Acme Racing Team',
|
|
||||||
ownerUserId: 'user-123',
|
|
||||||
contactEmail: null as any,
|
|
||||||
createdAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
// The entity stores null as null, not undefined
|
|
||||||
expect(company.getContactEmail()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle rehydration with undefined contact email', () => {
|
|
||||||
const createdAt = new Date('2024-01-01');
|
|
||||||
|
|
||||||
const company = Company.rehydrate({
|
|
||||||
id: 'comp-123',
|
|
||||||
name: 'Acme Racing Team',
|
|
||||||
ownerUserId: 'user-123',
|
|
||||||
contactEmail: undefined,
|
|
||||||
createdAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(company.getContactEmail()).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
/**
|
|
||||||
* Domain Error Tests: IdentityDomainError
|
|
||||||
*
|
|
||||||
* Tests for domain error classes and their behavior
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { IdentityDomainError, IdentityDomainValidationError, IdentityDomainInvariantError } from './IdentityDomainError';
|
|
||||||
|
|
||||||
describe('IdentityDomainError', () => {
|
|
||||||
describe('IdentityDomainError (base class)', () => {
|
|
||||||
it('should create an error with correct properties', () => {
|
|
||||||
const error = new IdentityDomainValidationError('Test error message');
|
|
||||||
|
|
||||||
expect(error.message).toBe('Test error message');
|
|
||||||
expect(error.type).toBe('domain');
|
|
||||||
expect(error.context).toBe('identity-domain');
|
|
||||||
expect(error.kind).toBe('validation');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be an instance of Error', () => {
|
|
||||||
const error = new IdentityDomainValidationError('Test error');
|
|
||||||
expect(error instanceof Error).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be an instance of IdentityDomainError', () => {
|
|
||||||
const error = new IdentityDomainValidationError('Test error');
|
|
||||||
expect(error instanceof IdentityDomainError).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have correct stack trace', () => {
|
|
||||||
const error = new IdentityDomainValidationError('Test error');
|
|
||||||
expect(error.stack).toBeDefined();
|
|
||||||
expect(error.stack).toContain('IdentityDomainError');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle empty error message', () => {
|
|
||||||
const error = new IdentityDomainValidationError('');
|
|
||||||
expect(error.message).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle error message with special characters', () => {
|
|
||||||
const error = new IdentityDomainValidationError('Error: Invalid input @#$%^&*()');
|
|
||||||
expect(error.message).toBe('Error: Invalid input @#$%^&*()');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle error message with newlines', () => {
|
|
||||||
const error = new IdentityDomainValidationError('Error line 1\nError line 2');
|
|
||||||
expect(error.message).toBe('Error line 1\nError line 2');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('IdentityDomainValidationError', () => {
|
|
||||||
it('should create a validation error with correct kind', () => {
|
|
||||||
const error = new IdentityDomainValidationError('Invalid email format');
|
|
||||||
|
|
||||||
expect(error.kind).toBe('validation');
|
|
||||||
expect(error.type).toBe('domain');
|
|
||||||
expect(error.context).toBe('identity-domain');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be an instance of IdentityDomainValidationError', () => {
|
|
||||||
const error = new IdentityDomainValidationError('Invalid email format');
|
|
||||||
expect(error instanceof IdentityDomainValidationError).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be an instance of IdentityDomainError', () => {
|
|
||||||
const error = new IdentityDomainValidationError('Invalid email format');
|
|
||||||
expect(error instanceof IdentityDomainError).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle validation error with empty message', () => {
|
|
||||||
const error = new IdentityDomainValidationError('');
|
|
||||||
expect(error.kind).toBe('validation');
|
|
||||||
expect(error.message).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle validation error with complex message', () => {
|
|
||||||
const error = new IdentityDomainValidationError(
|
|
||||||
'Validation failed: Email must be at least 6 characters long and contain a valid domain'
|
|
||||||
);
|
|
||||||
expect(error.kind).toBe('validation');
|
|
||||||
expect(error.message).toBe(
|
|
||||||
'Validation failed: Email must be at least 6 characters long and contain a valid domain'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('IdentityDomainInvariantError', () => {
|
|
||||||
it('should create an invariant error with correct kind', () => {
|
|
||||||
const error = new IdentityDomainInvariantError('User must have a valid email');
|
|
||||||
|
|
||||||
expect(error.kind).toBe('invariant');
|
|
||||||
expect(error.type).toBe('domain');
|
|
||||||
expect(error.context).toBe('identity-domain');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be an instance of IdentityDomainInvariantError', () => {
|
|
||||||
const error = new IdentityDomainInvariantError('User must have a valid email');
|
|
||||||
expect(error instanceof IdentityDomainInvariantError).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be an instance of IdentityDomainError', () => {
|
|
||||||
const error = new IdentityDomainInvariantError('User must have a valid email');
|
|
||||||
expect(error instanceof IdentityDomainError).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle invariant error with empty message', () => {
|
|
||||||
const error = new IdentityDomainInvariantError('');
|
|
||||||
expect(error.kind).toBe('invariant');
|
|
||||||
expect(error.message).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle invariant error with complex message', () => {
|
|
||||||
const error = new IdentityDomainInvariantError(
|
|
||||||
'Invariant violation: User rating must be between 0 and 100'
|
|
||||||
);
|
|
||||||
expect(error.kind).toBe('invariant');
|
|
||||||
expect(error.message).toBe(
|
|
||||||
'Invariant violation: User rating must be between 0 and 100'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Error hierarchy', () => {
|
|
||||||
it('should maintain correct error hierarchy for validation errors', () => {
|
|
||||||
const error = new IdentityDomainValidationError('Test');
|
|
||||||
|
|
||||||
expect(error instanceof IdentityDomainValidationError).toBe(true);
|
|
||||||
expect(error instanceof IdentityDomainError).toBe(true);
|
|
||||||
expect(error instanceof Error).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should maintain correct error hierarchy for invariant errors', () => {
|
|
||||||
const error = new IdentityDomainInvariantError('Test');
|
|
||||||
|
|
||||||
expect(error instanceof IdentityDomainInvariantError).toBe(true);
|
|
||||||
expect(error instanceof IdentityDomainError).toBe(true);
|
|
||||||
expect(error instanceof Error).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow catching as IdentityDomainError', () => {
|
|
||||||
const error = new IdentityDomainValidationError('Test');
|
|
||||||
|
|
||||||
try {
|
|
||||||
throw error;
|
|
||||||
} catch (e) {
|
|
||||||
expect(e instanceof IdentityDomainError).toBe(true);
|
|
||||||
expect((e as IdentityDomainError).kind).toBe('validation');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow catching as Error', () => {
|
|
||||||
const error = new IdentityDomainInvariantError('Test');
|
|
||||||
|
|
||||||
try {
|
|
||||||
throw error;
|
|
||||||
} catch (e) {
|
|
||||||
expect(e instanceof Error).toBe(true);
|
|
||||||
expect((e as Error).message).toBe('Test');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Error properties', () => {
|
|
||||||
it('should have consistent type property', () => {
|
|
||||||
const validationError = new IdentityDomainValidationError('Test');
|
|
||||||
const invariantError = new IdentityDomainInvariantError('Test');
|
|
||||||
|
|
||||||
expect(validationError.type).toBe('domain');
|
|
||||||
expect(invariantError.type).toBe('domain');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have consistent context property', () => {
|
|
||||||
const validationError = new IdentityDomainValidationError('Test');
|
|
||||||
const invariantError = new IdentityDomainInvariantError('Test');
|
|
||||||
|
|
||||||
expect(validationError.context).toBe('identity-domain');
|
|
||||||
expect(invariantError.context).toBe('identity-domain');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have different kind properties', () => {
|
|
||||||
const validationError = new IdentityDomainValidationError('Test');
|
|
||||||
const invariantError = new IdentityDomainInvariantError('Test');
|
|
||||||
|
|
||||||
expect(validationError.kind).toBe('validation');
|
|
||||||
expect(invariantError.kind).toBe('invariant');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Error usage patterns', () => {
|
|
||||||
it('should be usable in try-catch blocks', () => {
|
|
||||||
expect(() => {
|
|
||||||
throw new IdentityDomainValidationError('Invalid input');
|
|
||||||
}).toThrow(IdentityDomainValidationError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be usable with error instanceof checks', () => {
|
|
||||||
const error = new IdentityDomainValidationError('Test');
|
|
||||||
|
|
||||||
expect(error instanceof IdentityDomainValidationError).toBe(true);
|
|
||||||
expect(error instanceof IdentityDomainError).toBe(true);
|
|
||||||
expect(error instanceof Error).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be usable with error type narrowing', () => {
|
|
||||||
const error: IdentityDomainError = new IdentityDomainValidationError('Test');
|
|
||||||
|
|
||||||
if (error.kind === 'validation') {
|
|
||||||
expect(error instanceof IdentityDomainValidationError).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support error message extraction', () => {
|
|
||||||
const errorMessage = 'User email is required';
|
|
||||||
const error = new IdentityDomainValidationError(errorMessage);
|
|
||||||
|
|
||||||
expect(error.message).toBe(errorMessage);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
/**
|
|
||||||
* Domain Service Tests: PasswordHashingService
|
|
||||||
*
|
|
||||||
* Tests for password hashing and verification business logic
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
|
||||||
import { PasswordHashingService } from './PasswordHashingService';
|
|
||||||
|
|
||||||
describe('PasswordHashingService', () => {
|
|
||||||
let service: PasswordHashingService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
service = new PasswordHashingService();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('hash', () => {
|
|
||||||
it('should hash a plain text password', async () => {
|
|
||||||
const plainPassword = 'mySecurePassword123';
|
|
||||||
const hash = await service.hash(plainPassword);
|
|
||||||
|
|
||||||
expect(hash).toBeDefined();
|
|
||||||
expect(typeof hash).toBe('string');
|
|
||||||
expect(hash.length).toBeGreaterThan(0);
|
|
||||||
// Hash should not be the same as the plain password
|
|
||||||
expect(hash).not.toBe(plainPassword);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should produce different hashes for the same password (with salt)', async () => {
|
|
||||||
const plainPassword = 'mySecurePassword123';
|
|
||||||
const hash1 = await service.hash(plainPassword);
|
|
||||||
const hash2 = await service.hash(plainPassword);
|
|
||||||
|
|
||||||
// Due to salting, hashes should be different
|
|
||||||
expect(hash1).not.toBe(hash2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle empty string password', async () => {
|
|
||||||
const hash = await service.hash('');
|
|
||||||
expect(hash).toBeDefined();
|
|
||||||
expect(typeof hash).toBe('string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle special characters in password', async () => {
|
|
||||||
const specialPassword = 'P@ssw0rd!#$%^&*()_+-=[]{}|;:,.<>?';
|
|
||||||
const hash = await service.hash(specialPassword);
|
|
||||||
|
|
||||||
expect(hash).toBeDefined();
|
|
||||||
expect(typeof hash).toBe('string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle unicode characters in password', async () => {
|
|
||||||
const unicodePassword = 'Pässwörd!🔒';
|
|
||||||
const hash = await service.hash(unicodePassword);
|
|
||||||
|
|
||||||
expect(hash).toBeDefined();
|
|
||||||
expect(typeof hash).toBe('string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle very long passwords', async () => {
|
|
||||||
const longPassword = 'a'.repeat(1000);
|
|
||||||
const hash = await service.hash(longPassword);
|
|
||||||
|
|
||||||
expect(hash).toBeDefined();
|
|
||||||
expect(typeof hash).toBe('string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle whitespace-only password', async () => {
|
|
||||||
const whitespacePassword = ' ';
|
|
||||||
const hash = await service.hash(whitespacePassword);
|
|
||||||
|
|
||||||
expect(hash).toBeDefined();
|
|
||||||
expect(typeof hash).toBe('string');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('verify', () => {
|
|
||||||
it('should verify correct password against hash', async () => {
|
|
||||||
const plainPassword = 'mySecurePassword123';
|
|
||||||
const hash = await service.hash(plainPassword);
|
|
||||||
|
|
||||||
const isValid = await service.verify(plainPassword, hash);
|
|
||||||
expect(isValid).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject incorrect password', async () => {
|
|
||||||
const plainPassword = 'mySecurePassword123';
|
|
||||||
const hash = await service.hash(plainPassword);
|
|
||||||
|
|
||||||
const isValid = await service.verify('wrongPassword', hash);
|
|
||||||
expect(isValid).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject empty password against hash', async () => {
|
|
||||||
const plainPassword = 'mySecurePassword123';
|
|
||||||
const hash = await service.hash(plainPassword);
|
|
||||||
|
|
||||||
const isValid = await service.verify('', hash);
|
|
||||||
expect(isValid).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle verification with special characters', async () => {
|
|
||||||
const specialPassword = 'P@ssw0rd!#$%^&*()_+-=[]{}|;:,.<>?';
|
|
||||||
const hash = await service.hash(specialPassword);
|
|
||||||
|
|
||||||
const isValid = await service.verify(specialPassword, hash);
|
|
||||||
expect(isValid).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle verification with unicode characters', async () => {
|
|
||||||
const unicodePassword = 'Pässwörd!🔒';
|
|
||||||
const hash = await service.hash(unicodePassword);
|
|
||||||
|
|
||||||
const isValid = await service.verify(unicodePassword, hash);
|
|
||||||
expect(isValid).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle verification with very long passwords', async () => {
|
|
||||||
const longPassword = 'a'.repeat(1000);
|
|
||||||
const hash = await service.hash(longPassword);
|
|
||||||
|
|
||||||
const isValid = await service.verify(longPassword, hash);
|
|
||||||
expect(isValid).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle verification with whitespace-only password', async () => {
|
|
||||||
const whitespacePassword = ' ';
|
|
||||||
const hash = await service.hash(whitespacePassword);
|
|
||||||
|
|
||||||
const isValid = await service.verify(whitespacePassword, hash);
|
|
||||||
expect(isValid).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject verification with null hash', async () => {
|
|
||||||
// bcrypt throws an error when hash is null, which is expected behavior
|
|
||||||
await expect(service.verify('password', null as any)).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject verification with empty hash', async () => {
|
|
||||||
const isValid = await service.verify('password', '');
|
|
||||||
expect(isValid).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject verification with invalid hash format', async () => {
|
|
||||||
const isValid = await service.verify('password', 'invalid-hash-format');
|
|
||||||
expect(isValid).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Hash Consistency', () => {
|
|
||||||
it('should consistently verify the same password-hash pair', async () => {
|
|
||||||
const plainPassword = 'testPassword123';
|
|
||||||
const hash = await service.hash(plainPassword);
|
|
||||||
|
|
||||||
// Verify multiple times
|
|
||||||
const result1 = await service.verify(plainPassword, hash);
|
|
||||||
const result2 = await service.verify(plainPassword, hash);
|
|
||||||
const result3 = await service.verify(plainPassword, hash);
|
|
||||||
|
|
||||||
expect(result1).toBe(true);
|
|
||||||
expect(result2).toBe(true);
|
|
||||||
expect(result3).toBe(true);
|
|
||||||
}, 10000);
|
|
||||||
|
|
||||||
it('should consistently reject wrong password', async () => {
|
|
||||||
const plainPassword = 'testPassword123';
|
|
||||||
const wrongPassword = 'wrongPassword';
|
|
||||||
const hash = await service.hash(plainPassword);
|
|
||||||
|
|
||||||
// Verify multiple times with wrong password
|
|
||||||
const result1 = await service.verify(wrongPassword, hash);
|
|
||||||
const result2 = await service.verify(wrongPassword, hash);
|
|
||||||
const result3 = await service.verify(wrongPassword, hash);
|
|
||||||
|
|
||||||
expect(result1).toBe(false);
|
|
||||||
expect(result2).toBe(false);
|
|
||||||
expect(result3).toBe(false);
|
|
||||||
}, 10000);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Security Properties', () => {
|
|
||||||
it('should not leak information about the original password from hash', async () => {
|
|
||||||
const password1 = 'password123';
|
|
||||||
const password2 = 'password456';
|
|
||||||
|
|
||||||
const hash1 = await service.hash(password1);
|
|
||||||
const hash2 = await service.hash(password2);
|
|
||||||
|
|
||||||
// Hashes should be different
|
|
||||||
expect(hash1).not.toBe(hash2);
|
|
||||||
|
|
||||||
// Neither hash should contain the original password
|
|
||||||
expect(hash1).not.toContain(password1);
|
|
||||||
expect(hash2).not.toContain(password2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle case sensitivity correctly', async () => {
|
|
||||||
const password1 = 'Password';
|
|
||||||
const password2 = 'password';
|
|
||||||
|
|
||||||
const hash1 = await service.hash(password1);
|
|
||||||
const hash2 = await service.hash(password2);
|
|
||||||
|
|
||||||
// Should be treated as different passwords
|
|
||||||
const isValid1 = await service.verify(password1, hash1);
|
|
||||||
const isValid2 = await service.verify(password2, hash2);
|
|
||||||
const isCrossValid1 = await service.verify(password1, hash2);
|
|
||||||
const isCrossValid2 = await service.verify(password2, hash1);
|
|
||||||
|
|
||||||
expect(isValid1).toBe(true);
|
|
||||||
expect(isValid2).toBe(true);
|
|
||||||
expect(isCrossValid1).toBe(false);
|
|
||||||
expect(isCrossValid2).toBe(false);
|
|
||||||
}, 10000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,338 +0,0 @@
|
|||||||
/**
|
|
||||||
* Domain Types Tests: EmailAddress
|
|
||||||
*
|
|
||||||
* Tests for email validation and disposable email detection
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { validateEmail, isDisposableEmail, DISPOSABLE_DOMAINS } from './EmailAddress';
|
|
||||||
|
|
||||||
describe('EmailAddress', () => {
|
|
||||||
describe('validateEmail', () => {
|
|
||||||
describe('Valid emails', () => {
|
|
||||||
it('should validate standard email format', () => {
|
|
||||||
const result = validateEmail('user@example.com');
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
if (result.success) {
|
|
||||||
expect(result.email).toBe('user@example.com');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate email with subdomain', () => {
|
|
||||||
const result = validateEmail('user@mail.example.com');
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
if (result.success) {
|
|
||||||
expect(result.email).toBe('user@mail.example.com');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate email with plus sign', () => {
|
|
||||||
const result = validateEmail('user+tag@example.com');
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
if (result.success) {
|
|
||||||
expect(result.email).toBe('user+tag@example.com');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate email with numbers', () => {
|
|
||||||
const result = validateEmail('user123@example.com');
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
if (result.success) {
|
|
||||||
expect(result.email).toBe('user123@example.com');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate email with hyphens', () => {
|
|
||||||
const result = validateEmail('user-name@example.com');
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
if (result.success) {
|
|
||||||
expect(result.email).toBe('user-name@example.com');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate email with underscores', () => {
|
|
||||||
const result = validateEmail('user_name@example.com');
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
if (result.success) {
|
|
||||||
expect(result.email).toBe('user_name@example.com');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate email with dots in local part', () => {
|
|
||||||
const result = validateEmail('user.name@example.com');
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
if (result.success) {
|
|
||||||
expect(result.email).toBe('user.name@example.com');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate email with uppercase letters', () => {
|
|
||||||
const result = validateEmail('User@Example.com');
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
if (result.success) {
|
|
||||||
// Should be normalized to lowercase
|
|
||||||
expect(result.email).toBe('user@example.com');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate email with leading/trailing whitespace', () => {
|
|
||||||
const result = validateEmail(' user@example.com ');
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
if (result.success) {
|
|
||||||
// Should be trimmed
|
|
||||||
expect(result.email).toBe('user@example.com');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate minimum length email (6 chars)', () => {
|
|
||||||
const result = validateEmail('a@b.cd');
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
if (result.success) {
|
|
||||||
expect(result.email).toBe('a@b.cd');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should validate maximum length email (254 chars)', () => {
|
|
||||||
const localPart = 'a'.repeat(64);
|
|
||||||
const domain = 'example.com';
|
|
||||||
const email = `${localPart}@${domain}`;
|
|
||||||
const result = validateEmail(email);
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
if (result.success) {
|
|
||||||
expect(result.email).toBe(email);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Invalid emails', () => {
|
|
||||||
it('should reject empty string', () => {
|
|
||||||
const result = validateEmail('');
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
if (!result.success) {
|
|
||||||
expect(result.error).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject whitespace-only string', () => {
|
|
||||||
const result = validateEmail(' ');
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
if (!result.success) {
|
|
||||||
expect(result.error).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject email without @ symbol', () => {
|
|
||||||
const result = validateEmail('userexample.com');
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
if (!result.success) {
|
|
||||||
expect(result.error).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject email without domain', () => {
|
|
||||||
const result = validateEmail('user@');
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
if (!result.success) {
|
|
||||||
expect(result.error).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject email without local part', () => {
|
|
||||||
const result = validateEmail('@example.com');
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
if (!result.success) {
|
|
||||||
expect(result.error).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject email with multiple @ symbols', () => {
|
|
||||||
const result = validateEmail('user@domain@com');
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
if (!result.success) {
|
|
||||||
expect(result.error).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject email with spaces in local part', () => {
|
|
||||||
const result = validateEmail('user name@example.com');
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
if (!result.success) {
|
|
||||||
expect(result.error).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject email with spaces in domain', () => {
|
|
||||||
const result = validateEmail('user@ex ample.com');
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
if (!result.success) {
|
|
||||||
expect(result.error).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject email with invalid characters', () => {
|
|
||||||
const result = validateEmail('user#name@example.com');
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
if (!result.success) {
|
|
||||||
expect(result.error).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject email that is too short', () => {
|
|
||||||
const result = validateEmail('a@b.c');
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
if (!result.success) {
|
|
||||||
expect(result.error).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept email that is exactly 254 characters', () => {
|
|
||||||
// The maximum email length is 254 characters
|
|
||||||
const localPart = 'a'.repeat(64);
|
|
||||||
const domain = 'example.com';
|
|
||||||
const email = `${localPart}@${domain}`;
|
|
||||||
const result = validateEmail(email);
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
if (result.success) {
|
|
||||||
expect(result.email).toBe(email);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject email without TLD', () => {
|
|
||||||
const result = validateEmail('user@example');
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
if (!result.success) {
|
|
||||||
expect(result.error).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject email with invalid TLD format', () => {
|
|
||||||
const result = validateEmail('user@example.');
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
if (!result.success) {
|
|
||||||
expect(result.error).toBeDefined();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Edge cases', () => {
|
|
||||||
it('should handle null input gracefully', () => {
|
|
||||||
const result = validateEmail(null as any);
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle undefined input gracefully', () => {
|
|
||||||
const result = validateEmail(undefined as any);
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle non-string input gracefully', () => {
|
|
||||||
const result = validateEmail(123 as any);
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isDisposableEmail', () => {
|
|
||||||
describe('Disposable email domains', () => {
|
|
||||||
it('should detect tempmail.com as disposable', () => {
|
|
||||||
expect(isDisposableEmail('user@tempmail.com')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect throwaway.email as disposable', () => {
|
|
||||||
expect(isDisposableEmail('user@throwaway.email')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect guerrillamail.com as disposable', () => {
|
|
||||||
expect(isDisposableEmail('user@guerrillamail.com')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect mailinator.com as disposable', () => {
|
|
||||||
expect(isDisposableEmail('user@mailinator.com')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect 10minutemail.com as disposable', () => {
|
|
||||||
expect(isDisposableEmail('user@10minutemail.com')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect disposable domains case-insensitively', () => {
|
|
||||||
expect(isDisposableEmail('user@TEMPMAIL.COM')).toBe(true);
|
|
||||||
expect(isDisposableEmail('user@TempMail.Com')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect disposable domains with subdomains', () => {
|
|
||||||
// The current implementation only checks the exact domain, not subdomains
|
|
||||||
// So this test documents the current behavior
|
|
||||||
expect(isDisposableEmail('user@subdomain.tempmail.com')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Non-disposable email domains', () => {
|
|
||||||
it('should not detect gmail.com as disposable', () => {
|
|
||||||
expect(isDisposableEmail('user@gmail.com')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not detect yahoo.com as disposable', () => {
|
|
||||||
expect(isDisposableEmail('user@yahoo.com')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not detect outlook.com as disposable', () => {
|
|
||||||
expect(isDisposableEmail('user@outlook.com')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not detect company domains as disposable', () => {
|
|
||||||
expect(isDisposableEmail('user@example.com')).toBe(false);
|
|
||||||
expect(isDisposableEmail('user@company.com')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not detect custom domains as disposable', () => {
|
|
||||||
expect(isDisposableEmail('user@mydomain.com')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Edge cases', () => {
|
|
||||||
it('should handle email without domain', () => {
|
|
||||||
expect(isDisposableEmail('user@')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle email without @ symbol', () => {
|
|
||||||
expect(isDisposableEmail('user')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle empty string', () => {
|
|
||||||
expect(isDisposableEmail('')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle null input', () => {
|
|
||||||
// The current implementation throws an error when given null
|
|
||||||
// This is expected behavior - the function expects a string
|
|
||||||
expect(() => isDisposableEmail(null as any)).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle undefined input', () => {
|
|
||||||
// The current implementation throws an error when given undefined
|
|
||||||
// This is expected behavior - the function expects a string
|
|
||||||
expect(() => isDisposableEmail(undefined as any)).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DISPOSABLE_DOMAINS', () => {
|
|
||||||
it('should contain expected disposable domains', () => {
|
|
||||||
expect(DISPOSABLE_DOMAINS.has('tempmail.com')).toBe(true);
|
|
||||||
expect(DISPOSABLE_DOMAINS.has('throwaway.email')).toBe(true);
|
|
||||||
expect(DISPOSABLE_DOMAINS.has('guerrillamail.com')).toBe(true);
|
|
||||||
expect(DISPOSABLE_DOMAINS.has('mailinator.com')).toBe(true);
|
|
||||||
expect(DISPOSABLE_DOMAINS.has('10minutemail.com')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not contain non-disposable domains', () => {
|
|
||||||
expect(DISPOSABLE_DOMAINS.has('gmail.com')).toBe(false);
|
|
||||||
expect(DISPOSABLE_DOMAINS.has('yahoo.com')).toBe(false);
|
|
||||||
expect(DISPOSABLE_DOMAINS.has('outlook.com')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be a Set', () => {
|
|
||||||
expect(DISPOSABLE_DOMAINS instanceof Set).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
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"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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[];
|
||||||
|
}
|
||||||
40
core/leagues/application/ports/LeagueEventPublisher.ts
Normal file
40
core/leagues/application/ports/LeagueEventPublisher.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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 LeagueEventPublisher {
|
||||||
|
emitLeagueCreated(event: LeagueCreatedEvent): Promise<void>;
|
||||||
|
emitLeagueUpdated(event: LeagueUpdatedEvent): Promise<void>;
|
||||||
|
emitLeagueDeleted(event: LeagueDeletedEvent): Promise<void>;
|
||||||
|
emitLeagueAccessed(event: LeagueAccessedEvent): Promise<void>;
|
||||||
|
|
||||||
|
getLeagueCreatedEventCount(): number;
|
||||||
|
getLeagueUpdatedEventCount(): number;
|
||||||
|
getLeagueDeletedEventCount(): number;
|
||||||
|
getLeagueAccessedEventCount(): number;
|
||||||
|
|
||||||
|
clear(): void;
|
||||||
|
}
|
||||||
169
core/leagues/application/ports/LeagueRepository.ts
Normal file
169
core/leagues/application/ports/LeagueRepository.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
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 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>;
|
||||||
|
}
|
||||||
183
core/leagues/application/use-cases/CreateLeagueUseCase.ts
Normal file
183
core/leagues/application/use-cases/CreateLeagueUseCase.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
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.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import { Result } from '@core/shared/domain/Result';
|
|
||||||
import { describe, expect, it, vi, type Mock } from 'vitest';
|
|
||||||
import type { MediaStoragePort } from '../ports/MediaStoragePort';
|
|
||||||
import { GetUploadedMediaUseCase } from './GetUploadedMediaUseCase';
|
|
||||||
|
|
||||||
describe('GetUploadedMediaUseCase', () => {
|
|
||||||
let mediaStorage: {
|
|
||||||
getBytes: Mock;
|
|
||||||
getMetadata: Mock;
|
|
||||||
};
|
|
||||||
let useCase: GetUploadedMediaUseCase;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mediaStorage = {
|
|
||||||
getBytes: vi.fn(),
|
|
||||||
getMetadata: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
useCase = new GetUploadedMediaUseCase(
|
|
||||||
mediaStorage as unknown as MediaStoragePort,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when media is not found', async () => {
|
|
||||||
mediaStorage.getBytes.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const input = { storageKey: 'missing-key' };
|
|
||||||
const result = await useCase.execute(input);
|
|
||||||
|
|
||||||
expect(mediaStorage.getBytes).toHaveBeenCalledWith('missing-key');
|
|
||||||
expect(result).toBeInstanceOf(Result);
|
|
||||||
expect(result.isOk()).toBe(true);
|
|
||||||
expect(result.unwrap()).toBe(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns media bytes and content type when found', async () => {
|
|
||||||
const mockBytes = Buffer.from('test data');
|
|
||||||
const mockMetadata = { size: 9, contentType: 'image/png' };
|
|
||||||
|
|
||||||
mediaStorage.getBytes.mockResolvedValue(mockBytes);
|
|
||||||
mediaStorage.getMetadata.mockResolvedValue(mockMetadata);
|
|
||||||
|
|
||||||
const input = { storageKey: 'media-key' };
|
|
||||||
const result = await useCase.execute(input);
|
|
||||||
|
|
||||||
expect(mediaStorage.getBytes).toHaveBeenCalledWith('media-key');
|
|
||||||
expect(mediaStorage.getMetadata).toHaveBeenCalledWith('media-key');
|
|
||||||
expect(result.isOk()).toBe(true);
|
|
||||||
|
|
||||||
const successResult = result.unwrap();
|
|
||||||
expect(successResult).not.toBeNull();
|
|
||||||
expect(successResult!.bytes).toBeInstanceOf(Buffer);
|
|
||||||
expect(successResult!.bytes.toString()).toBe('test data');
|
|
||||||
expect(successResult!.contentType).toBe('image/png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns default content type when metadata is null', async () => {
|
|
||||||
const mockBytes = Buffer.from('test data');
|
|
||||||
|
|
||||||
mediaStorage.getBytes.mockResolvedValue(mockBytes);
|
|
||||||
mediaStorage.getMetadata.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const input = { storageKey: 'media-key' };
|
|
||||||
const result = await useCase.execute(input);
|
|
||||||
|
|
||||||
expect(result.isOk()).toBe(true);
|
|
||||||
|
|
||||||
const successResult = result.unwrap();
|
|
||||||
expect(successResult!.contentType).toBe('application/octet-stream');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns default content type when metadata has no contentType', async () => {
|
|
||||||
const mockBytes = Buffer.from('test data');
|
|
||||||
const mockMetadata = { size: 9 };
|
|
||||||
|
|
||||||
mediaStorage.getBytes.mockResolvedValue(mockBytes);
|
|
||||||
mediaStorage.getMetadata.mockResolvedValue(mockMetadata as any);
|
|
||||||
|
|
||||||
const input = { storageKey: 'media-key' };
|
|
||||||
const result = await useCase.execute(input);
|
|
||||||
|
|
||||||
expect(result.isOk()).toBe(true);
|
|
||||||
|
|
||||||
const successResult = result.unwrap();
|
|
||||||
expect(successResult!.contentType).toBe('application/octet-stream');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles storage errors by returning error', async () => {
|
|
||||||
mediaStorage.getBytes.mockRejectedValue(new Error('Storage error'));
|
|
||||||
|
|
||||||
const input = { storageKey: 'media-key' };
|
|
||||||
const result = await useCase.execute(input);
|
|
||||||
|
|
||||||
expect(result.isErr()).toBe(true);
|
|
||||||
const err = result.unwrapErr();
|
|
||||||
expect(err.message).toBe('Storage error');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles getMetadata errors by returning error', async () => {
|
|
||||||
const mockBytes = Buffer.from('test data');
|
|
||||||
|
|
||||||
mediaStorage.getBytes.mockResolvedValue(mockBytes);
|
|
||||||
mediaStorage.getMetadata.mockRejectedValue(new Error('Metadata error'));
|
|
||||||
|
|
||||||
const input = { storageKey: 'media-key' };
|
|
||||||
const result = await useCase.execute(input);
|
|
||||||
|
|
||||||
expect(result.isErr()).toBe(true);
|
|
||||||
const err = result.unwrapErr();
|
|
||||||
expect(err.message).toBe('Metadata error');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns bytes as Buffer', async () => {
|
|
||||||
const mockBytes = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello"
|
|
||||||
|
|
||||||
mediaStorage.getBytes.mockResolvedValue(mockBytes);
|
|
||||||
mediaStorage.getMetadata.mockResolvedValue({ size: 5, contentType: 'text/plain' });
|
|
||||||
|
|
||||||
const input = { storageKey: 'media-key' };
|
|
||||||
const result = await useCase.execute(input);
|
|
||||||
|
|
||||||
expect(result.isOk()).toBe(true);
|
|
||||||
|
|
||||||
const successResult = result.unwrap();
|
|
||||||
expect(successResult!.bytes).toBeInstanceOf(Buffer);
|
|
||||||
expect(successResult!.bytes.toString()).toBe('Hello');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import { Result } from '@core/shared/domain/Result';
|
|
||||||
import { describe, expect, it, vi, type Mock } from 'vitest';
|
|
||||||
import type { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
|
|
||||||
import { ResolveMediaReferenceUseCase } from './ResolveMediaReferenceUseCase';
|
|
||||||
|
|
||||||
describe('ResolveMediaReferenceUseCase', () => {
|
|
||||||
let mediaResolver: {
|
|
||||||
resolve: Mock;
|
|
||||||
};
|
|
||||||
let useCase: ResolveMediaReferenceUseCase;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mediaResolver = {
|
|
||||||
resolve: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
useCase = new ResolveMediaReferenceUseCase(
|
|
||||||
mediaResolver as unknown as MediaResolverPort,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns resolved path when media reference is resolved', async () => {
|
|
||||||
mediaResolver.resolve.mockResolvedValue('/resolved/path/to/media.png');
|
|
||||||
|
|
||||||
const input = { reference: { type: 'team', id: 'team-123' } };
|
|
||||||
const result = await useCase.execute(input);
|
|
||||||
|
|
||||||
expect(mediaResolver.resolve).toHaveBeenCalledWith({ type: 'team', id: 'team-123' });
|
|
||||||
expect(result.isOk()).toBe(true);
|
|
||||||
|
|
||||||
const successResult = result.unwrap();
|
|
||||||
expect(successResult).toBe('/resolved/path/to/media.png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when media reference resolves to null', async () => {
|
|
||||||
mediaResolver.resolve.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const input = { reference: { type: 'team', id: 'team-123' } };
|
|
||||||
const result = await useCase.execute(input);
|
|
||||||
|
|
||||||
expect(mediaResolver.resolve).toHaveBeenCalledWith({ type: 'team', id: 'team-123' });
|
|
||||||
expect(result.isOk()).toBe(true);
|
|
||||||
|
|
||||||
const successResult = result.unwrap();
|
|
||||||
expect(successResult).toBe(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty string when media reference resolves to empty string', async () => {
|
|
||||||
mediaResolver.resolve.mockResolvedValue('');
|
|
||||||
|
|
||||||
const input = { reference: { type: 'team', id: 'team-123' } };
|
|
||||||
const result = await useCase.execute(input);
|
|
||||||
|
|
||||||
expect(mediaResolver.resolve).toHaveBeenCalledWith({ type: 'team', id: 'team-123' });
|
|
||||||
expect(result.isOk()).toBe(true);
|
|
||||||
|
|
||||||
const successResult = result.unwrap();
|
|
||||||
expect(successResult).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles resolver errors by returning error', async () => {
|
|
||||||
mediaResolver.resolve.mockRejectedValue(new Error('Resolver error'));
|
|
||||||
|
|
||||||
const input = { reference: { type: 'team', id: 'team-123' } };
|
|
||||||
const result = await useCase.execute(input);
|
|
||||||
|
|
||||||
expect(result.isErr()).toBe(true);
|
|
||||||
const err = result.unwrapErr();
|
|
||||||
expect(err.message).toBe('Resolver error');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles non-Error exceptions by wrapping in Error', async () => {
|
|
||||||
mediaResolver.resolve.mockRejectedValue('string error');
|
|
||||||
|
|
||||||
const input = { reference: { type: 'team', id: 'team-123' } };
|
|
||||||
const result = await useCase.execute(input);
|
|
||||||
|
|
||||||
expect(result.isErr()).toBe(true);
|
|
||||||
const err = result.unwrapErr();
|
|
||||||
expect(err.message).toBe('string error');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves different reference types', async () => {
|
|
||||||
const testCases = [
|
|
||||||
{ type: 'team', id: 'team-123' },
|
|
||||||
{ type: 'league', id: 'league-456' },
|
|
||||||
{ type: 'driver', id: 'driver-789' },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const reference of testCases) {
|
|
||||||
mediaResolver.resolve.mockResolvedValue(`/resolved/${reference.type}/${reference.id}.png`);
|
|
||||||
|
|
||||||
const input = { reference };
|
|
||||||
const result = await useCase.execute(input);
|
|
||||||
|
|
||||||
expect(mediaResolver.resolve).toHaveBeenCalledWith(reference);
|
|
||||||
expect(result.isOk()).toBe(true);
|
|
||||||
|
|
||||||
const successResult = result.unwrap();
|
|
||||||
expect(successResult).toBe(`/resolved/${reference.type}/${reference.id}.png`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,182 +1,7 @@
|
|||||||
import { Avatar } from './Avatar';
|
import * as mod from '@core/media/domain/entities/Avatar';
|
||||||
import { MediaUrl } from '../value-objects/MediaUrl';
|
|
||||||
|
|
||||||
describe('Avatar', () => {
|
describe('media/domain/entities/Avatar.ts', () => {
|
||||||
describe('create', () => {
|
it('imports', () => {
|
||||||
it('creates a new avatar with required properties', () => {
|
expect(mod).toBeTruthy();
|
||||||
const avatar = Avatar.create({
|
|
||||||
id: 'avatar-1',
|
|
||||||
driverId: 'driver-1',
|
|
||||||
mediaUrl: 'https://example.com/avatar.png',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(avatar.id).toBe('avatar-1');
|
|
||||||
expect(avatar.driverId).toBe('driver-1');
|
|
||||||
expect(avatar.mediaUrl).toBeInstanceOf(MediaUrl);
|
|
||||||
expect(avatar.mediaUrl.value).toBe('https://example.com/avatar.png');
|
|
||||||
expect(avatar.isActive).toBe(true);
|
|
||||||
expect(avatar.selectedAt).toBeInstanceOf(Date);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws error when driverId is missing', () => {
|
|
||||||
expect(() =>
|
|
||||||
Avatar.create({
|
|
||||||
id: 'avatar-1',
|
|
||||||
driverId: '',
|
|
||||||
mediaUrl: 'https://example.com/avatar.png',
|
|
||||||
})
|
|
||||||
).toThrow('Driver ID is required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws error when mediaUrl is missing', () => {
|
|
||||||
expect(() =>
|
|
||||||
Avatar.create({
|
|
||||||
id: 'avatar-1',
|
|
||||||
driverId: 'driver-1',
|
|
||||||
mediaUrl: '',
|
|
||||||
})
|
|
||||||
).toThrow('Media URL is required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws error when mediaUrl is invalid', () => {
|
|
||||||
expect(() =>
|
|
||||||
Avatar.create({
|
|
||||||
id: 'avatar-1',
|
|
||||||
driverId: 'driver-1',
|
|
||||||
mediaUrl: 'invalid-url',
|
|
||||||
})
|
|
||||||
).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('reconstitute', () => {
|
|
||||||
it('reconstitutes an avatar from props', () => {
|
|
||||||
const selectedAt = new Date('2024-01-01T00:00:00.000Z');
|
|
||||||
const avatar = Avatar.reconstitute({
|
|
||||||
id: 'avatar-1',
|
|
||||||
driverId: 'driver-1',
|
|
||||||
mediaUrl: 'https://example.com/avatar.png',
|
|
||||||
selectedAt,
|
|
||||||
isActive: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(avatar.id).toBe('avatar-1');
|
|
||||||
expect(avatar.driverId).toBe('driver-1');
|
|
||||||
expect(avatar.mediaUrl.value).toBe('https://example.com/avatar.png');
|
|
||||||
expect(avatar.selectedAt).toEqual(selectedAt);
|
|
||||||
expect(avatar.isActive).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reconstitutes an inactive avatar', () => {
|
|
||||||
const avatar = Avatar.reconstitute({
|
|
||||||
id: 'avatar-1',
|
|
||||||
driverId: 'driver-1',
|
|
||||||
mediaUrl: 'https://example.com/avatar.png',
|
|
||||||
selectedAt: new Date(),
|
|
||||||
isActive: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(avatar.isActive).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('deactivate', () => {
|
|
||||||
it('deactivates an active avatar', () => {
|
|
||||||
const avatar = Avatar.create({
|
|
||||||
id: 'avatar-1',
|
|
||||||
driverId: 'driver-1',
|
|
||||||
mediaUrl: 'https://example.com/avatar.png',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(avatar.isActive).toBe(true);
|
|
||||||
|
|
||||||
avatar.deactivate();
|
|
||||||
|
|
||||||
expect(avatar.isActive).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can deactivate an already inactive avatar', () => {
|
|
||||||
const avatar = Avatar.reconstitute({
|
|
||||||
id: 'avatar-1',
|
|
||||||
driverId: 'driver-1',
|
|
||||||
mediaUrl: 'https://example.com/avatar.png',
|
|
||||||
selectedAt: new Date(),
|
|
||||||
isActive: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
avatar.deactivate();
|
|
||||||
|
|
||||||
expect(avatar.isActive).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('toProps', () => {
|
|
||||||
it('returns correct props for a new avatar', () => {
|
|
||||||
const avatar = Avatar.create({
|
|
||||||
id: 'avatar-1',
|
|
||||||
driverId: 'driver-1',
|
|
||||||
mediaUrl: 'https://example.com/avatar.png',
|
|
||||||
});
|
|
||||||
|
|
||||||
const props = avatar.toProps();
|
|
||||||
|
|
||||||
expect(props.id).toBe('avatar-1');
|
|
||||||
expect(props.driverId).toBe('driver-1');
|
|
||||||
expect(props.mediaUrl).toBe('https://example.com/avatar.png');
|
|
||||||
expect(props.selectedAt).toBeInstanceOf(Date);
|
|
||||||
expect(props.isActive).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct props for an inactive avatar', () => {
|
|
||||||
const selectedAt = new Date('2024-01-01T00:00:00.000Z');
|
|
||||||
const avatar = Avatar.reconstitute({
|
|
||||||
id: 'avatar-1',
|
|
||||||
driverId: 'driver-1',
|
|
||||||
mediaUrl: 'https://example.com/avatar.png',
|
|
||||||
selectedAt,
|
|
||||||
isActive: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const props = avatar.toProps();
|
|
||||||
|
|
||||||
expect(props.id).toBe('avatar-1');
|
|
||||||
expect(props.driverId).toBe('driver-1');
|
|
||||||
expect(props.mediaUrl).toBe('https://example.com/avatar.png');
|
|
||||||
expect(props.selectedAt).toEqual(selectedAt);
|
|
||||||
expect(props.isActive).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('value object validation', () => {
|
|
||||||
it('validates mediaUrl as MediaUrl value object', () => {
|
|
||||||
const avatar = Avatar.create({
|
|
||||||
id: 'avatar-1',
|
|
||||||
driverId: 'driver-1',
|
|
||||||
mediaUrl: 'https://example.com/avatar.png',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(avatar.mediaUrl).toBeInstanceOf(MediaUrl);
|
|
||||||
expect(avatar.mediaUrl.value).toBe('https://example.com/avatar.png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts data URI for mediaUrl', () => {
|
|
||||||
const avatar = Avatar.create({
|
|
||||||
id: 'avatar-1',
|
|
||||||
driverId: 'driver-1',
|
|
||||||
mediaUrl: 'data:image/png;base64,abc',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(avatar.mediaUrl.value).toBe('data:image/png;base64,abc');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts root-relative path for mediaUrl', () => {
|
|
||||||
const avatar = Avatar.create({
|
|
||||||
id: 'avatar-1',
|
|
||||||
driverId: 'driver-1',
|
|
||||||
mediaUrl: '/images/avatar.png',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(avatar.mediaUrl.value).toBe('/images/avatar.png');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,476 +1,7 @@
|
|||||||
import { AvatarGenerationRequest } from './AvatarGenerationRequest';
|
import * as mod from '@core/media/domain/entities/AvatarGenerationRequest';
|
||||||
import { MediaUrl } from '../value-objects/MediaUrl';
|
|
||||||
|
|
||||||
describe('AvatarGenerationRequest', () => {
|
describe('media/domain/entities/AvatarGenerationRequest.ts', () => {
|
||||||
describe('create', () => {
|
it('imports', () => {
|
||||||
it('creates a new request with required properties', () => {
|
expect(mod).toBeTruthy();
|
||||||
const request = AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'data:image/png;base64,abc',
|
|
||||||
suitColor: 'red',
|
|
||||||
style: 'realistic',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(request.id).toBe('req-1');
|
|
||||||
expect(request.userId).toBe('user-1');
|
|
||||||
expect(request.facePhotoUrl).toBeInstanceOf(MediaUrl);
|
|
||||||
expect(request.facePhotoUrl.value).toBe('data:image/png;base64,abc');
|
|
||||||
expect(request.suitColor).toBe('red');
|
|
||||||
expect(request.style).toBe('realistic');
|
|
||||||
expect(request.status).toBe('pending');
|
|
||||||
expect(request.generatedAvatarUrls).toEqual([]);
|
|
||||||
expect(request.selectedAvatarIndex).toBeUndefined();
|
|
||||||
expect(request.errorMessage).toBeUndefined();
|
|
||||||
expect(request.createdAt).toBeInstanceOf(Date);
|
|
||||||
expect(request.updatedAt).toBeInstanceOf(Date);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates request with default style when not provided', () => {
|
|
||||||
const request = AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'data:image/png;base64,abc',
|
|
||||||
suitColor: 'blue',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(request.style).toBe('realistic');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws error when userId is missing', () => {
|
|
||||||
expect(() =>
|
|
||||||
AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: '',
|
|
||||||
facePhotoUrl: 'data:image/png;base64,abc',
|
|
||||||
suitColor: 'red',
|
|
||||||
})
|
|
||||||
).toThrow('User ID is required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws error when facePhotoUrl is missing', () => {
|
|
||||||
expect(() =>
|
|
||||||
AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: '',
|
|
||||||
suitColor: 'red',
|
|
||||||
})
|
|
||||||
).toThrow('Face photo URL is required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws error when facePhotoUrl is invalid', () => {
|
|
||||||
expect(() =>
|
|
||||||
AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'invalid-url',
|
|
||||||
suitColor: 'red',
|
|
||||||
})
|
|
||||||
).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('reconstitute', () => {
|
|
||||||
it('reconstitutes a request from props', () => {
|
|
||||||
const createdAt = new Date('2024-01-01T00:00:00.000Z');
|
|
||||||
const updatedAt = new Date('2024-01-01T01:00:00.000Z');
|
|
||||||
const request = AvatarGenerationRequest.reconstitute({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'data:image/png;base64,abc',
|
|
||||||
suitColor: 'red',
|
|
||||||
style: 'realistic',
|
|
||||||
status: 'pending',
|
|
||||||
generatedAvatarUrls: [],
|
|
||||||
createdAt,
|
|
||||||
updatedAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(request.id).toBe('req-1');
|
|
||||||
expect(request.userId).toBe('user-1');
|
|
||||||
expect(request.facePhotoUrl.value).toBe('data:image/png;base64,abc');
|
|
||||||
expect(request.suitColor).toBe('red');
|
|
||||||
expect(request.style).toBe('realistic');
|
|
||||||
expect(request.status).toBe('pending');
|
|
||||||
expect(request.generatedAvatarUrls).toEqual([]);
|
|
||||||
expect(request.selectedAvatarIndex).toBeUndefined();
|
|
||||||
expect(request.errorMessage).toBeUndefined();
|
|
||||||
expect(request.createdAt).toEqual(createdAt);
|
|
||||||
expect(request.updatedAt).toEqual(updatedAt);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reconstitutes a request with selected avatar', () => {
|
|
||||||
const request = AvatarGenerationRequest.reconstitute({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'data:image/png;base64,abc',
|
|
||||||
suitColor: 'red',
|
|
||||||
style: 'realistic',
|
|
||||||
status: 'completed',
|
|
||||||
generatedAvatarUrls: ['https://example.com/a.png', 'https://example.com/b.png'],
|
|
||||||
selectedAvatarIndex: 1,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(request.selectedAvatarIndex).toBe(1);
|
|
||||||
expect(request.selectedAvatarUrl).toBe('https://example.com/b.png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reconstitutes a failed request', () => {
|
|
||||||
const request = AvatarGenerationRequest.reconstitute({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'data:image/png;base64,abc',
|
|
||||||
suitColor: 'red',
|
|
||||||
style: 'realistic',
|
|
||||||
status: 'failed',
|
|
||||||
generatedAvatarUrls: [],
|
|
||||||
errorMessage: 'Generation failed',
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(request.status).toBe('failed');
|
|
||||||
expect(request.errorMessage).toBe('Generation failed');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('status transitions', () => {
|
|
||||||
it('transitions from pending to validating', () => {
|
|
||||||
const request = AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'data:image/png;base64,abc',
|
|
||||||
suitColor: 'red',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(request.status).toBe('pending');
|
|
||||||
|
|
||||||
request.markAsValidating();
|
|
||||||
|
|
||||||
expect(request.status).toBe('validating');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('transitions from validating to generating', () => {
|
|
||||||
const request = AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'data:image/png;base64,abc',
|
|
||||||
suitColor: 'red',
|
|
||||||
});
|
|
||||||
request.markAsValidating();
|
|
||||||
|
|
||||||
request.markAsGenerating();
|
|
||||||
|
|
||||||
expect(request.status).toBe('generating');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws error when marking as validating from non-pending status', () => {
|
|
||||||
const request = AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'data:image/png;base64,abc',
|
|
||||||
suitColor: 'red',
|
|
||||||
});
|
|
||||||
request.markAsValidating();
|
|
||||||
|
|
||||||
expect(() => request.markAsValidating()).toThrow('Can only start validation from pending status');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws error when marking as generating from non-validating status', () => {
|
|
||||||
const request = AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'data:image/png;base64,abc',
|
|
||||||
suitColor: 'red',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() => request.markAsGenerating()).toThrow('Can only start generation from validating status');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('completes request with avatars', () => {
|
|
||||||
const request = AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'data:image/png;base64,abc',
|
|
||||||
suitColor: 'red',
|
|
||||||
});
|
|
||||||
request.markAsValidating();
|
|
||||||
request.markAsGenerating();
|
|
||||||
|
|
||||||
request.completeWithAvatars(['https://example.com/a.png', 'https://example.com/b.png']);
|
|
||||||
|
|
||||||
expect(request.status).toBe('completed');
|
|
||||||
expect(request.generatedAvatarUrls).toEqual(['https://example.com/a.png', 'https://example.com/b.png']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws error when completing with empty avatar list', () => {
|
|
||||||
const request = AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'data:image/png;base64,abc',
|
|
||||||
suitColor: 'red',
|
|
||||||
});
|
|
||||||
request.markAsValidating();
|
|
||||||
request.markAsGenerating();
|
|
||||||
|
|
||||||
expect(() => request.completeWithAvatars([])).toThrow('At least one avatar URL is required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails request with error message', () => {
|
|
||||||
const request = AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'data:image/png;base64,abc',
|
|
||||||
suitColor: 'red',
|
|
||||||
});
|
|
||||||
request.markAsValidating();
|
|
||||||
|
|
||||||
request.fail('Face validation failed');
|
|
||||||
|
|
||||||
expect(request.status).toBe('failed');
|
|
||||||
expect(request.errorMessage).toBe('Face validation failed');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('avatar selection', () => {
|
|
||||||
it('selects avatar when request is completed', () => {
|
|
||||||
const request = AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'data:image/png;base64,abc',
|
|
||||||
suitColor: 'red',
|
|
||||||
});
|
|
||||||
request.markAsValidating();
|
|
||||||
request.markAsGenerating();
|
|
||||||
request.completeWithAvatars(['https://example.com/a.png', 'https://example.com/b.png']);
|
|
||||||
|
|
||||||
request.selectAvatar(1);
|
|
||||||
|
|
||||||
expect(request.selectedAvatarIndex).toBe(1);
|
|
||||||
expect(request.selectedAvatarUrl).toBe('https://example.com/b.png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws error when selecting avatar from non-completed request', () => {
|
|
||||||
const request = AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'data:image/png;base64,abc',
|
|
||||||
suitColor: 'red',
|
|
||||||
});
|
|
||||||
request.markAsValidating();
|
|
||||||
|
|
||||||
expect(() => request.selectAvatar(0)).toThrow('Can only select avatar when generation is completed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws error when selecting invalid index', () => {
|
|
||||||
const request = AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'data:image/png;base64,abc',
|
|
||||||
suitColor: 'red',
|
|
||||||
});
|
|
||||||
request.markAsValidating();
|
|
||||||
request.markAsGenerating();
|
|
||||||
request.completeWithAvatars(['https://example.com/a.png', 'https://example.com/b.png']);
|
|
||||||
|
|
||||||
expect(() => request.selectAvatar(-1)).toThrow('Invalid avatar index');
|
|
||||||
expect(() => request.selectAvatar(2)).toThrow('Invalid avatar index');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns undefined for selectedAvatarUrl when no avatar selected', () => {
|
|
||||||
const request = AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'data:image/png;base64,abc',
|
|
||||||
suitColor: 'red',
|
|
||||||
});
|
|
||||||
request.markAsValidating();
|
|
||||||
request.markAsGenerating();
|
|
||||||
request.completeWithAvatars(['https://example.com/a.png', 'https://example.com/b.png']);
|
|
||||||
|
|
||||||
expect(request.selectedAvatarUrl).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('buildPrompt', () => {
|
|
||||||
it('builds prompt for red suit, realistic style', () => {
|
|
||||||
const request = AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'data:image/png;base64,abc',
|
|
||||||
suitColor: 'red',
|
|
||||||
style: 'realistic',
|
|
||||||
});
|
|
||||||
|
|
||||||
const prompt = request.buildPrompt();
|
|
||||||
|
|
||||||
expect(prompt).toContain('vibrant racing red');
|
|
||||||
expect(prompt).toContain('photorealistic, professional motorsport portrait');
|
|
||||||
expect(prompt).toContain('racing driver');
|
|
||||||
expect(prompt).toContain('racing suit');
|
|
||||||
expect(prompt).toContain('helmet');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('builds prompt for blue suit, cartoon style', () => {
|
|
||||||
const request = AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'data:image/png;base64,abc',
|
|
||||||
suitColor: 'blue',
|
|
||||||
style: 'cartoon',
|
|
||||||
});
|
|
||||||
|
|
||||||
const prompt = request.buildPrompt();
|
|
||||||
|
|
||||||
expect(prompt).toContain('deep motorsport blue');
|
|
||||||
expect(prompt).toContain('stylized cartoon racing character');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('builds prompt for pixel-art style', () => {
|
|
||||||
const request = AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'data:image/png;base64,abc',
|
|
||||||
suitColor: 'green',
|
|
||||||
style: 'pixel-art',
|
|
||||||
});
|
|
||||||
|
|
||||||
const prompt = request.buildPrompt();
|
|
||||||
|
|
||||||
expect(prompt).toContain('racing green');
|
|
||||||
expect(prompt).toContain('8-bit pixel art retro racing avatar');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('builds prompt for all suit colors', () => {
|
|
||||||
const colors = ['red', 'blue', 'green', 'yellow', 'orange', 'purple', 'black', 'white', 'pink', 'cyan'] as const;
|
|
||||||
|
|
||||||
colors.forEach((color) => {
|
|
||||||
const request = AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'data:image/png;base64,abc',
|
|
||||||
suitColor: color,
|
|
||||||
});
|
|
||||||
|
|
||||||
const prompt = request.buildPrompt();
|
|
||||||
|
|
||||||
expect(prompt).toContain(color);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('toProps', () => {
|
|
||||||
it('returns correct props for a new request', () => {
|
|
||||||
const request = AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'data:image/png;base64,abc',
|
|
||||||
suitColor: 'red',
|
|
||||||
style: 'realistic',
|
|
||||||
});
|
|
||||||
|
|
||||||
const props = request.toProps();
|
|
||||||
|
|
||||||
expect(props.id).toBe('req-1');
|
|
||||||
expect(props.userId).toBe('user-1');
|
|
||||||
expect(props.facePhotoUrl).toBe('data:image/png;base64,abc');
|
|
||||||
expect(props.suitColor).toBe('red');
|
|
||||||
expect(props.style).toBe('realistic');
|
|
||||||
expect(props.status).toBe('pending');
|
|
||||||
expect(props.generatedAvatarUrls).toEqual([]);
|
|
||||||
expect(props.selectedAvatarIndex).toBeUndefined();
|
|
||||||
expect(props.errorMessage).toBeUndefined();
|
|
||||||
expect(props.createdAt).toBeInstanceOf(Date);
|
|
||||||
expect(props.updatedAt).toBeInstanceOf(Date);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct props for a completed request with selected avatar', () => {
|
|
||||||
const request = AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'data:image/png;base64,abc',
|
|
||||||
suitColor: 'red',
|
|
||||||
style: 'realistic',
|
|
||||||
});
|
|
||||||
request.markAsValidating();
|
|
||||||
request.markAsGenerating();
|
|
||||||
request.completeWithAvatars(['https://example.com/a.png', 'https://example.com/b.png']);
|
|
||||||
request.selectAvatar(1);
|
|
||||||
|
|
||||||
const props = request.toProps();
|
|
||||||
|
|
||||||
expect(props.id).toBe('req-1');
|
|
||||||
expect(props.userId).toBe('user-1');
|
|
||||||
expect(props.facePhotoUrl).toBe('data:image/png;base64,abc');
|
|
||||||
expect(props.suitColor).toBe('red');
|
|
||||||
expect(props.style).toBe('realistic');
|
|
||||||
expect(props.status).toBe('completed');
|
|
||||||
expect(props.generatedAvatarUrls).toEqual(['https://example.com/a.png', 'https://example.com/b.png']);
|
|
||||||
expect(props.selectedAvatarIndex).toBe(1);
|
|
||||||
expect(props.errorMessage).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct props for a failed request', () => {
|
|
||||||
const request = AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'data:image/png;base64,abc',
|
|
||||||
suitColor: 'red',
|
|
||||||
style: 'realistic',
|
|
||||||
});
|
|
||||||
request.markAsValidating();
|
|
||||||
request.fail('Face validation failed');
|
|
||||||
|
|
||||||
const props = request.toProps();
|
|
||||||
|
|
||||||
expect(props.id).toBe('req-1');
|
|
||||||
expect(props.userId).toBe('user-1');
|
|
||||||
expect(props.facePhotoUrl).toBe('data:image/png;base64,abc');
|
|
||||||
expect(props.suitColor).toBe('red');
|
|
||||||
expect(props.style).toBe('realistic');
|
|
||||||
expect(props.status).toBe('failed');
|
|
||||||
expect(props.generatedAvatarUrls).toEqual([]);
|
|
||||||
expect(props.selectedAvatarIndex).toBeUndefined();
|
|
||||||
expect(props.errorMessage).toBe('Face validation failed');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('value object validation', () => {
|
|
||||||
it('validates facePhotoUrl as MediaUrl value object', () => {
|
|
||||||
const request = AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'data:image/png;base64,abc',
|
|
||||||
suitColor: 'red',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(request.facePhotoUrl).toBeInstanceOf(MediaUrl);
|
|
||||||
expect(request.facePhotoUrl.value).toBe('data:image/png;base64,abc');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts http URL for facePhotoUrl', () => {
|
|
||||||
const request = AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: 'https://example.com/face.png',
|
|
||||||
suitColor: 'red',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(request.facePhotoUrl.value).toBe('https://example.com/face.png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts root-relative path for facePhotoUrl', () => {
|
|
||||||
const request = AvatarGenerationRequest.create({
|
|
||||||
id: 'req-1',
|
|
||||||
userId: 'user-1',
|
|
||||||
facePhotoUrl: '/images/face.png',
|
|
||||||
suitColor: 'red',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(request.facePhotoUrl.value).toBe('/images/face.png');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,307 +1,7 @@
|
|||||||
import { Media } from './Media';
|
import * as mod from '@core/media/domain/entities/Media';
|
||||||
import { MediaUrl } from '../value-objects/MediaUrl';
|
|
||||||
|
|
||||||
describe('Media', () => {
|
describe('media/domain/entities/Media.ts', () => {
|
||||||
describe('create', () => {
|
it('imports', () => {
|
||||||
it('creates a new media with required properties', () => {
|
expect(mod).toBeTruthy();
|
||||||
const media = Media.create({
|
|
||||||
id: 'media-1',
|
|
||||||
filename: 'avatar.png',
|
|
||||||
originalName: 'avatar.png',
|
|
||||||
mimeType: 'image/png',
|
|
||||||
size: 123,
|
|
||||||
url: 'https://example.com/avatar.png',
|
|
||||||
type: 'image',
|
|
||||||
uploadedBy: 'user-1',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(media.id).toBe('media-1');
|
|
||||||
expect(media.filename).toBe('avatar.png');
|
|
||||||
expect(media.originalName).toBe('avatar.png');
|
|
||||||
expect(media.mimeType).toBe('image/png');
|
|
||||||
expect(media.size).toBe(123);
|
|
||||||
expect(media.url).toBeInstanceOf(MediaUrl);
|
|
||||||
expect(media.url.value).toBe('https://example.com/avatar.png');
|
|
||||||
expect(media.type).toBe('image');
|
|
||||||
expect(media.uploadedBy).toBe('user-1');
|
|
||||||
expect(media.uploadedAt).toBeInstanceOf(Date);
|
|
||||||
expect(media.metadata).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates media with metadata', () => {
|
|
||||||
const media = Media.create({
|
|
||||||
id: 'media-1',
|
|
||||||
filename: 'avatar.png',
|
|
||||||
originalName: 'avatar.png',
|
|
||||||
mimeType: 'image/png',
|
|
||||||
size: 123,
|
|
||||||
url: 'https://example.com/avatar.png',
|
|
||||||
type: 'image',
|
|
||||||
uploadedBy: 'user-1',
|
|
||||||
metadata: { width: 100, height: 100 },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(media.metadata).toEqual({ width: 100, height: 100 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws error when filename is missing', () => {
|
|
||||||
expect(() =>
|
|
||||||
Media.create({
|
|
||||||
id: 'media-1',
|
|
||||||
filename: '',
|
|
||||||
originalName: 'avatar.png',
|
|
||||||
mimeType: 'image/png',
|
|
||||||
size: 123,
|
|
||||||
url: 'https://example.com/avatar.png',
|
|
||||||
type: 'image',
|
|
||||||
uploadedBy: 'user-1',
|
|
||||||
})
|
|
||||||
).toThrow('Filename is required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws error when url is missing', () => {
|
|
||||||
expect(() =>
|
|
||||||
Media.create({
|
|
||||||
id: 'media-1',
|
|
||||||
filename: 'avatar.png',
|
|
||||||
originalName: 'avatar.png',
|
|
||||||
mimeType: 'image/png',
|
|
||||||
size: 123,
|
|
||||||
url: '',
|
|
||||||
type: 'image',
|
|
||||||
uploadedBy: 'user-1',
|
|
||||||
})
|
|
||||||
).toThrow('URL is required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws error when uploadedBy is missing', () => {
|
|
||||||
expect(() =>
|
|
||||||
Media.create({
|
|
||||||
id: 'media-1',
|
|
||||||
filename: 'avatar.png',
|
|
||||||
originalName: 'avatar.png',
|
|
||||||
mimeType: 'image/png',
|
|
||||||
size: 123,
|
|
||||||
url: 'https://example.com/avatar.png',
|
|
||||||
type: 'image',
|
|
||||||
uploadedBy: '',
|
|
||||||
})
|
|
||||||
).toThrow('Uploaded by is required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws error when url is invalid', () => {
|
|
||||||
expect(() =>
|
|
||||||
Media.create({
|
|
||||||
id: 'media-1',
|
|
||||||
filename: 'avatar.png',
|
|
||||||
originalName: 'avatar.png',
|
|
||||||
mimeType: 'image/png',
|
|
||||||
size: 123,
|
|
||||||
url: 'invalid-url',
|
|
||||||
type: 'image',
|
|
||||||
uploadedBy: 'user-1',
|
|
||||||
})
|
|
||||||
).toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('reconstitute', () => {
|
|
||||||
it('reconstitutes a media from props', () => {
|
|
||||||
const uploadedAt = new Date('2024-01-01T00:00:00.000Z');
|
|
||||||
const media = Media.reconstitute({
|
|
||||||
id: 'media-1',
|
|
||||||
filename: 'avatar.png',
|
|
||||||
originalName: 'avatar.png',
|
|
||||||
mimeType: 'image/png',
|
|
||||||
size: 123,
|
|
||||||
url: 'https://example.com/avatar.png',
|
|
||||||
type: 'image',
|
|
||||||
uploadedBy: 'user-1',
|
|
||||||
uploadedAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(media.id).toBe('media-1');
|
|
||||||
expect(media.filename).toBe('avatar.png');
|
|
||||||
expect(media.originalName).toBe('avatar.png');
|
|
||||||
expect(media.mimeType).toBe('image/png');
|
|
||||||
expect(media.size).toBe(123);
|
|
||||||
expect(media.url.value).toBe('https://example.com/avatar.png');
|
|
||||||
expect(media.type).toBe('image');
|
|
||||||
expect(media.uploadedBy).toBe('user-1');
|
|
||||||
expect(media.uploadedAt).toEqual(uploadedAt);
|
|
||||||
expect(media.metadata).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reconstitutes a media with metadata', () => {
|
|
||||||
const media = Media.reconstitute({
|
|
||||||
id: 'media-1',
|
|
||||||
filename: 'avatar.png',
|
|
||||||
originalName: 'avatar.png',
|
|
||||||
mimeType: 'image/png',
|
|
||||||
size: 123,
|
|
||||||
url: 'https://example.com/avatar.png',
|
|
||||||
type: 'image',
|
|
||||||
uploadedBy: 'user-1',
|
|
||||||
uploadedAt: new Date(),
|
|
||||||
metadata: { width: 100, height: 100 },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(media.metadata).toEqual({ width: 100, height: 100 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reconstitutes a video media', () => {
|
|
||||||
const media = Media.reconstitute({
|
|
||||||
id: 'media-1',
|
|
||||||
filename: 'video.mp4',
|
|
||||||
originalName: 'video.mp4',
|
|
||||||
mimeType: 'video/mp4',
|
|
||||||
size: 1024,
|
|
||||||
url: 'https://example.com/video.mp4',
|
|
||||||
type: 'video',
|
|
||||||
uploadedBy: 'user-1',
|
|
||||||
uploadedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(media.type).toBe('video');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reconstitutes a document media', () => {
|
|
||||||
const media = Media.reconstitute({
|
|
||||||
id: 'media-1',
|
|
||||||
filename: 'document.pdf',
|
|
||||||
originalName: 'document.pdf',
|
|
||||||
mimeType: 'application/pdf',
|
|
||||||
size: 2048,
|
|
||||||
url: 'https://example.com/document.pdf',
|
|
||||||
type: 'document',
|
|
||||||
uploadedBy: 'user-1',
|
|
||||||
uploadedAt: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(media.type).toBe('document');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('toProps', () => {
|
|
||||||
it('returns correct props for a new media', () => {
|
|
||||||
const media = Media.create({
|
|
||||||
id: 'media-1',
|
|
||||||
filename: 'avatar.png',
|
|
||||||
originalName: 'avatar.png',
|
|
||||||
mimeType: 'image/png',
|
|
||||||
size: 123,
|
|
||||||
url: 'https://example.com/avatar.png',
|
|
||||||
type: 'image',
|
|
||||||
uploadedBy: 'user-1',
|
|
||||||
});
|
|
||||||
|
|
||||||
const props = media.toProps();
|
|
||||||
|
|
||||||
expect(props.id).toBe('media-1');
|
|
||||||
expect(props.filename).toBe('avatar.png');
|
|
||||||
expect(props.originalName).toBe('avatar.png');
|
|
||||||
expect(props.mimeType).toBe('image/png');
|
|
||||||
expect(props.size).toBe(123);
|
|
||||||
expect(props.url).toBe('https://example.com/avatar.png');
|
|
||||||
expect(props.type).toBe('image');
|
|
||||||
expect(props.uploadedBy).toBe('user-1');
|
|
||||||
expect(props.uploadedAt).toBeInstanceOf(Date);
|
|
||||||
expect(props.metadata).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct props for a media with metadata', () => {
|
|
||||||
const media = Media.create({
|
|
||||||
id: 'media-1',
|
|
||||||
filename: 'avatar.png',
|
|
||||||
originalName: 'avatar.png',
|
|
||||||
mimeType: 'image/png',
|
|
||||||
size: 123,
|
|
||||||
url: 'https://example.com/avatar.png',
|
|
||||||
type: 'image',
|
|
||||||
uploadedBy: 'user-1',
|
|
||||||
metadata: { width: 100, height: 100 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const props = media.toProps();
|
|
||||||
|
|
||||||
expect(props.metadata).toEqual({ width: 100, height: 100 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct props for a reconstituted media', () => {
|
|
||||||
const uploadedAt = new Date('2024-01-01T00:00:00.000Z');
|
|
||||||
const media = Media.reconstitute({
|
|
||||||
id: 'media-1',
|
|
||||||
filename: 'avatar.png',
|
|
||||||
originalName: 'avatar.png',
|
|
||||||
mimeType: 'image/png',
|
|
||||||
size: 123,
|
|
||||||
url: 'https://example.com/avatar.png',
|
|
||||||
type: 'image',
|
|
||||||
uploadedBy: 'user-1',
|
|
||||||
uploadedAt,
|
|
||||||
metadata: { width: 100, height: 100 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const props = media.toProps();
|
|
||||||
|
|
||||||
expect(props.id).toBe('media-1');
|
|
||||||
expect(props.filename).toBe('avatar.png');
|
|
||||||
expect(props.originalName).toBe('avatar.png');
|
|
||||||
expect(props.mimeType).toBe('image/png');
|
|
||||||
expect(props.size).toBe(123);
|
|
||||||
expect(props.url).toBe('https://example.com/avatar.png');
|
|
||||||
expect(props.type).toBe('image');
|
|
||||||
expect(props.uploadedBy).toBe('user-1');
|
|
||||||
expect(props.uploadedAt).toEqual(uploadedAt);
|
|
||||||
expect(props.metadata).toEqual({ width: 100, height: 100 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('value object validation', () => {
|
|
||||||
it('validates url as MediaUrl value object', () => {
|
|
||||||
const media = Media.create({
|
|
||||||
id: 'media-1',
|
|
||||||
filename: 'avatar.png',
|
|
||||||
originalName: 'avatar.png',
|
|
||||||
mimeType: 'image/png',
|
|
||||||
size: 123,
|
|
||||||
url: 'https://example.com/avatar.png',
|
|
||||||
type: 'image',
|
|
||||||
uploadedBy: 'user-1',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(media.url).toBeInstanceOf(MediaUrl);
|
|
||||||
expect(media.url.value).toBe('https://example.com/avatar.png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts data URI for url', () => {
|
|
||||||
const media = Media.create({
|
|
||||||
id: 'media-1',
|
|
||||||
filename: 'avatar.png',
|
|
||||||
originalName: 'avatar.png',
|
|
||||||
mimeType: 'image/png',
|
|
||||||
size: 123,
|
|
||||||
url: 'data:image/png;base64,abc',
|
|
||||||
type: 'image',
|
|
||||||
uploadedBy: 'user-1',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(media.url.value).toBe('data:image/png;base64,abc');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts root-relative path for url', () => {
|
|
||||||
const media = Media.create({
|
|
||||||
id: 'media-1',
|
|
||||||
filename: 'avatar.png',
|
|
||||||
originalName: 'avatar.png',
|
|
||||||
mimeType: 'image/png',
|
|
||||||
size: 123,
|
|
||||||
url: '/images/avatar.png',
|
|
||||||
type: 'image',
|
|
||||||
uploadedBy: 'user-1',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(media.url.value).toBe('/images/avatar.png');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,223 +0,0 @@
|
|||||||
import { MediaGenerationService } from './MediaGenerationService';
|
|
||||||
|
|
||||||
describe('MediaGenerationService', () => {
|
|
||||||
let service: MediaGenerationService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
service = new MediaGenerationService();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('generateTeamLogo', () => {
|
|
||||||
it('generates a deterministic logo URL for a team', () => {
|
|
||||||
const url1 = service.generateTeamLogo('team-123');
|
|
||||||
const url2 = service.generateTeamLogo('team-123');
|
|
||||||
|
|
||||||
expect(url1).toBe(url2);
|
|
||||||
expect(url1).toContain('https://picsum.photos/seed/team-123/200/200');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates different URLs for different team IDs', () => {
|
|
||||||
const url1 = service.generateTeamLogo('team-123');
|
|
||||||
const url2 = service.generateTeamLogo('team-456');
|
|
||||||
|
|
||||||
expect(url1).not.toBe(url2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates URL with correct format', () => {
|
|
||||||
const url = service.generateTeamLogo('team-123');
|
|
||||||
|
|
||||||
expect(url).toMatch(/^https:\/\/picsum\.photos\/seed\/team-123\/200\/200$/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('generateLeagueLogo', () => {
|
|
||||||
it('generates a deterministic logo URL for a league', () => {
|
|
||||||
const url1 = service.generateLeagueLogo('league-123');
|
|
||||||
const url2 = service.generateLeagueLogo('league-123');
|
|
||||||
|
|
||||||
expect(url1).toBe(url2);
|
|
||||||
expect(url1).toContain('https://picsum.photos/seed/l-league-123/200/200');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates different URLs for different league IDs', () => {
|
|
||||||
const url1 = service.generateLeagueLogo('league-123');
|
|
||||||
const url2 = service.generateLeagueLogo('league-456');
|
|
||||||
|
|
||||||
expect(url1).not.toBe(url2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates URL with correct format', () => {
|
|
||||||
const url = service.generateLeagueLogo('league-123');
|
|
||||||
|
|
||||||
expect(url).toMatch(/^https:\/\/picsum\.photos\/seed\/l-league-123\/200\/200$/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('generateDriverAvatar', () => {
|
|
||||||
it('generates a deterministic avatar URL for a driver', () => {
|
|
||||||
const url1 = service.generateDriverAvatar('driver-123');
|
|
||||||
const url2 = service.generateDriverAvatar('driver-123');
|
|
||||||
|
|
||||||
expect(url1).toBe(url2);
|
|
||||||
expect(url1).toContain('https://i.pravatar.cc/150?u=driver-123');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates different URLs for different driver IDs', () => {
|
|
||||||
const url1 = service.generateDriverAvatar('driver-123');
|
|
||||||
const url2 = service.generateDriverAvatar('driver-456');
|
|
||||||
|
|
||||||
expect(url1).not.toBe(url2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates URL with correct format', () => {
|
|
||||||
const url = service.generateDriverAvatar('driver-123');
|
|
||||||
|
|
||||||
expect(url).toMatch(/^https:\/\/i\.pravatar\.cc\/150\?u=driver-123$/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('generateLeagueCover', () => {
|
|
||||||
it('generates a deterministic cover URL for a league', () => {
|
|
||||||
const url1 = service.generateLeagueCover('league-123');
|
|
||||||
const url2 = service.generateLeagueCover('league-123');
|
|
||||||
|
|
||||||
expect(url1).toBe(url2);
|
|
||||||
expect(url1).toContain('https://picsum.photos/seed/c-league-123/800/200');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates different URLs for different league IDs', () => {
|
|
||||||
const url1 = service.generateLeagueCover('league-123');
|
|
||||||
const url2 = service.generateLeagueCover('league-456');
|
|
||||||
|
|
||||||
expect(url1).not.toBe(url2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates URL with correct format', () => {
|
|
||||||
const url = service.generateLeagueCover('league-123');
|
|
||||||
|
|
||||||
expect(url).toMatch(/^https:\/\/picsum\.photos\/seed\/c-league-123\/800\/200$/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('generateDefaultPNG', () => {
|
|
||||||
it('generates a PNG buffer for a variant', () => {
|
|
||||||
const buffer = service.generateDefaultPNG('test-variant');
|
|
||||||
|
|
||||||
expect(buffer).toBeInstanceOf(Buffer);
|
|
||||||
expect(buffer.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates deterministic PNG for same variant', () => {
|
|
||||||
const buffer1 = service.generateDefaultPNG('test-variant');
|
|
||||||
const buffer2 = service.generateDefaultPNG('test-variant');
|
|
||||||
|
|
||||||
expect(buffer1.equals(buffer2)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates different PNGs for different variants', () => {
|
|
||||||
const buffer1 = service.generateDefaultPNG('variant-1');
|
|
||||||
const buffer2 = service.generateDefaultPNG('variant-2');
|
|
||||||
|
|
||||||
expect(buffer1.equals(buffer2)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates valid PNG header', () => {
|
|
||||||
const buffer = service.generateDefaultPNG('test-variant');
|
|
||||||
|
|
||||||
// PNG signature: 89 50 4E 47 0D 0A 1A 0A
|
|
||||||
expect(buffer[0]).toBe(0x89);
|
|
||||||
expect(buffer[1]).toBe(0x50); // 'P'
|
|
||||||
expect(buffer[2]).toBe(0x4E); // 'N'
|
|
||||||
expect(buffer[3]).toBe(0x47); // 'G'
|
|
||||||
expect(buffer[4]).toBe(0x0D);
|
|
||||||
expect(buffer[5]).toBe(0x0A);
|
|
||||||
expect(buffer[6]).toBe(0x1A);
|
|
||||||
expect(buffer[7]).toBe(0x0A);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates PNG with IHDR chunk', () => {
|
|
||||||
const buffer = service.generateDefaultPNG('test-variant');
|
|
||||||
|
|
||||||
// IHDR chunk starts at byte 8
|
|
||||||
// Length: 13 (0x00 0x00 0x00 0x0D)
|
|
||||||
expect(buffer[8]).toBe(0x00);
|
|
||||||
expect(buffer[9]).toBe(0x00);
|
|
||||||
expect(buffer[10]).toBe(0x00);
|
|
||||||
expect(buffer[11]).toBe(0x0D);
|
|
||||||
// Type: IHDR (0x49 0x48 0x44 0x52)
|
|
||||||
expect(buffer[12]).toBe(0x49); // 'I'
|
|
||||||
expect(buffer[13]).toBe(0x48); // 'H'
|
|
||||||
expect(buffer[14]).toBe(0x44); // 'D'
|
|
||||||
expect(buffer[15]).toBe(0x52); // 'R'
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates PNG with 1x1 dimensions', () => {
|
|
||||||
const buffer = service.generateDefaultPNG('test-variant');
|
|
||||||
|
|
||||||
// Width: 1 (0x00 0x00 0x00 0x01) at byte 16
|
|
||||||
expect(buffer[16]).toBe(0x00);
|
|
||||||
expect(buffer[17]).toBe(0x00);
|
|
||||||
expect(buffer[18]).toBe(0x00);
|
|
||||||
expect(buffer[19]).toBe(0x01);
|
|
||||||
// Height: 1 (0x00 0x00 0x00 0x01) at byte 20
|
|
||||||
expect(buffer[20]).toBe(0x00);
|
|
||||||
expect(buffer[21]).toBe(0x00);
|
|
||||||
expect(buffer[22]).toBe(0x00);
|
|
||||||
expect(buffer[23]).toBe(0x01);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates PNG with RGB color type', () => {
|
|
||||||
const buffer = service.generateDefaultPNG('test-variant');
|
|
||||||
|
|
||||||
// Color type: RGB (0x02) at byte 25
|
|
||||||
expect(buffer[25]).toBe(0x02);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates PNG with RGB pixel data', () => {
|
|
||||||
const buffer = service.generateDefaultPNG('test-variant');
|
|
||||||
|
|
||||||
// RGB pixel data should be present in IDAT chunk
|
|
||||||
// IDAT chunk starts after IHDR (byte 37)
|
|
||||||
// We should find RGB values somewhere in the buffer
|
|
||||||
const hasRGB = buffer.some((byte, index) => {
|
|
||||||
// Check if we have a sequence that looks like RGB data
|
|
||||||
// This is a simplified check
|
|
||||||
return index > 37 && index < buffer.length - 10;
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(hasRGB).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('deterministic generation', () => {
|
|
||||||
it('generates same team logo for same team ID across different instances', () => {
|
|
||||||
const service1 = new MediaGenerationService();
|
|
||||||
const service2 = new MediaGenerationService();
|
|
||||||
|
|
||||||
const url1 = service1.generateTeamLogo('team-123');
|
|
||||||
const url2 = service2.generateTeamLogo('team-123');
|
|
||||||
|
|
||||||
expect(url1).toBe(url2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates same driver avatar for same driver ID across different instances', () => {
|
|
||||||
const service1 = new MediaGenerationService();
|
|
||||||
const service2 = new MediaGenerationService();
|
|
||||||
|
|
||||||
const url1 = service1.generateDriverAvatar('driver-123');
|
|
||||||
const url2 = service2.generateDriverAvatar('driver-123');
|
|
||||||
|
|
||||||
expect(url1).toBe(url2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates same PNG for same variant across different instances', () => {
|
|
||||||
const service1 = new MediaGenerationService();
|
|
||||||
const service2 = new MediaGenerationService();
|
|
||||||
|
|
||||||
const buffer1 = service1.generateDefaultPNG('test-variant');
|
|
||||||
const buffer2 = service2.generateDefaultPNG('test-variant');
|
|
||||||
|
|
||||||
expect(buffer1.equals(buffer2)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,83 +1,7 @@
|
|||||||
import { AvatarId } from './AvatarId';
|
import * as mod from '@core/media/domain/value-objects/AvatarId';
|
||||||
|
|
||||||
describe('AvatarId', () => {
|
describe('media/domain/value-objects/AvatarId.ts', () => {
|
||||||
describe('create', () => {
|
it('imports', () => {
|
||||||
it('creates from valid string', () => {
|
expect(mod).toBeTruthy();
|
||||||
const avatarId = AvatarId.create('avatar-123');
|
|
||||||
|
|
||||||
expect(avatarId.toString()).toBe('avatar-123');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('trims whitespace', () => {
|
|
||||||
const avatarId = AvatarId.create(' avatar-123 ');
|
|
||||||
|
|
||||||
expect(avatarId.toString()).toBe('avatar-123');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws error when empty', () => {
|
|
||||||
expect(() => AvatarId.create('')).toThrow('Avatar ID cannot be empty');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws error when only whitespace', () => {
|
|
||||||
expect(() => AvatarId.create(' ')).toThrow('Avatar ID cannot be empty');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws error when null', () => {
|
|
||||||
expect(() => AvatarId.create(null as any)).toThrow('Avatar ID cannot be empty');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws error when undefined', () => {
|
|
||||||
expect(() => AvatarId.create(undefined as any)).toThrow('Avatar ID cannot be empty');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('toString', () => {
|
|
||||||
it('returns the string value', () => {
|
|
||||||
const avatarId = AvatarId.create('avatar-123');
|
|
||||||
|
|
||||||
expect(avatarId.toString()).toBe('avatar-123');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('equals', () => {
|
|
||||||
it('returns true for equal avatar IDs', () => {
|
|
||||||
const avatarId1 = AvatarId.create('avatar-123');
|
|
||||||
const avatarId2 = AvatarId.create('avatar-123');
|
|
||||||
|
|
||||||
expect(avatarId1.equals(avatarId2)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false for different avatar IDs', () => {
|
|
||||||
const avatarId1 = AvatarId.create('avatar-123');
|
|
||||||
const avatarId2 = AvatarId.create('avatar-456');
|
|
||||||
|
|
||||||
expect(avatarId1.equals(avatarId2)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false for different case', () => {
|
|
||||||
const avatarId1 = AvatarId.create('avatar-123');
|
|
||||||
const avatarId2 = AvatarId.create('AVATAR-123');
|
|
||||||
|
|
||||||
expect(avatarId1.equals(avatarId2)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('value object equality', () => {
|
|
||||||
it('implements value-based equality', () => {
|
|
||||||
const avatarId1 = AvatarId.create('avatar-123');
|
|
||||||
const avatarId2 = AvatarId.create('avatar-123');
|
|
||||||
const avatarId3 = AvatarId.create('avatar-456');
|
|
||||||
|
|
||||||
expect(avatarId1.equals(avatarId2)).toBe(true);
|
|
||||||
expect(avatarId1.equals(avatarId3)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maintains equality after toString', () => {
|
|
||||||
const avatarId1 = AvatarId.create('avatar-123');
|
|
||||||
const avatarId2 = AvatarId.create('avatar-123');
|
|
||||||
|
|
||||||
expect(avatarId1.toString()).toBe(avatarId2.toString());
|
|
||||||
expect(avatarId1.equals(avatarId2)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,319 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import { Notification } from '../../domain/entities/Notification';
|
|
||||||
import {
|
|
||||||
NotificationGateway,
|
|
||||||
NotificationGatewayRegistry,
|
|
||||||
NotificationDeliveryResult,
|
|
||||||
} from './NotificationGateway';
|
|
||||||
|
|
||||||
describe('NotificationGateway - Interface Contract', () => {
|
|
||||||
it('NotificationGateway interface defines send method', () => {
|
|
||||||
const mockGateway: NotificationGateway = {
|
|
||||||
send: vi.fn().mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
channel: 'in_app',
|
|
||||||
attemptedAt: new Date(),
|
|
||||||
}),
|
|
||||||
supportsChannel: vi.fn().mockReturnValue(true),
|
|
||||||
isConfigured: vi.fn().mockReturnValue(true),
|
|
||||||
getChannel: vi.fn().mockReturnValue('in_app'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const notification = Notification.create({
|
|
||||||
id: 'test-id',
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'system_announcement',
|
|
||||||
title: 'Test',
|
|
||||||
body: 'Test body',
|
|
||||||
channel: 'in_app',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockGateway.send).toBeDefined();
|
|
||||||
expect(typeof mockGateway.send).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationGateway interface defines supportsChannel method', () => {
|
|
||||||
const mockGateway: NotificationGateway = {
|
|
||||||
send: vi.fn().mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
channel: 'in_app',
|
|
||||||
attemptedAt: new Date(),
|
|
||||||
}),
|
|
||||||
supportsChannel: vi.fn().mockReturnValue(true),
|
|
||||||
isConfigured: vi.fn().mockReturnValue(true),
|
|
||||||
getChannel: vi.fn().mockReturnValue('in_app'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(mockGateway.supportsChannel).toBeDefined();
|
|
||||||
expect(typeof mockGateway.supportsChannel).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationGateway interface defines isConfigured method', () => {
|
|
||||||
const mockGateway: NotificationGateway = {
|
|
||||||
send: vi.fn().mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
channel: 'in_app',
|
|
||||||
attemptedAt: new Date(),
|
|
||||||
}),
|
|
||||||
supportsChannel: vi.fn().mockReturnValue(true),
|
|
||||||
isConfigured: vi.fn().mockReturnValue(true),
|
|
||||||
getChannel: vi.fn().mockReturnValue('in_app'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(mockGateway.isConfigured).toBeDefined();
|
|
||||||
expect(typeof mockGateway.isConfigured).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationGateway interface defines getChannel method', () => {
|
|
||||||
const mockGateway: NotificationGateway = {
|
|
||||||
send: vi.fn().mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
channel: 'in_app',
|
|
||||||
attemptedAt: new Date(),
|
|
||||||
}),
|
|
||||||
supportsChannel: vi.fn().mockReturnValue(true),
|
|
||||||
isConfigured: vi.fn().mockReturnValue(true),
|
|
||||||
getChannel: vi.fn().mockReturnValue('in_app'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(mockGateway.getChannel).toBeDefined();
|
|
||||||
expect(typeof mockGateway.getChannel).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationDeliveryResult has required properties', () => {
|
|
||||||
const result: NotificationDeliveryResult = {
|
|
||||||
success: true,
|
|
||||||
channel: 'in_app',
|
|
||||||
attemptedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(result).toHaveProperty('success');
|
|
||||||
expect(result).toHaveProperty('channel');
|
|
||||||
expect(result).toHaveProperty('attemptedAt');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationDeliveryResult can have optional externalId', () => {
|
|
||||||
const result: NotificationDeliveryResult = {
|
|
||||||
success: true,
|
|
||||||
channel: 'email',
|
|
||||||
externalId: 'email-123',
|
|
||||||
attemptedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(result.externalId).toBe('email-123');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationDeliveryResult can have optional error', () => {
|
|
||||||
const result: NotificationDeliveryResult = {
|
|
||||||
success: false,
|
|
||||||
channel: 'discord',
|
|
||||||
error: 'Failed to send to Discord',
|
|
||||||
attemptedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(result.error).toBe('Failed to send to Discord');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('NotificationGatewayRegistry - Interface Contract', () => {
|
|
||||||
it('NotificationGatewayRegistry interface defines register method', () => {
|
|
||||||
const mockRegistry: NotificationGatewayRegistry = {
|
|
||||||
register: vi.fn(),
|
|
||||||
getGateway: vi.fn().mockReturnValue(null),
|
|
||||||
getAllGateways: vi.fn().mockReturnValue([]),
|
|
||||||
send: vi.fn().mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
channel: 'in_app',
|
|
||||||
attemptedAt: new Date(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(mockRegistry.register).toBeDefined();
|
|
||||||
expect(typeof mockRegistry.register).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationGatewayRegistry interface defines getGateway method', () => {
|
|
||||||
const mockRegistry: NotificationGatewayRegistry = {
|
|
||||||
register: vi.fn(),
|
|
||||||
getGateway: vi.fn().mockReturnValue(null),
|
|
||||||
getAllGateways: vi.fn().mockReturnValue([]),
|
|
||||||
send: vi.fn().mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
channel: 'in_app',
|
|
||||||
attemptedAt: new Date(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(mockRegistry.getGateway).toBeDefined();
|
|
||||||
expect(typeof mockRegistry.getGateway).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationGatewayRegistry interface defines getAllGateways method', () => {
|
|
||||||
const mockRegistry: NotificationGatewayRegistry = {
|
|
||||||
register: vi.fn(),
|
|
||||||
getGateway: vi.fn().mockReturnValue(null),
|
|
||||||
getAllGateways: vi.fn().mockReturnValue([]),
|
|
||||||
send: vi.fn().mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
channel: 'in_app',
|
|
||||||
attemptedAt: new Date(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(mockRegistry.getAllGateways).toBeDefined();
|
|
||||||
expect(typeof mockRegistry.getAllGateways).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationGatewayRegistry interface defines send method', () => {
|
|
||||||
const mockRegistry: NotificationGatewayRegistry = {
|
|
||||||
register: vi.fn(),
|
|
||||||
getGateway: vi.fn().mockReturnValue(null),
|
|
||||||
getAllGateways: vi.fn().mockReturnValue([]),
|
|
||||||
send: vi.fn().mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
channel: 'in_app',
|
|
||||||
attemptedAt: new Date(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(mockRegistry.send).toBeDefined();
|
|
||||||
expect(typeof mockRegistry.send).toBe('function');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('NotificationGateway - Integration with Notification', () => {
|
|
||||||
it('gateway can send notification and return delivery result', async () => {
|
|
||||||
const mockGateway: NotificationGateway = {
|
|
||||||
send: vi.fn().mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
channel: 'in_app',
|
|
||||||
externalId: 'msg-123',
|
|
||||||
attemptedAt: new Date(),
|
|
||||||
}),
|
|
||||||
supportsChannel: vi.fn().mockReturnValue(true),
|
|
||||||
isConfigured: vi.fn().mockReturnValue(true),
|
|
||||||
getChannel: vi.fn().mockReturnValue('in_app'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const notification = Notification.create({
|
|
||||||
id: 'test-id',
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'system_announcement',
|
|
||||||
title: 'Test',
|
|
||||||
body: 'Test body',
|
|
||||||
channel: 'in_app',
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await mockGateway.send(notification);
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.channel).toBe('in_app');
|
|
||||||
expect(result.externalId).toBe('msg-123');
|
|
||||||
expect(mockGateway.send).toHaveBeenCalledWith(notification);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gateway can handle failed delivery', async () => {
|
|
||||||
const mockGateway: NotificationGateway = {
|
|
||||||
send: vi.fn().mockResolvedValue({
|
|
||||||
success: false,
|
|
||||||
channel: 'email',
|
|
||||||
error: 'SMTP server unavailable',
|
|
||||||
attemptedAt: new Date(),
|
|
||||||
}),
|
|
||||||
supportsChannel: vi.fn().mockReturnValue(true),
|
|
||||||
isConfigured: vi.fn().mockReturnValue(true),
|
|
||||||
getChannel: vi.fn().mockReturnValue('email'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const notification = Notification.create({
|
|
||||||
id: 'test-id',
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'race_registration_open',
|
|
||||||
title: 'Test',
|
|
||||||
body: 'Test body',
|
|
||||||
channel: 'email',
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await mockGateway.send(notification);
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.channel).toBe('email');
|
|
||||||
expect(result.error).toBe('SMTP server unavailable');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('NotificationGatewayRegistry - Integration', () => {
|
|
||||||
it('registry can route notification to appropriate gateway', async () => {
|
|
||||||
const inAppGateway: NotificationGateway = {
|
|
||||||
send: vi.fn().mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
channel: 'in_app',
|
|
||||||
attemptedAt: new Date(),
|
|
||||||
}),
|
|
||||||
supportsChannel: vi.fn().mockReturnValue(true),
|
|
||||||
isConfigured: vi.fn().mockReturnValue(true),
|
|
||||||
getChannel: vi.fn().mockReturnValue('in_app'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const emailGateway: NotificationGateway = {
|
|
||||||
send: vi.fn().mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
channel: 'email',
|
|
||||||
externalId: 'email-456',
|
|
||||||
attemptedAt: new Date(),
|
|
||||||
}),
|
|
||||||
supportsChannel: vi.fn().mockReturnValue(true),
|
|
||||||
isConfigured: vi.fn().mockReturnValue(true),
|
|
||||||
getChannel: vi.fn().mockReturnValue('email'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockRegistry: NotificationGatewayRegistry = {
|
|
||||||
register: vi.fn(),
|
|
||||||
getGateway: vi.fn().mockImplementation((channel) => {
|
|
||||||
if (channel === 'in_app') return inAppGateway;
|
|
||||||
if (channel === 'email') return emailGateway;
|
|
||||||
return null;
|
|
||||||
}),
|
|
||||||
getAllGateways: vi.fn().mockReturnValue([inAppGateway, emailGateway]),
|
|
||||||
send: vi.fn().mockImplementation(async (notification) => {
|
|
||||||
const gateway = mockRegistry.getGateway(notification.channel);
|
|
||||||
if (gateway) {
|
|
||||||
return gateway.send(notification);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
channel: notification.channel,
|
|
||||||
error: 'No gateway found',
|
|
||||||
attemptedAt: new Date(),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const inAppNotification = Notification.create({
|
|
||||||
id: 'test-1',
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'system_announcement',
|
|
||||||
title: 'Test',
|
|
||||||
body: 'Test body',
|
|
||||||
channel: 'in_app',
|
|
||||||
});
|
|
||||||
|
|
||||||
const emailNotification = Notification.create({
|
|
||||||
id: 'test-2',
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'race_registration_open',
|
|
||||||
title: 'Test',
|
|
||||||
body: 'Test body',
|
|
||||||
channel: 'email',
|
|
||||||
});
|
|
||||||
|
|
||||||
const inAppResult = await mockRegistry.send(inAppNotification);
|
|
||||||
expect(inAppResult.success).toBe(true);
|
|
||||||
expect(inAppResult.channel).toBe('in_app');
|
|
||||||
|
|
||||||
const emailResult = await mockRegistry.send(emailNotification);
|
|
||||||
expect(emailResult.success).toBe(true);
|
|
||||||
expect(emailResult.channel).toBe('email');
|
|
||||||
expect(emailResult.externalId).toBe('email-456');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import {
|
|
||||||
NotificationService,
|
|
||||||
SendNotificationCommand,
|
|
||||||
NotificationData,
|
|
||||||
NotificationAction,
|
|
||||||
} from './NotificationService';
|
|
||||||
|
|
||||||
describe('NotificationService - Interface Contract', () => {
|
|
||||||
it('NotificationService interface defines sendNotification method', () => {
|
|
||||||
const mockService: NotificationService = {
|
|
||||||
sendNotification: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(mockService.sendNotification).toBeDefined();
|
|
||||||
expect(typeof mockService.sendNotification).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('SendNotificationCommand has required properties', () => {
|
|
||||||
const command: SendNotificationCommand = {
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'system_announcement',
|
|
||||||
title: 'Test Notification',
|
|
||||||
body: 'This is a test notification',
|
|
||||||
channel: 'in_app',
|
|
||||||
urgency: 'toast',
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(command).toHaveProperty('recipientId');
|
|
||||||
expect(command).toHaveProperty('type');
|
|
||||||
expect(command).toHaveProperty('title');
|
|
||||||
expect(command).toHaveProperty('body');
|
|
||||||
expect(command).toHaveProperty('channel');
|
|
||||||
expect(command).toHaveProperty('urgency');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('SendNotificationCommand can have optional data', () => {
|
|
||||||
const command: SendNotificationCommand = {
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'race_results_posted',
|
|
||||||
title: 'Race Results',
|
|
||||||
body: 'Your race results are available',
|
|
||||||
channel: 'email',
|
|
||||||
urgency: 'toast',
|
|
||||||
data: {
|
|
||||||
raceEventId: 'event-123',
|
|
||||||
sessionId: 'session-456',
|
|
||||||
position: 5,
|
|
||||||
positionChange: 2,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(command.data).toBeDefined();
|
|
||||||
expect(command.data?.raceEventId).toBe('event-123');
|
|
||||||
expect(command.data?.position).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('SendNotificationCommand can have optional actionUrl', () => {
|
|
||||||
const command: SendNotificationCommand = {
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'protest_vote_required',
|
|
||||||
title: 'Vote Required',
|
|
||||||
body: 'You need to vote on a protest',
|
|
||||||
channel: 'in_app',
|
|
||||||
urgency: 'modal',
|
|
||||||
actionUrl: '/protests/vote/123',
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(command.actionUrl).toBe('/protests/vote/123');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('SendNotificationCommand can have optional actions array', () => {
|
|
||||||
const actions: NotificationAction[] = [
|
|
||||||
{
|
|
||||||
label: 'View Details',
|
|
||||||
type: 'primary',
|
|
||||||
href: '/protests/123',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Dismiss',
|
|
||||||
type: 'secondary',
|
|
||||||
actionId: 'dismiss',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const command: SendNotificationCommand = {
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'protest_filed',
|
|
||||||
title: 'Protest Filed',
|
|
||||||
body: 'A protest has been filed against you',
|
|
||||||
channel: 'in_app',
|
|
||||||
urgency: 'modal',
|
|
||||||
actions,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(command.actions).toBeDefined();
|
|
||||||
expect(command.actions?.length).toBe(2);
|
|
||||||
expect(command.actions?.[0].label).toBe('View Details');
|
|
||||||
expect(command.actions?.[1].type).toBe('secondary');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('SendNotificationCommand can have optional requiresResponse', () => {
|
|
||||||
const command: SendNotificationCommand = {
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'protest_vote_required',
|
|
||||||
title: 'Vote Required',
|
|
||||||
body: 'You need to vote on a protest',
|
|
||||||
channel: 'in_app',
|
|
||||||
urgency: 'modal',
|
|
||||||
requiresResponse: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(command.requiresResponse).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationData can have various optional fields', () => {
|
|
||||||
const data: NotificationData = {
|
|
||||||
raceEventId: 'event-123',
|
|
||||||
sessionId: 'session-456',
|
|
||||||
leagueId: 'league-789',
|
|
||||||
position: 3,
|
|
||||||
positionChange: 1,
|
|
||||||
incidents: 2,
|
|
||||||
provisionalRatingChange: 15,
|
|
||||||
finalRatingChange: 10,
|
|
||||||
hadPenaltiesApplied: true,
|
|
||||||
deadline: new Date('2024-01-01'),
|
|
||||||
protestId: 'protest-999',
|
|
||||||
customField: 'custom value',
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(data.raceEventId).toBe('event-123');
|
|
||||||
expect(data.sessionId).toBe('session-456');
|
|
||||||
expect(data.leagueId).toBe('league-789');
|
|
||||||
expect(data.position).toBe(3);
|
|
||||||
expect(data.positionChange).toBe(1);
|
|
||||||
expect(data.incidents).toBe(2);
|
|
||||||
expect(data.provisionalRatingChange).toBe(15);
|
|
||||||
expect(data.finalRatingChange).toBe(10);
|
|
||||||
expect(data.hadPenaltiesApplied).toBe(true);
|
|
||||||
expect(data.deadline).toBeInstanceOf(Date);
|
|
||||||
expect(data.protestId).toBe('protest-999');
|
|
||||||
expect(data.customField).toBe('custom value');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationData can have minimal fields', () => {
|
|
||||||
const data: NotificationData = {
|
|
||||||
raceEventId: 'event-123',
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(data.raceEventId).toBe('event-123');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationAction has required properties', () => {
|
|
||||||
const action: NotificationAction = {
|
|
||||||
label: 'View Details',
|
|
||||||
type: 'primary',
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(action).toHaveProperty('label');
|
|
||||||
expect(action).toHaveProperty('type');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationAction can have optional href', () => {
|
|
||||||
const action: NotificationAction = {
|
|
||||||
label: 'View Details',
|
|
||||||
type: 'primary',
|
|
||||||
href: '/protests/123',
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(action.href).toBe('/protests/123');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationAction can have optional actionId', () => {
|
|
||||||
const action: NotificationAction = {
|
|
||||||
label: 'Dismiss',
|
|
||||||
type: 'secondary',
|
|
||||||
actionId: 'dismiss',
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(action.actionId).toBe('dismiss');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationAction type can be primary, secondary, or danger', () => {
|
|
||||||
const primaryAction: NotificationAction = {
|
|
||||||
label: 'Accept',
|
|
||||||
type: 'primary',
|
|
||||||
};
|
|
||||||
|
|
||||||
const secondaryAction: NotificationAction = {
|
|
||||||
label: 'Cancel',
|
|
||||||
type: 'secondary',
|
|
||||||
};
|
|
||||||
|
|
||||||
const dangerAction: NotificationAction = {
|
|
||||||
label: 'Delete',
|
|
||||||
type: 'danger',
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(primaryAction.type).toBe('primary');
|
|
||||||
expect(secondaryAction.type).toBe('secondary');
|
|
||||||
expect(dangerAction.type).toBe('danger');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('NotificationService - Integration', () => {
|
|
||||||
it('service can send notification with all optional fields', async () => {
|
|
||||||
const mockService: NotificationService = {
|
|
||||||
sendNotification: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
const command: SendNotificationCommand = {
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'race_performance_summary',
|
|
||||||
title: 'Performance Summary',
|
|
||||||
body: 'Your performance summary is ready',
|
|
||||||
channel: 'email',
|
|
||||||
urgency: 'toast',
|
|
||||||
data: {
|
|
||||||
raceEventId: 'event-123',
|
|
||||||
sessionId: 'session-456',
|
|
||||||
position: 5,
|
|
||||||
positionChange: 2,
|
|
||||||
incidents: 1,
|
|
||||||
provisionalRatingChange: 10,
|
|
||||||
finalRatingChange: 8,
|
|
||||||
hadPenaltiesApplied: false,
|
|
||||||
},
|
|
||||||
actionUrl: '/performance/summary/123',
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
label: 'View Details',
|
|
||||||
type: 'primary',
|
|
||||||
href: '/performance/summary/123',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Dismiss',
|
|
||||||
type: 'secondary',
|
|
||||||
actionId: 'dismiss',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
requiresResponse: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
await mockService.sendNotification(command);
|
|
||||||
|
|
||||||
expect(mockService.sendNotification).toHaveBeenCalledWith(command);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('service can send notification with minimal fields', async () => {
|
|
||||||
const mockService: NotificationService = {
|
|
||||||
sendNotification: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
const command: SendNotificationCommand = {
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'system_announcement',
|
|
||||||
title: 'System Update',
|
|
||||||
body: 'System will be down for maintenance',
|
|
||||||
channel: 'in_app',
|
|
||||||
urgency: 'toast',
|
|
||||||
};
|
|
||||||
|
|
||||||
await mockService.sendNotification(command);
|
|
||||||
|
|
||||||
expect(mockService.sendNotification).toHaveBeenCalledWith(command);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('service can send notification with different urgency levels', async () => {
|
|
||||||
const mockService: NotificationService = {
|
|
||||||
sendNotification: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
const silentCommand: SendNotificationCommand = {
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'race_reminder',
|
|
||||||
title: 'Race Reminder',
|
|
||||||
body: 'Your race starts in 30 minutes',
|
|
||||||
channel: 'in_app',
|
|
||||||
urgency: 'silent',
|
|
||||||
};
|
|
||||||
|
|
||||||
const toastCommand: SendNotificationCommand = {
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'league_invite',
|
|
||||||
title: 'League Invite',
|
|
||||||
body: 'You have been invited to a league',
|
|
||||||
channel: 'in_app',
|
|
||||||
urgency: 'toast',
|
|
||||||
};
|
|
||||||
|
|
||||||
const modalCommand: SendNotificationCommand = {
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'protest_vote_required',
|
|
||||||
title: 'Vote Required',
|
|
||||||
body: 'You need to vote on a protest',
|
|
||||||
channel: 'in_app',
|
|
||||||
urgency: 'modal',
|
|
||||||
};
|
|
||||||
|
|
||||||
await mockService.sendNotification(silentCommand);
|
|
||||||
await mockService.sendNotification(toastCommand);
|
|
||||||
await mockService.sendNotification(modalCommand);
|
|
||||||
|
|
||||||
expect(mockService.sendNotification).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('service can send notification through different channels', async () => {
|
|
||||||
const mockService: NotificationService = {
|
|
||||||
sendNotification: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
const inAppCommand: SendNotificationCommand = {
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'system_announcement',
|
|
||||||
title: 'System Update',
|
|
||||||
body: 'System will be down for maintenance',
|
|
||||||
channel: 'in_app',
|
|
||||||
urgency: 'toast',
|
|
||||||
};
|
|
||||||
|
|
||||||
const emailCommand: SendNotificationCommand = {
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'race_results_posted',
|
|
||||||
title: 'Race Results',
|
|
||||||
body: 'Your race results are available',
|
|
||||||
channel: 'email',
|
|
||||||
urgency: 'toast',
|
|
||||||
};
|
|
||||||
|
|
||||||
const discordCommand: SendNotificationCommand = {
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'sponsorship_request_received',
|
|
||||||
title: 'Sponsorship Request',
|
|
||||||
body: 'A sponsor wants to sponsor you',
|
|
||||||
channel: 'discord',
|
|
||||||
urgency: 'toast',
|
|
||||||
};
|
|
||||||
|
|
||||||
await mockService.sendNotification(inAppCommand);
|
|
||||||
await mockService.sendNotification(emailCommand);
|
|
||||||
await mockService.sendNotification(discordCommand);
|
|
||||||
|
|
||||||
expect(mockService.sendNotification).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import type { Logger } from '@core/shared/domain/Logger';
|
|
||||||
import { Result } from '@core/shared/domain/Result';
|
|
||||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
|
||||||
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
|
||||||
import { Notification } from '../../domain/entities/Notification';
|
|
||||||
import { NotificationRepository } from '../../domain/repositories/NotificationRepository';
|
|
||||||
import {
|
|
||||||
GetAllNotificationsUseCase,
|
|
||||||
type GetAllNotificationsInput,
|
|
||||||
} from './GetAllNotificationsUseCase';
|
|
||||||
|
|
||||||
interface NotificationRepositoryMock {
|
|
||||||
findByRecipientId: Mock;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('GetAllNotificationsUseCase', () => {
|
|
||||||
let notificationRepository: NotificationRepositoryMock;
|
|
||||||
let logger: Logger;
|
|
||||||
let useCase: GetAllNotificationsUseCase;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
notificationRepository = {
|
|
||||||
findByRecipientId: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
logger = {
|
|
||||||
debug: vi.fn(),
|
|
||||||
info: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
} as unknown as Logger;
|
|
||||||
|
|
||||||
useCase = new GetAllNotificationsUseCase(
|
|
||||||
notificationRepository as unknown as NotificationRepository,
|
|
||||||
logger,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns all notifications and total count for recipient', async () => {
|
|
||||||
const recipientId = 'driver-1';
|
|
||||||
const notifications: Notification[] = [
|
|
||||||
Notification.create({
|
|
||||||
id: 'n1',
|
|
||||||
recipientId,
|
|
||||||
type: 'system_announcement',
|
|
||||||
title: 'Test 1',
|
|
||||||
body: 'Body 1',
|
|
||||||
channel: 'in_app',
|
|
||||||
}),
|
|
||||||
Notification.create({
|
|
||||||
id: 'n2',
|
|
||||||
recipientId,
|
|
||||||
type: 'race_registration_open',
|
|
||||||
title: 'Test 2',
|
|
||||||
body: 'Body 2',
|
|
||||||
channel: 'email',
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
notificationRepository.findByRecipientId.mockResolvedValue(notifications);
|
|
||||||
|
|
||||||
const input: GetAllNotificationsInput = { recipientId };
|
|
||||||
|
|
||||||
const result = await useCase.execute(input);
|
|
||||||
|
|
||||||
expect(notificationRepository.findByRecipientId).toHaveBeenCalledWith(recipientId);
|
|
||||||
expect(result).toBeInstanceOf(Result);
|
|
||||||
expect(result.isOk()).toBe(true);
|
|
||||||
|
|
||||||
const successResult = result.unwrap();
|
|
||||||
expect(successResult.notifications).toEqual(notifications);
|
|
||||||
expect(successResult.totalCount).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty array when no notifications exist', async () => {
|
|
||||||
const recipientId = 'driver-1';
|
|
||||||
notificationRepository.findByRecipientId.mockResolvedValue([]);
|
|
||||||
|
|
||||||
const input: GetAllNotificationsInput = { recipientId };
|
|
||||||
|
|
||||||
const result = await useCase.execute(input);
|
|
||||||
|
|
||||||
expect(notificationRepository.findByRecipientId).toHaveBeenCalledWith(recipientId);
|
|
||||||
expect(result.isOk()).toBe(true);
|
|
||||||
|
|
||||||
const successResult = result.unwrap();
|
|
||||||
expect(successResult.notifications).toEqual([]);
|
|
||||||
expect(successResult.totalCount).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles repository errors by logging and returning error result', async () => {
|
|
||||||
const recipientId = 'driver-1';
|
|
||||||
const error = new Error('DB error');
|
|
||||||
notificationRepository.findByRecipientId.mockRejectedValue(error);
|
|
||||||
|
|
||||||
const input: GetAllNotificationsInput = { recipientId };
|
|
||||||
|
|
||||||
const result = await useCase.execute(input);
|
|
||||||
|
|
||||||
expect(result.isErr()).toBe(true);
|
|
||||||
const err = result.unwrapErr() as ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>;
|
|
||||||
expect(err.code).toBe('REPOSITORY_ERROR');
|
|
||||||
expect(err.details.message).toBe('DB error');
|
|
||||||
expect((logger.error as unknown as Mock)).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('logs debug message when starting execution', async () => {
|
|
||||||
const recipientId = 'driver-1';
|
|
||||||
notificationRepository.findByRecipientId.mockResolvedValue([]);
|
|
||||||
|
|
||||||
const input: GetAllNotificationsInput = { recipientId };
|
|
||||||
|
|
||||||
await useCase.execute(input);
|
|
||||||
|
|
||||||
expect(logger.debug).toHaveBeenCalledWith(
|
|
||||||
`Attempting to retrieve all notifications for recipient ID: ${recipientId}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('logs info message on successful retrieval', async () => {
|
|
||||||
const recipientId = 'driver-1';
|
|
||||||
const notifications: Notification[] = [
|
|
||||||
Notification.create({
|
|
||||||
id: 'n1',
|
|
||||||
recipientId,
|
|
||||||
type: 'system_announcement',
|
|
||||||
title: 'Test',
|
|
||||||
body: 'Body',
|
|
||||||
channel: 'in_app',
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
notificationRepository.findByRecipientId.mockResolvedValue(notifications);
|
|
||||||
|
|
||||||
const input: GetAllNotificationsInput = { recipientId };
|
|
||||||
|
|
||||||
await useCase.execute(input);
|
|
||||||
|
|
||||||
expect(logger.info).toHaveBeenCalledWith(
|
|
||||||
`Successfully retrieved 1 notifications for recipient ID: ${recipientId}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { NotificationDomainError } from './NotificationDomainError';
|
|
||||||
|
|
||||||
describe('NotificationDomainError', () => {
|
|
||||||
it('creates an error with default validation kind', () => {
|
|
||||||
const error = new NotificationDomainError('Invalid notification data');
|
|
||||||
|
|
||||||
expect(error.name).toBe('NotificationDomainError');
|
|
||||||
expect(error.type).toBe('domain');
|
|
||||||
expect(error.context).toBe('notifications');
|
|
||||||
expect(error.kind).toBe('validation');
|
|
||||||
expect(error.message).toBe('Invalid notification data');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates an error with custom kind', () => {
|
|
||||||
const error = new NotificationDomainError('Notification not found', 'not_found');
|
|
||||||
|
|
||||||
expect(error.kind).toBe('not_found');
|
|
||||||
expect(error.message).toBe('Notification not found');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates an error with business rule kind', () => {
|
|
||||||
const error = new NotificationDomainError('Cannot send notification during quiet hours', 'business_rule');
|
|
||||||
|
|
||||||
expect(error.kind).toBe('business_rule');
|
|
||||||
expect(error.message).toBe('Cannot send notification during quiet hours');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates an error with conflict kind', () => {
|
|
||||||
const error = new NotificationDomainError('Notification already read', 'conflict');
|
|
||||||
|
|
||||||
expect(error.kind).toBe('conflict');
|
|
||||||
expect(error.message).toBe('Notification already read');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates an error with unauthorized kind', () => {
|
|
||||||
const error = new NotificationDomainError('Cannot access notification', 'unauthorized');
|
|
||||||
|
|
||||||
expect(error.kind).toBe('unauthorized');
|
|
||||||
expect(error.message).toBe('Cannot access notification');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('inherits from Error', () => {
|
|
||||||
const error = new NotificationDomainError('Test error');
|
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(Error);
|
|
||||||
expect(error.stack).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has correct error properties', () => {
|
|
||||||
const error = new NotificationDomainError('Test error', 'validation');
|
|
||||||
|
|
||||||
expect(error.name).toBe('NotificationDomainError');
|
|
||||||
expect(error.type).toBe('domain');
|
|
||||||
expect(error.context).toBe('notifications');
|
|
||||||
expect(error.kind).toBe('validation');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import { NotificationPreference } from '../entities/NotificationPreference';
|
|
||||||
import { NotificationPreferenceRepository } from './NotificationPreferenceRepository';
|
|
||||||
|
|
||||||
describe('NotificationPreferenceRepository - Interface Contract', () => {
|
|
||||||
it('NotificationPreferenceRepository interface defines findByDriverId method', () => {
|
|
||||||
const mockRepository: NotificationPreferenceRepository = {
|
|
||||||
findByDriverId: vi.fn().mockResolvedValue(null),
|
|
||||||
save: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
getOrCreateDefault: vi.fn().mockResolvedValue({} as NotificationPreference),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(mockRepository.findByDriverId).toBeDefined();
|
|
||||||
expect(typeof mockRepository.findByDriverId).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationPreferenceRepository interface defines save method', () => {
|
|
||||||
const mockRepository: NotificationPreferenceRepository = {
|
|
||||||
findByDriverId: vi.fn().mockResolvedValue(null),
|
|
||||||
save: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
getOrCreateDefault: vi.fn().mockResolvedValue({} as NotificationPreference),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(mockRepository.save).toBeDefined();
|
|
||||||
expect(typeof mockRepository.save).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationPreferenceRepository interface defines delete method', () => {
|
|
||||||
const mockRepository: NotificationPreferenceRepository = {
|
|
||||||
findByDriverId: vi.fn().mockResolvedValue(null),
|
|
||||||
save: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
getOrCreateDefault: vi.fn().mockResolvedValue({} as NotificationPreference),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(mockRepository.delete).toBeDefined();
|
|
||||||
expect(typeof mockRepository.delete).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationPreferenceRepository interface defines getOrCreateDefault method', () => {
|
|
||||||
const mockRepository: NotificationPreferenceRepository = {
|
|
||||||
findByDriverId: vi.fn().mockResolvedValue(null),
|
|
||||||
save: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
getOrCreateDefault: vi.fn().mockResolvedValue({} as NotificationPreference),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(mockRepository.getOrCreateDefault).toBeDefined();
|
|
||||||
expect(typeof mockRepository.getOrCreateDefault).toBe('function');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('NotificationPreferenceRepository - Integration', () => {
|
|
||||||
it('can find preferences by driver ID', async () => {
|
|
||||||
const mockPreference = NotificationPreference.create({
|
|
||||||
id: 'driver-1',
|
|
||||||
driverId: 'driver-1',
|
|
||||||
channels: {
|
|
||||||
in_app: { enabled: true },
|
|
||||||
email: { enabled: true },
|
|
||||||
discord: { enabled: false },
|
|
||||||
push: { enabled: false },
|
|
||||||
},
|
|
||||||
quietHoursStart: 22,
|
|
||||||
quietHoursEnd: 7,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockRepository: NotificationPreferenceRepository = {
|
|
||||||
findByDriverId: vi.fn().mockResolvedValue(mockPreference),
|
|
||||||
save: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
getOrCreateDefault: vi.fn().mockResolvedValue(mockPreference),
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await mockRepository.findByDriverId('driver-1');
|
|
||||||
|
|
||||||
expect(result).toBe(mockPreference);
|
|
||||||
expect(mockRepository.findByDriverId).toHaveBeenCalledWith('driver-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when preferences not found', async () => {
|
|
||||||
const mockRepository: NotificationPreferenceRepository = {
|
|
||||||
findByDriverId: vi.fn().mockResolvedValue(null),
|
|
||||||
save: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
getOrCreateDefault: vi.fn().mockResolvedValue({} as NotificationPreference),
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await mockRepository.findByDriverId('driver-999');
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
expect(mockRepository.findByDriverId).toHaveBeenCalledWith('driver-999');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can save preferences', async () => {
|
|
||||||
const mockPreference = NotificationPreference.create({
|
|
||||||
id: 'driver-1',
|
|
||||||
driverId: 'driver-1',
|
|
||||||
channels: {
|
|
||||||
in_app: { enabled: true },
|
|
||||||
email: { enabled: true },
|
|
||||||
discord: { enabled: false },
|
|
||||||
push: { enabled: false },
|
|
||||||
},
|
|
||||||
quietHoursStart: 22,
|
|
||||||
quietHoursEnd: 7,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockRepository: NotificationPreferenceRepository = {
|
|
||||||
findByDriverId: vi.fn().mockResolvedValue(mockPreference),
|
|
||||||
save: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
getOrCreateDefault: vi.fn().mockResolvedValue(mockPreference),
|
|
||||||
};
|
|
||||||
|
|
||||||
await mockRepository.save(mockPreference);
|
|
||||||
|
|
||||||
expect(mockRepository.save).toHaveBeenCalledWith(mockPreference);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can delete preferences by driver ID', async () => {
|
|
||||||
const mockRepository: NotificationPreferenceRepository = {
|
|
||||||
findByDriverId: vi.fn().mockResolvedValue(null),
|
|
||||||
save: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
getOrCreateDefault: vi.fn().mockResolvedValue({} as NotificationPreference),
|
|
||||||
};
|
|
||||||
|
|
||||||
await mockRepository.delete('driver-1');
|
|
||||||
|
|
||||||
expect(mockRepository.delete).toHaveBeenCalledWith('driver-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can get or create default preferences', async () => {
|
|
||||||
const defaultPreference = NotificationPreference.createDefault('driver-1');
|
|
||||||
|
|
||||||
const mockRepository: NotificationPreferenceRepository = {
|
|
||||||
findByDriverId: vi.fn().mockResolvedValue(null),
|
|
||||||
save: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
getOrCreateDefault: vi.fn().mockResolvedValue(defaultPreference),
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await mockRepository.getOrCreateDefault('driver-1');
|
|
||||||
|
|
||||||
expect(result).toBe(defaultPreference);
|
|
||||||
expect(mockRepository.getOrCreateDefault).toHaveBeenCalledWith('driver-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles workflow: find, update, save', async () => {
|
|
||||||
const existingPreference = NotificationPreference.create({
|
|
||||||
id: 'driver-1',
|
|
||||||
driverId: 'driver-1',
|
|
||||||
channels: {
|
|
||||||
in_app: { enabled: true },
|
|
||||||
email: { enabled: false },
|
|
||||||
discord: { enabled: false },
|
|
||||||
push: { enabled: false },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updatedPreference = NotificationPreference.create({
|
|
||||||
id: 'driver-1',
|
|
||||||
driverId: 'driver-1',
|
|
||||||
channels: {
|
|
||||||
in_app: { enabled: true },
|
|
||||||
email: { enabled: true },
|
|
||||||
discord: { enabled: true },
|
|
||||||
push: { enabled: false },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockRepository: NotificationPreferenceRepository = {
|
|
||||||
findByDriverId: vi.fn()
|
|
||||||
.mockResolvedValueOnce(existingPreference)
|
|
||||||
.mockResolvedValueOnce(updatedPreference),
|
|
||||||
save: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
getOrCreateDefault: vi.fn().mockResolvedValue(existingPreference),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Find existing preferences
|
|
||||||
const found = await mockRepository.findByDriverId('driver-1');
|
|
||||||
expect(found).toBe(existingPreference);
|
|
||||||
|
|
||||||
// Update preferences
|
|
||||||
const updated = found!.updateChannel('email', { enabled: true });
|
|
||||||
const updated2 = updated.updateChannel('discord', { enabled: true });
|
|
||||||
|
|
||||||
// Save updated preferences
|
|
||||||
await mockRepository.save(updated2);
|
|
||||||
expect(mockRepository.save).toHaveBeenCalledWith(updated2);
|
|
||||||
|
|
||||||
// Verify update
|
|
||||||
const updatedFound = await mockRepository.findByDriverId('driver-1');
|
|
||||||
expect(updatedFound).toBe(updatedPreference);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles workflow: get or create, then update', async () => {
|
|
||||||
const defaultPreference = NotificationPreference.createDefault('driver-1');
|
|
||||||
|
|
||||||
const updatedPreference = NotificationPreference.create({
|
|
||||||
id: 'driver-1',
|
|
||||||
driverId: 'driver-1',
|
|
||||||
channels: {
|
|
||||||
in_app: { enabled: true },
|
|
||||||
email: { enabled: true },
|
|
||||||
discord: { enabled: false },
|
|
||||||
push: { enabled: false },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockRepository: NotificationPreferenceRepository = {
|
|
||||||
findByDriverId: vi.fn().mockResolvedValue(null),
|
|
||||||
save: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
getOrCreateDefault: vi.fn().mockResolvedValue(defaultPreference),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get or create default preferences
|
|
||||||
const preferences = await mockRepository.getOrCreateDefault('driver-1');
|
|
||||||
expect(preferences).toBe(defaultPreference);
|
|
||||||
|
|
||||||
// Update preferences
|
|
||||||
const updated = preferences.updateChannel('email', { enabled: true });
|
|
||||||
|
|
||||||
// Save updated preferences
|
|
||||||
await mockRepository.save(updated);
|
|
||||||
expect(mockRepository.save).toHaveBeenCalledWith(updated);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles workflow: delete preferences', async () => {
|
|
||||||
const mockRepository: NotificationPreferenceRepository = {
|
|
||||||
findByDriverId: vi.fn().mockResolvedValue(null),
|
|
||||||
save: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
getOrCreateDefault: vi.fn().mockResolvedValue({} as NotificationPreference),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Delete preferences
|
|
||||||
await mockRepository.delete('driver-1');
|
|
||||||
expect(mockRepository.delete).toHaveBeenCalledWith('driver-1');
|
|
||||||
|
|
||||||
// Verify deletion
|
|
||||||
const result = await mockRepository.findByDriverId('driver-1');
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,539 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import { Notification } from '../entities/Notification';
|
|
||||||
import { NotificationRepository } from './NotificationRepository';
|
|
||||||
|
|
||||||
describe('NotificationRepository - Interface Contract', () => {
|
|
||||||
it('NotificationRepository interface defines findById method', () => {
|
|
||||||
const mockRepository: NotificationRepository = {
|
|
||||||
findById: vi.fn().mockResolvedValue(null),
|
|
||||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
|
||||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(mockRepository.findById).toBeDefined();
|
|
||||||
expect(typeof mockRepository.findById).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationRepository interface defines findByRecipientId method', () => {
|
|
||||||
const mockRepository: NotificationRepository = {
|
|
||||||
findById: vi.fn().mockResolvedValue(null),
|
|
||||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
|
||||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(mockRepository.findByRecipientId).toBeDefined();
|
|
||||||
expect(typeof mockRepository.findByRecipientId).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationRepository interface defines findUnreadByRecipientId method', () => {
|
|
||||||
const mockRepository: NotificationRepository = {
|
|
||||||
findById: vi.fn().mockResolvedValue(null),
|
|
||||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
|
||||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(mockRepository.findUnreadByRecipientId).toBeDefined();
|
|
||||||
expect(typeof mockRepository.findUnreadByRecipientId).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationRepository interface defines findByRecipientIdAndType method', () => {
|
|
||||||
const mockRepository: NotificationRepository = {
|
|
||||||
findById: vi.fn().mockResolvedValue(null),
|
|
||||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
|
||||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(mockRepository.findByRecipientIdAndType).toBeDefined();
|
|
||||||
expect(typeof mockRepository.findByRecipientIdAndType).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationRepository interface defines countUnreadByRecipientId method', () => {
|
|
||||||
const mockRepository: NotificationRepository = {
|
|
||||||
findById: vi.fn().mockResolvedValue(null),
|
|
||||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
|
||||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(mockRepository.countUnreadByRecipientId).toBeDefined();
|
|
||||||
expect(typeof mockRepository.countUnreadByRecipientId).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationRepository interface defines create method', () => {
|
|
||||||
const mockRepository: NotificationRepository = {
|
|
||||||
findById: vi.fn().mockResolvedValue(null),
|
|
||||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
|
||||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(mockRepository.create).toBeDefined();
|
|
||||||
expect(typeof mockRepository.create).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationRepository interface defines update method', () => {
|
|
||||||
const mockRepository: NotificationRepository = {
|
|
||||||
findById: vi.fn().mockResolvedValue(null),
|
|
||||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
|
||||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(mockRepository.update).toBeDefined();
|
|
||||||
expect(typeof mockRepository.update).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationRepository interface defines delete method', () => {
|
|
||||||
const mockRepository: NotificationRepository = {
|
|
||||||
findById: vi.fn().mockResolvedValue(null),
|
|
||||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
|
||||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(mockRepository.delete).toBeDefined();
|
|
||||||
expect(typeof mockRepository.delete).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationRepository interface defines deleteAllByRecipientId method', () => {
|
|
||||||
const mockRepository: NotificationRepository = {
|
|
||||||
findById: vi.fn().mockResolvedValue(null),
|
|
||||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
|
||||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(mockRepository.deleteAllByRecipientId).toBeDefined();
|
|
||||||
expect(typeof mockRepository.deleteAllByRecipientId).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('NotificationRepository interface defines markAllAsReadByRecipientId method', () => {
|
|
||||||
const mockRepository: NotificationRepository = {
|
|
||||||
findById: vi.fn().mockResolvedValue(null),
|
|
||||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
|
||||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(mockRepository.markAllAsReadByRecipientId).toBeDefined();
|
|
||||||
expect(typeof mockRepository.markAllAsReadByRecipientId).toBe('function');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('NotificationRepository - Integration', () => {
|
|
||||||
it('can find notification by ID', async () => {
|
|
||||||
const notification = Notification.create({
|
|
||||||
id: 'notification-1',
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'system_announcement',
|
|
||||||
title: 'Test',
|
|
||||||
body: 'Test body',
|
|
||||||
channel: 'in_app',
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockRepository: NotificationRepository = {
|
|
||||||
findById: vi.fn().mockResolvedValue(notification),
|
|
||||||
findByRecipientId: vi.fn().mockResolvedValue([notification]),
|
|
||||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([notification]),
|
|
||||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([notification]),
|
|
||||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(1),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await mockRepository.findById('notification-1');
|
|
||||||
|
|
||||||
expect(result).toBe(notification);
|
|
||||||
expect(mockRepository.findById).toHaveBeenCalledWith('notification-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when notification not found by ID', async () => {
|
|
||||||
const mockRepository: NotificationRepository = {
|
|
||||||
findById: vi.fn().mockResolvedValue(null),
|
|
||||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
|
||||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await mockRepository.findById('notification-999');
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
expect(mockRepository.findById).toHaveBeenCalledWith('notification-999');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can find all notifications for a recipient', async () => {
|
|
||||||
const notifications = [
|
|
||||||
Notification.create({
|
|
||||||
id: 'notification-1',
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'system_announcement',
|
|
||||||
title: 'Test 1',
|
|
||||||
body: 'Body 1',
|
|
||||||
channel: 'in_app',
|
|
||||||
}),
|
|
||||||
Notification.create({
|
|
||||||
id: 'notification-2',
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'race_registration_open',
|
|
||||||
title: 'Test 2',
|
|
||||||
body: 'Body 2',
|
|
||||||
channel: 'email',
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockRepository: NotificationRepository = {
|
|
||||||
findById: vi.fn().mockResolvedValue(null),
|
|
||||||
findByRecipientId: vi.fn().mockResolvedValue(notifications),
|
|
||||||
findUnreadByRecipientId: vi.fn().mockResolvedValue(notifications),
|
|
||||||
findByRecipientIdAndType: vi.fn().mockResolvedValue(notifications),
|
|
||||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(2),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await mockRepository.findByRecipientId('driver-1');
|
|
||||||
|
|
||||||
expect(result).toBe(notifications);
|
|
||||||
expect(mockRepository.findByRecipientId).toHaveBeenCalledWith('driver-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can find unread notifications for a recipient', async () => {
|
|
||||||
const unreadNotifications = [
|
|
||||||
Notification.create({
|
|
||||||
id: 'notification-1',
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'system_announcement',
|
|
||||||
title: 'Test 1',
|
|
||||||
body: 'Body 1',
|
|
||||||
channel: 'in_app',
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockRepository: NotificationRepository = {
|
|
||||||
findById: vi.fn().mockResolvedValue(null),
|
|
||||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findUnreadByRecipientId: vi.fn().mockResolvedValue(unreadNotifications),
|
|
||||||
findByRecipientIdAndType: vi.fn().mockResolvedValue(unreadNotifications),
|
|
||||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(1),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await mockRepository.findUnreadByRecipientId('driver-1');
|
|
||||||
|
|
||||||
expect(result).toBe(unreadNotifications);
|
|
||||||
expect(mockRepository.findUnreadByRecipientId).toHaveBeenCalledWith('driver-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can find notifications by type for a recipient', async () => {
|
|
||||||
const protestNotifications = [
|
|
||||||
Notification.create({
|
|
||||||
id: 'notification-1',
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'protest_filed',
|
|
||||||
title: 'Protest Filed',
|
|
||||||
body: 'A protest has been filed',
|
|
||||||
channel: 'in_app',
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockRepository: NotificationRepository = {
|
|
||||||
findById: vi.fn().mockResolvedValue(null),
|
|
||||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findByRecipientIdAndType: vi.fn().mockResolvedValue(protestNotifications),
|
|
||||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await mockRepository.findByRecipientIdAndType('driver-1', 'protest_filed');
|
|
||||||
|
|
||||||
expect(result).toBe(protestNotifications);
|
|
||||||
expect(mockRepository.findByRecipientIdAndType).toHaveBeenCalledWith('driver-1', 'protest_filed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can count unread notifications for a recipient', async () => {
|
|
||||||
const mockRepository: NotificationRepository = {
|
|
||||||
findById: vi.fn().mockResolvedValue(null),
|
|
||||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
|
||||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(3),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
const count = await mockRepository.countUnreadByRecipientId('driver-1');
|
|
||||||
|
|
||||||
expect(count).toBe(3);
|
|
||||||
expect(mockRepository.countUnreadByRecipientId).toHaveBeenCalledWith('driver-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can create a new notification', async () => {
|
|
||||||
const notification = Notification.create({
|
|
||||||
id: 'notification-1',
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'system_announcement',
|
|
||||||
title: 'Test',
|
|
||||||
body: 'Test body',
|
|
||||||
channel: 'in_app',
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockRepository: NotificationRepository = {
|
|
||||||
findById: vi.fn().mockResolvedValue(null),
|
|
||||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
|
||||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
await mockRepository.create(notification);
|
|
||||||
|
|
||||||
expect(mockRepository.create).toHaveBeenCalledWith(notification);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can update an existing notification', async () => {
|
|
||||||
const notification = Notification.create({
|
|
||||||
id: 'notification-1',
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'system_announcement',
|
|
||||||
title: 'Test',
|
|
||||||
body: 'Test body',
|
|
||||||
channel: 'in_app',
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockRepository: NotificationRepository = {
|
|
||||||
findById: vi.fn().mockResolvedValue(notification),
|
|
||||||
findByRecipientId: vi.fn().mockResolvedValue([notification]),
|
|
||||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([notification]),
|
|
||||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([notification]),
|
|
||||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(1),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
await mockRepository.update(notification);
|
|
||||||
|
|
||||||
expect(mockRepository.update).toHaveBeenCalledWith(notification);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can delete a notification by ID', async () => {
|
|
||||||
const mockRepository: NotificationRepository = {
|
|
||||||
findById: vi.fn().mockResolvedValue(null),
|
|
||||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
|
||||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
await mockRepository.delete('notification-1');
|
|
||||||
|
|
||||||
expect(mockRepository.delete).toHaveBeenCalledWith('notification-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can delete all notifications for a recipient', async () => {
|
|
||||||
const mockRepository: NotificationRepository = {
|
|
||||||
findById: vi.fn().mockResolvedValue(null),
|
|
||||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
|
||||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
await mockRepository.deleteAllByRecipientId('driver-1');
|
|
||||||
|
|
||||||
expect(mockRepository.deleteAllByRecipientId).toHaveBeenCalledWith('driver-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can mark all notifications as read for a recipient', async () => {
|
|
||||||
const mockRepository: NotificationRepository = {
|
|
||||||
findById: vi.fn().mockResolvedValue(null),
|
|
||||||
findByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findUnreadByRecipientId: vi.fn().mockResolvedValue([]),
|
|
||||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
|
||||||
countUnreadByRecipientId: vi.fn().mockResolvedValue(0),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
await mockRepository.markAllAsReadByRecipientId('driver-1');
|
|
||||||
|
|
||||||
expect(mockRepository.markAllAsReadByRecipientId).toHaveBeenCalledWith('driver-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles workflow: create, find, update, delete', async () => {
|
|
||||||
const notification = Notification.create({
|
|
||||||
id: 'notification-1',
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'system_announcement',
|
|
||||||
title: 'Test',
|
|
||||||
body: 'Test body',
|
|
||||||
channel: 'in_app',
|
|
||||||
});
|
|
||||||
|
|
||||||
const updatedNotification = Notification.create({
|
|
||||||
id: 'notification-1',
|
|
||||||
recipientId: 'driver-1',
|
|
||||||
type: 'system_announcement',
|
|
||||||
title: 'Updated Test',
|
|
||||||
body: 'Updated body',
|
|
||||||
channel: 'in_app',
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockRepository: NotificationRepository = {
|
|
||||||
findById: vi.fn()
|
|
||||||
.mockResolvedValueOnce(notification)
|
|
||||||
.mockResolvedValueOnce(updatedNotification)
|
|
||||||
.mockResolvedValueOnce(null),
|
|
||||||
findByRecipientId: vi.fn()
|
|
||||||
.mockResolvedValueOnce([notification])
|
|
||||||
.mockResolvedValueOnce([updatedNotification])
|
|
||||||
.mockResolvedValueOnce([]),
|
|
||||||
findUnreadByRecipientId: vi.fn()
|
|
||||||
.mockResolvedValueOnce([notification])
|
|
||||||
.mockResolvedValueOnce([updatedNotification])
|
|
||||||
.mockResolvedValueOnce([]),
|
|
||||||
findByRecipientIdAndType: vi.fn().mockResolvedValue([]),
|
|
||||||
countUnreadByRecipientId: vi.fn()
|
|
||||||
.mockResolvedValueOnce(1)
|
|
||||||
.mockResolvedValueOnce(1)
|
|
||||||
.mockResolvedValueOnce(0),
|
|
||||||
create: vi.fn().mockResolvedValue(undefined),
|
|
||||||
update: vi.fn().mockResolvedValue(undefined),
|
|
||||||
delete: vi.fn().mockResolvedValue(undefined),
|
|
||||||
deleteAllByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
markAllAsReadByRecipientId: vi.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create notification
|
|
||||||
await mockRepository.create(notification);
|
|
||||||
expect(mockRepository.create).toHaveBeenCalledWith(notification);
|
|
||||||
|
|
||||||
// Find notification
|
|
||||||
const found = await mockRepository.findById('notification-1');
|
|
||||||
expect(found).toBe(notification);
|
|
||||||
|
|
||||||
// Update notification
|
|
||||||
await mockRepository.update(updatedNotification);
|
|
||||||
expect(mockRepository.update).toHaveBeenCalledWith(updatedNotification);
|
|
||||||
|
|
||||||
// Verify update
|
|
||||||
const updatedFound = await mockRepository.findById('notification-1');
|
|
||||||
expect(updatedFound).toBe(updatedNotification);
|
|
||||||
|
|
||||||
// Delete notification
|
|
||||||
await mockRepository.delete('notification-1');
|
|
||||||
expect(mockRepository.delete).toHaveBeenCalledWith('notification-1');
|
|
||||||
|
|
||||||
// Verify deletion
|
|
||||||
const deletedFound = await mockRepository.findById('notification-1');
|
|
||||||
expect(deletedFound).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,419 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import {
|
|
||||||
getChannelDisplayName,
|
|
||||||
isExternalChannel,
|
|
||||||
DEFAULT_ENABLED_CHANNELS,
|
|
||||||
ALL_CHANNELS,
|
|
||||||
getNotificationTypeTitle,
|
|
||||||
getNotificationTypePriority,
|
|
||||||
type NotificationChannel,
|
|
||||||
type NotificationType,
|
|
||||||
} from './NotificationTypes';
|
|
||||||
|
|
||||||
describe('NotificationTypes - Channel Functions', () => {
|
|
||||||
describe('getChannelDisplayName', () => {
|
|
||||||
it('returns correct display name for in_app channel', () => {
|
|
||||||
expect(getChannelDisplayName('in_app')).toBe('In-App');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct display name for email channel', () => {
|
|
||||||
expect(getChannelDisplayName('email')).toBe('Email');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct display name for discord channel', () => {
|
|
||||||
expect(getChannelDisplayName('discord')).toBe('Discord');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct display name for push channel', () => {
|
|
||||||
expect(getChannelDisplayName('push')).toBe('Push Notification');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isExternalChannel', () => {
|
|
||||||
it('returns false for in_app channel', () => {
|
|
||||||
expect(isExternalChannel('in_app')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns true for email channel', () => {
|
|
||||||
expect(isExternalChannel('email')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns true for discord channel', () => {
|
|
||||||
expect(isExternalChannel('discord')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns true for push channel', () => {
|
|
||||||
expect(isExternalChannel('push')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DEFAULT_ENABLED_CHANNELS', () => {
|
|
||||||
it('contains only in_app channel', () => {
|
|
||||||
expect(DEFAULT_ENABLED_CHANNELS).toEqual(['in_app']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is an array', () => {
|
|
||||||
expect(Array.isArray(DEFAULT_ENABLED_CHANNELS)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ALL_CHANNELS', () => {
|
|
||||||
it('contains all notification channels', () => {
|
|
||||||
expect(ALL_CHANNELS).toEqual(['in_app', 'email', 'discord', 'push']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is an array', () => {
|
|
||||||
expect(Array.isArray(ALL_CHANNELS)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has correct length', () => {
|
|
||||||
expect(ALL_CHANNELS.length).toBe(4);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('NotificationTypes - Notification Type Functions', () => {
|
|
||||||
describe('getNotificationTypeTitle', () => {
|
|
||||||
it('returns correct title for protest_filed', () => {
|
|
||||||
expect(getNotificationTypeTitle('protest_filed')).toBe('Protest Filed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for protest_defense_requested', () => {
|
|
||||||
expect(getNotificationTypeTitle('protest_defense_requested')).toBe('Defense Requested');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for protest_defense_submitted', () => {
|
|
||||||
expect(getNotificationTypeTitle('protest_defense_submitted')).toBe('Defense Submitted');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for protest_comment_added', () => {
|
|
||||||
expect(getNotificationTypeTitle('protest_comment_added')).toBe('New Comment');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for protest_vote_required', () => {
|
|
||||||
expect(getNotificationTypeTitle('protest_vote_required')).toBe('Vote Required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for protest_vote_cast', () => {
|
|
||||||
expect(getNotificationTypeTitle('protest_vote_cast')).toBe('Vote Cast');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for protest_resolved', () => {
|
|
||||||
expect(getNotificationTypeTitle('protest_resolved')).toBe('Protest Resolved');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for penalty_issued', () => {
|
|
||||||
expect(getNotificationTypeTitle('penalty_issued')).toBe('Penalty Issued');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for penalty_appealed', () => {
|
|
||||||
expect(getNotificationTypeTitle('penalty_appealed')).toBe('Penalty Appealed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for penalty_appeal_resolved', () => {
|
|
||||||
expect(getNotificationTypeTitle('penalty_appeal_resolved')).toBe('Appeal Resolved');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for race_registration_open', () => {
|
|
||||||
expect(getNotificationTypeTitle('race_registration_open')).toBe('Registration Open');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for race_reminder', () => {
|
|
||||||
expect(getNotificationTypeTitle('race_reminder')).toBe('Race Reminder');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for race_results_posted', () => {
|
|
||||||
expect(getNotificationTypeTitle('race_results_posted')).toBe('Results Posted');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for race_performance_summary', () => {
|
|
||||||
expect(getNotificationTypeTitle('race_performance_summary')).toBe('Performance Summary');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for race_final_results', () => {
|
|
||||||
expect(getNotificationTypeTitle('race_final_results')).toBe('Final Results');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for league_invite', () => {
|
|
||||||
expect(getNotificationTypeTitle('league_invite')).toBe('League Invitation');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for league_join_request', () => {
|
|
||||||
expect(getNotificationTypeTitle('league_join_request')).toBe('Join Request');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for league_join_approved', () => {
|
|
||||||
expect(getNotificationTypeTitle('league_join_approved')).toBe('Request Approved');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for league_join_rejected', () => {
|
|
||||||
expect(getNotificationTypeTitle('league_join_rejected')).toBe('Request Rejected');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for league_role_changed', () => {
|
|
||||||
expect(getNotificationTypeTitle('league_role_changed')).toBe('Role Changed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for team_invite', () => {
|
|
||||||
expect(getNotificationTypeTitle('team_invite')).toBe('Team Invitation');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for team_join_request', () => {
|
|
||||||
expect(getNotificationTypeTitle('team_join_request')).toBe('Team Join Request');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for team_join_approved', () => {
|
|
||||||
expect(getNotificationTypeTitle('team_join_approved')).toBe('Team Request Approved');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for sponsorship_request_received', () => {
|
|
||||||
expect(getNotificationTypeTitle('sponsorship_request_received')).toBe('Sponsorship Request');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for sponsorship_request_accepted', () => {
|
|
||||||
expect(getNotificationTypeTitle('sponsorship_request_accepted')).toBe('Sponsorship Accepted');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for sponsorship_request_rejected', () => {
|
|
||||||
expect(getNotificationTypeTitle('sponsorship_request_rejected')).toBe('Sponsorship Rejected');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for sponsorship_request_withdrawn', () => {
|
|
||||||
expect(getNotificationTypeTitle('sponsorship_request_withdrawn')).toBe('Sponsorship Withdrawn');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for sponsorship_activated', () => {
|
|
||||||
expect(getNotificationTypeTitle('sponsorship_activated')).toBe('Sponsorship Active');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for sponsorship_payment_received', () => {
|
|
||||||
expect(getNotificationTypeTitle('sponsorship_payment_received')).toBe('Payment Received');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct title for system_announcement', () => {
|
|
||||||
expect(getNotificationTypeTitle('system_announcement')).toBe('Announcement');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getNotificationTypePriority', () => {
|
|
||||||
it('returns correct priority for protest_filed', () => {
|
|
||||||
expect(getNotificationTypePriority('protest_filed')).toBe(8);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for protest_defense_requested', () => {
|
|
||||||
expect(getNotificationTypePriority('protest_defense_requested')).toBe(9);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for protest_defense_submitted', () => {
|
|
||||||
expect(getNotificationTypePriority('protest_defense_submitted')).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for protest_comment_added', () => {
|
|
||||||
expect(getNotificationTypePriority('protest_comment_added')).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for protest_vote_required', () => {
|
|
||||||
expect(getNotificationTypePriority('protest_vote_required')).toBe(8);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for protest_vote_cast', () => {
|
|
||||||
expect(getNotificationTypePriority('protest_vote_cast')).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for protest_resolved', () => {
|
|
||||||
expect(getNotificationTypePriority('protest_resolved')).toBe(7);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for penalty_issued', () => {
|
|
||||||
expect(getNotificationTypePriority('penalty_issued')).toBe(9);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for penalty_appealed', () => {
|
|
||||||
expect(getNotificationTypePriority('penalty_appealed')).toBe(7);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for penalty_appeal_resolved', () => {
|
|
||||||
expect(getNotificationTypePriority('penalty_appeal_resolved')).toBe(7);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for race_registration_open', () => {
|
|
||||||
expect(getNotificationTypePriority('race_registration_open')).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for race_reminder', () => {
|
|
||||||
expect(getNotificationTypePriority('race_reminder')).toBe(8);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for race_results_posted', () => {
|
|
||||||
expect(getNotificationTypePriority('race_results_posted')).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for race_performance_summary', () => {
|
|
||||||
expect(getNotificationTypePriority('race_performance_summary')).toBe(9);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for race_final_results', () => {
|
|
||||||
expect(getNotificationTypePriority('race_final_results')).toBe(7);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for league_invite', () => {
|
|
||||||
expect(getNotificationTypePriority('league_invite')).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for league_join_request', () => {
|
|
||||||
expect(getNotificationTypePriority('league_join_request')).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for league_join_approved', () => {
|
|
||||||
expect(getNotificationTypePriority('league_join_approved')).toBe(7);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for league_join_rejected', () => {
|
|
||||||
expect(getNotificationTypePriority('league_join_rejected')).toBe(7);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for league_role_changed', () => {
|
|
||||||
expect(getNotificationTypePriority('league_role_changed')).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for team_invite', () => {
|
|
||||||
expect(getNotificationTypePriority('team_invite')).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for team_join_request', () => {
|
|
||||||
expect(getNotificationTypePriority('team_join_request')).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for team_join_approved', () => {
|
|
||||||
expect(getNotificationTypePriority('team_join_approved')).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for sponsorship_request_received', () => {
|
|
||||||
expect(getNotificationTypePriority('sponsorship_request_received')).toBe(7);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for sponsorship_request_accepted', () => {
|
|
||||||
expect(getNotificationTypePriority('sponsorship_request_accepted')).toBe(8);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for sponsorship_request_rejected', () => {
|
|
||||||
expect(getNotificationTypePriority('sponsorship_request_rejected')).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for sponsorship_request_withdrawn', () => {
|
|
||||||
expect(getNotificationTypePriority('sponsorship_request_withdrawn')).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for sponsorship_activated', () => {
|
|
||||||
expect(getNotificationTypePriority('sponsorship_activated')).toBe(7);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for sponsorship_payment_received', () => {
|
|
||||||
expect(getNotificationTypePriority('sponsorship_payment_received')).toBe(8);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns correct priority for system_announcement', () => {
|
|
||||||
expect(getNotificationTypePriority('system_announcement')).toBe(10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('NotificationTypes - Type Safety', () => {
|
|
||||||
it('ALL_CHANNELS contains all NotificationChannel values', () => {
|
|
||||||
const channels: NotificationChannel[] = ['in_app', 'email', 'discord', 'push'];
|
|
||||||
channels.forEach(channel => {
|
|
||||||
expect(ALL_CHANNELS).toContain(channel);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('DEFAULT_ENABLED_CHANNELS is a subset of ALL_CHANNELS', () => {
|
|
||||||
DEFAULT_ENABLED_CHANNELS.forEach(channel => {
|
|
||||||
expect(ALL_CHANNELS).toContain(channel);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('all notification types have titles', () => {
|
|
||||||
const types: NotificationType[] = [
|
|
||||||
'protest_filed',
|
|
||||||
'protest_defense_requested',
|
|
||||||
'protest_defense_submitted',
|
|
||||||
'protest_comment_added',
|
|
||||||
'protest_vote_required',
|
|
||||||
'protest_vote_cast',
|
|
||||||
'protest_resolved',
|
|
||||||
'penalty_issued',
|
|
||||||
'penalty_appealed',
|
|
||||||
'penalty_appeal_resolved',
|
|
||||||
'race_registration_open',
|
|
||||||
'race_reminder',
|
|
||||||
'race_results_posted',
|
|
||||||
'race_performance_summary',
|
|
||||||
'race_final_results',
|
|
||||||
'league_invite',
|
|
||||||
'league_join_request',
|
|
||||||
'league_join_approved',
|
|
||||||
'league_join_rejected',
|
|
||||||
'league_role_changed',
|
|
||||||
'team_invite',
|
|
||||||
'team_join_request',
|
|
||||||
'team_join_approved',
|
|
||||||
'sponsorship_request_received',
|
|
||||||
'sponsorship_request_accepted',
|
|
||||||
'sponsorship_request_rejected',
|
|
||||||
'sponsorship_request_withdrawn',
|
|
||||||
'sponsorship_activated',
|
|
||||||
'sponsorship_payment_received',
|
|
||||||
'system_announcement',
|
|
||||||
];
|
|
||||||
|
|
||||||
types.forEach(type => {
|
|
||||||
const title = getNotificationTypeTitle(type);
|
|
||||||
expect(title).toBeDefined();
|
|
||||||
expect(typeof title).toBe('string');
|
|
||||||
expect(title.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('all notification types have priorities', () => {
|
|
||||||
const types: NotificationType[] = [
|
|
||||||
'protest_filed',
|
|
||||||
'protest_defense_requested',
|
|
||||||
'protest_defense_submitted',
|
|
||||||
'protest_comment_added',
|
|
||||||
'protest_vote_required',
|
|
||||||
'protest_vote_cast',
|
|
||||||
'protest_resolved',
|
|
||||||
'penalty_issued',
|
|
||||||
'penalty_appealed',
|
|
||||||
'penalty_appeal_resolved',
|
|
||||||
'race_registration_open',
|
|
||||||
'race_reminder',
|
|
||||||
'race_results_posted',
|
|
||||||
'race_performance_summary',
|
|
||||||
'race_final_results',
|
|
||||||
'league_invite',
|
|
||||||
'league_join_request',
|
|
||||||
'league_join_approved',
|
|
||||||
'league_join_rejected',
|
|
||||||
'league_role_changed',
|
|
||||||
'team_invite',
|
|
||||||
'team_join_request',
|
|
||||||
'team_join_approved',
|
|
||||||
'sponsorship_request_received',
|
|
||||||
'sponsorship_request_accepted',
|
|
||||||
'sponsorship_request_rejected',
|
|
||||||
'sponsorship_request_withdrawn',
|
|
||||||
'sponsorship_activated',
|
|
||||||
'sponsorship_payment_received',
|
|
||||||
'system_announcement',
|
|
||||||
];
|
|
||||||
|
|
||||||
types.forEach(type => {
|
|
||||||
const priority = getNotificationTypePriority(type);
|
|
||||||
expect(priority).toBeDefined();
|
|
||||||
expect(typeof priority).toBe('number');
|
|
||||||
expect(priority).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(priority).toBeLessThanOrEqual(10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,174 +1,8 @@
|
|||||||
import {
|
import * as mod from '@core/payments/domain/entities/MemberPayment';
|
||||||
MemberPayment,
|
|
||||||
MemberPaymentStatus,
|
|
||||||
} from '@core/payments/domain/entities/MemberPayment';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
describe('payments/domain/entities/MemberPayment', () => {
|
describe('payments/domain/entities/MemberPayment.ts', () => {
|
||||||
describe('MemberPaymentStatus enum', () => {
|
it('imports', () => {
|
||||||
it('should have correct status values', () => {
|
expect(mod).toBeTruthy();
|
||||||
expect(MemberPaymentStatus.PENDING).toBe('pending');
|
|
||||||
expect(MemberPaymentStatus.PAID).toBe('paid');
|
|
||||||
expect(MemberPaymentStatus.OVERDUE).toBe('overdue');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('MemberPayment interface', () => {
|
|
||||||
it('should have all required properties', () => {
|
|
||||||
const payment: MemberPayment = {
|
|
||||||
id: 'payment-123',
|
|
||||||
feeId: 'fee-456',
|
|
||||||
driverId: 'driver-789',
|
|
||||||
amount: 100,
|
|
||||||
platformFee: 10,
|
|
||||||
netAmount: 90,
|
|
||||||
status: MemberPaymentStatus.PENDING,
|
|
||||||
dueDate: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(payment.id).toBe('payment-123');
|
|
||||||
expect(payment.feeId).toBe('fee-456');
|
|
||||||
expect(payment.driverId).toBe('driver-789');
|
|
||||||
expect(payment.amount).toBe(100);
|
|
||||||
expect(payment.platformFee).toBe(10);
|
|
||||||
expect(payment.netAmount).toBe(90);
|
|
||||||
expect(payment.status).toBe(MemberPaymentStatus.PENDING);
|
|
||||||
expect(payment.dueDate).toEqual(new Date('2024-01-01'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support optional paidAt property', () => {
|
|
||||||
const payment: MemberPayment = {
|
|
||||||
id: 'payment-123',
|
|
||||||
feeId: 'fee-456',
|
|
||||||
driverId: 'driver-789',
|
|
||||||
amount: 100,
|
|
||||||
platformFee: 10,
|
|
||||||
netAmount: 90,
|
|
||||||
status: MemberPaymentStatus.PAID,
|
|
||||||
dueDate: new Date('2024-01-01'),
|
|
||||||
paidAt: new Date('2024-01-15'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(payment.paidAt).toEqual(new Date('2024-01-15'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('MemberPayment.rehydrate', () => {
|
|
||||||
it('should rehydrate a MemberPayment from props', () => {
|
|
||||||
const props: MemberPayment = {
|
|
||||||
id: 'payment-123',
|
|
||||||
feeId: 'fee-456',
|
|
||||||
driverId: 'driver-789',
|
|
||||||
amount: 100,
|
|
||||||
platformFee: 10,
|
|
||||||
netAmount: 90,
|
|
||||||
status: MemberPaymentStatus.PENDING,
|
|
||||||
dueDate: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const rehydrated = MemberPayment.rehydrate(props);
|
|
||||||
|
|
||||||
expect(rehydrated).toEqual(props);
|
|
||||||
expect(rehydrated.id).toBe('payment-123');
|
|
||||||
expect(rehydrated.feeId).toBe('fee-456');
|
|
||||||
expect(rehydrated.driverId).toBe('driver-789');
|
|
||||||
expect(rehydrated.amount).toBe(100);
|
|
||||||
expect(rehydrated.platformFee).toBe(10);
|
|
||||||
expect(rehydrated.netAmount).toBe(90);
|
|
||||||
expect(rehydrated.status).toBe(MemberPaymentStatus.PENDING);
|
|
||||||
expect(rehydrated.dueDate).toEqual(new Date('2024-01-01'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve optional paidAt when rehydrating', () => {
|
|
||||||
const props: MemberPayment = {
|
|
||||||
id: 'payment-123',
|
|
||||||
feeId: 'fee-456',
|
|
||||||
driverId: 'driver-789',
|
|
||||||
amount: 100,
|
|
||||||
platformFee: 10,
|
|
||||||
netAmount: 90,
|
|
||||||
status: MemberPaymentStatus.PAID,
|
|
||||||
dueDate: new Date('2024-01-01'),
|
|
||||||
paidAt: new Date('2024-01-15'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const rehydrated = MemberPayment.rehydrate(props);
|
|
||||||
|
|
||||||
expect(rehydrated.paidAt).toEqual(new Date('2024-01-15'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Business rules and invariants', () => {
|
|
||||||
it('should calculate netAmount correctly (amount - platformFee)', () => {
|
|
||||||
const payment: MemberPayment = {
|
|
||||||
id: 'payment-123',
|
|
||||||
feeId: 'fee-456',
|
|
||||||
driverId: 'driver-789',
|
|
||||||
amount: 100,
|
|
||||||
platformFee: 10,
|
|
||||||
netAmount: 90,
|
|
||||||
status: MemberPaymentStatus.PENDING,
|
|
||||||
dueDate: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(payment.netAmount).toBe(payment.amount - payment.platformFee);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support different payment statuses', () => {
|
|
||||||
const pendingPayment: MemberPayment = {
|
|
||||||
id: 'payment-123',
|
|
||||||
feeId: 'fee-456',
|
|
||||||
driverId: 'driver-789',
|
|
||||||
amount: 100,
|
|
||||||
platformFee: 10,
|
|
||||||
netAmount: 90,
|
|
||||||
status: MemberPaymentStatus.PENDING,
|
|
||||||
dueDate: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const paidPayment: MemberPayment = {
|
|
||||||
id: 'payment-124',
|
|
||||||
feeId: 'fee-456',
|
|
||||||
driverId: 'driver-789',
|
|
||||||
amount: 100,
|
|
||||||
platformFee: 10,
|
|
||||||
netAmount: 90,
|
|
||||||
status: MemberPaymentStatus.PAID,
|
|
||||||
dueDate: new Date('2024-01-01'),
|
|
||||||
paidAt: new Date('2024-01-15'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const overduePayment: MemberPayment = {
|
|
||||||
id: 'payment-125',
|
|
||||||
feeId: 'fee-456',
|
|
||||||
driverId: 'driver-789',
|
|
||||||
amount: 100,
|
|
||||||
platformFee: 10,
|
|
||||||
netAmount: 90,
|
|
||||||
status: MemberPaymentStatus.OVERDUE,
|
|
||||||
dueDate: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(pendingPayment.status).toBe(MemberPaymentStatus.PENDING);
|
|
||||||
expect(paidPayment.status).toBe(MemberPaymentStatus.PAID);
|
|
||||||
expect(overduePayment.status).toBe(MemberPaymentStatus.OVERDUE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle zero and negative amounts', () => {
|
|
||||||
const zeroPayment: MemberPayment = {
|
|
||||||
id: 'payment-123',
|
|
||||||
feeId: 'fee-456',
|
|
||||||
driverId: 'driver-789',
|
|
||||||
amount: 0,
|
|
||||||
platformFee: 0,
|
|
||||||
netAmount: 0,
|
|
||||||
status: MemberPaymentStatus.PENDING,
|
|
||||||
dueDate: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(zeroPayment.amount).toBe(0);
|
|
||||||
expect(zeroPayment.platformFee).toBe(0);
|
|
||||||
expect(zeroPayment.netAmount).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,200 +1,8 @@
|
|||||||
import {
|
import * as mod from '@core/payments/domain/entities/MembershipFee';
|
||||||
MembershipFee,
|
|
||||||
MembershipFeeType,
|
|
||||||
} from '@core/payments/domain/entities/MembershipFee';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
describe('payments/domain/entities/MembershipFee', () => {
|
describe('payments/domain/entities/MembershipFee.ts', () => {
|
||||||
describe('MembershipFeeType enum', () => {
|
it('imports', () => {
|
||||||
it('should have correct fee type values', () => {
|
expect(mod).toBeTruthy();
|
||||||
expect(MembershipFeeType.SEASON).toBe('season');
|
|
||||||
expect(MembershipFeeType.MONTHLY).toBe('monthly');
|
|
||||||
expect(MembershipFeeType.PER_RACE).toBe('per_race');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('MembershipFee interface', () => {
|
|
||||||
it('should have all required properties', () => {
|
|
||||||
const fee: MembershipFee = {
|
|
||||||
id: 'fee-123',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
type: MembershipFeeType.SEASON,
|
|
||||||
amount: 100,
|
|
||||||
enabled: true,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
updatedAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(fee.id).toBe('fee-123');
|
|
||||||
expect(fee.leagueId).toBe('league-456');
|
|
||||||
expect(fee.type).toBe(MembershipFeeType.SEASON);
|
|
||||||
expect(fee.amount).toBe(100);
|
|
||||||
expect(fee.enabled).toBe(true);
|
|
||||||
expect(fee.createdAt).toEqual(new Date('2024-01-01'));
|
|
||||||
expect(fee.updatedAt).toEqual(new Date('2024-01-01'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support optional seasonId property', () => {
|
|
||||||
const fee: MembershipFee = {
|
|
||||||
id: 'fee-123',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
seasonId: 'season-789',
|
|
||||||
type: MembershipFeeType.SEASON,
|
|
||||||
amount: 100,
|
|
||||||
enabled: true,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
updatedAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(fee.seasonId).toBe('season-789');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('MembershipFee.rehydrate', () => {
|
|
||||||
it('should rehydrate a MembershipFee from props', () => {
|
|
||||||
const props: MembershipFee = {
|
|
||||||
id: 'fee-123',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
type: MembershipFeeType.SEASON,
|
|
||||||
amount: 100,
|
|
||||||
enabled: true,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
updatedAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const rehydrated = MembershipFee.rehydrate(props);
|
|
||||||
|
|
||||||
expect(rehydrated).toEqual(props);
|
|
||||||
expect(rehydrated.id).toBe('fee-123');
|
|
||||||
expect(rehydrated.leagueId).toBe('league-456');
|
|
||||||
expect(rehydrated.type).toBe(MembershipFeeType.SEASON);
|
|
||||||
expect(rehydrated.amount).toBe(100);
|
|
||||||
expect(rehydrated.enabled).toBe(true);
|
|
||||||
expect(rehydrated.createdAt).toEqual(new Date('2024-01-01'));
|
|
||||||
expect(rehydrated.updatedAt).toEqual(new Date('2024-01-01'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve optional seasonId when rehydrating', () => {
|
|
||||||
const props: MembershipFee = {
|
|
||||||
id: 'fee-123',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
seasonId: 'season-789',
|
|
||||||
type: MembershipFeeType.SEASON,
|
|
||||||
amount: 100,
|
|
||||||
enabled: true,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
updatedAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const rehydrated = MembershipFee.rehydrate(props);
|
|
||||||
|
|
||||||
expect(rehydrated.seasonId).toBe('season-789');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Business rules and invariants', () => {
|
|
||||||
it('should support different fee types', () => {
|
|
||||||
const seasonFee: MembershipFee = {
|
|
||||||
id: 'fee-123',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
type: MembershipFeeType.SEASON,
|
|
||||||
amount: 100,
|
|
||||||
enabled: true,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
updatedAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const monthlyFee: MembershipFee = {
|
|
||||||
id: 'fee-124',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
type: MembershipFeeType.MONTHLY,
|
|
||||||
amount: 50,
|
|
||||||
enabled: true,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
updatedAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const perRaceFee: MembershipFee = {
|
|
||||||
id: 'fee-125',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
type: MembershipFeeType.PER_RACE,
|
|
||||||
amount: 10,
|
|
||||||
enabled: true,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
updatedAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(seasonFee.type).toBe(MembershipFeeType.SEASON);
|
|
||||||
expect(monthlyFee.type).toBe(MembershipFeeType.MONTHLY);
|
|
||||||
expect(perRaceFee.type).toBe(MembershipFeeType.PER_RACE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle enabled/disabled state', () => {
|
|
||||||
const enabledFee: MembershipFee = {
|
|
||||||
id: 'fee-123',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
type: MembershipFeeType.SEASON,
|
|
||||||
amount: 100,
|
|
||||||
enabled: true,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
updatedAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const disabledFee: MembershipFee = {
|
|
||||||
id: 'fee-124',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
type: MembershipFeeType.SEASON,
|
|
||||||
amount: 0,
|
|
||||||
enabled: false,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
updatedAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(enabledFee.enabled).toBe(true);
|
|
||||||
expect(disabledFee.enabled).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle zero and negative amounts', () => {
|
|
||||||
const zeroFee: MembershipFee = {
|
|
||||||
id: 'fee-123',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
type: MembershipFeeType.SEASON,
|
|
||||||
amount: 0,
|
|
||||||
enabled: false,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
updatedAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(zeroFee.amount).toBe(0);
|
|
||||||
expect(zeroFee.enabled).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle different league and season combinations', () => {
|
|
||||||
const leagueOnlyFee: MembershipFee = {
|
|
||||||
id: 'fee-123',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
type: MembershipFeeType.MONTHLY,
|
|
||||||
amount: 50,
|
|
||||||
enabled: true,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
updatedAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const leagueAndSeasonFee: MembershipFee = {
|
|
||||||
id: 'fee-124',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
seasonId: 'season-789',
|
|
||||||
type: MembershipFeeType.SEASON,
|
|
||||||
amount: 100,
|
|
||||||
enabled: true,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
updatedAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(leagueOnlyFee.leagueId).toBe('league-456');
|
|
||||||
expect(leagueOnlyFee.seasonId).toBeUndefined();
|
|
||||||
expect(leagueAndSeasonFee.leagueId).toBe('league-456');
|
|
||||||
expect(leagueAndSeasonFee.seasonId).toBe('season-789');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,311 +1,8 @@
|
|||||||
import {
|
import * as mod from '@core/payments/domain/entities/Payment';
|
||||||
Payment,
|
|
||||||
PaymentStatus,
|
|
||||||
PaymentType,
|
|
||||||
PayerType,
|
|
||||||
} from '@core/payments/domain/entities/Payment';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
describe('payments/domain/entities/Payment', () => {
|
describe('payments/domain/entities/Payment.ts', () => {
|
||||||
describe('PaymentType enum', () => {
|
it('imports', () => {
|
||||||
it('should have correct payment type values', () => {
|
expect(mod).toBeTruthy();
|
||||||
expect(PaymentType.SPONSORSHIP).toBe('sponsorship');
|
|
||||||
expect(PaymentType.MEMBERSHIP_FEE).toBe('membership_fee');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PayerType enum', () => {
|
|
||||||
it('should have correct payer type values', () => {
|
|
||||||
expect(PayerType.SPONSOR).toBe('sponsor');
|
|
||||||
expect(PayerType.DRIVER).toBe('driver');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('PaymentStatus enum', () => {
|
|
||||||
it('should have correct status values', () => {
|
|
||||||
expect(PaymentStatus.PENDING).toBe('pending');
|
|
||||||
expect(PaymentStatus.COMPLETED).toBe('completed');
|
|
||||||
expect(PaymentStatus.FAILED).toBe('failed');
|
|
||||||
expect(PaymentStatus.REFUNDED).toBe('refunded');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Payment interface', () => {
|
|
||||||
it('should have all required properties', () => {
|
|
||||||
const payment: Payment = {
|
|
||||||
id: 'payment-123',
|
|
||||||
type: PaymentType.SPONSORSHIP,
|
|
||||||
amount: 1000,
|
|
||||||
platformFee: 50,
|
|
||||||
netAmount: 950,
|
|
||||||
payerId: 'sponsor-456',
|
|
||||||
payerType: PayerType.SPONSOR,
|
|
||||||
leagueId: 'league-789',
|
|
||||||
status: PaymentStatus.PENDING,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(payment.id).toBe('payment-123');
|
|
||||||
expect(payment.type).toBe(PaymentType.SPONSORSHIP);
|
|
||||||
expect(payment.amount).toBe(1000);
|
|
||||||
expect(payment.platformFee).toBe(50);
|
|
||||||
expect(payment.netAmount).toBe(950);
|
|
||||||
expect(payment.payerId).toBe('sponsor-456');
|
|
||||||
expect(payment.payerType).toBe(PayerType.SPONSOR);
|
|
||||||
expect(payment.leagueId).toBe('league-789');
|
|
||||||
expect(payment.status).toBe(PaymentStatus.PENDING);
|
|
||||||
expect(payment.createdAt).toEqual(new Date('2024-01-01'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support optional seasonId property', () => {
|
|
||||||
const payment: Payment = {
|
|
||||||
id: 'payment-123',
|
|
||||||
type: PaymentType.MEMBERSHIP_FEE,
|
|
||||||
amount: 100,
|
|
||||||
platformFee: 5,
|
|
||||||
netAmount: 95,
|
|
||||||
payerId: 'driver-456',
|
|
||||||
payerType: PayerType.DRIVER,
|
|
||||||
leagueId: 'league-789',
|
|
||||||
seasonId: 'season-999',
|
|
||||||
status: PaymentStatus.COMPLETED,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
completedAt: new Date('2024-01-15'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(payment.seasonId).toBe('season-999');
|
|
||||||
expect(payment.completedAt).toEqual(new Date('2024-01-15'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support optional completedAt property', () => {
|
|
||||||
const payment: Payment = {
|
|
||||||
id: 'payment-123',
|
|
||||||
type: PaymentType.SPONSORSHIP,
|
|
||||||
amount: 1000,
|
|
||||||
platformFee: 50,
|
|
||||||
netAmount: 950,
|
|
||||||
payerId: 'sponsor-456',
|
|
||||||
payerType: PayerType.SPONSOR,
|
|
||||||
leagueId: 'league-789',
|
|
||||||
status: PaymentStatus.COMPLETED,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
completedAt: new Date('2024-01-15'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(payment.completedAt).toEqual(new Date('2024-01-15'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Payment.rehydrate', () => {
|
|
||||||
it('should rehydrate a Payment from props', () => {
|
|
||||||
const props: Payment = {
|
|
||||||
id: 'payment-123',
|
|
||||||
type: PaymentType.SPONSORSHIP,
|
|
||||||
amount: 1000,
|
|
||||||
platformFee: 50,
|
|
||||||
netAmount: 950,
|
|
||||||
payerId: 'sponsor-456',
|
|
||||||
payerType: PayerType.SPONSOR,
|
|
||||||
leagueId: 'league-789',
|
|
||||||
status: PaymentStatus.PENDING,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const rehydrated = Payment.rehydrate(props);
|
|
||||||
|
|
||||||
expect(rehydrated).toEqual(props);
|
|
||||||
expect(rehydrated.id).toBe('payment-123');
|
|
||||||
expect(rehydrated.type).toBe(PaymentType.SPONSORSHIP);
|
|
||||||
expect(rehydrated.amount).toBe(1000);
|
|
||||||
expect(rehydrated.platformFee).toBe(50);
|
|
||||||
expect(rehydrated.netAmount).toBe(950);
|
|
||||||
expect(rehydrated.payerId).toBe('sponsor-456');
|
|
||||||
expect(rehydrated.payerType).toBe(PayerType.SPONSOR);
|
|
||||||
expect(rehydrated.leagueId).toBe('league-789');
|
|
||||||
expect(rehydrated.status).toBe(PaymentStatus.PENDING);
|
|
||||||
expect(rehydrated.createdAt).toEqual(new Date('2024-01-01'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve optional seasonId when rehydrating', () => {
|
|
||||||
const props: Payment = {
|
|
||||||
id: 'payment-123',
|
|
||||||
type: PaymentType.MEMBERSHIP_FEE,
|
|
||||||
amount: 100,
|
|
||||||
platformFee: 5,
|
|
||||||
netAmount: 95,
|
|
||||||
payerId: 'driver-456',
|
|
||||||
payerType: PayerType.DRIVER,
|
|
||||||
leagueId: 'league-789',
|
|
||||||
seasonId: 'season-999',
|
|
||||||
status: PaymentStatus.COMPLETED,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
completedAt: new Date('2024-01-15'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const rehydrated = Payment.rehydrate(props);
|
|
||||||
|
|
||||||
expect(rehydrated.seasonId).toBe('season-999');
|
|
||||||
expect(rehydrated.completedAt).toEqual(new Date('2024-01-15'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Business rules and invariants', () => {
|
|
||||||
it('should calculate netAmount correctly (amount - platformFee)', () => {
|
|
||||||
const payment: Payment = {
|
|
||||||
id: 'payment-123',
|
|
||||||
type: PaymentType.SPONSORSHIP,
|
|
||||||
amount: 1000,
|
|
||||||
platformFee: 50,
|
|
||||||
netAmount: 950,
|
|
||||||
payerId: 'sponsor-456',
|
|
||||||
payerType: PayerType.SPONSOR,
|
|
||||||
leagueId: 'league-789',
|
|
||||||
status: PaymentStatus.PENDING,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(payment.netAmount).toBe(payment.amount - payment.platformFee);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support different payment types', () => {
|
|
||||||
const sponsorshipPayment: Payment = {
|
|
||||||
id: 'payment-123',
|
|
||||||
type: PaymentType.SPONSORSHIP,
|
|
||||||
amount: 1000,
|
|
||||||
platformFee: 50,
|
|
||||||
netAmount: 950,
|
|
||||||
payerId: 'sponsor-456',
|
|
||||||
payerType: PayerType.SPONSOR,
|
|
||||||
leagueId: 'league-789',
|
|
||||||
status: PaymentStatus.PENDING,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const membershipFeePayment: Payment = {
|
|
||||||
id: 'payment-124',
|
|
||||||
type: PaymentType.MEMBERSHIP_FEE,
|
|
||||||
amount: 100,
|
|
||||||
platformFee: 5,
|
|
||||||
netAmount: 95,
|
|
||||||
payerId: 'driver-456',
|
|
||||||
payerType: PayerType.DRIVER,
|
|
||||||
leagueId: 'league-789',
|
|
||||||
status: PaymentStatus.COMPLETED,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(sponsorshipPayment.type).toBe(PaymentType.SPONSORSHIP);
|
|
||||||
expect(membershipFeePayment.type).toBe(PaymentType.MEMBERSHIP_FEE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support different payer types', () => {
|
|
||||||
const sponsorPayment: Payment = {
|
|
||||||
id: 'payment-123',
|
|
||||||
type: PaymentType.SPONSORSHIP,
|
|
||||||
amount: 1000,
|
|
||||||
platformFee: 50,
|
|
||||||
netAmount: 950,
|
|
||||||
payerId: 'sponsor-456',
|
|
||||||
payerType: PayerType.SPONSOR,
|
|
||||||
leagueId: 'league-789',
|
|
||||||
status: PaymentStatus.PENDING,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const driverPayment: Payment = {
|
|
||||||
id: 'payment-124',
|
|
||||||
type: PaymentType.MEMBERSHIP_FEE,
|
|
||||||
amount: 100,
|
|
||||||
platformFee: 5,
|
|
||||||
netAmount: 95,
|
|
||||||
payerId: 'driver-456',
|
|
||||||
payerType: PayerType.DRIVER,
|
|
||||||
leagueId: 'league-789',
|
|
||||||
status: PaymentStatus.COMPLETED,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(sponsorPayment.payerType).toBe(PayerType.SPONSOR);
|
|
||||||
expect(driverPayment.payerType).toBe(PayerType.DRIVER);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support different payment statuses', () => {
|
|
||||||
const pendingPayment: Payment = {
|
|
||||||
id: 'payment-123',
|
|
||||||
type: PaymentType.SPONSORSHIP,
|
|
||||||
amount: 1000,
|
|
||||||
platformFee: 50,
|
|
||||||
netAmount: 950,
|
|
||||||
payerId: 'sponsor-456',
|
|
||||||
payerType: PayerType.SPONSOR,
|
|
||||||
leagueId: 'league-789',
|
|
||||||
status: PaymentStatus.PENDING,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const completedPayment: Payment = {
|
|
||||||
id: 'payment-124',
|
|
||||||
type: PaymentType.SPONSORSHIP,
|
|
||||||
amount: 1000,
|
|
||||||
platformFee: 50,
|
|
||||||
netAmount: 950,
|
|
||||||
payerId: 'sponsor-456',
|
|
||||||
payerType: PayerType.SPONSOR,
|
|
||||||
leagueId: 'league-789',
|
|
||||||
status: PaymentStatus.COMPLETED,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
completedAt: new Date('2024-01-15'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const failedPayment: Payment = {
|
|
||||||
id: 'payment-125',
|
|
||||||
type: PaymentType.SPONSORSHIP,
|
|
||||||
amount: 1000,
|
|
||||||
platformFee: 50,
|
|
||||||
netAmount: 950,
|
|
||||||
payerId: 'sponsor-456',
|
|
||||||
payerType: PayerType.SPONSOR,
|
|
||||||
leagueId: 'league-789',
|
|
||||||
status: PaymentStatus.FAILED,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const refundedPayment: Payment = {
|
|
||||||
id: 'payment-126',
|
|
||||||
type: PaymentType.SPONSORSHIP,
|
|
||||||
amount: 1000,
|
|
||||||
platformFee: 50,
|
|
||||||
netAmount: 950,
|
|
||||||
payerId: 'sponsor-456',
|
|
||||||
payerType: PayerType.SPONSOR,
|
|
||||||
leagueId: 'league-789',
|
|
||||||
status: PaymentStatus.REFUNDED,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(pendingPayment.status).toBe(PaymentStatus.PENDING);
|
|
||||||
expect(completedPayment.status).toBe(PaymentStatus.COMPLETED);
|
|
||||||
expect(failedPayment.status).toBe(PaymentStatus.FAILED);
|
|
||||||
expect(refundedPayment.status).toBe(PaymentStatus.REFUNDED);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle zero and negative amounts', () => {
|
|
||||||
const zeroPayment: Payment = {
|
|
||||||
id: 'payment-123',
|
|
||||||
type: PaymentType.SPONSORSHIP,
|
|
||||||
amount: 0,
|
|
||||||
platformFee: 0,
|
|
||||||
netAmount: 0,
|
|
||||||
payerId: 'sponsor-456',
|
|
||||||
payerType: PayerType.SPONSOR,
|
|
||||||
leagueId: 'league-789',
|
|
||||||
status: PaymentStatus.PENDING,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(zeroPayment.amount).toBe(0);
|
|
||||||
expect(zeroPayment.platformFee).toBe(0);
|
|
||||||
expect(zeroPayment.netAmount).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,298 +1,8 @@
|
|||||||
import { Prize, PrizeType } from '@core/payments/domain/entities/Prize';
|
import * as mod from '@core/payments/domain/entities/Prize';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
describe('payments/domain/entities/Prize', () => {
|
describe('payments/domain/entities/Prize.ts', () => {
|
||||||
describe('PrizeType enum', () => {
|
it('imports', () => {
|
||||||
it('should have correct prize type values', () => {
|
expect(mod).toBeTruthy();
|
||||||
expect(PrizeType.CASH).toBe('cash');
|
|
||||||
expect(PrizeType.MERCHANDISE).toBe('merchandise');
|
|
||||||
expect(PrizeType.OTHER).toBe('other');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Prize interface', () => {
|
|
||||||
it('should have all required properties', () => {
|
|
||||||
const prize: Prize = {
|
|
||||||
id: 'prize-123',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
seasonId: 'season-789',
|
|
||||||
position: 1,
|
|
||||||
name: 'Champion Prize',
|
|
||||||
amount: 1000,
|
|
||||||
type: PrizeType.CASH,
|
|
||||||
awarded: false,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(prize.id).toBe('prize-123');
|
|
||||||
expect(prize.leagueId).toBe('league-456');
|
|
||||||
expect(prize.seasonId).toBe('season-789');
|
|
||||||
expect(prize.position).toBe(1);
|
|
||||||
expect(prize.name).toBe('Champion Prize');
|
|
||||||
expect(prize.amount).toBe(1000);
|
|
||||||
expect(prize.type).toBe(PrizeType.CASH);
|
|
||||||
expect(prize.awarded).toBe(false);
|
|
||||||
expect(prize.createdAt).toEqual(new Date('2024-01-01'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support optional description property', () => {
|
|
||||||
const prize: Prize = {
|
|
||||||
id: 'prize-123',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
seasonId: 'season-789',
|
|
||||||
position: 1,
|
|
||||||
name: 'Champion Prize',
|
|
||||||
amount: 1000,
|
|
||||||
type: PrizeType.CASH,
|
|
||||||
description: 'Awarded to the champion of the season',
|
|
||||||
awarded: false,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(prize.description).toBe('Awarded to the champion of the season');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support optional awardedTo and awardedAt properties', () => {
|
|
||||||
const prize: Prize = {
|
|
||||||
id: 'prize-123',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
seasonId: 'season-789',
|
|
||||||
position: 1,
|
|
||||||
name: 'Champion Prize',
|
|
||||||
amount: 1000,
|
|
||||||
type: PrizeType.CASH,
|
|
||||||
awarded: true,
|
|
||||||
awardedTo: 'driver-999',
|
|
||||||
awardedAt: new Date('2024-06-01'),
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(prize.awardedTo).toBe('driver-999');
|
|
||||||
expect(prize.awardedAt).toEqual(new Date('2024-06-01'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Prize.rehydrate', () => {
|
|
||||||
it('should rehydrate a Prize from props', () => {
|
|
||||||
const props: Prize = {
|
|
||||||
id: 'prize-123',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
seasonId: 'season-789',
|
|
||||||
position: 1,
|
|
||||||
name: 'Champion Prize',
|
|
||||||
amount: 1000,
|
|
||||||
type: PrizeType.CASH,
|
|
||||||
awarded: false,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const rehydrated = Prize.rehydrate(props);
|
|
||||||
|
|
||||||
expect(rehydrated).toEqual(props);
|
|
||||||
expect(rehydrated.id).toBe('prize-123');
|
|
||||||
expect(rehydrated.leagueId).toBe('league-456');
|
|
||||||
expect(rehydrated.seasonId).toBe('season-789');
|
|
||||||
expect(rehydrated.position).toBe(1);
|
|
||||||
expect(rehydrated.name).toBe('Champion Prize');
|
|
||||||
expect(rehydrated.amount).toBe(1000);
|
|
||||||
expect(rehydrated.type).toBe(PrizeType.CASH);
|
|
||||||
expect(rehydrated.awarded).toBe(false);
|
|
||||||
expect(rehydrated.createdAt).toEqual(new Date('2024-01-01'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve optional description when rehydrating', () => {
|
|
||||||
const props: Prize = {
|
|
||||||
id: 'prize-123',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
seasonId: 'season-789',
|
|
||||||
position: 1,
|
|
||||||
name: 'Champion Prize',
|
|
||||||
amount: 1000,
|
|
||||||
type: PrizeType.CASH,
|
|
||||||
description: 'Awarded to the champion of the season',
|
|
||||||
awarded: false,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const rehydrated = Prize.rehydrate(props);
|
|
||||||
|
|
||||||
expect(rehydrated.description).toBe('Awarded to the champion of the season');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve optional awardedTo and awardedAt when rehydrating', () => {
|
|
||||||
const props: Prize = {
|
|
||||||
id: 'prize-123',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
seasonId: 'season-789',
|
|
||||||
position: 1,
|
|
||||||
name: 'Champion Prize',
|
|
||||||
amount: 1000,
|
|
||||||
type: PrizeType.CASH,
|
|
||||||
awarded: true,
|
|
||||||
awardedTo: 'driver-999',
|
|
||||||
awardedAt: new Date('2024-06-01'),
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const rehydrated = Prize.rehydrate(props);
|
|
||||||
|
|
||||||
expect(rehydrated.awardedTo).toBe('driver-999');
|
|
||||||
expect(rehydrated.awardedAt).toEqual(new Date('2024-06-01'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Business rules and invariants', () => {
|
|
||||||
it('should support different prize types', () => {
|
|
||||||
const cashPrize: Prize = {
|
|
||||||
id: 'prize-123',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
seasonId: 'season-789',
|
|
||||||
position: 1,
|
|
||||||
name: 'Champion Prize',
|
|
||||||
amount: 1000,
|
|
||||||
type: PrizeType.CASH,
|
|
||||||
awarded: false,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const merchandisePrize: Prize = {
|
|
||||||
id: 'prize-124',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
seasonId: 'season-789',
|
|
||||||
position: 2,
|
|
||||||
name: 'T-Shirt',
|
|
||||||
amount: 50,
|
|
||||||
type: PrizeType.MERCHANDISE,
|
|
||||||
awarded: false,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const otherPrize: Prize = {
|
|
||||||
id: 'prize-125',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
seasonId: 'season-789',
|
|
||||||
position: 3,
|
|
||||||
name: 'Special Recognition',
|
|
||||||
amount: 0,
|
|
||||||
type: PrizeType.OTHER,
|
|
||||||
awarded: false,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(cashPrize.type).toBe(PrizeType.CASH);
|
|
||||||
expect(merchandisePrize.type).toBe(PrizeType.MERCHANDISE);
|
|
||||||
expect(otherPrize.type).toBe(PrizeType.OTHER);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle awarded and unawarded prizes', () => {
|
|
||||||
const unawardedPrize: Prize = {
|
|
||||||
id: 'prize-123',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
seasonId: 'season-789',
|
|
||||||
position: 1,
|
|
||||||
name: 'Champion Prize',
|
|
||||||
amount: 1000,
|
|
||||||
type: PrizeType.CASH,
|
|
||||||
awarded: false,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const awardedPrize: Prize = {
|
|
||||||
id: 'prize-124',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
seasonId: 'season-789',
|
|
||||||
position: 1,
|
|
||||||
name: 'Champion Prize',
|
|
||||||
amount: 1000,
|
|
||||||
type: PrizeType.CASH,
|
|
||||||
awarded: true,
|
|
||||||
awardedTo: 'driver-999',
|
|
||||||
awardedAt: new Date('2024-06-01'),
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(unawardedPrize.awarded).toBe(false);
|
|
||||||
expect(unawardedPrize.awardedTo).toBeUndefined();
|
|
||||||
expect(unawardedPrize.awardedAt).toBeUndefined();
|
|
||||||
|
|
||||||
expect(awardedPrize.awarded).toBe(true);
|
|
||||||
expect(awardedPrize.awardedTo).toBe('driver-999');
|
|
||||||
expect(awardedPrize.awardedAt).toEqual(new Date('2024-06-01'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle different positions', () => {
|
|
||||||
const firstPlacePrize: Prize = {
|
|
||||||
id: 'prize-123',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
seasonId: 'season-789',
|
|
||||||
position: 1,
|
|
||||||
name: 'Champion Prize',
|
|
||||||
amount: 1000,
|
|
||||||
type: PrizeType.CASH,
|
|
||||||
awarded: false,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const secondPlacePrize: Prize = {
|
|
||||||
id: 'prize-124',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
seasonId: 'season-789',
|
|
||||||
position: 2,
|
|
||||||
name: 'Runner-Up Prize',
|
|
||||||
amount: 500,
|
|
||||||
type: PrizeType.CASH,
|
|
||||||
awarded: false,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const thirdPlacePrize: Prize = {
|
|
||||||
id: 'prize-125',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
seasonId: 'season-789',
|
|
||||||
position: 3,
|
|
||||||
name: 'Third Place Prize',
|
|
||||||
amount: 250,
|
|
||||||
type: PrizeType.CASH,
|
|
||||||
awarded: false,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(firstPlacePrize.position).toBe(1);
|
|
||||||
expect(secondPlacePrize.position).toBe(2);
|
|
||||||
expect(thirdPlacePrize.position).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle zero and negative amounts', () => {
|
|
||||||
const zeroPrize: Prize = {
|
|
||||||
id: 'prize-123',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
seasonId: 'season-789',
|
|
||||||
position: 1,
|
|
||||||
name: 'Participation Prize',
|
|
||||||
amount: 0,
|
|
||||||
type: PrizeType.OTHER,
|
|
||||||
awarded: false,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(zeroPrize.amount).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle different league and season combinations', () => {
|
|
||||||
const leagueOnlyPrize: Prize = {
|
|
||||||
id: 'prize-123',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
seasonId: 'season-789',
|
|
||||||
position: 1,
|
|
||||||
name: 'Champion Prize',
|
|
||||||
amount: 1000,
|
|
||||||
type: PrizeType.CASH,
|
|
||||||
awarded: false,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(leagueOnlyPrize.leagueId).toBe('league-456');
|
|
||||||
expect(leagueOnlyPrize.seasonId).toBe('season-789');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,284 +1,8 @@
|
|||||||
import {
|
import * as mod from '@core/payments/domain/entities/Wallet';
|
||||||
ReferenceType,
|
|
||||||
Transaction,
|
|
||||||
TransactionType,
|
|
||||||
Wallet,
|
|
||||||
} from '@core/payments/domain/entities/Wallet';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
describe('payments/domain/entities/Wallet', () => {
|
describe('payments/domain/entities/Wallet.ts', () => {
|
||||||
describe('TransactionType enum', () => {
|
it('imports', () => {
|
||||||
it('should have correct transaction type values', () => {
|
expect(mod).toBeTruthy();
|
||||||
expect(TransactionType.DEPOSIT).toBe('deposit');
|
|
||||||
expect(TransactionType.WITHDRAWAL).toBe('withdrawal');
|
|
||||||
expect(TransactionType.PLATFORM_FEE).toBe('platform_fee');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ReferenceType enum', () => {
|
|
||||||
it('should have correct reference type values', () => {
|
|
||||||
expect(ReferenceType.SPONSORSHIP).toBe('sponsorship');
|
|
||||||
expect(ReferenceType.MEMBERSHIP_FEE).toBe('membership_fee');
|
|
||||||
expect(ReferenceType.PRIZE).toBe('prize');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Wallet interface', () => {
|
|
||||||
it('should have all required properties', () => {
|
|
||||||
const wallet: Wallet = {
|
|
||||||
id: 'wallet-123',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
balance: 1000,
|
|
||||||
totalRevenue: 5000,
|
|
||||||
totalPlatformFees: 250,
|
|
||||||
totalWithdrawn: 3750,
|
|
||||||
currency: 'USD',
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(wallet.id).toBe('wallet-123');
|
|
||||||
expect(wallet.leagueId).toBe('league-456');
|
|
||||||
expect(wallet.balance).toBe(1000);
|
|
||||||
expect(wallet.totalRevenue).toBe(5000);
|
|
||||||
expect(wallet.totalPlatformFees).toBe(250);
|
|
||||||
expect(wallet.totalWithdrawn).toBe(3750);
|
|
||||||
expect(wallet.currency).toBe('USD');
|
|
||||||
expect(wallet.createdAt).toEqual(new Date('2024-01-01'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Wallet.rehydrate', () => {
|
|
||||||
it('should rehydrate a Wallet from props', () => {
|
|
||||||
const props: Wallet = {
|
|
||||||
id: 'wallet-123',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
balance: 1000,
|
|
||||||
totalRevenue: 5000,
|
|
||||||
totalPlatformFees: 250,
|
|
||||||
totalWithdrawn: 3750,
|
|
||||||
currency: 'USD',
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const rehydrated = Wallet.rehydrate(props);
|
|
||||||
|
|
||||||
expect(rehydrated).toEqual(props);
|
|
||||||
expect(rehydrated.id).toBe('wallet-123');
|
|
||||||
expect(rehydrated.leagueId).toBe('league-456');
|
|
||||||
expect(rehydrated.balance).toBe(1000);
|
|
||||||
expect(rehydrated.totalRevenue).toBe(5000);
|
|
||||||
expect(rehydrated.totalPlatformFees).toBe(250);
|
|
||||||
expect(rehydrated.totalWithdrawn).toBe(3750);
|
|
||||||
expect(rehydrated.currency).toBe('USD');
|
|
||||||
expect(rehydrated.createdAt).toEqual(new Date('2024-01-01'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Transaction interface', () => {
|
|
||||||
it('should have all required properties', () => {
|
|
||||||
const transaction: Transaction = {
|
|
||||||
id: 'txn-123',
|
|
||||||
walletId: 'wallet-456',
|
|
||||||
type: TransactionType.DEPOSIT,
|
|
||||||
amount: 1000,
|
|
||||||
description: 'Sponsorship payment',
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(transaction.id).toBe('txn-123');
|
|
||||||
expect(transaction.walletId).toBe('wallet-456');
|
|
||||||
expect(transaction.type).toBe(TransactionType.DEPOSIT);
|
|
||||||
expect(transaction.amount).toBe(1000);
|
|
||||||
expect(transaction.description).toBe('Sponsorship payment');
|
|
||||||
expect(transaction.createdAt).toEqual(new Date('2024-01-01'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support optional referenceId and referenceType properties', () => {
|
|
||||||
const transaction: Transaction = {
|
|
||||||
id: 'txn-123',
|
|
||||||
walletId: 'wallet-456',
|
|
||||||
type: TransactionType.DEPOSIT,
|
|
||||||
amount: 1000,
|
|
||||||
description: 'Sponsorship payment',
|
|
||||||
referenceId: 'payment-789',
|
|
||||||
referenceType: ReferenceType.SPONSORSHIP,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(transaction.referenceId).toBe('payment-789');
|
|
||||||
expect(transaction.referenceType).toBe(ReferenceType.SPONSORSHIP);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Transaction.rehydrate', () => {
|
|
||||||
it('should rehydrate a Transaction from props', () => {
|
|
||||||
const props: Transaction = {
|
|
||||||
id: 'txn-123',
|
|
||||||
walletId: 'wallet-456',
|
|
||||||
type: TransactionType.DEPOSIT,
|
|
||||||
amount: 1000,
|
|
||||||
description: 'Sponsorship payment',
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const rehydrated = Transaction.rehydrate(props);
|
|
||||||
|
|
||||||
expect(rehydrated).toEqual(props);
|
|
||||||
expect(rehydrated.id).toBe('txn-123');
|
|
||||||
expect(rehydrated.walletId).toBe('wallet-456');
|
|
||||||
expect(rehydrated.type).toBe(TransactionType.DEPOSIT);
|
|
||||||
expect(rehydrated.amount).toBe(1000);
|
|
||||||
expect(rehydrated.description).toBe('Sponsorship payment');
|
|
||||||
expect(rehydrated.createdAt).toEqual(new Date('2024-01-01'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve optional referenceId and referenceType when rehydrating', () => {
|
|
||||||
const props: Transaction = {
|
|
||||||
id: 'txn-123',
|
|
||||||
walletId: 'wallet-456',
|
|
||||||
type: TransactionType.DEPOSIT,
|
|
||||||
amount: 1000,
|
|
||||||
description: 'Sponsorship payment',
|
|
||||||
referenceId: 'payment-789',
|
|
||||||
referenceType: ReferenceType.SPONSORSHIP,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const rehydrated = Transaction.rehydrate(props);
|
|
||||||
|
|
||||||
expect(rehydrated.referenceId).toBe('payment-789');
|
|
||||||
expect(rehydrated.referenceType).toBe(ReferenceType.SPONSORSHIP);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Business rules and invariants', () => {
|
|
||||||
it('should calculate balance correctly', () => {
|
|
||||||
const wallet: Wallet = {
|
|
||||||
id: 'wallet-123',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
balance: 1000,
|
|
||||||
totalRevenue: 5000,
|
|
||||||
totalPlatformFees: 250,
|
|
||||||
totalWithdrawn: 3750,
|
|
||||||
currency: 'USD',
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Balance should be: totalRevenue - totalPlatformFees - totalWithdrawn
|
|
||||||
const expectedBalance = wallet.totalRevenue - wallet.totalPlatformFees - wallet.totalWithdrawn;
|
|
||||||
expect(wallet.balance).toBe(expectedBalance);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support different transaction types', () => {
|
|
||||||
const depositTransaction: Transaction = {
|
|
||||||
id: 'txn-123',
|
|
||||||
walletId: 'wallet-456',
|
|
||||||
type: TransactionType.DEPOSIT,
|
|
||||||
amount: 1000,
|
|
||||||
description: 'Sponsorship payment',
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const withdrawalTransaction: Transaction = {
|
|
||||||
id: 'txn-124',
|
|
||||||
walletId: 'wallet-456',
|
|
||||||
type: TransactionType.WITHDRAWAL,
|
|
||||||
amount: 500,
|
|
||||||
description: 'Withdrawal to bank',
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const platformFeeTransaction: Transaction = {
|
|
||||||
id: 'txn-125',
|
|
||||||
walletId: 'wallet-456',
|
|
||||||
type: TransactionType.PLATFORM_FEE,
|
|
||||||
amount: 50,
|
|
||||||
description: 'Platform fee deduction',
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(depositTransaction.type).toBe(TransactionType.DEPOSIT);
|
|
||||||
expect(withdrawalTransaction.type).toBe(TransactionType.WITHDRAWAL);
|
|
||||||
expect(platformFeeTransaction.type).toBe(TransactionType.PLATFORM_FEE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support different reference types', () => {
|
|
||||||
const sponsorshipTransaction: Transaction = {
|
|
||||||
id: 'txn-123',
|
|
||||||
walletId: 'wallet-456',
|
|
||||||
type: TransactionType.DEPOSIT,
|
|
||||||
amount: 1000,
|
|
||||||
description: 'Sponsorship payment',
|
|
||||||
referenceId: 'payment-789',
|
|
||||||
referenceType: ReferenceType.SPONSORSHIP,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const membershipFeeTransaction: Transaction = {
|
|
||||||
id: 'txn-124',
|
|
||||||
walletId: 'wallet-456',
|
|
||||||
type: TransactionType.DEPOSIT,
|
|
||||||
amount: 100,
|
|
||||||
description: 'Membership fee payment',
|
|
||||||
referenceId: 'payment-790',
|
|
||||||
referenceType: ReferenceType.MEMBERSHIP_FEE,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const prizeTransaction: Transaction = {
|
|
||||||
id: 'txn-125',
|
|
||||||
walletId: 'wallet-456',
|
|
||||||
type: TransactionType.WITHDRAWAL,
|
|
||||||
amount: 500,
|
|
||||||
description: 'Prize payout',
|
|
||||||
referenceId: 'prize-791',
|
|
||||||
referenceType: ReferenceType.PRIZE,
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(sponsorshipTransaction.referenceType).toBe(ReferenceType.SPONSORSHIP);
|
|
||||||
expect(membershipFeeTransaction.referenceType).toBe(ReferenceType.MEMBERSHIP_FEE);
|
|
||||||
expect(prizeTransaction.referenceType).toBe(ReferenceType.PRIZE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle zero and negative amounts', () => {
|
|
||||||
const zeroTransaction: Transaction = {
|
|
||||||
id: 'txn-123',
|
|
||||||
walletId: 'wallet-456',
|
|
||||||
type: TransactionType.DEPOSIT,
|
|
||||||
amount: 0,
|
|
||||||
description: 'Zero amount transaction',
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(zeroTransaction.amount).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle different currencies', () => {
|
|
||||||
const usdWallet: Wallet = {
|
|
||||||
id: 'wallet-123',
|
|
||||||
leagueId: 'league-456',
|
|
||||||
balance: 1000,
|
|
||||||
totalRevenue: 5000,
|
|
||||||
totalPlatformFees: 250,
|
|
||||||
totalWithdrawn: 3750,
|
|
||||||
currency: 'USD',
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const eurWallet: Wallet = {
|
|
||||||
id: 'wallet-124',
|
|
||||||
leagueId: 'league-457',
|
|
||||||
balance: 1000,
|
|
||||||
totalRevenue: 5000,
|
|
||||||
totalPlatformFees: 250,
|
|
||||||
totalWithdrawn: 3750,
|
|
||||||
currency: 'EUR',
|
|
||||||
createdAt: new Date('2024-01-01'),
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(usdWallet.currency).toBe('USD');
|
|
||||||
expect(eurWallet.currency).toBe('EUR');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,501 +0,0 @@
|
|||||||
/**
|
|
||||||
* Comprehensive Tests for MediaResolverPort
|
|
||||||
*
|
|
||||||
* Tests cover:
|
|
||||||
* - Interface contract compliance
|
|
||||||
* - ResolutionStrategies for all reference types
|
|
||||||
* - resolveWithDefaults helper function
|
|
||||||
* - isMediaResolverPort type guard
|
|
||||||
* - Edge cases and error handling
|
|
||||||
* - Business logic decisions
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import {
|
|
||||||
MediaResolverPort,
|
|
||||||
ResolutionStrategies,
|
|
||||||
resolveWithDefaults,
|
|
||||||
isMediaResolverPort,
|
|
||||||
} from './MediaResolverPort';
|
|
||||||
|
|
||||||
describe('MediaResolverPort - Comprehensive Tests', () => {
|
|
||||||
describe('Interface Contract Compliance', () => {
|
|
||||||
it('should define resolve method signature correctly', () => {
|
|
||||||
// Verify the interface has the correct method signature
|
|
||||||
const testInterface: MediaResolverPort = {
|
|
||||||
resolve: async (ref: MediaReference): Promise<string | null> => {
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(testInterface).toBeDefined();
|
|
||||||
expect(typeof testInterface.resolve).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept MediaReference and return Promise<string | null>', async () => {
|
|
||||||
const mockResolver: MediaResolverPort = {
|
|
||||||
resolve: async (ref: MediaReference): Promise<string | null> => {
|
|
||||||
// Verify ref is a MediaReference instance
|
|
||||||
expect(ref).toBeInstanceOf(MediaReference);
|
|
||||||
return '/test/path';
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const ref = MediaReference.createSystemDefault('avatar');
|
|
||||||
const result = await mockResolver.resolve(ref);
|
|
||||||
|
|
||||||
expect(result).toBe('/test/path');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ResolutionStrategies - System Default', () => {
|
|
||||||
it('should resolve system-default avatar without variant', () => {
|
|
||||||
const ref = MediaReference.createSystemDefault('avatar');
|
|
||||||
const result = ResolutionStrategies.systemDefault(ref);
|
|
||||||
|
|
||||||
expect(result).toBe('/media/default/neutral-default-avatar.png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should resolve system-default avatar with male variant', () => {
|
|
||||||
const ref = MediaReference.createSystemDefault('avatar', 'male');
|
|
||||||
const result = ResolutionStrategies.systemDefault(ref);
|
|
||||||
|
|
||||||
expect(result).toBe('/media/default/male-default-avatar.png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should resolve system-default avatar with female variant', () => {
|
|
||||||
const ref = MediaReference.createSystemDefault('avatar', 'female');
|
|
||||||
const result = ResolutionStrategies.systemDefault(ref);
|
|
||||||
|
|
||||||
expect(result).toBe('/media/default/female-default-avatar.png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should resolve system-default avatar with neutral variant', () => {
|
|
||||||
const ref = MediaReference.createSystemDefault('avatar', 'neutral');
|
|
||||||
const result = ResolutionStrategies.systemDefault(ref);
|
|
||||||
|
|
||||||
expect(result).toBe('/media/default/neutral-default-avatar.png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should resolve system-default logo', () => {
|
|
||||||
const ref = MediaReference.createSystemDefault('logo');
|
|
||||||
const result = ResolutionStrategies.systemDefault(ref);
|
|
||||||
|
|
||||||
expect(result).toBe('/media/default/logo.png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for non-system-default reference', () => {
|
|
||||||
const ref = MediaReference.createGenerated('team-123');
|
|
||||||
const result = ResolutionStrategies.systemDefault(ref);
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ResolutionStrategies - Generated', () => {
|
|
||||||
it('should resolve generated reference for team', () => {
|
|
||||||
const ref = MediaReference.createGenerated('team-123');
|
|
||||||
const result = ResolutionStrategies.generated(ref);
|
|
||||||
|
|
||||||
expect(result).toBe('/media/teams/123/logo');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should resolve generated reference for league', () => {
|
|
||||||
const ref = MediaReference.createGenerated('league-456');
|
|
||||||
const result = ResolutionStrategies.generated(ref);
|
|
||||||
|
|
||||||
expect(result).toBe('/media/leagues/456/logo');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should resolve generated reference for driver', () => {
|
|
||||||
const ref = MediaReference.createGenerated('driver-789');
|
|
||||||
const result = ResolutionStrategies.generated(ref);
|
|
||||||
|
|
||||||
expect(result).toBe('/media/avatar/789');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should resolve generated reference for unknown type', () => {
|
|
||||||
const ref = MediaReference.createGenerated('unknown-999');
|
|
||||||
const result = ResolutionStrategies.generated(ref);
|
|
||||||
|
|
||||||
expect(result).toBe('/media/generated/unknown/999');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for generated reference without generationRequestId', () => {
|
|
||||||
// Create a reference with missing generationRequestId
|
|
||||||
const ref = MediaReference.createGenerated('valid-id');
|
|
||||||
// Manually create an invalid reference
|
|
||||||
const invalidRef = { type: 'generated' } as MediaReference;
|
|
||||||
const result = ResolutionStrategies.generated(invalidRef);
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for non-generated reference', () => {
|
|
||||||
const ref = MediaReference.createSystemDefault('avatar');
|
|
||||||
const result = ResolutionStrategies.generated(ref);
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle generated reference with special characters in ID', () => {
|
|
||||||
const ref = MediaReference.createGenerated('team-abc-123_XYZ');
|
|
||||||
const result = ResolutionStrategies.generated(ref);
|
|
||||||
|
|
||||||
expect(result).toBe('/media/teams/abc-123_XYZ/logo');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle generated reference with multiple hyphens', () => {
|
|
||||||
const ref = MediaReference.createGenerated('team-abc-def-123');
|
|
||||||
const result = ResolutionStrategies.generated(ref);
|
|
||||||
|
|
||||||
expect(result).toBe('/media/teams/abc-def-123/logo');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ResolutionStrategies - Uploaded', () => {
|
|
||||||
it('should resolve uploaded reference', () => {
|
|
||||||
const ref = MediaReference.createUploaded('media-456');
|
|
||||||
const result = ResolutionStrategies.uploaded(ref);
|
|
||||||
|
|
||||||
expect(result).toBe('/media/uploaded/media-456');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for uploaded reference without mediaId', () => {
|
|
||||||
// Create a reference with missing mediaId
|
|
||||||
const ref = MediaReference.createUploaded('valid-id');
|
|
||||||
// Manually create an invalid reference
|
|
||||||
const invalidRef = { type: 'uploaded' } as MediaReference;
|
|
||||||
const result = ResolutionStrategies.uploaded(invalidRef);
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for non-uploaded reference', () => {
|
|
||||||
const ref = MediaReference.createSystemDefault('avatar');
|
|
||||||
const result = ResolutionStrategies.uploaded(ref);
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle uploaded reference with special characters', () => {
|
|
||||||
const ref = MediaReference.createUploaded('media-abc-123_XYZ');
|
|
||||||
const result = ResolutionStrategies.uploaded(ref);
|
|
||||||
|
|
||||||
expect(result).toBe('/media/uploaded/media-abc-123_XYZ');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle uploaded reference with very long ID', () => {
|
|
||||||
const longId = 'a'.repeat(1000);
|
|
||||||
const ref = MediaReference.createUploaded(longId);
|
|
||||||
const result = ResolutionStrategies.uploaded(ref);
|
|
||||||
|
|
||||||
expect(result).toBe(`/media/uploaded/${longId}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ResolutionStrategies - None', () => {
|
|
||||||
it('should return null for none reference', () => {
|
|
||||||
const ref = MediaReference.createNone();
|
|
||||||
const result = ResolutionStrategies.none(ref);
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for any reference passed to none strategy', () => {
|
|
||||||
const ref = MediaReference.createSystemDefault('avatar');
|
|
||||||
const result = ResolutionStrategies.none(ref);
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('resolveWithDefaults - Integration Tests', () => {
|
|
||||||
it('should resolve system-default reference using resolveWithDefaults', () => {
|
|
||||||
const ref = MediaReference.createSystemDefault('avatar');
|
|
||||||
const result = resolveWithDefaults(ref);
|
|
||||||
|
|
||||||
expect(result).toBe('/media/default/neutral-default-avatar.png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should resolve system-default avatar with male variant using resolveWithDefaults', () => {
|
|
||||||
const ref = MediaReference.createSystemDefault('avatar', 'male');
|
|
||||||
const result = resolveWithDefaults(ref);
|
|
||||||
|
|
||||||
expect(result).toBe('/media/default/male-default-avatar.png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should resolve system-default logo using resolveWithDefaults', () => {
|
|
||||||
const ref = MediaReference.createSystemDefault('logo');
|
|
||||||
const result = resolveWithDefaults(ref);
|
|
||||||
|
|
||||||
expect(result).toBe('/media/default/logo.png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should resolve generated reference using resolveWithDefaults', () => {
|
|
||||||
const ref = MediaReference.createGenerated('team-123');
|
|
||||||
const result = resolveWithDefaults(ref);
|
|
||||||
|
|
||||||
expect(result).toBe('/media/teams/123/logo');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should resolve uploaded reference using resolveWithDefaults', () => {
|
|
||||||
const ref = MediaReference.createUploaded('media-456');
|
|
||||||
const result = resolveWithDefaults(ref);
|
|
||||||
|
|
||||||
expect(result).toBe('/media/uploaded/media-456');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should resolve none reference using resolveWithDefaults', () => {
|
|
||||||
const ref = MediaReference.createNone();
|
|
||||||
const result = resolveWithDefaults(ref);
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle all reference types in sequence', () => {
|
|
||||||
const refs = [
|
|
||||||
MediaReference.createSystemDefault('avatar'),
|
|
||||||
MediaReference.createSystemDefault('avatar', 'male'),
|
|
||||||
MediaReference.createSystemDefault('logo'),
|
|
||||||
MediaReference.createGenerated('team-123'),
|
|
||||||
MediaReference.createGenerated('league-456'),
|
|
||||||
MediaReference.createGenerated('driver-789'),
|
|
||||||
MediaReference.createUploaded('media-456'),
|
|
||||||
MediaReference.createNone(),
|
|
||||||
];
|
|
||||||
|
|
||||||
const results = refs.map(ref => resolveWithDefaults(ref));
|
|
||||||
|
|
||||||
expect(results).toEqual([
|
|
||||||
'/media/default/neutral-default-avatar.png',
|
|
||||||
'/media/default/male-default-avatar.png',
|
|
||||||
'/media/default/logo.png',
|
|
||||||
'/media/teams/123/logo',
|
|
||||||
'/media/leagues/456/logo',
|
|
||||||
'/media/avatar/789',
|
|
||||||
'/media/uploaded/media-456',
|
|
||||||
null,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isMediaResolverPort Type Guard', () => {
|
|
||||||
it('should return true for valid MediaResolverPort implementation', () => {
|
|
||||||
const validResolver: MediaResolverPort = {
|
|
||||||
resolve: async (ref: MediaReference): Promise<string | null> => {
|
|
||||||
return '/test/path';
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(isMediaResolverPort(validResolver)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for null', () => {
|
|
||||||
expect(isMediaResolverPort(null)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for undefined', () => {
|
|
||||||
expect(isMediaResolverPort(undefined)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for non-object', () => {
|
|
||||||
expect(isMediaResolverPort('string')).toBe(false);
|
|
||||||
expect(isMediaResolverPort(123)).toBe(false);
|
|
||||||
expect(isMediaResolverPort(true)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for object without resolve method', () => {
|
|
||||||
const invalidResolver = {
|
|
||||||
someOtherMethod: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(isMediaResolverPort(invalidResolver)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for object with resolve property but not a function', () => {
|
|
||||||
const invalidResolver = {
|
|
||||||
resolve: 'not a function',
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(isMediaResolverPort(invalidResolver)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for object with resolve as non-function property', () => {
|
|
||||||
const invalidResolver = {
|
|
||||||
resolve: 123,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(isMediaResolverPort(invalidResolver)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for object with resolve method and other properties', () => {
|
|
||||||
const validResolver = {
|
|
||||||
resolve: async (ref: MediaReference): Promise<string | null> => {
|
|
||||||
return '/test/path';
|
|
||||||
},
|
|
||||||
extraProperty: 'value',
|
|
||||||
anotherMethod: () => {},
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(isMediaResolverPort(validResolver)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Business Logic Decisions', () => {
|
|
||||||
it('should make correct decision for system-default avatar without variant', () => {
|
|
||||||
const ref = MediaReference.createSystemDefault('avatar');
|
|
||||||
const result = resolveWithDefaults(ref);
|
|
||||||
|
|
||||||
// Decision: Should use neutral default avatar
|
|
||||||
expect(result).toBe('/media/default/neutral-default-avatar.png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should make correct decision for system-default avatar with specific variant', () => {
|
|
||||||
const ref = MediaReference.createSystemDefault('avatar', 'female');
|
|
||||||
const result = resolveWithDefaults(ref);
|
|
||||||
|
|
||||||
// Decision: Should use the specified variant
|
|
||||||
expect(result).toBe('/media/default/female-default-avatar.png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should make correct decision for generated team reference', () => {
|
|
||||||
const ref = MediaReference.createGenerated('team-123');
|
|
||||||
const result = resolveWithDefaults(ref);
|
|
||||||
|
|
||||||
// Decision: Should resolve to team logo path
|
|
||||||
expect(result).toBe('/media/teams/123/logo');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should make correct decision for generated league reference', () => {
|
|
||||||
const ref = MediaReference.createGenerated('league-456');
|
|
||||||
const result = resolveWithDefaults(ref);
|
|
||||||
|
|
||||||
// Decision: Should resolve to league logo path
|
|
||||||
expect(result).toBe('/media/leagues/456/logo');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should make correct decision for generated driver reference', () => {
|
|
||||||
const ref = MediaReference.createGenerated('driver-789');
|
|
||||||
const result = resolveWithDefaults(ref);
|
|
||||||
|
|
||||||
// Decision: Should resolve to avatar path
|
|
||||||
expect(result).toBe('/media/avatar/789');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should make correct decision for uploaded reference', () => {
|
|
||||||
const ref = MediaReference.createUploaded('media-456');
|
|
||||||
const result = resolveWithDefaults(ref);
|
|
||||||
|
|
||||||
// Decision: Should resolve to uploaded media path
|
|
||||||
expect(result).toBe('/media/uploaded/media-456');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should make correct decision for none reference', () => {
|
|
||||||
const ref = MediaReference.createNone();
|
|
||||||
const result = resolveWithDefaults(ref);
|
|
||||||
|
|
||||||
// Decision: Should return null (no media)
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should make correct decision for unknown generated type', () => {
|
|
||||||
const ref = MediaReference.createGenerated('unknown-999');
|
|
||||||
const result = resolveWithDefaults(ref);
|
|
||||||
|
|
||||||
// Decision: Should fall back to generic generated path
|
|
||||||
expect(result).toBe('/media/generated/unknown/999');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Edge Cases and Error Handling', () => {
|
|
||||||
it('should handle empty string IDs gracefully', () => {
|
|
||||||
// MediaReference factory methods throw on empty strings
|
|
||||||
// This tests that the strategies handle invalid refs gracefully
|
|
||||||
const invalidRef = { type: 'generated' } as MediaReference;
|
|
||||||
const result = ResolutionStrategies.generated(invalidRef);
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle references with missing properties', () => {
|
|
||||||
const invalidRef = { type: 'uploaded' } as MediaReference;
|
|
||||||
const result = ResolutionStrategies.uploaded(invalidRef);
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle very long IDs without performance issues', () => {
|
|
||||||
const longId = 'a'.repeat(10000);
|
|
||||||
const ref = MediaReference.createUploaded(longId);
|
|
||||||
const result = resolveWithDefaults(ref);
|
|
||||||
|
|
||||||
expect(result).toBe(`/media/uploaded/${longId}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle Unicode characters in IDs', () => {
|
|
||||||
const ref = MediaReference.createUploaded('media-日本語-123');
|
|
||||||
const result = resolveWithDefaults(ref);
|
|
||||||
|
|
||||||
expect(result).toBe('/media/uploaded/media-日本語-123');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle special characters in generated IDs', () => {
|
|
||||||
const ref = MediaReference.createGenerated('team-abc_def-123');
|
|
||||||
const result = resolveWithDefaults(ref);
|
|
||||||
|
|
||||||
expect(result).toBe('/media/teams/abc_def-123/logo');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Path Format Consistency', () => {
|
|
||||||
it('should maintain consistent path format for system-default', () => {
|
|
||||||
const ref = MediaReference.createSystemDefault('avatar');
|
|
||||||
const result = resolveWithDefaults(ref);
|
|
||||||
|
|
||||||
// Should start with /media/default/
|
|
||||||
expect(result).toMatch(/^\/media\/default\//);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should maintain consistent path format for generated team', () => {
|
|
||||||
const ref = MediaReference.createGenerated('team-123');
|
|
||||||
const result = resolveWithDefaults(ref);
|
|
||||||
|
|
||||||
// Should start with /media/teams/
|
|
||||||
expect(result).toMatch(/^\/media\/teams\//);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should maintain consistent path format for generated league', () => {
|
|
||||||
const ref = MediaReference.createGenerated('league-456');
|
|
||||||
const result = resolveWithDefaults(ref);
|
|
||||||
|
|
||||||
// Should start with /media/leagues/
|
|
||||||
expect(result).toMatch(/^\/media\/leagues\//);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should maintain consistent path format for generated driver', () => {
|
|
||||||
const ref = MediaReference.createGenerated('driver-789');
|
|
||||||
const result = resolveWithDefaults(ref);
|
|
||||||
|
|
||||||
// Should start with /media/avatar/
|
|
||||||
expect(result).toMatch(/^\/media\/avatar\//);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should maintain consistent path format for uploaded', () => {
|
|
||||||
const ref = MediaReference.createUploaded('media-456');
|
|
||||||
const result = resolveWithDefaults(ref);
|
|
||||||
|
|
||||||
// Should start with /media/uploaded/
|
|
||||||
expect(result).toMatch(/^\/media\/uploaded\//);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should maintain consistent path format for unknown generated type', () => {
|
|
||||||
const ref = MediaReference.createGenerated('unknown-999');
|
|
||||||
const result = resolveWithDefaults(ref);
|
|
||||||
|
|
||||||
// Should start with /media/generated/
|
|
||||||
expect(result).toMatch(/^\/media\/generated\//);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
|
||||||
import { DriverStatsUseCase, type DriverStats } from './DriverStatsUseCase';
|
|
||||||
import type { ResultRepository } from '../../domain/repositories/ResultRepository';
|
|
||||||
import type { StandingRepository } from '../../domain/repositories/StandingRepository';
|
|
||||||
import type { DriverStatsRepository } from '../../domain/repositories/DriverStatsRepository';
|
|
||||||
import type { Logger } from '@core/shared/domain/Logger';
|
|
||||||
|
|
||||||
describe('DriverStatsUseCase', () => {
|
|
||||||
const mockResultRepository = {} as ResultRepository;
|
|
||||||
const mockStandingRepository = {} as StandingRepository;
|
|
||||||
const mockDriverStatsRepository = {
|
|
||||||
getDriverStats: vi.fn(),
|
|
||||||
} as unknown as DriverStatsRepository;
|
|
||||||
const mockLogger = {
|
|
||||||
debug: vi.fn(),
|
|
||||||
} as unknown as Logger;
|
|
||||||
|
|
||||||
const useCase = new DriverStatsUseCase(
|
|
||||||
mockResultRepository,
|
|
||||||
mockStandingRepository,
|
|
||||||
mockDriverStatsRepository,
|
|
||||||
mockLogger
|
|
||||||
);
|
|
||||||
|
|
||||||
it('should return driver stats when found', async () => {
|
|
||||||
const mockStats: DriverStats = {
|
|
||||||
rating: 1500,
|
|
||||||
safetyRating: 4.5,
|
|
||||||
sportsmanshipRating: 4.8,
|
|
||||||
totalRaces: 10,
|
|
||||||
wins: 2,
|
|
||||||
podiums: 5,
|
|
||||||
dnfs: 0,
|
|
||||||
avgFinish: 3.5,
|
|
||||||
bestFinish: 1,
|
|
||||||
worstFinish: 8,
|
|
||||||
consistency: 0.9,
|
|
||||||
experienceLevel: 'Intermediate',
|
|
||||||
overallRank: 42,
|
|
||||||
};
|
|
||||||
vi.mocked(mockDriverStatsRepository.getDriverStats).mockResolvedValue(mockStats);
|
|
||||||
|
|
||||||
const result = await useCase.getDriverStats('driver-1');
|
|
||||||
|
|
||||||
expect(result).toEqual(mockStats);
|
|
||||||
expect(mockLogger.debug).toHaveBeenCalledWith('Getting stats for driver driver-1');
|
|
||||||
expect(mockDriverStatsRepository.getDriverStats).toHaveBeenCalledWith('driver-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null when stats are not found', async () => {
|
|
||||||
vi.mocked(mockDriverStatsRepository.getDriverStats).mockResolvedValue(null);
|
|
||||||
|
|
||||||
const result = await useCase.getDriverStats('non-existent');
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
|
||||||
import { GetDriverUseCase } from './GetDriverUseCase';
|
|
||||||
import { Result } from '@core/shared/domain/Result';
|
|
||||||
import type { DriverRepository } from '../../domain/repositories/DriverRepository';
|
|
||||||
import type { Driver } from '../../domain/entities/Driver';
|
|
||||||
|
|
||||||
describe('GetDriverUseCase', () => {
|
|
||||||
const mockDriverRepository = {
|
|
||||||
findById: vi.fn(),
|
|
||||||
} as unknown as DriverRepository;
|
|
||||||
|
|
||||||
const useCase = new GetDriverUseCase(mockDriverRepository);
|
|
||||||
|
|
||||||
it('should return a driver when found', async () => {
|
|
||||||
const mockDriver = { id: 'driver-1', name: 'John Doe' } as unknown as Driver;
|
|
||||||
vi.mocked(mockDriverRepository.findById).mockResolvedValue(mockDriver);
|
|
||||||
|
|
||||||
const result = await useCase.execute({ driverId: 'driver-1' });
|
|
||||||
|
|
||||||
expect(result.isOk()).toBe(true);
|
|
||||||
expect(result.unwrap()).toBe(mockDriver);
|
|
||||||
expect(mockDriverRepository.findById).toHaveBeenCalledWith('driver-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null when driver is not found', async () => {
|
|
||||||
vi.mocked(mockDriverRepository.findById).mockResolvedValue(null);
|
|
||||||
|
|
||||||
const result = await useCase.execute({ driverId: 'non-existent' });
|
|
||||||
|
|
||||||
expect(result.isOk()).toBe(true);
|
|
||||||
expect(result.unwrap()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return an error when repository throws', async () => {
|
|
||||||
const error = new Error('Repository error');
|
|
||||||
vi.mocked(mockDriverRepository.findById).mockRejectedValue(error);
|
|
||||||
|
|
||||||
const result = await useCase.execute({ driverId: 'driver-1' });
|
|
||||||
|
|
||||||
expect(result.isErr()).toBe(true);
|
|
||||||
expect(result.error).toBe(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
|
||||||
import { GetTeamsLeaderboardUseCase } from './GetTeamsLeaderboardUseCase';
|
|
||||||
import { Result } from '@core/shared/domain/Result';
|
|
||||||
import type { TeamRepository } from '../../domain/repositories/TeamRepository';
|
|
||||||
import type { TeamMembershipRepository } from '../../domain/repositories/TeamMembershipRepository';
|
|
||||||
import type { Logger } from '@core/shared/domain/Logger';
|
|
||||||
import type { Team } from '../../domain/entities/Team';
|
|
||||||
|
|
||||||
describe('GetTeamsLeaderboardUseCase', () => {
|
|
||||||
const mockTeamRepository = {
|
|
||||||
findAll: vi.fn(),
|
|
||||||
} as unknown as TeamRepository;
|
|
||||||
|
|
||||||
const mockTeamMembershipRepository = {
|
|
||||||
getTeamMembers: vi.fn(),
|
|
||||||
} as unknown as TeamMembershipRepository;
|
|
||||||
|
|
||||||
const mockGetDriverStats = vi.fn();
|
|
||||||
|
|
||||||
const mockLogger = {
|
|
||||||
error: vi.fn(),
|
|
||||||
} as unknown as Logger;
|
|
||||||
|
|
||||||
const useCase = new GetTeamsLeaderboardUseCase(
|
|
||||||
mockTeamRepository,
|
|
||||||
mockTeamMembershipRepository,
|
|
||||||
mockGetDriverStats,
|
|
||||||
mockLogger
|
|
||||||
);
|
|
||||||
|
|
||||||
it('should return teams leaderboard with calculated stats', async () => {
|
|
||||||
const mockTeam1 = { id: 'team-1', name: 'Team 1' } as unknown as Team;
|
|
||||||
const mockTeam2 = { id: 'team-2', name: 'Team 2' } as unknown as Team;
|
|
||||||
vi.mocked(mockTeamRepository.findAll).mockResolvedValue([mockTeam1, mockTeam2]);
|
|
||||||
|
|
||||||
vi.mocked(mockTeamMembershipRepository.getTeamMembers).mockImplementation(async (teamId) => {
|
|
||||||
if (teamId === 'team-1') return [{ driverId: 'driver-1' }, { driverId: 'driver-2' }] as any;
|
|
||||||
if (teamId === 'team-2') return [{ driverId: 'driver-3' }] as any;
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
|
|
||||||
mockGetDriverStats.mockImplementation((driverId) => {
|
|
||||||
if (driverId === 'driver-1') return { rating: 1000, wins: 1, totalRaces: 5 };
|
|
||||||
if (driverId === 'driver-2') return { rating: 2000, wins: 2, totalRaces: 10 };
|
|
||||||
if (driverId === 'driver-3') return { rating: 1500, wins: 0, totalRaces: 2 };
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await useCase.execute({ leagueId: 'league-1' });
|
|
||||||
|
|
||||||
expect(result.isOk()).toBe(true);
|
|
||||||
const data = result.unwrap();
|
|
||||||
expect(data.items).toHaveLength(2);
|
|
||||||
|
|
||||||
const item1 = data.items.find(i => i.team.id === 'team-1');
|
|
||||||
expect(item1?.rating).toBe(1500); // (1000 + 2000) / 2
|
|
||||||
expect(item1?.totalWins).toBe(3);
|
|
||||||
expect(item1?.totalRaces).toBe(15);
|
|
||||||
|
|
||||||
const item2 = data.items.find(i => i.team.id === 'team-2');
|
|
||||||
expect(item2?.rating).toBe(1500);
|
|
||||||
expect(item2?.totalWins).toBe(0);
|
|
||||||
expect(item2?.totalRaces).toBe(2);
|
|
||||||
|
|
||||||
expect(data.topItems).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle teams with no members', async () => {
|
|
||||||
const mockTeam = { id: 'team-empty', name: 'Empty Team' } as unknown as Team;
|
|
||||||
vi.mocked(mockTeamRepository.findAll).mockResolvedValue([mockTeam]);
|
|
||||||
vi.mocked(mockTeamMembershipRepository.getTeamMembers).mockResolvedValue([]);
|
|
||||||
|
|
||||||
const result = await useCase.execute({ leagueId: 'league-1' });
|
|
||||||
|
|
||||||
expect(result.isOk()).toBe(true);
|
|
||||||
const data = result.unwrap();
|
|
||||||
expect(data.items[0].rating).toBeNull();
|
|
||||||
expect(data.items[0].performanceLevel).toBe('beginner');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return error when repository fails', async () => {
|
|
||||||
vi.mocked(mockTeamRepository.findAll).mockRejectedValue(new Error('DB Error'));
|
|
||||||
|
|
||||||
const result = await useCase.execute({ leagueId: 'league-1' });
|
|
||||||
|
|
||||||
expect(result.isErr()).toBe(true);
|
|
||||||
expect(result.error.code).toBe('REPOSITORY_ERROR');
|
|
||||||
expect(mockLogger.error).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
|
||||||
import { RankingUseCase, type DriverRanking } from './RankingUseCase';
|
|
||||||
import type { StandingRepository } from '../../domain/repositories/StandingRepository';
|
|
||||||
import type { DriverRepository } from '../../domain/repositories/DriverRepository';
|
|
||||||
import type { DriverStatsRepository } from '../../domain/repositories/DriverStatsRepository';
|
|
||||||
import type { Logger } from '@core/shared/domain/Logger';
|
|
||||||
|
|
||||||
describe('RankingUseCase', () => {
|
|
||||||
const mockStandingRepository = {} as StandingRepository;
|
|
||||||
const mockDriverRepository = {} as DriverRepository;
|
|
||||||
const mockDriverStatsRepository = {
|
|
||||||
getAllStats: vi.fn(),
|
|
||||||
} as unknown as DriverStatsRepository;
|
|
||||||
const mockLogger = {
|
|
||||||
debug: vi.fn(),
|
|
||||||
} as unknown as Logger;
|
|
||||||
|
|
||||||
const useCase = new RankingUseCase(
|
|
||||||
mockStandingRepository,
|
|
||||||
mockDriverRepository,
|
|
||||||
mockDriverStatsRepository,
|
|
||||||
mockLogger
|
|
||||||
);
|
|
||||||
|
|
||||||
it('should return all driver rankings', async () => {
|
|
||||||
const mockStatsMap = new Map([
|
|
||||||
['driver-1', { rating: 1500, wins: 2, totalRaces: 10, overallRank: 1 }],
|
|
||||||
['driver-2', { rating: 1200, wins: 0, totalRaces: 5, overallRank: 2 }],
|
|
||||||
]);
|
|
||||||
vi.mocked(mockDriverStatsRepository.getAllStats).mockResolvedValue(mockStatsMap as any);
|
|
||||||
|
|
||||||
const result = await useCase.getAllDriverRankings();
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
|
||||||
expect(result).toContainEqual({
|
|
||||||
driverId: 'driver-1',
|
|
||||||
rating: 1500,
|
|
||||||
wins: 2,
|
|
||||||
totalRaces: 10,
|
|
||||||
overallRank: 1,
|
|
||||||
});
|
|
||||||
expect(result).toContainEqual({
|
|
||||||
driverId: 'driver-2',
|
|
||||||
rating: 1200,
|
|
||||||
wins: 0,
|
|
||||||
totalRaces: 5,
|
|
||||||
overallRank: 2,
|
|
||||||
});
|
|
||||||
expect(mockLogger.debug).toHaveBeenCalledWith('Getting all driver rankings');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return empty array when no stats exist', async () => {
|
|
||||||
vi.mocked(mockDriverStatsRepository.getAllStats).mockResolvedValue(new Map());
|
|
||||||
|
|
||||||
const result = await useCase.getAllDriverRankings();
|
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
|
||||||
import { RaceResultGenerator } from './RaceResultGenerator';
|
|
||||||
|
|
||||||
describe('RaceResultGenerator', () => {
|
|
||||||
it('should generate results for all drivers', () => {
|
|
||||||
const raceId = 'race-1';
|
|
||||||
const driverIds = ['d1', 'd2', 'd3'];
|
|
||||||
const driverRatings = new Map([
|
|
||||||
['d1', 2000],
|
|
||||||
['d2', 1500],
|
|
||||||
['d3', 1000],
|
|
||||||
]);
|
|
||||||
|
|
||||||
const results = RaceResultGenerator.generateRaceResults(raceId, driverIds, driverRatings);
|
|
||||||
|
|
||||||
expect(results).toHaveLength(3);
|
|
||||||
const resultDriverIds = results.map(r => r.driverId.toString());
|
|
||||||
expect(resultDriverIds).toContain('d1');
|
|
||||||
expect(resultDriverIds).toContain('d2');
|
|
||||||
expect(resultDriverIds).toContain('d3');
|
|
||||||
|
|
||||||
results.forEach(r => {
|
|
||||||
expect(r.raceId.toString()).toBe(raceId);
|
|
||||||
expect(r.position.toNumber()).toBeGreaterThan(0);
|
|
||||||
expect(r.position.toNumber()).toBeLessThanOrEqual(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should provide incident descriptions', () => {
|
|
||||||
expect(RaceResultGenerator.getIncidentDescription(0)).toBe('Clean race');
|
|
||||||
expect(RaceResultGenerator.getIncidentDescription(1)).toBe('Track limits violation');
|
|
||||||
expect(RaceResultGenerator.getIncidentDescription(2)).toBe('Contact with another car');
|
|
||||||
expect(RaceResultGenerator.getIncidentDescription(3)).toBe('Off-track incident');
|
|
||||||
expect(RaceResultGenerator.getIncidentDescription(4)).toBe('Collision requiring safety car');
|
|
||||||
expect(RaceResultGenerator.getIncidentDescription(5)).toBe('5 incidents');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should calculate incident penalty points', () => {
|
|
||||||
expect(RaceResultGenerator.getIncidentPenaltyPoints(0)).toBe(0);
|
|
||||||
expect(RaceResultGenerator.getIncidentPenaltyPoints(1)).toBe(0);
|
|
||||||
expect(RaceResultGenerator.getIncidentPenaltyPoints(2)).toBe(2);
|
|
||||||
expect(RaceResultGenerator.getIncidentPenaltyPoints(3)).toBe(4);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { RaceResultGeneratorWithIncidents } from './RaceResultGeneratorWithIncidents';
|
|
||||||
import { RaceIncidents } from '../../domain/value-objects/RaceIncidents';
|
|
||||||
|
|
||||||
describe('RaceResultGeneratorWithIncidents', () => {
|
|
||||||
it('should generate results for all drivers', () => {
|
|
||||||
const raceId = 'race-1';
|
|
||||||
const driverIds = ['d1', 'd2'];
|
|
||||||
const driverRatings = new Map([
|
|
||||||
['d1', 2000],
|
|
||||||
['d2', 1500],
|
|
||||||
]);
|
|
||||||
|
|
||||||
const results = RaceResultGeneratorWithIncidents.generateRaceResults(raceId, driverIds, driverRatings);
|
|
||||||
|
|
||||||
expect(results).toHaveLength(2);
|
|
||||||
results.forEach(r => {
|
|
||||||
expect(r.raceId.toString()).toBe(raceId);
|
|
||||||
expect(r.incidents).toBeInstanceOf(RaceIncidents);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should calculate incident penalty points', () => {
|
|
||||||
const incidents = new RaceIncidents([
|
|
||||||
{ type: 'contact', lap: 1, description: 'desc', penaltyPoints: 2 },
|
|
||||||
{ type: 'unsafe_rejoin', lap: 5, description: 'desc', penaltyPoints: 3 },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(RaceResultGeneratorWithIncidents.getIncidentPenaltyPoints(incidents)).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should get incident description', () => {
|
|
||||||
const incidents = new RaceIncidents([
|
|
||||||
{ type: 'contact', lap: 1, description: 'desc', penaltyPoints: 2 },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const description = RaceResultGeneratorWithIncidents.getIncidentDescription(incidents);
|
|
||||||
expect(description).toContain('1 incidents');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
|
||||||
import { ChampionshipAggregator } from './ChampionshipAggregator';
|
|
||||||
import type { DropScoreApplier } from './DropScoreApplier';
|
|
||||||
import { Points } from '../value-objects/Points';
|
|
||||||
|
|
||||||
describe('ChampionshipAggregator', () => {
|
|
||||||
const mockDropScoreApplier = {
|
|
||||||
apply: vi.fn(),
|
|
||||||
} as unknown as DropScoreApplier;
|
|
||||||
|
|
||||||
const aggregator = new ChampionshipAggregator(mockDropScoreApplier);
|
|
||||||
|
|
||||||
it('should aggregate points and sort standings by total points', () => {
|
|
||||||
const seasonId = 'season-1';
|
|
||||||
const championship = {
|
|
||||||
id: 'champ-1',
|
|
||||||
dropScorePolicy: { strategy: 'none' },
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
const eventPointsByEventId = {
|
|
||||||
'event-1': [
|
|
||||||
{
|
|
||||||
participant: { id: 'p1', type: 'driver' },
|
|
||||||
totalPoints: 10,
|
|
||||||
basePoints: 10,
|
|
||||||
bonusPoints: 0,
|
|
||||||
penaltyPoints: 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
participant: { id: 'p2', type: 'driver' },
|
|
||||||
totalPoints: 20,
|
|
||||||
basePoints: 20,
|
|
||||||
bonusPoints: 0,
|
|
||||||
penaltyPoints: 0
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'event-2': [
|
|
||||||
{
|
|
||||||
participant: { id: 'p1', type: 'driver' },
|
|
||||||
totalPoints: 15,
|
|
||||||
basePoints: 15,
|
|
||||||
bonusPoints: 0,
|
|
||||||
penaltyPoints: 0
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
vi.mocked(mockDropScoreApplier.apply).mockImplementation((policy, events) => {
|
|
||||||
const total = events.reduce((sum, e) => sum + e.points, 0);
|
|
||||||
return {
|
|
||||||
totalPoints: total,
|
|
||||||
counted: events,
|
|
||||||
dropped: [],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const standings = aggregator.aggregate({
|
|
||||||
seasonId,
|
|
||||||
championship,
|
|
||||||
eventPointsByEventId,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(standings).toHaveLength(2);
|
|
||||||
|
|
||||||
// p1 should be first (10 + 15 = 25 points)
|
|
||||||
expect(standings[0].participant.id).toBe('p1');
|
|
||||||
expect(standings[0].totalPoints.toNumber()).toBe(25);
|
|
||||||
expect(standings[0].position.toNumber()).toBe(1);
|
|
||||||
|
|
||||||
// p2 should be second (20 points)
|
|
||||||
expect(standings[1].participant.id).toBe('p2');
|
|
||||||
expect(standings[1].totalPoints.toNumber()).toBe(20);
|
|
||||||
expect(standings[1].position.toNumber()).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -59,7 +59,7 @@ export class ChampionshipAggregator {
|
|||||||
totalPoints,
|
totalPoints,
|
||||||
resultsCounted,
|
resultsCounted,
|
||||||
resultsDropped,
|
resultsDropped,
|
||||||
position: 1,
|
position: 0,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { SeasonScheduleGenerator } from './SeasonScheduleGenerator';
|
|
||||||
import { SeasonSchedule } from '../value-objects/SeasonSchedule';
|
|
||||||
import { RecurrenceStrategy } from '../value-objects/RecurrenceStrategy';
|
|
||||||
import { RaceTimeOfDay } from '../value-objects/RaceTimeOfDay';
|
|
||||||
import { WeekdaySet } from '../value-objects/WeekdaySet';
|
|
||||||
import { LeagueTimezone } from '../value-objects/LeagueTimezone';
|
|
||||||
import { MonthlyRecurrencePattern } from '../value-objects/MonthlyRecurrencePattern';
|
|
||||||
|
|
||||||
describe('SeasonScheduleGenerator', () => {
|
|
||||||
it('should generate weekly slots', () => {
|
|
||||||
const startDate = new Date(2024, 0, 1); // Monday, Jan 1st 2024
|
|
||||||
const schedule = new SeasonSchedule({
|
|
||||||
startDate,
|
|
||||||
plannedRounds: 4,
|
|
||||||
timeOfDay: new RaceTimeOfDay(20, 0),
|
|
||||||
timezone: LeagueTimezone.create('UTC'),
|
|
||||||
recurrence: RecurrenceStrategy.weekly(WeekdaySet.fromArray(['Mon'])),
|
|
||||||
});
|
|
||||||
|
|
||||||
const slots = SeasonScheduleGenerator.generateSlots(schedule);
|
|
||||||
|
|
||||||
expect(slots).toHaveLength(4);
|
|
||||||
expect(slots[0].roundNumber).toBe(1);
|
|
||||||
expect(slots[0].scheduledAt.getHours()).toBe(20);
|
|
||||||
expect(slots[0].scheduledAt.getMinutes()).toBe(0);
|
|
||||||
expect(slots[0].scheduledAt.getFullYear()).toBe(2024);
|
|
||||||
expect(slots[0].scheduledAt.getMonth()).toBe(0);
|
|
||||||
expect(slots[0].scheduledAt.getDate()).toBe(1);
|
|
||||||
|
|
||||||
expect(slots[1].roundNumber).toBe(2);
|
|
||||||
expect(slots[1].scheduledAt.getDate()).toBe(8);
|
|
||||||
expect(slots[2].roundNumber).toBe(3);
|
|
||||||
expect(slots[2].scheduledAt.getDate()).toBe(15);
|
|
||||||
expect(slots[3].roundNumber).toBe(4);
|
|
||||||
expect(slots[3].scheduledAt.getDate()).toBe(22);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should generate slots every 2 weeks', () => {
|
|
||||||
const startDate = new Date(2024, 0, 1);
|
|
||||||
const schedule = new SeasonSchedule({
|
|
||||||
startDate,
|
|
||||||
plannedRounds: 2,
|
|
||||||
timeOfDay: new RaceTimeOfDay(20, 0),
|
|
||||||
timezone: LeagueTimezone.create('UTC'),
|
|
||||||
recurrence: RecurrenceStrategy.everyNWeeks(2, WeekdaySet.fromArray(['Mon'])),
|
|
||||||
});
|
|
||||||
|
|
||||||
const slots = SeasonScheduleGenerator.generateSlots(schedule);
|
|
||||||
|
|
||||||
expect(slots).toHaveLength(2);
|
|
||||||
expect(slots[0].scheduledAt.getDate()).toBe(1);
|
|
||||||
expect(slots[1].scheduledAt.getDate()).toBe(15);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should generate monthly slots (nth weekday)', () => {
|
|
||||||
const startDate = new Date(2024, 0, 1);
|
|
||||||
const schedule = new SeasonSchedule({
|
|
||||||
startDate,
|
|
||||||
plannedRounds: 2,
|
|
||||||
timeOfDay: new RaceTimeOfDay(20, 0),
|
|
||||||
timezone: LeagueTimezone.create('UTC'),
|
|
||||||
recurrence: RecurrenceStrategy.monthlyNthWeekday(MonthlyRecurrencePattern.create(1, 'Mon')),
|
|
||||||
});
|
|
||||||
|
|
||||||
const slots = SeasonScheduleGenerator.generateSlots(schedule);
|
|
||||||
|
|
||||||
expect(slots).toHaveLength(2);
|
|
||||||
expect(slots[0].scheduledAt.getMonth()).toBe(0);
|
|
||||||
expect(slots[0].scheduledAt.getDate()).toBe(1);
|
|
||||||
expect(slots[1].scheduledAt.getMonth()).toBe(1);
|
|
||||||
expect(slots[1].scheduledAt.getDate()).toBe(5);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { SkillLevelService } from './SkillLevelService';
|
|
||||||
|
|
||||||
describe('SkillLevelService', () => {
|
|
||||||
describe('getSkillLevel', () => {
|
|
||||||
it('should return pro for rating >= 3000', () => {
|
|
||||||
expect(SkillLevelService.getSkillLevel(3000)).toBe('pro');
|
|
||||||
expect(SkillLevelService.getSkillLevel(5000)).toBe('pro');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return advanced for rating >= 2500 and < 3000', () => {
|
|
||||||
expect(SkillLevelService.getSkillLevel(2500)).toBe('advanced');
|
|
||||||
expect(SkillLevelService.getSkillLevel(2999)).toBe('advanced');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return intermediate for rating >= 1800 and < 2500', () => {
|
|
||||||
expect(SkillLevelService.getSkillLevel(1800)).toBe('intermediate');
|
|
||||||
expect(SkillLevelService.getSkillLevel(2499)).toBe('intermediate');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return beginner for rating < 1800', () => {
|
|
||||||
expect(SkillLevelService.getSkillLevel(1799)).toBe('beginner');
|
|
||||||
expect(SkillLevelService.getSkillLevel(500)).toBe('beginner');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getTeamPerformanceLevel', () => {
|
|
||||||
it('should return beginner for null rating', () => {
|
|
||||||
expect(SkillLevelService.getTeamPerformanceLevel(null)).toBe('beginner');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return pro for rating >= 4500', () => {
|
|
||||||
expect(SkillLevelService.getTeamPerformanceLevel(4500)).toBe('pro');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return advanced for rating >= 3000 and < 4500', () => {
|
|
||||||
expect(SkillLevelService.getTeamPerformanceLevel(3000)).toBe('advanced');
|
|
||||||
expect(SkillLevelService.getTeamPerformanceLevel(4499)).toBe('advanced');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return intermediate for rating >= 2000 and < 3000', () => {
|
|
||||||
expect(SkillLevelService.getTeamPerformanceLevel(2000)).toBe('intermediate');
|
|
||||||
expect(SkillLevelService.getTeamPerformanceLevel(2999)).toBe('intermediate');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return beginner for rating < 2000', () => {
|
|
||||||
expect(SkillLevelService.getTeamPerformanceLevel(1999)).toBe('beginner');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { AverageStrengthOfFieldCalculator } from './StrengthOfFieldCalculator';
|
|
||||||
|
|
||||||
describe('AverageStrengthOfFieldCalculator', () => {
|
|
||||||
const calculator = new AverageStrengthOfFieldCalculator();
|
|
||||||
|
|
||||||
it('should calculate average SOF and round it', () => {
|
|
||||||
const ratings = [
|
|
||||||
{ driverId: 'd1', rating: 1500 },
|
|
||||||
{ driverId: 'd2', rating: 2000 },
|
|
||||||
{ driverId: 'd3', rating: 1750 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const sof = calculator.calculate(ratings);
|
|
||||||
|
|
||||||
expect(sof).toBe(1750);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle rounding correctly', () => {
|
|
||||||
const ratings = [
|
|
||||||
{ driverId: 'd1', rating: 1000 },
|
|
||||||
{ driverId: 'd2', rating: 1001 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const sof = calculator.calculate(ratings);
|
|
||||||
|
|
||||||
expect(sof).toBe(1001); // (1000 + 1001) / 2 = 1000.5 -> 1001
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null for empty ratings', () => {
|
|
||||||
expect(calculator.calculate([])).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should filter out non-positive ratings', () => {
|
|
||||||
const ratings = [
|
|
||||||
{ driverId: 'd1', rating: 1500 },
|
|
||||||
{ driverId: 'd2', rating: 0 },
|
|
||||||
{ driverId: 'd3', rating: -100 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const sof = calculator.calculate(ratings);
|
|
||||||
|
|
||||||
expect(sof).toBe(1500);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null if all ratings are non-positive', () => {
|
|
||||||
const ratings = [
|
|
||||||
{ driverId: 'd1', rating: 0 },
|
|
||||||
{ driverId: 'd2', rating: -500 },
|
|
||||||
];
|
|
||||||
|
|
||||||
expect(calculator.calculate(ratings)).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,412 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { AsyncUseCase } from './AsyncUseCase';
|
|
||||||
import { Result } from '../domain/Result';
|
|
||||||
import { ApplicationErrorCode } from '../errors/ApplicationErrorCode';
|
|
||||||
|
|
||||||
describe('AsyncUseCase', () => {
|
|
||||||
describe('AsyncUseCase interface', () => {
|
|
||||||
it('should have execute method returning Promise<Result>', async () => {
|
|
||||||
// Concrete implementation for testing
|
|
||||||
class TestAsyncUseCase implements AsyncUseCase<{ id: string }, { data: string }, 'NOT_FOUND'> {
|
|
||||||
async execute(input: { id: string }): Promise<Result<{ data: string }, ApplicationErrorCode<'NOT_FOUND'>>> {
|
|
||||||
if (input.id === 'not-found') {
|
|
||||||
return Result.err({ code: 'NOT_FOUND' });
|
|
||||||
}
|
|
||||||
return Result.ok({ data: `Data for ${input.id}` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const useCase = new TestAsyncUseCase();
|
|
||||||
|
|
||||||
const successResult = await useCase.execute({ id: 'test-123' });
|
|
||||||
expect(successResult.isOk()).toBe(true);
|
|
||||||
expect(successResult.unwrap()).toEqual({ data: 'Data for test-123' });
|
|
||||||
|
|
||||||
const errorResult = await useCase.execute({ id: 'not-found' });
|
|
||||||
expect(errorResult.isErr()).toBe(true);
|
|
||||||
expect(errorResult.unwrapErr()).toEqual({ code: 'NOT_FOUND' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support different input types', async () => {
|
|
||||||
interface GetUserInput {
|
|
||||||
userId: string;
|
|
||||||
includeProfile?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserDTO {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
profile?: {
|
|
||||||
avatar: string;
|
|
||||||
bio: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type GetUserErrorCode = 'USER_NOT_FOUND' | 'PERMISSION_DENIED';
|
|
||||||
|
|
||||||
class GetUserUseCase implements AsyncUseCase<GetUserInput, UserDTO, GetUserErrorCode> {
|
|
||||||
async execute(input: GetUserInput): Promise<Result<UserDTO, ApplicationErrorCode<GetUserErrorCode>>> {
|
|
||||||
if (input.userId === 'not-found') {
|
|
||||||
return Result.err({ code: 'USER_NOT_FOUND' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.userId === 'no-permission') {
|
|
||||||
return Result.err({ code: 'PERMISSION_DENIED' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const user: UserDTO = {
|
|
||||||
id: input.userId,
|
|
||||||
name: 'John Doe',
|
|
||||||
email: 'john@example.com'
|
|
||||||
};
|
|
||||||
|
|
||||||
if (input.includeProfile) {
|
|
||||||
user.profile = {
|
|
||||||
avatar: 'avatar.jpg',
|
|
||||||
bio: 'Software developer'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.ok(user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const useCase = new GetUserUseCase();
|
|
||||||
|
|
||||||
// Success case with profile
|
|
||||||
const successWithProfile = await useCase.execute({
|
|
||||||
userId: 'user-123',
|
|
||||||
includeProfile: true
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(successWithProfile.isOk()).toBe(true);
|
|
||||||
const userWithProfile = successWithProfile.unwrap();
|
|
||||||
expect(userWithProfile.id).toBe('user-123');
|
|
||||||
expect(userWithProfile.profile).toBeDefined();
|
|
||||||
expect(userWithProfile.profile?.avatar).toBe('avatar.jpg');
|
|
||||||
|
|
||||||
// Success case without profile
|
|
||||||
const successWithoutProfile = await useCase.execute({
|
|
||||||
userId: 'user-456',
|
|
||||||
includeProfile: false
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(successWithoutProfile.isOk()).toBe(true);
|
|
||||||
const userWithoutProfile = successWithoutProfile.unwrap();
|
|
||||||
expect(userWithoutProfile.id).toBe('user-456');
|
|
||||||
expect(userWithoutProfile.profile).toBeUndefined();
|
|
||||||
|
|
||||||
// Error cases
|
|
||||||
const notFoundResult = await useCase.execute({ userId: 'not-found' });
|
|
||||||
expect(notFoundResult.isErr()).toBe(true);
|
|
||||||
expect(notFoundResult.unwrapErr()).toEqual({ code: 'USER_NOT_FOUND' });
|
|
||||||
|
|
||||||
const permissionResult = await useCase.execute({ userId: 'no-permission' });
|
|
||||||
expect(permissionResult.isErr()).toBe(true);
|
|
||||||
expect(permissionResult.unwrapErr()).toEqual({ code: 'PERMISSION_DENIED' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support complex query patterns', async () => {
|
|
||||||
interface SearchOrdersInput {
|
|
||||||
customerId?: string;
|
|
||||||
status?: 'pending' | 'completed' | 'cancelled';
|
|
||||||
dateRange?: { start: Date; end: Date };
|
|
||||||
page?: number;
|
|
||||||
limit?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrderDTO {
|
|
||||||
id: string;
|
|
||||||
customerId: string;
|
|
||||||
status: string;
|
|
||||||
total: number;
|
|
||||||
items: Array<{ productId: string; quantity: number; price: number }>;
|
|
||||||
createdAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrdersResult {
|
|
||||||
orders: OrderDTO[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
totalPages: number;
|
|
||||||
filters: SearchOrdersInput;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SearchOrdersErrorCode = 'INVALID_FILTERS' | 'NO_ORDERS_FOUND';
|
|
||||||
|
|
||||||
class SearchOrdersUseCase implements AsyncUseCase<SearchOrdersInput, OrdersResult, SearchOrdersErrorCode> {
|
|
||||||
async execute(input: SearchOrdersInput): Promise<Result<OrdersResult, ApplicationErrorCode<SearchOrdersErrorCode>>> {
|
|
||||||
// Validate at least one filter
|
|
||||||
if (!input.customerId && !input.status && !input.dateRange) {
|
|
||||||
return Result.err({ code: 'INVALID_FILTERS' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simulate database query
|
|
||||||
const allOrders: OrderDTO[] = [
|
|
||||||
{
|
|
||||||
id: 'order-1',
|
|
||||||
customerId: 'cust-1',
|
|
||||||
status: 'completed',
|
|
||||||
total: 150,
|
|
||||||
items: [{ productId: 'prod-1', quantity: 2, price: 75 }],
|
|
||||||
createdAt: new Date('2024-01-01')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'order-2',
|
|
||||||
customerId: 'cust-1',
|
|
||||||
status: 'pending',
|
|
||||||
total: 200,
|
|
||||||
items: [{ productId: 'prod-2', quantity: 1, price: 200 }],
|
|
||||||
createdAt: new Date('2024-01-02')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'order-3',
|
|
||||||
customerId: 'cust-2',
|
|
||||||
status: 'completed',
|
|
||||||
total: 300,
|
|
||||||
items: [{ productId: 'prod-3', quantity: 3, price: 100 }],
|
|
||||||
createdAt: new Date('2024-01-03')
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Apply filters
|
|
||||||
let filteredOrders = allOrders;
|
|
||||||
|
|
||||||
if (input.customerId) {
|
|
||||||
filteredOrders = filteredOrders.filter(o => o.customerId === input.customerId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.status) {
|
|
||||||
filteredOrders = filteredOrders.filter(o => o.status === input.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.dateRange) {
|
|
||||||
filteredOrders = filteredOrders.filter(o => {
|
|
||||||
const orderDate = o.createdAt.getTime();
|
|
||||||
return orderDate >= input.dateRange!.start.getTime() &&
|
|
||||||
orderDate <= input.dateRange!.end.getTime();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filteredOrders.length === 0) {
|
|
||||||
return Result.err({ code: 'NO_ORDERS_FOUND' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply pagination
|
|
||||||
const page = input.page || 1;
|
|
||||||
const limit = input.limit || 10;
|
|
||||||
const start = (page - 1) * limit;
|
|
||||||
const end = start + limit;
|
|
||||||
const paginatedOrders = filteredOrders.slice(start, end);
|
|
||||||
|
|
||||||
const result: OrdersResult = {
|
|
||||||
orders: paginatedOrders,
|
|
||||||
total: filteredOrders.length,
|
|
||||||
page,
|
|
||||||
totalPages: Math.ceil(filteredOrders.length / limit),
|
|
||||||
filters: input
|
|
||||||
};
|
|
||||||
|
|
||||||
return Result.ok(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const useCase = new SearchOrdersUseCase();
|
|
||||||
|
|
||||||
// Success case - filter by customer
|
|
||||||
const customerResult = await useCase.execute({ customerId: 'cust-1' });
|
|
||||||
expect(customerResult.isOk()).toBe(true);
|
|
||||||
const customerOrders = customerResult.unwrap();
|
|
||||||
expect(customerOrders.orders).toHaveLength(2);
|
|
||||||
expect(customerOrders.total).toBe(2);
|
|
||||||
|
|
||||||
// Success case - filter by status
|
|
||||||
const statusResult = await useCase.execute({ status: 'completed' });
|
|
||||||
expect(statusResult.isOk()).toBe(true);
|
|
||||||
const completedOrders = statusResult.unwrap();
|
|
||||||
expect(completedOrders.orders).toHaveLength(2);
|
|
||||||
expect(completedOrders.total).toBe(2);
|
|
||||||
|
|
||||||
// Success case - filter by date range
|
|
||||||
const dateResult = await useCase.execute({
|
|
||||||
dateRange: {
|
|
||||||
start: new Date('2024-01-01'),
|
|
||||||
end: new Date('2024-01-02')
|
|
||||||
}
|
|
||||||
});
|
|
||||||
expect(dateResult.isOk()).toBe(true);
|
|
||||||
const dateOrders = dateResult.unwrap();
|
|
||||||
expect(dateOrders.orders).toHaveLength(2);
|
|
||||||
expect(dateOrders.total).toBe(2);
|
|
||||||
|
|
||||||
// Error case - no filters
|
|
||||||
const noFiltersResult = await useCase.execute({});
|
|
||||||
expect(noFiltersResult.isErr()).toBe(true);
|
|
||||||
expect(noFiltersResult.unwrapErr()).toEqual({ code: 'INVALID_FILTERS' });
|
|
||||||
|
|
||||||
// Error case - no matching orders
|
|
||||||
const noOrdersResult = await useCase.execute({ customerId: 'nonexistent' });
|
|
||||||
expect(noOrdersResult.isErr()).toBe(true);
|
|
||||||
expect(noOrdersResult.unwrapErr()).toEqual({ code: 'NO_ORDERS_FOUND' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support async operations with delays', async () => {
|
|
||||||
interface ProcessBatchInput {
|
|
||||||
items: Array<{ id: string; data: string }>;
|
|
||||||
delayMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProcessBatchResult {
|
|
||||||
processed: number;
|
|
||||||
failed: number;
|
|
||||||
results: Array<{ id: string; status: 'success' | 'failed'; message?: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProcessBatchErrorCode = 'EMPTY_BATCH' | 'PROCESSING_ERROR';
|
|
||||||
|
|
||||||
class ProcessBatchUseCase implements AsyncUseCase<ProcessBatchInput, ProcessBatchResult, ProcessBatchErrorCode> {
|
|
||||||
async execute(input: ProcessBatchInput): Promise<Result<ProcessBatchResult, ApplicationErrorCode<ProcessBatchErrorCode>>> {
|
|
||||||
if (input.items.length === 0) {
|
|
||||||
return Result.err({ code: 'EMPTY_BATCH' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const delay = input.delayMs || 10;
|
|
||||||
const results: Array<{ id: string; status: 'success' | 'failed'; message?: string }> = [];
|
|
||||||
let processed = 0;
|
|
||||||
let failed = 0;
|
|
||||||
|
|
||||||
for (const item of input.items) {
|
|
||||||
// Simulate async processing with delay
|
|
||||||
await new Promise(resolve => setTimeout(resolve, delay));
|
|
||||||
|
|
||||||
// Simulate some failures
|
|
||||||
if (item.id === 'fail-1' || item.id === 'fail-2') {
|
|
||||||
results.push({ id: item.id, status: 'failed', message: 'Processing failed' });
|
|
||||||
failed++;
|
|
||||||
} else {
|
|
||||||
results.push({ id: item.id, status: 'success' });
|
|
||||||
processed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.ok({
|
|
||||||
processed,
|
|
||||||
failed,
|
|
||||||
results
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const useCase = new ProcessBatchUseCase();
|
|
||||||
|
|
||||||
// Success case
|
|
||||||
const successResult = await useCase.execute({
|
|
||||||
items: [
|
|
||||||
{ id: 'item-1', data: 'data1' },
|
|
||||||
{ id: 'item-2', data: 'data2' },
|
|
||||||
{ id: 'item-3', data: 'data3' }
|
|
||||||
],
|
|
||||||
delayMs: 5
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(successResult.isOk()).toBe(true);
|
|
||||||
const batchResult = successResult.unwrap();
|
|
||||||
expect(batchResult.processed).toBe(3);
|
|
||||||
expect(batchResult.failed).toBe(0);
|
|
||||||
expect(batchResult.results).toHaveLength(3);
|
|
||||||
|
|
||||||
// Mixed success/failure case
|
|
||||||
const mixedResult = await useCase.execute({
|
|
||||||
items: [
|
|
||||||
{ id: 'item-1', data: 'data1' },
|
|
||||||
{ id: 'fail-1', data: 'data2' },
|
|
||||||
{ id: 'item-3', data: 'data3' },
|
|
||||||
{ id: 'fail-2', data: 'data4' }
|
|
||||||
],
|
|
||||||
delayMs: 5
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mixedResult.isOk()).toBe(true);
|
|
||||||
const mixedBatchResult = mixedResult.unwrap();
|
|
||||||
expect(mixedBatchResult.processed).toBe(2);
|
|
||||||
expect(mixedBatchResult.failed).toBe(2);
|
|
||||||
expect(mixedBatchResult.results).toHaveLength(4);
|
|
||||||
|
|
||||||
// Error case - empty batch
|
|
||||||
const emptyResult = await useCase.execute({ items: [] });
|
|
||||||
expect(emptyResult.isErr()).toBe(true);
|
|
||||||
expect(emptyResult.unwrapErr()).toEqual({ code: 'EMPTY_BATCH' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support streaming-like operations', async () => {
|
|
||||||
interface StreamInput {
|
|
||||||
source: string;
|
|
||||||
chunkSize?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StreamResult {
|
|
||||||
chunks: string[];
|
|
||||||
totalSize: number;
|
|
||||||
source: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type StreamErrorCode = 'SOURCE_NOT_FOUND' | 'STREAM_ERROR';
|
|
||||||
|
|
||||||
class StreamUseCase implements AsyncUseCase<StreamInput, StreamResult, StreamErrorCode> {
|
|
||||||
async execute(input: StreamInput): Promise<Result<StreamResult, ApplicationErrorCode<StreamErrorCode>>> {
|
|
||||||
if (input.source === 'not-found') {
|
|
||||||
return Result.err({ code: 'SOURCE_NOT_FOUND' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.source === 'error') {
|
|
||||||
return Result.err({ code: 'STREAM_ERROR' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunkSize = input.chunkSize || 10;
|
|
||||||
const data = 'This is a test data stream that will be split into chunks';
|
|
||||||
const chunks: string[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < data.length; i += chunkSize) {
|
|
||||||
// Simulate async chunk reading
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1));
|
|
||||||
chunks.push(data.slice(i, i + chunkSize));
|
|
||||||
}
|
|
||||||
|
|
||||||
return Result.ok({
|
|
||||||
chunks,
|
|
||||||
totalSize: data.length,
|
|
||||||
source: input.source
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const useCase = new StreamUseCase();
|
|
||||||
|
|
||||||
// Success case with default chunk size
|
|
||||||
const defaultResult = await useCase.execute({ source: 'test-source' });
|
|
||||||
expect(defaultResult.isOk()).toBe(true);
|
|
||||||
const defaultStream = defaultResult.unwrap();
|
|
||||||
expect(defaultStream.chunks).toHaveLength(6);
|
|
||||||
expect(defaultStream.totalSize).toBe(57);
|
|
||||||
expect(defaultStream.source).toBe('test-source');
|
|
||||||
|
|
||||||
// Success case with custom chunk size
|
|
||||||
const customResult = await useCase.execute({ source: 'test-source', chunkSize: 15 });
|
|
||||||
expect(customResult.isOk()).toBe(true);
|
|
||||||
const customStream = customResult.unwrap();
|
|
||||||
expect(customStream.chunks).toHaveLength(4);
|
|
||||||
expect(customStream.totalSize).toBe(57);
|
|
||||||
|
|
||||||
// Error case - source not found
|
|
||||||
const notFoundResult = await useCase.execute({ source: 'not-found' });
|
|
||||||
expect(notFoundResult.isErr()).toBe(true);
|
|
||||||
expect(notFoundResult.unwrapErr()).toEqual({ code: 'SOURCE_NOT_FOUND' });
|
|
||||||
|
|
||||||
// Error case - stream error
|
|
||||||
const errorResult = await useCase.execute({ source: 'error' });
|
|
||||||
expect(errorResult.isErr()).toBe(true);
|
|
||||||
expect(errorResult.unwrapErr()).toEqual({ code: 'STREAM_ERROR' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,366 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { ErrorReporter } from './ErrorReporter';
|
|
||||||
|
|
||||||
describe('ErrorReporter', () => {
|
|
||||||
describe('ErrorReporter interface', () => {
|
|
||||||
it('should have report method', () => {
|
|
||||||
const errors: Array<{ error: Error; context?: unknown }> = [];
|
|
||||||
|
|
||||||
const reporter: ErrorReporter = {
|
|
||||||
report: (error: Error, context?: unknown) => {
|
|
||||||
errors.push({ error, context });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const testError = new Error('Test error');
|
|
||||||
reporter.report(testError, { userId: 123 });
|
|
||||||
|
|
||||||
expect(errors).toHaveLength(1);
|
|
||||||
expect(errors[0].error).toBe(testError);
|
|
||||||
expect(errors[0].context).toEqual({ userId: 123 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support reporting without context', () => {
|
|
||||||
const errors: Error[] = [];
|
|
||||||
|
|
||||||
const reporter: ErrorReporter = {
|
|
||||||
report: (error: Error) => {
|
|
||||||
errors.push(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const testError = new Error('Test error');
|
|
||||||
reporter.report(testError);
|
|
||||||
|
|
||||||
expect(errors).toHaveLength(1);
|
|
||||||
expect(errors[0]).toBe(testError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support different error types', () => {
|
|
||||||
const errors: Array<{ error: Error; context?: unknown }> = [];
|
|
||||||
|
|
||||||
const reporter: ErrorReporter = {
|
|
||||||
report: (error: Error, context?: unknown) => {
|
|
||||||
errors.push({ error, context });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Standard Error
|
|
||||||
const standardError = new Error('Standard error');
|
|
||||||
reporter.report(standardError, { type: 'standard' });
|
|
||||||
|
|
||||||
// Custom Error
|
|
||||||
class CustomError extends Error {
|
|
||||||
constructor(message: string, public code: string) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'CustomError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const customError = new CustomError('Custom error', 'CUSTOM_CODE');
|
|
||||||
reporter.report(customError, { type: 'custom' });
|
|
||||||
|
|
||||||
// TypeError
|
|
||||||
const typeError = new TypeError('Type error');
|
|
||||||
reporter.report(typeError, { type: 'type' });
|
|
||||||
|
|
||||||
expect(errors).toHaveLength(3);
|
|
||||||
expect(errors[0].error).toBe(standardError);
|
|
||||||
expect(errors[1].error).toBe(customError);
|
|
||||||
expect(errors[2].error).toBe(typeError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support complex context objects', () => {
|
|
||||||
const errors: Array<{ error: Error; context?: unknown }> = [];
|
|
||||||
|
|
||||||
const reporter: ErrorReporter = {
|
|
||||||
report: (error: Error, context?: unknown) => {
|
|
||||||
errors.push({ error, context });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const complexContext = {
|
|
||||||
user: {
|
|
||||||
id: 'user-123',
|
|
||||||
name: 'John Doe',
|
|
||||||
email: 'john@example.com'
|
|
||||||
},
|
|
||||||
request: {
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/users',
|
|
||||||
headers: {
|
|
||||||
'content-type': 'application/json',
|
|
||||||
'authorization': 'Bearer token'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
timestamp: new Date('2024-01-01T12:00:00Z'),
|
|
||||||
metadata: {
|
|
||||||
retryCount: 3,
|
|
||||||
timeout: 5000
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const error = new Error('Request failed');
|
|
||||||
reporter.report(error, complexContext);
|
|
||||||
|
|
||||||
expect(errors).toHaveLength(1);
|
|
||||||
expect(errors[0].error).toBe(error);
|
|
||||||
expect(errors[0].context).toEqual(complexContext);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ErrorReporter behavior', () => {
|
|
||||||
it('should support logging error with stack trace', () => {
|
|
||||||
const logs: Array<{ message: string; stack?: string; context?: unknown }> = [];
|
|
||||||
|
|
||||||
const reporter: ErrorReporter = {
|
|
||||||
report: (error: Error, context?: unknown) => {
|
|
||||||
logs.push({
|
|
||||||
message: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
context
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const error = new Error('Database connection failed');
|
|
||||||
reporter.report(error, { retryCount: 3 });
|
|
||||||
|
|
||||||
expect(logs).toHaveLength(1);
|
|
||||||
expect(logs[0].message).toBe('Database connection failed');
|
|
||||||
expect(logs[0].stack).toBeDefined();
|
|
||||||
expect(logs[0].context).toEqual({ retryCount: 3 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support error aggregation', () => {
|
|
||||||
const errorCounts: Record<string, number> = {};
|
|
||||||
|
|
||||||
const reporter: ErrorReporter = {
|
|
||||||
report: (error: Error) => {
|
|
||||||
const errorType = error.name || 'Unknown';
|
|
||||||
errorCounts[errorType] = (errorCounts[errorType] || 0) + 1;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const error1 = new Error('Error 1');
|
|
||||||
const error2 = new TypeError('Type error');
|
|
||||||
const error3 = new Error('Error 2');
|
|
||||||
const error4 = new TypeError('Another type error');
|
|
||||||
|
|
||||||
reporter.report(error1);
|
|
||||||
reporter.report(error2);
|
|
||||||
reporter.report(error3);
|
|
||||||
reporter.report(error4);
|
|
||||||
|
|
||||||
expect(errorCounts['Error']).toBe(2);
|
|
||||||
expect(errorCounts['TypeError']).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support error filtering', () => {
|
|
||||||
const criticalErrors: Error[] = [];
|
|
||||||
const warnings: Error[] = [];
|
|
||||||
|
|
||||||
const reporter: ErrorReporter = {
|
|
||||||
report: (error: Error, context?: unknown) => {
|
|
||||||
const isCritical = context && typeof context === 'object' && 'severity' in context &&
|
|
||||||
(context as { severity: string }).severity === 'critical';
|
|
||||||
|
|
||||||
if (isCritical) {
|
|
||||||
criticalErrors.push(error);
|
|
||||||
} else {
|
|
||||||
warnings.push(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const criticalError = new Error('Critical failure');
|
|
||||||
const warningError = new Error('Warning');
|
|
||||||
|
|
||||||
reporter.report(criticalError, { severity: 'critical' });
|
|
||||||
reporter.report(warningError, { severity: 'warning' });
|
|
||||||
|
|
||||||
expect(criticalErrors).toHaveLength(1);
|
|
||||||
expect(criticalErrors[0]).toBe(criticalError);
|
|
||||||
expect(warnings).toHaveLength(1);
|
|
||||||
expect(warnings[0]).toBe(warningError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support error enrichment', () => {
|
|
||||||
const enrichedErrors: Array<{ error: Error; enrichedContext: unknown }> = [];
|
|
||||||
|
|
||||||
const reporter: ErrorReporter = {
|
|
||||||
report: (error: Error, context?: unknown) => {
|
|
||||||
const enrichedContext: Record<string, unknown> = {
|
|
||||||
errorName: error.name,
|
|
||||||
errorMessage: error.message,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
environment: process.env.NODE_ENV || 'development'
|
|
||||||
};
|
|
||||||
|
|
||||||
if (context && typeof context === 'object') {
|
|
||||||
Object.assign(enrichedContext, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
enrichedErrors.push({ error, enrichedContext });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const error = new Error('Something went wrong');
|
|
||||||
reporter.report(error, { userId: 'user-123', action: 'login' });
|
|
||||||
|
|
||||||
expect(enrichedErrors).toHaveLength(1);
|
|
||||||
expect(enrichedErrors[0].error).toBe(error);
|
|
||||||
expect(enrichedErrors[0].enrichedContext).toMatchObject({
|
|
||||||
userId: 'user-123',
|
|
||||||
action: 'login',
|
|
||||||
errorName: 'Error',
|
|
||||||
errorMessage: 'Something went wrong',
|
|
||||||
environment: 'test'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support error deduplication', () => {
|
|
||||||
const uniqueErrors: Error[] = [];
|
|
||||||
const seenMessages = new Set<string>();
|
|
||||||
|
|
||||||
const reporter: ErrorReporter = {
|
|
||||||
report: (error: Error) => {
|
|
||||||
if (!seenMessages.has(error.message)) {
|
|
||||||
uniqueErrors.push(error);
|
|
||||||
seenMessages.add(error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const error1 = new Error('Duplicate error');
|
|
||||||
const error2 = new Error('Duplicate error');
|
|
||||||
const error3 = new Error('Unique error');
|
|
||||||
|
|
||||||
reporter.report(error1);
|
|
||||||
reporter.report(error2);
|
|
||||||
reporter.report(error3);
|
|
||||||
|
|
||||||
expect(uniqueErrors).toHaveLength(2);
|
|
||||||
expect(uniqueErrors[0].message).toBe('Duplicate error');
|
|
||||||
expect(uniqueErrors[1].message).toBe('Unique error');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support error rate limiting', () => {
|
|
||||||
const errors: Error[] = [];
|
|
||||||
let errorCount = 0;
|
|
||||||
const rateLimit = 5;
|
|
||||||
|
|
||||||
const reporter: ErrorReporter = {
|
|
||||||
report: (error: Error) => {
|
|
||||||
errorCount++;
|
|
||||||
if (errorCount <= rateLimit) {
|
|
||||||
errors.push(error);
|
|
||||||
}
|
|
||||||
// Silently drop errors beyond rate limit
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Report 10 errors
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
reporter.report(new Error(`Error ${i}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(errors).toHaveLength(rateLimit);
|
|
||||||
expect(errorCount).toBe(10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('ErrorReporter implementation patterns', () => {
|
|
||||||
it('should support console logger implementation', () => {
|
|
||||||
const consoleErrors: string[] = [];
|
|
||||||
const originalConsoleError = console.error;
|
|
||||||
|
|
||||||
// Mock console.error
|
|
||||||
console.error = (...args: unknown[]) => consoleErrors.push(args.join(' '));
|
|
||||||
|
|
||||||
const consoleReporter: ErrorReporter = {
|
|
||||||
report: (error: Error, context?: unknown) => {
|
|
||||||
console.error('Error:', error.message, 'Context:', context);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const error = new Error('Test error');
|
|
||||||
consoleReporter.report(error, { userId: 123 });
|
|
||||||
|
|
||||||
// Restore console.error
|
|
||||||
console.error = originalConsoleError;
|
|
||||||
|
|
||||||
expect(consoleErrors).toHaveLength(1);
|
|
||||||
expect(consoleErrors[0]).toContain('Error:');
|
|
||||||
expect(consoleErrors[0]).toContain('Test error');
|
|
||||||
expect(consoleErrors[0]).toContain('Context:');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support file logger implementation', () => {
|
|
||||||
const fileLogs: Array<{ timestamp: string; error: string; context?: unknown }> = [];
|
|
||||||
|
|
||||||
const fileReporter: ErrorReporter = {
|
|
||||||
report: (error: Error, context?: unknown) => {
|
|
||||||
fileLogs.push({
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
error: error.message,
|
|
||||||
context
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const error = new Error('File error');
|
|
||||||
fileReporter.report(error, { file: 'test.txt', line: 42 });
|
|
||||||
|
|
||||||
expect(fileLogs).toHaveLength(1);
|
|
||||||
expect(fileLogs[0].error).toBe('File error');
|
|
||||||
expect(fileLogs[0].context).toEqual({ file: 'test.txt', line: 42 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support remote reporter implementation', async () => {
|
|
||||||
const remoteErrors: Array<{ error: string; context?: unknown }> = [];
|
|
||||||
|
|
||||||
const remoteReporter: ErrorReporter = {
|
|
||||||
report: async (error: Error, context?: unknown) => {
|
|
||||||
remoteErrors.push({ error: error.message, context });
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const error = new Error('Remote error');
|
|
||||||
await remoteReporter.report(error, { endpoint: '/api/data' });
|
|
||||||
|
|
||||||
expect(remoteErrors).toHaveLength(1);
|
|
||||||
expect(remoteErrors[0].error).toBe('Remote error');
|
|
||||||
expect(remoteErrors[0].context).toEqual({ endpoint: '/api/data' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support batch error reporting', () => {
|
|
||||||
const batchErrors: Error[] = [];
|
|
||||||
const batchSize = 3;
|
|
||||||
let currentBatch: Error[] = [];
|
|
||||||
|
|
||||||
const reporter: ErrorReporter = {
|
|
||||||
report: (error: Error) => {
|
|
||||||
currentBatch.push(error);
|
|
||||||
|
|
||||||
if (currentBatch.length >= batchSize) {
|
|
||||||
batchErrors.push(...currentBatch);
|
|
||||||
currentBatch = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Report 7 errors
|
|
||||||
for (let i = 0; i < 7; i++) {
|
|
||||||
reporter.report(new Error(`Error ${i}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add remaining errors
|
|
||||||
if (currentBatch.length > 0) {
|
|
||||||
batchErrors.push(...currentBatch);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(batchErrors).toHaveLength(7);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user