Compare commits
26 Commits
94b92a9314
...
tests/core
| Author | SHA1 | Date | |
|---|---|---|---|
| 838f1602de | |||
| 1e821c4a5c | |||
| 5da14b1b21 | |||
| 3bef15f3bd | |||
| 78c9c1ec75 | |||
| 5e12570442 | |||
| e000a997d0 | |||
| 94ae216de4 | |||
| 9ccecbf3bb | |||
| 9bb6b228f1 | |||
| 95276df5af | |||
| 34eae53184 | |||
| a00ca4edfd | |||
| 6df38a462a | |||
| a0f41f242f | |||
| eaf51712a7 | |||
| 853ec7b0ce | |||
| 2fba80da57 | |||
| cf7a551117 | |||
| 5612df2e33 | |||
| 648dce2193 | |||
| 280d6fc199 | |||
| 093eece3d7 | |||
| 35cc7cf12b | |||
| 597bb48248 | |||
| 0a37454171 |
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:
|
||||
|
||||
- **web-api**: Backend REST API server
|
||||
- **web-client**: Frontend React application
|
||||
- **web-client**: Frontend React application
|
||||
- **companion**: Desktop companion application
|
||||
|
||||
## 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.
|
||||
|
||||
### Local Verification Pipeline
|
||||
Run this sequence before pushing to ensure correctness:
|
||||
```bash
|
||||
npm run lint && npm run typecheck && npm run test:unit && npm run test:integration
|
||||
```
|
||||
|
||||
GridPilot uses **lint-staged** to automatically validate only changed files on commit:
|
||||
|
||||
- `eslint --fix` runs on changed JS/TS/TSX files
|
||||
- `vitest related --run` runs tests related to changed files
|
||||
- `prettier --write` formats JSON, MD, and YAML files
|
||||
|
||||
This ensures fast commits without running the full test suite.
|
||||
|
||||
### Pre-Push Hook
|
||||
|
||||
A **pre-push hook** runs the full verification pipeline before pushing to remote:
|
||||
|
||||
- `npm run lint` - Check for linting errors
|
||||
- `npm run typecheck` - Verify TypeScript types
|
||||
- `npm run test:unit` - Run unit tests
|
||||
- `npm run test:integration` - Run integration tests
|
||||
|
||||
You can skip this with `git push --no-verify` if needed.
|
||||
|
||||
### Individual Commands
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
npm test
|
||||
@@ -147,4 +163,4 @@ Comprehensive documentation is available in the [`/docs`](docs/) directory:
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) file for details.
|
||||
MIT License - see [LICENSE](LICENSE) file for details.
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { TypeOrmPersistenceSchemaAdapter } from './TypeOrmPersistenceSchemaAdapterError';
|
||||
|
||||
describe('TypeOrmPersistenceSchemaAdapter', () => {
|
||||
describe('constructor', () => {
|
||||
// Given: valid parameters with all required fields
|
||||
// When: TypeOrmPersistenceSchemaAdapter is instantiated
|
||||
// Then: it should create an error with correct properties
|
||||
it('should create an error with all required properties', () => {
|
||||
// Given
|
||||
const params = {
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'name',
|
||||
reason: 'not_string',
|
||||
};
|
||||
|
||||
// When
|
||||
const error = new TypeOrmPersistenceSchemaAdapter(params);
|
||||
|
||||
// Then
|
||||
expect(error).toBeInstanceOf(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(error.name).toBe('TypeOrmPersistenceSchemaAdapter');
|
||||
expect(error.entityName).toBe('Achievement');
|
||||
expect(error.fieldName).toBe('name');
|
||||
expect(error.reason).toBe('not_string');
|
||||
expect(error.message).toBe('Schema validation failed for Achievement.name: not_string');
|
||||
});
|
||||
|
||||
// Given: valid parameters with custom message
|
||||
// When: TypeOrmPersistenceSchemaAdapter is instantiated
|
||||
// Then: it should use the custom message
|
||||
it('should use custom message when provided', () => {
|
||||
// Given
|
||||
const params = {
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'name',
|
||||
reason: 'not_string',
|
||||
message: 'Custom error message',
|
||||
};
|
||||
|
||||
// When
|
||||
const error = new TypeOrmPersistenceSchemaAdapter(params);
|
||||
|
||||
// Then
|
||||
expect(error.message).toBe('Custom error message');
|
||||
});
|
||||
|
||||
// Given: parameters with empty string entityName
|
||||
// When: TypeOrmPersistenceSchemaAdapter is instantiated
|
||||
// Then: it should still create an error with the provided entityName
|
||||
it('should handle empty string entityName', () => {
|
||||
// Given
|
||||
const params = {
|
||||
entityName: '',
|
||||
fieldName: 'name',
|
||||
reason: 'not_string',
|
||||
};
|
||||
|
||||
// When
|
||||
const error = new TypeOrmPersistenceSchemaAdapter(params);
|
||||
|
||||
// Then
|
||||
expect(error.entityName).toBe('');
|
||||
expect(error.message).toBe('Schema validation failed for .name: not_string');
|
||||
});
|
||||
|
||||
// Given: parameters with empty string fieldName
|
||||
// When: TypeOrmPersistenceSchemaAdapter is instantiated
|
||||
// Then: it should still create an error with the provided fieldName
|
||||
it('should handle empty string fieldName', () => {
|
||||
// Given
|
||||
const params = {
|
||||
entityName: 'Achievement',
|
||||
fieldName: '',
|
||||
reason: 'not_string',
|
||||
};
|
||||
|
||||
// When
|
||||
const error = new TypeOrmPersistenceSchemaAdapter(params);
|
||||
|
||||
// Then
|
||||
expect(error.fieldName).toBe('');
|
||||
expect(error.message).toBe('Schema validation failed for Achievement.: not_string');
|
||||
});
|
||||
|
||||
// Given: parameters with empty string reason
|
||||
// When: TypeOrmPersistenceSchemaAdapter is instantiated
|
||||
// Then: it should still create an error with the provided reason
|
||||
it('should handle empty string reason', () => {
|
||||
// Given
|
||||
const params = {
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'name',
|
||||
reason: '',
|
||||
};
|
||||
|
||||
// When
|
||||
const error = new TypeOrmPersistenceSchemaAdapter(params);
|
||||
|
||||
// Then
|
||||
expect(error.reason).toBe('');
|
||||
expect(error.message).toBe('Schema validation failed for Achievement.name: ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error details shape', () => {
|
||||
// Given: an error instance
|
||||
// When: checking the error structure
|
||||
// Then: it should have the correct shape with entityName, fieldName, and reason
|
||||
it('should have correct error details shape', () => {
|
||||
// Given
|
||||
const error = new TypeOrmPersistenceSchemaAdapter({
|
||||
entityName: 'UserAchievement',
|
||||
fieldName: 'userId',
|
||||
reason: 'empty_string',
|
||||
});
|
||||
|
||||
// When & Then
|
||||
expect(error).toHaveProperty('entityName');
|
||||
expect(error).toHaveProperty('fieldName');
|
||||
expect(error).toHaveProperty('reason');
|
||||
expect(error).toHaveProperty('message');
|
||||
expect(error).toHaveProperty('name');
|
||||
});
|
||||
|
||||
// Given: an error instance
|
||||
// When: checking the error is an instance of Error
|
||||
// Then: it should be an instance of Error
|
||||
it('should be an instance of Error', () => {
|
||||
// Given
|
||||
const error = new TypeOrmPersistenceSchemaAdapter({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'points',
|
||||
reason: 'not_integer',
|
||||
});
|
||||
|
||||
// When & Then
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
// Given: an error instance
|
||||
// When: checking the error name
|
||||
// Then: it should be 'TypeOrmPersistenceSchemaAdapter'
|
||||
it('should have correct error name', () => {
|
||||
// Given
|
||||
const error = new TypeOrmPersistenceSchemaAdapter({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'category',
|
||||
reason: 'invalid_enum_value',
|
||||
});
|
||||
|
||||
// When & Then
|
||||
expect(error.name).toBe('TypeOrmPersistenceSchemaAdapter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error message format', () => {
|
||||
// Given: an error with standard parameters
|
||||
// When: checking the error message
|
||||
// Then: it should follow the standard format
|
||||
it('should follow standard message format', () => {
|
||||
// Given
|
||||
const error = new TypeOrmPersistenceSchemaAdapter({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'requirements[0].type',
|
||||
reason: 'not_string',
|
||||
});
|
||||
|
||||
// When & Then
|
||||
expect(error.message).toBe('Schema validation failed for Achievement.requirements[0].type: not_string');
|
||||
});
|
||||
|
||||
// Given: an error with nested field name
|
||||
// When: checking the error message
|
||||
// Then: it should include the nested field path
|
||||
it('should include nested field path in message', () => {
|
||||
// Given
|
||||
const error = new TypeOrmPersistenceSchemaAdapter({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'requirements[0].operator',
|
||||
reason: 'invalid_enum_value',
|
||||
});
|
||||
|
||||
// When & Then
|
||||
expect(error.message).toBe('Schema validation failed for Achievement.requirements[0].operator: invalid_enum_value');
|
||||
});
|
||||
|
||||
// Given: an error with custom message
|
||||
// When: checking the error message
|
||||
// Then: it should use the custom message
|
||||
it('should use custom message when provided', () => {
|
||||
// Given
|
||||
const error = new TypeOrmPersistenceSchemaAdapter({
|
||||
entityName: 'UserAchievement',
|
||||
fieldName: 'earnedAt',
|
||||
reason: 'invalid_date',
|
||||
message: 'The earnedAt field must be a valid date',
|
||||
});
|
||||
|
||||
// When & Then
|
||||
expect(error.message).toBe('The earnedAt field must be a valid date');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error property immutability', () => {
|
||||
// Given: an error instance
|
||||
// When: checking the properties
|
||||
// Then: properties should be defined and accessible
|
||||
it('should have defined properties', () => {
|
||||
// Given
|
||||
const error = new TypeOrmPersistenceSchemaAdapter({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'name',
|
||||
reason: 'not_string',
|
||||
});
|
||||
|
||||
// When & Then
|
||||
expect(error.entityName).toBe('Achievement');
|
||||
expect(error.fieldName).toBe('name');
|
||||
expect(error.reason).toBe('not_string');
|
||||
});
|
||||
|
||||
// Given: an error instance
|
||||
// When: trying to modify properties
|
||||
// Then: properties can be modified (TypeScript readonly doesn't enforce runtime immutability)
|
||||
it('should allow property modification (TypeScript readonly is compile-time only)', () => {
|
||||
// Given
|
||||
const error = new TypeOrmPersistenceSchemaAdapter({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'name',
|
||||
reason: 'not_string',
|
||||
});
|
||||
|
||||
// When
|
||||
(error as any).entityName = 'NewEntity';
|
||||
(error as any).fieldName = 'newField';
|
||||
(error as any).reason = 'new_reason';
|
||||
|
||||
// Then
|
||||
expect(error.entityName).toBe('NewEntity');
|
||||
expect(error.fieldName).toBe('newField');
|
||||
expect(error.reason).toBe('new_reason');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error serialization', () => {
|
||||
// Given: an error instance
|
||||
// When: converting to string
|
||||
// Then: it should include the error message
|
||||
it('should serialize to string with message', () => {
|
||||
// Given
|
||||
const error = new TypeOrmPersistenceSchemaAdapter({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'name',
|
||||
reason: 'not_string',
|
||||
});
|
||||
|
||||
// When
|
||||
const stringRepresentation = error.toString();
|
||||
|
||||
// Then
|
||||
expect(stringRepresentation).toContain('TypeOrmPersistenceSchemaAdapter');
|
||||
expect(stringRepresentation).toContain('Schema validation failed for Achievement.name: not_string');
|
||||
});
|
||||
|
||||
// Given: an error instance
|
||||
// When: converting to JSON
|
||||
// Then: it should include all error properties
|
||||
it('should serialize to JSON with all properties', () => {
|
||||
// Given
|
||||
const error = new TypeOrmPersistenceSchemaAdapter({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'name',
|
||||
reason: 'not_string',
|
||||
});
|
||||
|
||||
// When
|
||||
const jsonRepresentation = JSON.parse(JSON.stringify(error));
|
||||
|
||||
// Then
|
||||
expect(jsonRepresentation).toHaveProperty('entityName', 'Achievement');
|
||||
expect(jsonRepresentation).toHaveProperty('fieldName', 'name');
|
||||
expect(jsonRepresentation).toHaveProperty('reason', 'not_string');
|
||||
expect(jsonRepresentation).toHaveProperty('message', 'Schema validation failed for Achievement.name: not_string');
|
||||
expect(jsonRepresentation).toHaveProperty('name', 'TypeOrmPersistenceSchemaAdapter');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,639 @@
|
||||
import { Achievement, AchievementCategory, AchievementRequirement } from '@core/identity/domain/entities/Achievement';
|
||||
import { UserAchievement } from '@core/identity/domain/entities/UserAchievement';
|
||||
import { AchievementOrmEntity } from '../entities/AchievementOrmEntity';
|
||||
import { UserAchievementOrmEntity } from '../entities/UserAchievementOrmEntity';
|
||||
import { TypeOrmPersistenceSchemaAdapter } from '../errors/TypeOrmPersistenceSchemaAdapterError';
|
||||
import { AchievementOrmMapper } from './AchievementOrmMapper';
|
||||
|
||||
describe('AchievementOrmMapper', () => {
|
||||
let mapper: AchievementOrmMapper;
|
||||
|
||||
beforeEach(() => {
|
||||
mapper = new AchievementOrmMapper();
|
||||
});
|
||||
|
||||
describe('toOrmEntity', () => {
|
||||
// Given: a valid Achievement domain entity
|
||||
// When: toOrmEntity is called
|
||||
// Then: it should return a properly mapped AchievementOrmEntity
|
||||
it('should map Achievement domain entity to ORM entity', () => {
|
||||
// Given
|
||||
const achievement = Achievement.create({
|
||||
id: 'ach-123',
|
||||
name: 'First Race',
|
||||
description: 'Complete your first race',
|
||||
category: 'driver' as AchievementCategory,
|
||||
rarity: 'common',
|
||||
points: 10,
|
||||
requirements: [
|
||||
{ type: 'races_completed', value: 1, operator: '>=' } as AchievementRequirement,
|
||||
],
|
||||
isSecret: false,
|
||||
});
|
||||
|
||||
// When
|
||||
const result = mapper.toOrmEntity(achievement);
|
||||
|
||||
// Then
|
||||
expect(result).toBeInstanceOf(AchievementOrmEntity);
|
||||
expect(result.id).toBe('ach-123');
|
||||
expect(result.name).toBe('First Race');
|
||||
expect(result.description).toBe('Complete your first race');
|
||||
expect(result.category).toBe('driver');
|
||||
expect(result.rarity).toBe('common');
|
||||
expect(result.points).toBe(10);
|
||||
expect(result.requirements).toEqual([
|
||||
{ type: 'races_completed', value: 1, operator: '>=' },
|
||||
]);
|
||||
expect(result.isSecret).toBe(false);
|
||||
expect(result.createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
// Given: an Achievement with optional iconUrl
|
||||
// When: toOrmEntity is called
|
||||
// Then: it should map iconUrl correctly (or null if not provided)
|
||||
it('should map Achievement with iconUrl to ORM entity', () => {
|
||||
// Given
|
||||
const achievement = Achievement.create({
|
||||
id: 'ach-456',
|
||||
name: 'Champion',
|
||||
description: 'Win a championship',
|
||||
category: 'driver' as AchievementCategory,
|
||||
rarity: 'legendary',
|
||||
points: 100,
|
||||
requirements: [
|
||||
{ type: 'championships_won', value: 1, operator: '>=' } as AchievementRequirement,
|
||||
],
|
||||
isSecret: false,
|
||||
iconUrl: 'https://example.com/icon.png',
|
||||
});
|
||||
|
||||
// When
|
||||
const result = mapper.toOrmEntity(achievement);
|
||||
|
||||
// Then
|
||||
expect(result.iconUrl).toBe('https://example.com/icon.png');
|
||||
});
|
||||
|
||||
// Given: an Achievement without iconUrl
|
||||
// When: toOrmEntity is called
|
||||
// Then: it should map iconUrl to null
|
||||
it('should map Achievement without iconUrl to null in ORM entity', () => {
|
||||
// Given
|
||||
const achievement = Achievement.create({
|
||||
id: 'ach-789',
|
||||
name: 'Clean Race',
|
||||
description: 'Complete a race without incidents',
|
||||
category: 'driver' as AchievementCategory,
|
||||
rarity: 'uncommon',
|
||||
points: 25,
|
||||
requirements: [
|
||||
{ type: 'clean_races', value: 1, operator: '>=' } as AchievementRequirement,
|
||||
],
|
||||
isSecret: false,
|
||||
});
|
||||
|
||||
// When
|
||||
const result = mapper.toOrmEntity(achievement);
|
||||
|
||||
// Then
|
||||
expect(result.iconUrl).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toDomain', () => {
|
||||
// Given: a valid AchievementOrmEntity
|
||||
// When: toDomain is called
|
||||
// Then: it should return a properly mapped Achievement domain entity
|
||||
it('should map AchievementOrmEntity to domain entity', () => {
|
||||
// Given
|
||||
const entity = new AchievementOrmEntity();
|
||||
entity.id = 'ach-123';
|
||||
entity.name = 'First Race';
|
||||
entity.description = 'Complete your first race';
|
||||
entity.category = 'driver';
|
||||
entity.rarity = 'common';
|
||||
entity.points = 10;
|
||||
entity.requirements = [
|
||||
{ type: 'races_completed', value: 1, operator: '>=' },
|
||||
];
|
||||
entity.isSecret = false;
|
||||
entity.createdAt = new Date('2024-01-01');
|
||||
|
||||
// When
|
||||
const result = mapper.toDomain(entity);
|
||||
|
||||
// Then
|
||||
expect(result).toBeInstanceOf(Achievement);
|
||||
expect(result.id).toBe('ach-123');
|
||||
expect(result.name).toBe('First Race');
|
||||
expect(result.description).toBe('Complete your first race');
|
||||
expect(result.category).toBe('driver');
|
||||
expect(result.rarity).toBe('common');
|
||||
expect(result.points).toBe(10);
|
||||
expect(result.requirements).toEqual([
|
||||
{ type: 'races_completed', value: 1, operator: '>=' },
|
||||
]);
|
||||
expect(result.isSecret).toBe(false);
|
||||
expect(result.createdAt).toEqual(new Date('2024-01-01'));
|
||||
});
|
||||
|
||||
// Given: an AchievementOrmEntity with iconUrl
|
||||
// When: toDomain is called
|
||||
// Then: it should map iconUrl correctly
|
||||
it('should map AchievementOrmEntity with iconUrl to domain entity', () => {
|
||||
// Given
|
||||
const entity = new AchievementOrmEntity();
|
||||
entity.id = 'ach-456';
|
||||
entity.name = 'Champion';
|
||||
entity.description = 'Win a championship';
|
||||
entity.category = 'driver';
|
||||
entity.rarity = 'legendary';
|
||||
entity.points = 100;
|
||||
entity.requirements = [
|
||||
{ type: 'championships_won', value: 1, operator: '>=' },
|
||||
];
|
||||
entity.isSecret = false;
|
||||
entity.iconUrl = 'https://example.com/icon.png';
|
||||
entity.createdAt = new Date('2024-01-01');
|
||||
|
||||
// When
|
||||
const result = mapper.toDomain(entity);
|
||||
|
||||
// Then
|
||||
expect(result.iconUrl).toBe('https://example.com/icon.png');
|
||||
});
|
||||
|
||||
// Given: an AchievementOrmEntity with null iconUrl
|
||||
// When: toDomain is called
|
||||
// Then: it should map iconUrl to empty string
|
||||
it('should map AchievementOrmEntity with null iconUrl to empty string in domain entity', () => {
|
||||
// Given
|
||||
const entity = new AchievementOrmEntity();
|
||||
entity.id = 'ach-789';
|
||||
entity.name = 'Clean Race';
|
||||
entity.description = 'Complete a race without incidents';
|
||||
entity.category = 'driver';
|
||||
entity.rarity = 'uncommon';
|
||||
entity.points = 25;
|
||||
entity.requirements = [
|
||||
{ type: 'clean_races', value: 1, operator: '>=' },
|
||||
];
|
||||
entity.isSecret = false;
|
||||
entity.iconUrl = null;
|
||||
entity.createdAt = new Date('2024-01-01');
|
||||
|
||||
// When
|
||||
const result = mapper.toDomain(entity);
|
||||
|
||||
// Then
|
||||
expect(result.iconUrl).toBe('');
|
||||
});
|
||||
|
||||
// Given: an AchievementOrmEntity with invalid id (empty string)
|
||||
// When: toDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when id is empty string', () => {
|
||||
// Given
|
||||
const entity = new AchievementOrmEntity();
|
||||
entity.id = '';
|
||||
entity.name = 'First Race';
|
||||
entity.description = 'Complete your first race';
|
||||
entity.category = 'driver';
|
||||
entity.rarity = 'common';
|
||||
entity.points = 10;
|
||||
entity.requirements = [
|
||||
{ type: 'races_completed', value: 1, operator: '>=' },
|
||||
];
|
||||
entity.isSecret = false;
|
||||
entity.createdAt = new Date('2024-01-01');
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'id',
|
||||
reason: 'empty_string',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: an AchievementOrmEntity with invalid name (not a string)
|
||||
// When: toDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when name is not a string', () => {
|
||||
// Given
|
||||
const entity = new AchievementOrmEntity();
|
||||
entity.id = 'ach-123';
|
||||
entity.name = 123 as any;
|
||||
entity.description = 'Complete your first race';
|
||||
entity.category = 'driver';
|
||||
entity.rarity = 'common';
|
||||
entity.points = 10;
|
||||
entity.requirements = [
|
||||
{ type: 'races_completed', value: 1, operator: '>=' },
|
||||
];
|
||||
entity.isSecret = false;
|
||||
entity.createdAt = new Date('2024-01-01');
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'name',
|
||||
reason: 'not_string',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: an AchievementOrmEntity with invalid category (not in valid categories)
|
||||
// When: toDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when category is invalid', () => {
|
||||
// Given
|
||||
const entity = new AchievementOrmEntity();
|
||||
entity.id = 'ach-123';
|
||||
entity.name = 'First Race';
|
||||
entity.description = 'Complete your first race';
|
||||
entity.category = 'invalid_category' as any;
|
||||
entity.rarity = 'common';
|
||||
entity.points = 10;
|
||||
entity.requirements = [
|
||||
{ type: 'races_completed', value: 1, operator: '>=' },
|
||||
];
|
||||
entity.isSecret = false;
|
||||
entity.createdAt = new Date('2024-01-01');
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'category',
|
||||
reason: 'invalid_enum_value',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: an AchievementOrmEntity with invalid points (not an integer)
|
||||
// When: toDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when points is not an integer', () => {
|
||||
// Given
|
||||
const entity = new AchievementOrmEntity();
|
||||
entity.id = 'ach-123';
|
||||
entity.name = 'First Race';
|
||||
entity.description = 'Complete your first race';
|
||||
entity.category = 'driver';
|
||||
entity.rarity = 'common';
|
||||
entity.points = 10.5;
|
||||
entity.requirements = [
|
||||
{ type: 'races_completed', value: 1, operator: '>=' },
|
||||
];
|
||||
entity.isSecret = false;
|
||||
entity.createdAt = new Date('2024-01-01');
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'points',
|
||||
reason: 'not_integer',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: an AchievementOrmEntity with invalid requirements (not an array)
|
||||
// When: toDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when requirements is not an array', () => {
|
||||
// Given
|
||||
const entity = new AchievementOrmEntity();
|
||||
entity.id = 'ach-123';
|
||||
entity.name = 'First Race';
|
||||
entity.description = 'Complete your first race';
|
||||
entity.category = 'driver';
|
||||
entity.rarity = 'common';
|
||||
entity.points = 10;
|
||||
entity.requirements = 'not_an_array' as any;
|
||||
entity.isSecret = false;
|
||||
entity.createdAt = new Date('2024-01-01');
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'requirements',
|
||||
reason: 'not_array',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: an AchievementOrmEntity with invalid requirement object (null)
|
||||
// When: toDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when requirement is null', () => {
|
||||
// Given
|
||||
const entity = new AchievementOrmEntity();
|
||||
entity.id = 'ach-123';
|
||||
entity.name = 'First Race';
|
||||
entity.description = 'Complete your first race';
|
||||
entity.category = 'driver';
|
||||
entity.rarity = 'common';
|
||||
entity.points = 10;
|
||||
entity.requirements = [null as any];
|
||||
entity.isSecret = false;
|
||||
entity.createdAt = new Date('2024-01-01');
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'requirements[0]',
|
||||
reason: 'invalid_requirement_object',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: an AchievementOrmEntity with invalid requirement type (not a string)
|
||||
// When: toDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when requirement type is not a string', () => {
|
||||
// Given
|
||||
const entity = new AchievementOrmEntity();
|
||||
entity.id = 'ach-123';
|
||||
entity.name = 'First Race';
|
||||
entity.description = 'Complete your first race';
|
||||
entity.category = 'driver';
|
||||
entity.rarity = 'common';
|
||||
entity.points = 10;
|
||||
entity.requirements = [{ type: 123, value: 1, operator: '>=' } as any];
|
||||
entity.isSecret = false;
|
||||
entity.createdAt = new Date('2024-01-01');
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'requirements[0].type',
|
||||
reason: 'not_string',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: an AchievementOrmEntity with invalid requirement operator (not in valid operators)
|
||||
// When: toDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when requirement operator is invalid', () => {
|
||||
// Given
|
||||
const entity = new AchievementOrmEntity();
|
||||
entity.id = 'ach-123';
|
||||
entity.name = 'First Race';
|
||||
entity.description = 'Complete your first race';
|
||||
entity.category = 'driver';
|
||||
entity.rarity = 'common';
|
||||
entity.points = 10;
|
||||
entity.requirements = [{ type: 'races_completed', value: 1, operator: 'invalid' } as any];
|
||||
entity.isSecret = false;
|
||||
entity.createdAt = new Date('2024-01-01');
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'requirements[0].operator',
|
||||
reason: 'invalid_enum_value',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: an AchievementOrmEntity with invalid createdAt (not a Date)
|
||||
// When: toDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when createdAt is not a Date', () => {
|
||||
// Given
|
||||
const entity = new AchievementOrmEntity();
|
||||
entity.id = 'ach-123';
|
||||
entity.name = 'First Race';
|
||||
entity.description = 'Complete your first race';
|
||||
entity.category = 'driver';
|
||||
entity.rarity = 'common';
|
||||
entity.points = 10;
|
||||
entity.requirements = [
|
||||
{ type: 'races_completed', value: 1, operator: '>=' },
|
||||
];
|
||||
entity.isSecret = false;
|
||||
entity.createdAt = 'not_a_date' as any;
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'Achievement',
|
||||
fieldName: 'createdAt',
|
||||
reason: 'not_date',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toUserAchievementOrmEntity', () => {
|
||||
// Given: a valid UserAchievement domain entity
|
||||
// When: toUserAchievementOrmEntity is called
|
||||
// Then: it should return a properly mapped UserAchievementOrmEntity
|
||||
it('should map UserAchievement domain entity to ORM entity', () => {
|
||||
// Given
|
||||
const userAchievement = UserAchievement.create({
|
||||
id: 'ua-123',
|
||||
userId: 'user-456',
|
||||
achievementId: 'ach-789',
|
||||
earnedAt: new Date('2024-01-01'),
|
||||
progress: 50,
|
||||
});
|
||||
|
||||
// When
|
||||
const result = mapper.toUserAchievementOrmEntity(userAchievement);
|
||||
|
||||
// Then
|
||||
expect(result).toBeInstanceOf(UserAchievementOrmEntity);
|
||||
expect(result.id).toBe('ua-123');
|
||||
expect(result.userId).toBe('user-456');
|
||||
expect(result.achievementId).toBe('ach-789');
|
||||
expect(result.earnedAt).toEqual(new Date('2024-01-01'));
|
||||
expect(result.progress).toBe(50);
|
||||
expect(result.notifiedAt).toBeNull();
|
||||
});
|
||||
|
||||
// Given: a UserAchievement with notifiedAt
|
||||
// When: toUserAchievementOrmEntity is called
|
||||
// Then: it should map notifiedAt correctly
|
||||
it('should map UserAchievement with notifiedAt to ORM entity', () => {
|
||||
// Given
|
||||
const userAchievement = UserAchievement.create({
|
||||
id: 'ua-123',
|
||||
userId: 'user-456',
|
||||
achievementId: 'ach-789',
|
||||
earnedAt: new Date('2024-01-01'),
|
||||
progress: 100,
|
||||
notifiedAt: new Date('2024-01-02'),
|
||||
});
|
||||
|
||||
// When
|
||||
const result = mapper.toUserAchievementOrmEntity(userAchievement);
|
||||
|
||||
// Then
|
||||
expect(result.notifiedAt).toEqual(new Date('2024-01-02'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('toUserAchievementDomain', () => {
|
||||
// Given: a valid UserAchievementOrmEntity
|
||||
// When: toUserAchievementDomain is called
|
||||
// Then: it should return a properly mapped UserAchievement domain entity
|
||||
it('should map UserAchievementOrmEntity to domain entity', () => {
|
||||
// Given
|
||||
const entity = new UserAchievementOrmEntity();
|
||||
entity.id = 'ua-123';
|
||||
entity.userId = 'user-456';
|
||||
entity.achievementId = 'ach-789';
|
||||
entity.earnedAt = new Date('2024-01-01');
|
||||
entity.progress = 50;
|
||||
entity.notifiedAt = null;
|
||||
|
||||
// When
|
||||
const result = mapper.toUserAchievementDomain(entity);
|
||||
|
||||
// Then
|
||||
expect(result).toBeInstanceOf(UserAchievement);
|
||||
expect(result.id).toBe('ua-123');
|
||||
expect(result.userId).toBe('user-456');
|
||||
expect(result.achievementId).toBe('ach-789');
|
||||
expect(result.earnedAt).toEqual(new Date('2024-01-01'));
|
||||
expect(result.progress).toBe(50);
|
||||
expect(result.notifiedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
// Given: a UserAchievementOrmEntity with notifiedAt
|
||||
// When: toUserAchievementDomain is called
|
||||
// Then: it should map notifiedAt correctly
|
||||
it('should map UserAchievementOrmEntity with notifiedAt to domain entity', () => {
|
||||
// Given
|
||||
const entity = new UserAchievementOrmEntity();
|
||||
entity.id = 'ua-123';
|
||||
entity.userId = 'user-456';
|
||||
entity.achievementId = 'ach-789';
|
||||
entity.earnedAt = new Date('2024-01-01');
|
||||
entity.progress = 100;
|
||||
entity.notifiedAt = new Date('2024-01-02');
|
||||
|
||||
// When
|
||||
const result = mapper.toUserAchievementDomain(entity);
|
||||
|
||||
// Then
|
||||
expect(result.notifiedAt).toEqual(new Date('2024-01-02'));
|
||||
});
|
||||
|
||||
// Given: a UserAchievementOrmEntity with invalid id (empty string)
|
||||
// When: toUserAchievementDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when id is empty string', () => {
|
||||
// Given
|
||||
const entity = new UserAchievementOrmEntity();
|
||||
entity.id = '';
|
||||
entity.userId = 'user-456';
|
||||
entity.achievementId = 'ach-789';
|
||||
entity.earnedAt = new Date('2024-01-01');
|
||||
entity.progress = 50;
|
||||
entity.notifiedAt = null;
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toUserAchievementDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toUserAchievementDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'UserAchievement',
|
||||
fieldName: 'id',
|
||||
reason: 'empty_string',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: a UserAchievementOrmEntity with invalid userId (not a string)
|
||||
// When: toUserAchievementDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when userId is not a string', () => {
|
||||
// Given
|
||||
const entity = new UserAchievementOrmEntity();
|
||||
entity.id = 'ua-123';
|
||||
entity.userId = 123 as any;
|
||||
entity.achievementId = 'ach-789';
|
||||
entity.earnedAt = new Date('2024-01-01');
|
||||
entity.progress = 50;
|
||||
entity.notifiedAt = null;
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toUserAchievementDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toUserAchievementDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'UserAchievement',
|
||||
fieldName: 'userId',
|
||||
reason: 'not_string',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: a UserAchievementOrmEntity with invalid progress (not an integer)
|
||||
// When: toUserAchievementDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when progress is not an integer', () => {
|
||||
// Given
|
||||
const entity = new UserAchievementOrmEntity();
|
||||
entity.id = 'ua-123';
|
||||
entity.userId = 'user-456';
|
||||
entity.achievementId = 'ach-789';
|
||||
entity.earnedAt = new Date('2024-01-01');
|
||||
entity.progress = 50.5;
|
||||
entity.notifiedAt = null;
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toUserAchievementDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toUserAchievementDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'UserAchievement',
|
||||
fieldName: 'progress',
|
||||
reason: 'not_integer',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: a UserAchievementOrmEntity with invalid earnedAt (not a Date)
|
||||
// When: toUserAchievementDomain is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter error
|
||||
it('should throw TypeOrmPersistenceSchemaAdapter when earnedAt is not a Date', () => {
|
||||
// Given
|
||||
const entity = new UserAchievementOrmEntity();
|
||||
entity.id = 'ua-123';
|
||||
entity.userId = 'user-456';
|
||||
entity.achievementId = 'ach-789';
|
||||
entity.earnedAt = 'not_a_date' as any;
|
||||
entity.progress = 50;
|
||||
entity.notifiedAt = null;
|
||||
|
||||
// When & Then
|
||||
expect(() => mapper.toUserAchievementDomain(entity)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => mapper.toUserAchievementDomain(entity)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName: 'UserAchievement',
|
||||
fieldName: 'earnedAt',
|
||||
reason: 'not_date',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -111,7 +111,11 @@ export class AchievementOrmMapper {
|
||||
assertNonEmptyString(entityName, 'achievementId', entity.achievementId);
|
||||
assertInteger(entityName, 'progress', entity.progress);
|
||||
assertDate(entityName, 'earnedAt', entity.earnedAt);
|
||||
assertOptionalStringOrNull(entityName, 'notifiedAt', entity.notifiedAt);
|
||||
|
||||
// Validate notifiedAt (Date | null)
|
||||
if (entity.notifiedAt !== null) {
|
||||
assertDate(entityName, 'notifiedAt', entity.notifiedAt);
|
||||
}
|
||||
|
||||
try {
|
||||
return UserAchievement.create({
|
||||
|
||||
@@ -0,0 +1,808 @@
|
||||
import { vi } from 'vitest';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { Achievement } from '@core/identity/domain/entities/Achievement';
|
||||
import { UserAchievement } from '@core/identity/domain/entities/UserAchievement';
|
||||
import { AchievementOrmEntity } from '../entities/AchievementOrmEntity';
|
||||
import { UserAchievementOrmEntity } from '../entities/UserAchievementOrmEntity';
|
||||
import { AchievementOrmMapper } from '../mappers/AchievementOrmMapper';
|
||||
import { TypeOrmAchievementRepository } from './TypeOrmAchievementRepository';
|
||||
|
||||
describe('TypeOrmAchievementRepository', () => {
|
||||
let mockDataSource: { getRepository: ReturnType<typeof vi.fn> };
|
||||
let mockAchievementRepo: { findOne: ReturnType<typeof vi.fn>; find: ReturnType<typeof vi.fn>; save: ReturnType<typeof vi.fn> };
|
||||
let mockUserAchievementRepo: { findOne: ReturnType<typeof vi.fn>; find: ReturnType<typeof vi.fn>; save: ReturnType<typeof vi.fn> };
|
||||
let mockMapper: { toOrmEntity: ReturnType<typeof vi.fn>; toDomain: ReturnType<typeof vi.fn>; toUserAchievementOrmEntity: ReturnType<typeof vi.fn>; toUserAchievementDomain: ReturnType<typeof vi.fn> };
|
||||
let repository: TypeOrmAchievementRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
// Given: mocked TypeORM DataSource and repositories
|
||||
mockAchievementRepo = {
|
||||
findOne: vi.fn(),
|
||||
find: vi.fn(),
|
||||
save: vi.fn(),
|
||||
};
|
||||
|
||||
mockUserAchievementRepo = {
|
||||
findOne: vi.fn(),
|
||||
find: vi.fn(),
|
||||
save: vi.fn(),
|
||||
};
|
||||
|
||||
mockDataSource = {
|
||||
getRepository: vi.fn((entityClass) => {
|
||||
if (entityClass === AchievementOrmEntity) {
|
||||
return mockAchievementRepo;
|
||||
}
|
||||
if (entityClass === UserAchievementOrmEntity) {
|
||||
return mockUserAchievementRepo;
|
||||
}
|
||||
throw new Error('Unknown entity class');
|
||||
}),
|
||||
};
|
||||
|
||||
mockMapper = {
|
||||
toOrmEntity: vi.fn(),
|
||||
toDomain: vi.fn(),
|
||||
toUserAchievementOrmEntity: vi.fn(),
|
||||
toUserAchievementDomain: vi.fn(),
|
||||
};
|
||||
|
||||
// When: repository is instantiated with mocked dependencies
|
||||
repository = new TypeOrmAchievementRepository(mockDataSource as any, mockMapper as any);
|
||||
});
|
||||
|
||||
describe('DI Boundary - Constructor', () => {
|
||||
// Given: both dependencies provided
|
||||
// When: repository is instantiated
|
||||
// Then: it should create repository successfully
|
||||
it('should create repository with valid dependencies', () => {
|
||||
// Given & When & Then
|
||||
expect(repository).toBeInstanceOf(TypeOrmAchievementRepository);
|
||||
});
|
||||
|
||||
// Given: repository instance
|
||||
// When: checking repository properties
|
||||
// Then: it should have injected dependencies
|
||||
it('should have injected dependencies', () => {
|
||||
// Given & When & Then
|
||||
expect((repository as any).dataSource).toBe(mockDataSource);
|
||||
expect((repository as any).mapper).toBe(mockMapper);
|
||||
});
|
||||
|
||||
// Given: repository instance
|
||||
// When: checking repository methods
|
||||
// Then: it should have all required methods
|
||||
it('should have all required repository methods', () => {
|
||||
// Given & When & Then
|
||||
expect(repository.findAchievementById).toBeDefined();
|
||||
expect(repository.findAllAchievements).toBeDefined();
|
||||
expect(repository.findAchievementsByCategory).toBeDefined();
|
||||
expect(repository.createAchievement).toBeDefined();
|
||||
expect(repository.findUserAchievementById).toBeDefined();
|
||||
expect(repository.findUserAchievementsByUserId).toBeDefined();
|
||||
expect(repository.findUserAchievementByUserAndAchievement).toBeDefined();
|
||||
expect(repository.hasUserEarnedAchievement).toBeDefined();
|
||||
expect(repository.createUserAchievement).toBeDefined();
|
||||
expect(repository.updateUserAchievement).toBeDefined();
|
||||
expect(repository.getAchievementLeaderboard).toBeDefined();
|
||||
expect(repository.getUserAchievementStats).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAchievementById', () => {
|
||||
// Given: an achievement exists in the database
|
||||
// When: findAchievementById is called
|
||||
// Then: it should return the achievement domain entity
|
||||
it('should return achievement when found', async () => {
|
||||
// Given
|
||||
const achievementId = 'ach-123';
|
||||
const ormEntity = new AchievementOrmEntity();
|
||||
ormEntity.id = achievementId;
|
||||
ormEntity.name = 'First Race';
|
||||
ormEntity.description = 'Complete your first race';
|
||||
ormEntity.category = 'driver';
|
||||
ormEntity.rarity = 'common';
|
||||
ormEntity.points = 10;
|
||||
ormEntity.requirements = [{ type: 'races_completed', value: 1, operator: '>=' }];
|
||||
ormEntity.isSecret = false;
|
||||
ormEntity.createdAt = new Date('2024-01-01');
|
||||
|
||||
const domainEntity = Achievement.create({
|
||||
id: achievementId,
|
||||
name: 'First Race',
|
||||
description: 'Complete your first race',
|
||||
category: 'driver',
|
||||
rarity: 'common',
|
||||
points: 10,
|
||||
requirements: [{ type: 'races_completed', value: 1, operator: '>=' }],
|
||||
isSecret: false,
|
||||
});
|
||||
|
||||
mockAchievementRepo.findOne.mockResolvedValue(ormEntity);
|
||||
mockMapper.toDomain.mockReturnValue(domainEntity);
|
||||
|
||||
// When
|
||||
const result = await repository.findAchievementById(achievementId);
|
||||
|
||||
// Then
|
||||
expect(mockAchievementRepo.findOne).toHaveBeenCalledWith({ where: { id: achievementId } });
|
||||
expect(mockMapper.toDomain).toHaveBeenCalledWith(ormEntity);
|
||||
expect(result).toBe(domainEntity);
|
||||
});
|
||||
|
||||
// Given: no achievement exists with the given ID
|
||||
// When: findAchievementById is called
|
||||
// Then: it should return null
|
||||
it('should return null when achievement not found', async () => {
|
||||
// Given
|
||||
const achievementId = 'ach-999';
|
||||
mockAchievementRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
// When
|
||||
const result = await repository.findAchievementById(achievementId);
|
||||
|
||||
// Then
|
||||
expect(mockAchievementRepo.findOne).toHaveBeenCalledWith({ where: { id: achievementId } });
|
||||
expect(mockMapper.toDomain).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAllAchievements', () => {
|
||||
// Given: multiple achievements exist in the database
|
||||
// When: findAllAchievements is called
|
||||
// Then: it should return all achievement domain entities
|
||||
it('should return all achievements', async () => {
|
||||
// Given
|
||||
const ormEntity1 = new AchievementOrmEntity();
|
||||
ormEntity1.id = 'ach-1';
|
||||
ormEntity1.name = 'First Race';
|
||||
ormEntity1.description = 'Complete your first race';
|
||||
ormEntity1.category = 'driver';
|
||||
ormEntity1.rarity = 'common';
|
||||
ormEntity1.points = 10;
|
||||
ormEntity1.requirements = [{ type: 'races_completed', value: 1, operator: '>=' }];
|
||||
ormEntity1.isSecret = false;
|
||||
ormEntity1.createdAt = new Date('2024-01-01');
|
||||
|
||||
const ormEntity2 = new AchievementOrmEntity();
|
||||
ormEntity2.id = 'ach-2';
|
||||
ormEntity2.name = 'Champion';
|
||||
ormEntity2.description = 'Win a championship';
|
||||
ormEntity2.category = 'driver';
|
||||
ormEntity2.rarity = 'legendary';
|
||||
ormEntity2.points = 100;
|
||||
ormEntity2.requirements = [{ type: 'championships_won', value: 1, operator: '>=' }];
|
||||
ormEntity2.isSecret = false;
|
||||
ormEntity2.createdAt = new Date('2024-01-02');
|
||||
|
||||
const domainEntity1 = Achievement.create({
|
||||
id: 'ach-1',
|
||||
name: 'First Race',
|
||||
description: 'Complete your first race',
|
||||
category: 'driver',
|
||||
rarity: 'common',
|
||||
points: 10,
|
||||
requirements: [{ type: 'races_completed', value: 1, operator: '>=' }],
|
||||
isSecret: false,
|
||||
});
|
||||
|
||||
const domainEntity2 = Achievement.create({
|
||||
id: 'ach-2',
|
||||
name: 'Champion',
|
||||
description: 'Win a championship',
|
||||
category: 'driver',
|
||||
rarity: 'legendary',
|
||||
points: 100,
|
||||
requirements: [{ type: 'championships_won', value: 1, operator: '>=' }],
|
||||
isSecret: false,
|
||||
});
|
||||
|
||||
mockAchievementRepo.find.mockResolvedValue([ormEntity1, ormEntity2]);
|
||||
mockMapper.toDomain
|
||||
.mockReturnValueOnce(domainEntity1)
|
||||
.mockReturnValueOnce(domainEntity2);
|
||||
|
||||
// When
|
||||
const result = await repository.findAllAchievements();
|
||||
|
||||
// Then
|
||||
expect(mockAchievementRepo.find).toHaveBeenCalledWith();
|
||||
expect(mockMapper.toDomain).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual([domainEntity1, domainEntity2]);
|
||||
});
|
||||
|
||||
// Given: no achievements exist in the database
|
||||
// When: findAllAchievements is called
|
||||
// Then: it should return an empty array
|
||||
it('should return empty array when no achievements exist', async () => {
|
||||
// Given
|
||||
mockAchievementRepo.find.mockResolvedValue([]);
|
||||
|
||||
// When
|
||||
const result = await repository.findAllAchievements();
|
||||
|
||||
// Then
|
||||
expect(mockAchievementRepo.find).toHaveBeenCalledWith();
|
||||
expect(mockMapper.toDomain).not.toHaveBeenCalled();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAchievementsByCategory', () => {
|
||||
// Given: achievements exist in a specific category
|
||||
// When: findAchievementsByCategory is called
|
||||
// Then: it should return achievements from that category
|
||||
it('should return achievements by category', async () => {
|
||||
// Given
|
||||
const category = 'driver';
|
||||
const ormEntity = new AchievementOrmEntity();
|
||||
ormEntity.id = 'ach-1';
|
||||
ormEntity.name = 'First Race';
|
||||
ormEntity.description = 'Complete your first race';
|
||||
ormEntity.category = 'driver';
|
||||
ormEntity.rarity = 'common';
|
||||
ormEntity.points = 10;
|
||||
ormEntity.requirements = [{ type: 'races_completed', value: 1, operator: '>=' }];
|
||||
ormEntity.isSecret = false;
|
||||
ormEntity.createdAt = new Date('2024-01-01');
|
||||
|
||||
const domainEntity = Achievement.create({
|
||||
id: 'ach-1',
|
||||
name: 'First Race',
|
||||
description: 'Complete your first race',
|
||||
category: 'driver',
|
||||
rarity: 'common',
|
||||
points: 10,
|
||||
requirements: [{ type: 'races_completed', value: 1, operator: '>=' }],
|
||||
isSecret: false,
|
||||
});
|
||||
|
||||
mockAchievementRepo.find.mockResolvedValue([ormEntity]);
|
||||
mockMapper.toDomain.mockReturnValue(domainEntity);
|
||||
|
||||
// When
|
||||
const result = await repository.findAchievementsByCategory(category);
|
||||
|
||||
// Then
|
||||
expect(mockAchievementRepo.find).toHaveBeenCalledWith({ where: { category } });
|
||||
expect(mockMapper.toDomain).toHaveBeenCalledWith(ormEntity);
|
||||
expect(result).toEqual([domainEntity]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAchievement', () => {
|
||||
// Given: a valid achievement domain entity
|
||||
// When: createAchievement is called
|
||||
// Then: it should save the achievement and return it
|
||||
it('should create and save achievement', async () => {
|
||||
// Given
|
||||
const achievement = Achievement.create({
|
||||
id: 'ach-123',
|
||||
name: 'First Race',
|
||||
description: 'Complete your first race',
|
||||
category: 'driver',
|
||||
rarity: 'common',
|
||||
points: 10,
|
||||
requirements: [{ type: 'races_completed', value: 1, operator: '>=' }],
|
||||
isSecret: false,
|
||||
});
|
||||
|
||||
const ormEntity = new AchievementOrmEntity();
|
||||
ormEntity.id = 'ach-123';
|
||||
ormEntity.name = 'First Race';
|
||||
ormEntity.description = 'Complete your first race';
|
||||
ormEntity.category = 'driver';
|
||||
ormEntity.rarity = 'common';
|
||||
ormEntity.points = 10;
|
||||
ormEntity.requirements = [{ type: 'races_completed', value: 1, operator: '>=' }];
|
||||
ormEntity.isSecret = false;
|
||||
ormEntity.createdAt = new Date('2024-01-01');
|
||||
|
||||
mockMapper.toOrmEntity.mockReturnValue(ormEntity);
|
||||
mockAchievementRepo.save.mockResolvedValue(ormEntity);
|
||||
|
||||
// When
|
||||
const result = await repository.createAchievement(achievement);
|
||||
|
||||
// Then
|
||||
expect(mockMapper.toOrmEntity).toHaveBeenCalledWith(achievement);
|
||||
expect(mockAchievementRepo.save).toHaveBeenCalledWith(ormEntity);
|
||||
expect(result).toBe(achievement);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUserAchievementById', () => {
|
||||
// Given: a user achievement exists in the database
|
||||
// When: findUserAchievementById is called
|
||||
// Then: it should return the user achievement domain entity
|
||||
it('should return user achievement when found', async () => {
|
||||
// Given
|
||||
const userAchievementId = 'ua-123';
|
||||
const ormEntity = new UserAchievementOrmEntity();
|
||||
ormEntity.id = userAchievementId;
|
||||
ormEntity.userId = 'user-456';
|
||||
ormEntity.achievementId = 'ach-789';
|
||||
ormEntity.earnedAt = new Date('2024-01-01');
|
||||
ormEntity.progress = 50;
|
||||
ormEntity.notifiedAt = null;
|
||||
|
||||
const domainEntity = UserAchievement.create({
|
||||
id: userAchievementId,
|
||||
userId: 'user-456',
|
||||
achievementId: 'ach-789',
|
||||
earnedAt: new Date('2024-01-01'),
|
||||
progress: 50,
|
||||
});
|
||||
|
||||
mockUserAchievementRepo.findOne.mockResolvedValue(ormEntity);
|
||||
mockMapper.toUserAchievementDomain.mockReturnValue(domainEntity);
|
||||
|
||||
// When
|
||||
const result = await repository.findUserAchievementById(userAchievementId);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.findOne).toHaveBeenCalledWith({ where: { id: userAchievementId } });
|
||||
expect(mockMapper.toUserAchievementDomain).toHaveBeenCalledWith(ormEntity);
|
||||
expect(result).toBe(domainEntity);
|
||||
});
|
||||
|
||||
// Given: no user achievement exists with the given ID
|
||||
// When: findUserAchievementById is called
|
||||
// Then: it should return null
|
||||
it('should return null when user achievement not found', async () => {
|
||||
// Given
|
||||
const userAchievementId = 'ua-999';
|
||||
mockUserAchievementRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
// When
|
||||
const result = await repository.findUserAchievementById(userAchievementId);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.findOne).toHaveBeenCalledWith({ where: { id: userAchievementId } });
|
||||
expect(mockMapper.toUserAchievementDomain).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUserAchievementsByUserId', () => {
|
||||
// Given: user achievements exist for a specific user
|
||||
// When: findUserAchievementsByUserId is called
|
||||
// Then: it should return user achievements for that user
|
||||
it('should return user achievements by user ID', async () => {
|
||||
// Given
|
||||
const userId = 'user-456';
|
||||
const ormEntity = new UserAchievementOrmEntity();
|
||||
ormEntity.id = 'ua-123';
|
||||
ormEntity.userId = userId;
|
||||
ormEntity.achievementId = 'ach-789';
|
||||
ormEntity.earnedAt = new Date('2024-01-01');
|
||||
ormEntity.progress = 50;
|
||||
ormEntity.notifiedAt = null;
|
||||
|
||||
const domainEntity = UserAchievement.create({
|
||||
id: 'ua-123',
|
||||
userId: userId,
|
||||
achievementId: 'ach-789',
|
||||
earnedAt: new Date('2024-01-01'),
|
||||
progress: 50,
|
||||
});
|
||||
|
||||
mockUserAchievementRepo.find.mockResolvedValue([ormEntity]);
|
||||
mockMapper.toUserAchievementDomain.mockReturnValue(domainEntity);
|
||||
|
||||
// When
|
||||
const result = await repository.findUserAchievementsByUserId(userId);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.find).toHaveBeenCalledWith({ where: { userId } });
|
||||
expect(mockMapper.toUserAchievementDomain).toHaveBeenCalledWith(ormEntity);
|
||||
expect(result).toEqual([domainEntity]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUserAchievementByUserAndAchievement', () => {
|
||||
// Given: a user achievement exists for a specific user and achievement
|
||||
// When: findUserAchievementByUserAndAchievement is called
|
||||
// Then: it should return the user achievement
|
||||
it('should return user achievement by user and achievement IDs', async () => {
|
||||
// Given
|
||||
const userId = 'user-456';
|
||||
const achievementId = 'ach-789';
|
||||
const ormEntity = new UserAchievementOrmEntity();
|
||||
ormEntity.id = 'ua-123';
|
||||
ormEntity.userId = userId;
|
||||
ormEntity.achievementId = achievementId;
|
||||
ormEntity.earnedAt = new Date('2024-01-01');
|
||||
ormEntity.progress = 50;
|
||||
ormEntity.notifiedAt = null;
|
||||
|
||||
const domainEntity = UserAchievement.create({
|
||||
id: 'ua-123',
|
||||
userId: userId,
|
||||
achievementId: achievementId,
|
||||
earnedAt: new Date('2024-01-01'),
|
||||
progress: 50,
|
||||
});
|
||||
|
||||
mockUserAchievementRepo.findOne.mockResolvedValue(ormEntity);
|
||||
mockMapper.toUserAchievementDomain.mockReturnValue(domainEntity);
|
||||
|
||||
// When
|
||||
const result = await repository.findUserAchievementByUserAndAchievement(userId, achievementId);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.findOne).toHaveBeenCalledWith({ where: { userId, achievementId } });
|
||||
expect(mockMapper.toUserAchievementDomain).toHaveBeenCalledWith(ormEntity);
|
||||
expect(result).toBe(domainEntity);
|
||||
});
|
||||
|
||||
// Given: no user achievement exists for the given user and achievement
|
||||
// When: findUserAchievementByUserAndAchievement is called
|
||||
// Then: it should return null
|
||||
it('should return null when user achievement not found', async () => {
|
||||
// Given
|
||||
const userId = 'user-456';
|
||||
const achievementId = 'ach-999';
|
||||
mockUserAchievementRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
// When
|
||||
const result = await repository.findUserAchievementByUserAndAchievement(userId, achievementId);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.findOne).toHaveBeenCalledWith({ where: { userId, achievementId } });
|
||||
expect(mockMapper.toUserAchievementDomain).not.toHaveBeenCalled();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasUserEarnedAchievement', () => {
|
||||
// Given: a user has earned an achievement (progress = 100)
|
||||
// When: hasUserEarnedAchievement is called
|
||||
// Then: it should return true
|
||||
it('should return true when user has earned achievement', async () => {
|
||||
// Given
|
||||
const userId = 'user-456';
|
||||
const achievementId = 'ach-789';
|
||||
const ormEntity = new UserAchievementOrmEntity();
|
||||
ormEntity.id = 'ua-123';
|
||||
ormEntity.userId = userId;
|
||||
ormEntity.achievementId = achievementId;
|
||||
ormEntity.earnedAt = new Date('2024-01-01');
|
||||
ormEntity.progress = 100;
|
||||
ormEntity.notifiedAt = null;
|
||||
|
||||
const domainEntity = UserAchievement.create({
|
||||
id: 'ua-123',
|
||||
userId: userId,
|
||||
achievementId: achievementId,
|
||||
earnedAt: new Date('2024-01-01'),
|
||||
progress: 100,
|
||||
});
|
||||
|
||||
mockUserAchievementRepo.findOne.mockResolvedValue(ormEntity);
|
||||
mockMapper.toUserAchievementDomain.mockReturnValue(domainEntity);
|
||||
|
||||
// When
|
||||
const result = await repository.hasUserEarnedAchievement(userId, achievementId);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.findOne).toHaveBeenCalledWith({ where: { userId, achievementId } });
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
// Given: a user has not earned an achievement (progress < 100)
|
||||
// When: hasUserEarnedAchievement is called
|
||||
// Then: it should return false
|
||||
it('should return false when user has not earned achievement', async () => {
|
||||
// Given
|
||||
const userId = 'user-456';
|
||||
const achievementId = 'ach-789';
|
||||
const ormEntity = new UserAchievementOrmEntity();
|
||||
ormEntity.id = 'ua-123';
|
||||
ormEntity.userId = userId;
|
||||
ormEntity.achievementId = achievementId;
|
||||
ormEntity.earnedAt = new Date('2024-01-01');
|
||||
ormEntity.progress = 50;
|
||||
ormEntity.notifiedAt = null;
|
||||
|
||||
const domainEntity = UserAchievement.create({
|
||||
id: 'ua-123',
|
||||
userId: userId,
|
||||
achievementId: achievementId,
|
||||
earnedAt: new Date('2024-01-01'),
|
||||
progress: 50,
|
||||
});
|
||||
|
||||
mockUserAchievementRepo.findOne.mockResolvedValue(ormEntity);
|
||||
mockMapper.toUserAchievementDomain.mockReturnValue(domainEntity);
|
||||
|
||||
// When
|
||||
const result = await repository.hasUserEarnedAchievement(userId, achievementId);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.findOne).toHaveBeenCalledWith({ where: { userId, achievementId } });
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
// Given: no user achievement exists
|
||||
// When: hasUserEarnedAchievement is called
|
||||
// Then: it should return false
|
||||
it('should return false when user achievement not found', async () => {
|
||||
// Given
|
||||
const userId = 'user-456';
|
||||
const achievementId = 'ach-999';
|
||||
mockUserAchievementRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
// When
|
||||
const result = await repository.hasUserEarnedAchievement(userId, achievementId);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.findOne).toHaveBeenCalledWith({ where: { userId, achievementId } });
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createUserAchievement', () => {
|
||||
// Given: a valid user achievement domain entity
|
||||
// When: createUserAchievement is called
|
||||
// Then: it should save the user achievement and return it
|
||||
it('should create and save user achievement', async () => {
|
||||
// Given
|
||||
const userAchievement = UserAchievement.create({
|
||||
id: 'ua-123',
|
||||
userId: 'user-456',
|
||||
achievementId: 'ach-789',
|
||||
earnedAt: new Date('2024-01-01'),
|
||||
progress: 50,
|
||||
});
|
||||
|
||||
const ormEntity = new UserAchievementOrmEntity();
|
||||
ormEntity.id = 'ua-123';
|
||||
ormEntity.userId = 'user-456';
|
||||
ormEntity.achievementId = 'ach-789';
|
||||
ormEntity.earnedAt = new Date('2024-01-01');
|
||||
ormEntity.progress = 50;
|
||||
ormEntity.notifiedAt = null;
|
||||
|
||||
mockMapper.toUserAchievementOrmEntity.mockReturnValue(ormEntity);
|
||||
mockUserAchievementRepo.save.mockResolvedValue(ormEntity);
|
||||
|
||||
// When
|
||||
const result = await repository.createUserAchievement(userAchievement);
|
||||
|
||||
// Then
|
||||
expect(mockMapper.toUserAchievementOrmEntity).toHaveBeenCalledWith(userAchievement);
|
||||
expect(mockUserAchievementRepo.save).toHaveBeenCalledWith(ormEntity);
|
||||
expect(result).toBe(userAchievement);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUserAchievement', () => {
|
||||
// Given: an existing user achievement to update
|
||||
// When: updateUserAchievement is called
|
||||
// Then: it should update the user achievement and return it
|
||||
it('should update and save user achievement', async () => {
|
||||
// Given
|
||||
const userAchievement = UserAchievement.create({
|
||||
id: 'ua-123',
|
||||
userId: 'user-456',
|
||||
achievementId: 'ach-789',
|
||||
earnedAt: new Date('2024-01-01'),
|
||||
progress: 75,
|
||||
});
|
||||
|
||||
const ormEntity = new UserAchievementOrmEntity();
|
||||
ormEntity.id = 'ua-123';
|
||||
ormEntity.userId = 'user-456';
|
||||
ormEntity.achievementId = 'ach-789';
|
||||
ormEntity.earnedAt = new Date('2024-01-01');
|
||||
ormEntity.progress = 75;
|
||||
ormEntity.notifiedAt = null;
|
||||
|
||||
mockMapper.toUserAchievementOrmEntity.mockReturnValue(ormEntity);
|
||||
mockUserAchievementRepo.save.mockResolvedValue(ormEntity);
|
||||
|
||||
// When
|
||||
const result = await repository.updateUserAchievement(userAchievement);
|
||||
|
||||
// Then
|
||||
expect(mockMapper.toUserAchievementOrmEntity).toHaveBeenCalledWith(userAchievement);
|
||||
expect(mockUserAchievementRepo.save).toHaveBeenCalledWith(ormEntity);
|
||||
expect(result).toBe(userAchievement);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAchievementLeaderboard', () => {
|
||||
// Given: multiple users have completed achievements
|
||||
// When: getAchievementLeaderboard is called
|
||||
// Then: it should return sorted leaderboard
|
||||
it('should return achievement leaderboard', async () => {
|
||||
// Given
|
||||
const userAchievement1 = new UserAchievementOrmEntity();
|
||||
userAchievement1.id = 'ua-1';
|
||||
userAchievement1.userId = 'user-1';
|
||||
userAchievement1.achievementId = 'ach-1';
|
||||
userAchievement1.progress = 100;
|
||||
|
||||
const userAchievement2 = new UserAchievementOrmEntity();
|
||||
userAchievement2.id = 'ua-2';
|
||||
userAchievement2.userId = 'user-2';
|
||||
userAchievement2.achievementId = 'ach-2';
|
||||
userAchievement2.progress = 100;
|
||||
|
||||
const achievement1 = new AchievementOrmEntity();
|
||||
achievement1.id = 'ach-1';
|
||||
achievement1.points = 10;
|
||||
|
||||
const achievement2 = new AchievementOrmEntity();
|
||||
achievement2.id = 'ach-2';
|
||||
achievement2.points = 20;
|
||||
|
||||
mockUserAchievementRepo.find.mockResolvedValue([userAchievement1, userAchievement2]);
|
||||
mockAchievementRepo.findOne
|
||||
.mockResolvedValueOnce(achievement1)
|
||||
.mockResolvedValueOnce(achievement2);
|
||||
|
||||
// When
|
||||
const result = await repository.getAchievementLeaderboard(10);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.find).toHaveBeenCalledWith({ where: { progress: 100 } });
|
||||
expect(mockAchievementRepo.findOne).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual([
|
||||
{ userId: 'user-2', points: 20, count: 1 },
|
||||
{ userId: 'user-1', points: 10, count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
// Given: no completed user achievements exist
|
||||
// When: getAchievementLeaderboard is called
|
||||
// Then: it should return empty array
|
||||
it('should return empty array when no completed achievements', async () => {
|
||||
// Given
|
||||
mockUserAchievementRepo.find.mockResolvedValue([]);
|
||||
|
||||
// When
|
||||
const result = await repository.getAchievementLeaderboard(10);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.find).toHaveBeenCalledWith({ where: { progress: 100 } });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
// Given: user achievements exist but achievement not found
|
||||
// When: getAchievementLeaderboard is called
|
||||
// Then: it should skip those achievements
|
||||
it('should skip achievements that cannot be found', async () => {
|
||||
// Given
|
||||
const userAchievement = new UserAchievementOrmEntity();
|
||||
userAchievement.id = 'ua-1';
|
||||
userAchievement.userId = 'user-1';
|
||||
userAchievement.achievementId = 'ach-999';
|
||||
userAchievement.progress = 100;
|
||||
|
||||
mockUserAchievementRepo.find.mockResolvedValue([userAchievement]);
|
||||
mockAchievementRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
// When
|
||||
const result = await repository.getAchievementLeaderboard(10);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.find).toHaveBeenCalledWith({ where: { progress: 100 } });
|
||||
expect(mockAchievementRepo.findOne).toHaveBeenCalledWith({ where: { id: 'ach-999' } });
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserAchievementStats', () => {
|
||||
// Given: a user has completed achievements
|
||||
// When: getUserAchievementStats is called
|
||||
// Then: it should return user statistics
|
||||
it('should return user achievement statistics', async () => {
|
||||
// Given
|
||||
const userId = 'user-1';
|
||||
const userAchievement1 = new UserAchievementOrmEntity();
|
||||
userAchievement1.id = 'ua-1';
|
||||
userAchievement1.userId = userId;
|
||||
userAchievement1.achievementId = 'ach-1';
|
||||
userAchievement1.progress = 100;
|
||||
|
||||
const userAchievement2 = new UserAchievementOrmEntity();
|
||||
userAchievement2.id = 'ua-2';
|
||||
userAchievement2.userId = userId;
|
||||
userAchievement2.achievementId = 'ach-2';
|
||||
userAchievement2.progress = 100;
|
||||
|
||||
const achievement1 = new AchievementOrmEntity();
|
||||
achievement1.id = 'ach-1';
|
||||
achievement1.category = 'driver';
|
||||
achievement1.points = 10;
|
||||
|
||||
const achievement2 = new AchievementOrmEntity();
|
||||
achievement2.id = 'ach-2';
|
||||
achievement2.category = 'steward';
|
||||
achievement2.points = 20;
|
||||
|
||||
mockUserAchievementRepo.find.mockResolvedValue([userAchievement1, userAchievement2]);
|
||||
mockAchievementRepo.findOne
|
||||
.mockResolvedValueOnce(achievement1)
|
||||
.mockResolvedValueOnce(achievement2);
|
||||
|
||||
// When
|
||||
const result = await repository.getUserAchievementStats(userId);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.find).toHaveBeenCalledWith({ where: { userId, progress: 100 } });
|
||||
expect(mockAchievementRepo.findOne).toHaveBeenCalledTimes(2);
|
||||
expect(result).toEqual({
|
||||
total: 2,
|
||||
points: 30,
|
||||
byCategory: {
|
||||
driver: 1,
|
||||
steward: 1,
|
||||
admin: 0,
|
||||
community: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Given: a user has no completed achievements
|
||||
// When: getUserAchievementStats is called
|
||||
// Then: it should return zero statistics
|
||||
it('should return zero statistics when no completed achievements', async () => {
|
||||
// Given
|
||||
const userId = 'user-1';
|
||||
mockUserAchievementRepo.find.mockResolvedValue([]);
|
||||
|
||||
// When
|
||||
const result = await repository.getUserAchievementStats(userId);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.find).toHaveBeenCalledWith({ where: { userId, progress: 100 } });
|
||||
expect(result).toEqual({
|
||||
total: 0,
|
||||
points: 0,
|
||||
byCategory: {
|
||||
driver: 0,
|
||||
steward: 0,
|
||||
admin: 0,
|
||||
community: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Given: a user has completed achievements but achievement not found
|
||||
// When: getUserAchievementStats is called
|
||||
// Then: it should skip those achievements
|
||||
it('should skip achievements that cannot be found', async () => {
|
||||
// Given
|
||||
const userId = 'user-1';
|
||||
const userAchievement = new UserAchievementOrmEntity();
|
||||
userAchievement.id = 'ua-1';
|
||||
userAchievement.userId = userId;
|
||||
userAchievement.achievementId = 'ach-999';
|
||||
userAchievement.progress = 100;
|
||||
|
||||
mockUserAchievementRepo.find.mockResolvedValue([userAchievement]);
|
||||
mockAchievementRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
// When
|
||||
const result = await repository.getUserAchievementStats(userId);
|
||||
|
||||
// Then
|
||||
expect(mockUserAchievementRepo.find).toHaveBeenCalledWith({ where: { userId, progress: 100 } });
|
||||
expect(mockAchievementRepo.findOne).toHaveBeenCalledWith({ where: { id: 'ach-999' } });
|
||||
expect(result).toEqual({
|
||||
total: 1,
|
||||
points: 0,
|
||||
byCategory: {
|
||||
driver: 0,
|
||||
steward: 0,
|
||||
admin: 0,
|
||||
community: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,550 @@
|
||||
import { TypeOrmPersistenceSchemaAdapter } from '../errors/TypeOrmPersistenceSchemaAdapterError';
|
||||
import {
|
||||
assertNonEmptyString,
|
||||
assertDate,
|
||||
assertEnumValue,
|
||||
assertArray,
|
||||
assertNumber,
|
||||
assertInteger,
|
||||
assertBoolean,
|
||||
assertOptionalStringOrNull,
|
||||
assertRecord,
|
||||
} from './AchievementSchemaGuard';
|
||||
|
||||
describe('AchievementSchemaGuard', () => {
|
||||
describe('assertNonEmptyString', () => {
|
||||
// Given: a valid non-empty string
|
||||
// When: assertNonEmptyString is called
|
||||
// Then: it should not throw an error
|
||||
it('should accept a valid non-empty string', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = 'valid string';
|
||||
|
||||
// When & Then
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
// Given: a value that is not a string
|
||||
// When: assertNonEmptyString is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_string'
|
||||
it('should reject a non-string value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = 123;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_string',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: an empty string
|
||||
// When: assertNonEmptyString is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'empty_string'
|
||||
it('should reject an empty string', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = '';
|
||||
|
||||
// When & Then
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'empty_string',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: a string with only whitespace
|
||||
// When: assertNonEmptyString is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'empty_string'
|
||||
it('should reject a string with only whitespace', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = ' ';
|
||||
|
||||
// When & Then
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'empty_string',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertDate', () => {
|
||||
// Given: a valid Date object
|
||||
// When: assertDate is called
|
||||
// Then: it should not throw an error
|
||||
it('should accept a valid Date object', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = new Date();
|
||||
|
||||
// When & Then
|
||||
expect(() => assertDate(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
// Given: a value that is not a Date
|
||||
// When: assertDate is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_date'
|
||||
it('should reject a non-Date value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = '2024-01-01';
|
||||
|
||||
// When & Then
|
||||
expect(() => assertDate(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertDate(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_date',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: an invalid Date object (NaN)
|
||||
// When: assertDate is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'invalid_date'
|
||||
it('should reject an invalid Date object', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = new Date('invalid');
|
||||
|
||||
// When & Then
|
||||
expect(() => assertDate(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertDate(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'invalid_date',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertEnumValue', () => {
|
||||
const VALID_VALUES = ['option1', 'option2', 'option3'] as const;
|
||||
|
||||
// Given: a valid enum value
|
||||
// When: assertEnumValue is called
|
||||
// Then: it should not throw an error
|
||||
it('should accept a valid enum value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = 'option1';
|
||||
|
||||
// When & Then
|
||||
expect(() => assertEnumValue(entityName, fieldName, value, VALID_VALUES)).not.toThrow();
|
||||
});
|
||||
|
||||
// Given: a value that is not a string
|
||||
// When: assertEnumValue is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_string'
|
||||
it('should reject a non-string value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = 123;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertEnumValue(entityName, fieldName, value, VALID_VALUES)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertEnumValue(entityName, fieldName, value, VALID_VALUES)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_string',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: an invalid enum value
|
||||
// When: assertEnumValue is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'invalid_enum_value'
|
||||
it('should reject an invalid enum value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = 'invalid_option';
|
||||
|
||||
// When & Then
|
||||
expect(() => assertEnumValue(entityName, fieldName, value, VALID_VALUES)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertEnumValue(entityName, fieldName, value, VALID_VALUES)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'invalid_enum_value',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertArray', () => {
|
||||
// Given: a valid array
|
||||
// When: assertArray is called
|
||||
// Then: it should not throw an error
|
||||
it('should accept a valid array', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = [1, 2, 3];
|
||||
|
||||
// When & Then
|
||||
expect(() => assertArray(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
// Given: a value that is not an array
|
||||
// When: assertArray is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_array'
|
||||
it('should reject a non-array value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = { key: 'value' };
|
||||
|
||||
// When & Then
|
||||
expect(() => assertArray(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertArray(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_array',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: null value
|
||||
// When: assertArray is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_array'
|
||||
it('should reject null value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = null;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertArray(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertArray(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_array',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertNumber', () => {
|
||||
// Given: a valid number
|
||||
// When: assertNumber is called
|
||||
// Then: it should not throw an error
|
||||
it('should accept a valid number', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = 42;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertNumber(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
// Given: a value that is not a number
|
||||
// When: assertNumber is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_number'
|
||||
it('should reject a non-number value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = '42';
|
||||
|
||||
// When & Then
|
||||
expect(() => assertNumber(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertNumber(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_number',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: NaN value
|
||||
// When: assertNumber is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_number'
|
||||
it('should reject NaN value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = NaN;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertNumber(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertNumber(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_number',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertInteger', () => {
|
||||
// Given: a valid integer
|
||||
// When: assertInteger is called
|
||||
// Then: it should not throw an error
|
||||
it('should accept a valid integer', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = 42;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertInteger(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
// Given: a value that is not an integer (float)
|
||||
// When: assertInteger is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_integer'
|
||||
it('should reject a float value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = 42.5;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertInteger(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertInteger(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_integer',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: a value that is not a number
|
||||
// When: assertInteger is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_integer'
|
||||
it('should reject a non-number value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = '42';
|
||||
|
||||
// When & Then
|
||||
expect(() => assertInteger(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertInteger(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_integer',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertBoolean', () => {
|
||||
// Given: a valid boolean (true)
|
||||
// When: assertBoolean is called
|
||||
// Then: it should not throw an error
|
||||
it('should accept true', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = true;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertBoolean(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
// Given: a valid boolean (false)
|
||||
// When: assertBoolean is called
|
||||
// Then: it should not throw an error
|
||||
it('should accept false', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = false;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertBoolean(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
// Given: a value that is not a boolean
|
||||
// When: assertBoolean is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_boolean'
|
||||
it('should reject a non-boolean value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = 'true';
|
||||
|
||||
// When & Then
|
||||
expect(() => assertBoolean(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertBoolean(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_boolean',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertOptionalStringOrNull', () => {
|
||||
// Given: a valid string
|
||||
// When: assertOptionalStringOrNull is called
|
||||
// Then: it should not throw an error
|
||||
it('should accept a valid string', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = 'valid string';
|
||||
|
||||
// When & Then
|
||||
expect(() => assertOptionalStringOrNull(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
// Given: null value
|
||||
// When: assertOptionalStringOrNull is called
|
||||
// Then: it should not throw an error
|
||||
it('should accept null value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = null;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertOptionalStringOrNull(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
// Given: undefined value
|
||||
// When: assertOptionalStringOrNull is called
|
||||
// Then: it should not throw an error
|
||||
it('should accept undefined value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = undefined;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertOptionalStringOrNull(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
// Given: a value that is not a string, null, or undefined
|
||||
// When: assertOptionalStringOrNull is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_string'
|
||||
it('should reject a non-string value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = 123;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertOptionalStringOrNull(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertOptionalStringOrNull(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_string',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertRecord', () => {
|
||||
// Given: a valid record (object)
|
||||
// When: assertRecord is called
|
||||
// Then: it should not throw an error
|
||||
it('should accept a valid record', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = { key: 'value' };
|
||||
|
||||
// When & Then
|
||||
expect(() => assertRecord(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
// Given: a value that is not an object (null)
|
||||
// When: assertRecord is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_object'
|
||||
it('should reject null value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = null;
|
||||
|
||||
// When & Then
|
||||
expect(() => assertRecord(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertRecord(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_object',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: a value that is an array
|
||||
// When: assertRecord is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_object'
|
||||
it('should reject array value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = [1, 2, 3];
|
||||
|
||||
// When & Then
|
||||
expect(() => assertRecord(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertRecord(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_object',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Given: a value that is a primitive (string)
|
||||
// When: assertRecord is called
|
||||
// Then: it should throw TypeOrmPersistenceSchemaAdapter with reason 'not_object'
|
||||
it('should reject string value', () => {
|
||||
// Given
|
||||
const entityName = 'TestEntity';
|
||||
const fieldName = 'testField';
|
||||
const value = 'not an object';
|
||||
|
||||
// When & Then
|
||||
expect(() => assertRecord(entityName, fieldName, value)).toThrow(TypeOrmPersistenceSchemaAdapter);
|
||||
expect(() => assertRecord(entityName, fieldName, value)).toThrow(
|
||||
expect.objectContaining({
|
||||
entityName,
|
||||
fieldName,
|
||||
reason: 'not_object',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { InMemoryActivityRepository } from './InMemoryActivityRepository';
|
||||
import { DriverData } from '../../../../core/dashboard/application/ports/DashboardRepository';
|
||||
|
||||
describe('InMemoryActivityRepository', () => {
|
||||
let repository: InMemoryActivityRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = new InMemoryActivityRepository();
|
||||
});
|
||||
|
||||
describe('findDriverById', () => {
|
||||
it('should return null when driver does not exist', async () => {
|
||||
// Given
|
||||
const driverId = 'non-existent';
|
||||
|
||||
// When
|
||||
const result = await repository.findDriverById(driverId);
|
||||
|
||||
// Then
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return driver when it exists', async () => {
|
||||
// Given
|
||||
const driver: DriverData = {
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1500,
|
||||
rank: 10,
|
||||
starts: 100,
|
||||
wins: 10,
|
||||
podiums: 30,
|
||||
leagues: 5,
|
||||
};
|
||||
repository.addDriver(driver);
|
||||
|
||||
// When
|
||||
const result = await repository.findDriverById(driver.id);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(driver);
|
||||
});
|
||||
|
||||
it('should overwrite driver with same id (idempotency/uniqueness)', async () => {
|
||||
// Given
|
||||
const driverId = 'driver-1';
|
||||
const driver1: DriverData = {
|
||||
id: driverId,
|
||||
name: 'John Doe',
|
||||
rating: 1500,
|
||||
rank: 10,
|
||||
starts: 100,
|
||||
wins: 10,
|
||||
podiums: 30,
|
||||
leagues: 5,
|
||||
};
|
||||
const driver2: DriverData = {
|
||||
id: driverId,
|
||||
name: 'John Updated',
|
||||
rating: 1600,
|
||||
rank: 5,
|
||||
starts: 101,
|
||||
wins: 11,
|
||||
podiums: 31,
|
||||
leagues: 5,
|
||||
};
|
||||
|
||||
// When
|
||||
repository.addDriver(driver1);
|
||||
repository.addDriver(driver2);
|
||||
const result = await repository.findDriverById(driverId);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(driver2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upcomingRaces', () => {
|
||||
it('should return empty array when no races for driver', async () => {
|
||||
// When
|
||||
const result = await repository.getUpcomingRaces('driver-1');
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return races when they exist', async () => {
|
||||
// Given
|
||||
const driverId = 'driver-1';
|
||||
const races = [{ id: 'race-1', name: 'Grand Prix', date: new Date().toISOString() }];
|
||||
repository.addUpcomingRaces(driverId, races);
|
||||
|
||||
// When
|
||||
const result = await repository.getUpcomingRaces(driverId);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(races);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AdminUser } from '../../domain/entities/AdminUser';
|
||||
import { UserRole } from '../../domain/value-objects/UserRole';
|
||||
import { UserStatus } from '../../domain/value-objects/UserStatus';
|
||||
import { AdminUser } from '@core/admin/domain/entities/AdminUser';
|
||||
import { UserRole } from '@core/admin/domain/value-objects/UserRole';
|
||||
import { UserStatus } from '@core/admin/domain/value-objects/UserStatus';
|
||||
import { InMemoryAdminUserRepository } from './InMemoryAdminUserRepository';
|
||||
|
||||
describe('InMemoryAdminUserRepository', () => {
|
||||
@@ -787,4 +787,4 @@ describe('InMemoryAdminUserRepository', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AdminUser } from '../../domain/entities/AdminUser';
|
||||
import { AdminUserRepository, StoredAdminUser, UserFilter, UserListQuery, UserListResult } from '../../domain/repositories/AdminUserRepository';
|
||||
import { Email } from '../../domain/value-objects/Email';
|
||||
import { UserId } from '../../domain/value-objects/UserId';
|
||||
import { AdminUser } from '@core/admin/domain/entities/AdminUser';
|
||||
import { AdminUserRepository, StoredAdminUser, UserFilter, UserListQuery, UserListResult } from '@core/admin/domain/repositories/AdminUserRepository';
|
||||
import { Email } from '@core/admin/domain/value-objects/Email';
|
||||
import { UserId } from '@core/admin/domain/value-objects/UserId';
|
||||
|
||||
/**
|
||||
* In-memory implementation of AdminUserRepository for testing and development
|
||||
@@ -254,4 +254,4 @@ export class InMemoryAdminUserRepository implements AdminUserRepository {
|
||||
|
||||
return AdminUser.rehydrate(props);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,610 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AdminUserOrmEntity } from './AdminUserOrmEntity';
|
||||
|
||||
describe('AdminUserOrmEntity', () => {
|
||||
describe('TDD - Test First', () => {
|
||||
describe('entity properties', () => {
|
||||
it('should have id property', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act & Assert
|
||||
expect(entity).toHaveProperty('id');
|
||||
});
|
||||
|
||||
it('should have email property', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act & Assert
|
||||
expect(entity).toHaveProperty('email');
|
||||
});
|
||||
|
||||
it('should have displayName property', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act & Assert
|
||||
expect(entity).toHaveProperty('displayName');
|
||||
});
|
||||
|
||||
it('should have roles property', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act & Assert
|
||||
expect(entity).toHaveProperty('roles');
|
||||
});
|
||||
|
||||
it('should have status property', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act & Assert
|
||||
expect(entity).toHaveProperty('status');
|
||||
});
|
||||
|
||||
it('should have primaryDriverId property', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act & Assert
|
||||
expect(entity).toHaveProperty('primaryDriverId');
|
||||
});
|
||||
|
||||
it('should have lastLoginAt property', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act & Assert
|
||||
expect(entity).toHaveProperty('lastLoginAt');
|
||||
});
|
||||
|
||||
it('should have createdAt property', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act & Assert
|
||||
expect(entity).toHaveProperty('createdAt');
|
||||
});
|
||||
|
||||
it('should have updatedAt property', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act & Assert
|
||||
expect(entity).toHaveProperty('updatedAt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('property types', () => {
|
||||
it('should have id as string', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
entity.id = 'test-id';
|
||||
|
||||
// Act & Assert
|
||||
expect(typeof entity.id).toBe('string');
|
||||
expect(entity.id).toBe('test-id');
|
||||
});
|
||||
|
||||
it('should have email as string', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
entity.email = 'test@example.com';
|
||||
|
||||
// Act & Assert
|
||||
expect(typeof entity.email).toBe('string');
|
||||
expect(entity.email).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('should have displayName as string', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
entity.displayName = 'Test User';
|
||||
|
||||
// Act & Assert
|
||||
expect(typeof entity.displayName).toBe('string');
|
||||
expect(entity.displayName).toBe('Test User');
|
||||
});
|
||||
|
||||
it('should have roles as string array', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
entity.roles = ['admin', 'user'];
|
||||
|
||||
// Act & Assert
|
||||
expect(Array.isArray(entity.roles)).toBe(true);
|
||||
expect(entity.roles).toEqual(['admin', 'user']);
|
||||
});
|
||||
|
||||
it('should have status as string', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
entity.status = 'active';
|
||||
|
||||
// Act & Assert
|
||||
expect(typeof entity.status).toBe('string');
|
||||
expect(entity.status).toBe('active');
|
||||
});
|
||||
|
||||
it('should have primaryDriverId as optional string', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act & Assert
|
||||
expect(entity.primaryDriverId).toBeUndefined();
|
||||
|
||||
entity.primaryDriverId = 'driver-123';
|
||||
expect(typeof entity.primaryDriverId).toBe('string');
|
||||
expect(entity.primaryDriverId).toBe('driver-123');
|
||||
});
|
||||
|
||||
it('should have lastLoginAt as optional Date', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act & Assert
|
||||
expect(entity.lastLoginAt).toBeUndefined();
|
||||
|
||||
const now = new Date();
|
||||
entity.lastLoginAt = now;
|
||||
expect(entity.lastLoginAt).toBeInstanceOf(Date);
|
||||
expect(entity.lastLoginAt).toBe(now);
|
||||
});
|
||||
|
||||
it('should have createdAt as Date', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const now = new Date();
|
||||
entity.createdAt = now;
|
||||
|
||||
// Act & Assert
|
||||
expect(entity.createdAt).toBeInstanceOf(Date);
|
||||
expect(entity.createdAt).toBe(now);
|
||||
});
|
||||
|
||||
it('should have updatedAt as Date', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const now = new Date();
|
||||
entity.updatedAt = now;
|
||||
|
||||
// Act & Assert
|
||||
expect(entity.updatedAt).toBeInstanceOf(Date);
|
||||
expect(entity.updatedAt).toBe(now);
|
||||
});
|
||||
});
|
||||
|
||||
describe('property values', () => {
|
||||
it('should handle valid UUID for id', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const uuid = '123e4567-e89b-12d3-a456-426614174000';
|
||||
|
||||
// Act
|
||||
entity.id = uuid;
|
||||
|
||||
// Assert
|
||||
expect(entity.id).toBe(uuid);
|
||||
});
|
||||
|
||||
it('should handle email with special characters', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const email = 'user+tag@example-domain.com';
|
||||
|
||||
// Act
|
||||
entity.email = email;
|
||||
|
||||
// Assert
|
||||
expect(entity.email).toBe(email);
|
||||
});
|
||||
|
||||
it('should handle display name with spaces', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const displayName = 'John Doe Smith';
|
||||
|
||||
// Act
|
||||
entity.displayName = displayName;
|
||||
|
||||
// Assert
|
||||
expect(entity.displayName).toBe(displayName);
|
||||
});
|
||||
|
||||
it('should handle roles with multiple entries', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const roles = ['owner', 'admin', 'user', 'moderator'];
|
||||
|
||||
// Act
|
||||
entity.roles = roles;
|
||||
|
||||
// Assert
|
||||
expect(entity.roles).toEqual(roles);
|
||||
expect(entity.roles).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should handle status with different values', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act & Assert
|
||||
entity.status = 'active';
|
||||
expect(entity.status).toBe('active');
|
||||
|
||||
entity.status = 'suspended';
|
||||
expect(entity.status).toBe('suspended');
|
||||
|
||||
entity.status = 'deleted';
|
||||
expect(entity.status).toBe('deleted');
|
||||
});
|
||||
|
||||
it('should handle primaryDriverId with valid driver ID', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const driverId = 'driver-abc123';
|
||||
|
||||
// Act
|
||||
entity.primaryDriverId = driverId;
|
||||
|
||||
// Assert
|
||||
expect(entity.primaryDriverId).toBe(driverId);
|
||||
});
|
||||
|
||||
it('should handle lastLoginAt with current date', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const now = new Date();
|
||||
|
||||
// Act
|
||||
entity.lastLoginAt = now;
|
||||
|
||||
// Assert
|
||||
expect(entity.lastLoginAt).toBe(now);
|
||||
});
|
||||
|
||||
it('should handle createdAt with specific date', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const specificDate = new Date('2024-01-01T00:00:00.000Z');
|
||||
|
||||
// Act
|
||||
entity.createdAt = specificDate;
|
||||
|
||||
// Assert
|
||||
expect(entity.createdAt).toBe(specificDate);
|
||||
});
|
||||
|
||||
it('should handle updatedAt with specific date', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const specificDate = new Date('2024-01-02T00:00:00.000Z');
|
||||
|
||||
// Act
|
||||
entity.updatedAt = specificDate;
|
||||
|
||||
// Assert
|
||||
expect(entity.updatedAt).toBe(specificDate);
|
||||
});
|
||||
});
|
||||
|
||||
describe('property assignments', () => {
|
||||
it('should allow setting all properties', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const now = new Date();
|
||||
|
||||
// Act
|
||||
entity.id = 'user-123';
|
||||
entity.email = 'test@example.com';
|
||||
entity.displayName = 'Test User';
|
||||
entity.roles = ['admin'];
|
||||
entity.status = 'active';
|
||||
entity.primaryDriverId = 'driver-456';
|
||||
entity.lastLoginAt = now;
|
||||
entity.createdAt = now;
|
||||
entity.updatedAt = now;
|
||||
|
||||
// Assert
|
||||
expect(entity.id).toBe('user-123');
|
||||
expect(entity.email).toBe('test@example.com');
|
||||
expect(entity.displayName).toBe('Test User');
|
||||
expect(entity.roles).toEqual(['admin']);
|
||||
expect(entity.status).toBe('active');
|
||||
expect(entity.primaryDriverId).toBe('driver-456');
|
||||
expect(entity.lastLoginAt).toBe(now);
|
||||
expect(entity.createdAt).toBe(now);
|
||||
expect(entity.updatedAt).toBe(now);
|
||||
});
|
||||
|
||||
it('should allow updating properties', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const now = new Date();
|
||||
const later = new Date(now.getTime() + 1000);
|
||||
|
||||
// Act
|
||||
entity.id = 'user-123';
|
||||
entity.email = 'test@example.com';
|
||||
entity.displayName = 'Test User';
|
||||
entity.roles = ['user'];
|
||||
entity.status = 'active';
|
||||
entity.primaryDriverId = 'driver-456';
|
||||
entity.lastLoginAt = now;
|
||||
entity.createdAt = now;
|
||||
entity.updatedAt = now;
|
||||
|
||||
// Update
|
||||
entity.displayName = 'Updated Name';
|
||||
entity.roles = ['admin', 'user'];
|
||||
entity.status = 'suspended';
|
||||
entity.lastLoginAt = later;
|
||||
entity.updatedAt = later;
|
||||
|
||||
// Assert
|
||||
expect(entity.displayName).toBe('Updated Name');
|
||||
expect(entity.roles).toEqual(['admin', 'user']);
|
||||
expect(entity.status).toBe('suspended');
|
||||
expect(entity.lastLoginAt).toBe(later);
|
||||
expect(entity.updatedAt).toBe(later);
|
||||
});
|
||||
|
||||
it('should allow clearing optional properties', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const now = new Date();
|
||||
|
||||
// Act
|
||||
entity.primaryDriverId = 'driver-123';
|
||||
entity.lastLoginAt = now;
|
||||
|
||||
// Clear
|
||||
entity.primaryDriverId = undefined;
|
||||
entity.lastLoginAt = undefined;
|
||||
|
||||
// Assert
|
||||
expect(entity.primaryDriverId).toBeUndefined();
|
||||
expect(entity.lastLoginAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty entity', () => {
|
||||
it('should create entity with undefined properties', () => {
|
||||
// Arrange & Act
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Assert
|
||||
expect(entity.id).toBeUndefined();
|
||||
expect(entity.email).toBeUndefined();
|
||||
expect(entity.displayName).toBeUndefined();
|
||||
expect(entity.roles).toBeUndefined();
|
||||
expect(entity.status).toBeUndefined();
|
||||
expect(entity.primaryDriverId).toBeUndefined();
|
||||
expect(entity.lastLoginAt).toBeUndefined();
|
||||
expect(entity.createdAt).toBeUndefined();
|
||||
expect(entity.updatedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow partial initialization', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act
|
||||
entity.id = 'user-123';
|
||||
entity.email = 'test@example.com';
|
||||
|
||||
// Assert
|
||||
expect(entity.id).toBe('user-123');
|
||||
expect(entity.email).toBe('test@example.com');
|
||||
expect(entity.displayName).toBeUndefined();
|
||||
expect(entity.roles).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world scenarios', () => {
|
||||
it('should handle complete user entity', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const now = new Date();
|
||||
|
||||
// Act
|
||||
entity.id = '123e4567-e89b-12d3-a456-426614174000';
|
||||
entity.email = 'admin@example.com';
|
||||
entity.displayName = 'Administrator';
|
||||
entity.roles = ['owner', 'admin'];
|
||||
entity.status = 'active';
|
||||
entity.primaryDriverId = 'driver-789';
|
||||
entity.lastLoginAt = now;
|
||||
entity.createdAt = now;
|
||||
entity.updatedAt = now;
|
||||
|
||||
// Assert
|
||||
expect(entity.id).toBe('123e4567-e89b-12d3-a456-426614174000');
|
||||
expect(entity.email).toBe('admin@example.com');
|
||||
expect(entity.displayName).toBe('Administrator');
|
||||
expect(entity.roles).toEqual(['owner', 'admin']);
|
||||
expect(entity.status).toBe('active');
|
||||
expect(entity.primaryDriverId).toBe('driver-789');
|
||||
expect(entity.lastLoginAt).toBe(now);
|
||||
expect(entity.createdAt).toBe(now);
|
||||
expect(entity.updatedAt).toBe(now);
|
||||
});
|
||||
|
||||
it('should handle user without primary driver', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const now = new Date();
|
||||
|
||||
// Act
|
||||
entity.id = 'user-456';
|
||||
entity.email = 'user@example.com';
|
||||
entity.displayName = 'Regular User';
|
||||
entity.roles = ['user'];
|
||||
entity.status = 'active';
|
||||
entity.createdAt = now;
|
||||
entity.updatedAt = now;
|
||||
|
||||
// Assert
|
||||
expect(entity.primaryDriverId).toBeUndefined();
|
||||
expect(entity.lastLoginAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle suspended user', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const now = new Date();
|
||||
|
||||
// Act
|
||||
entity.id = 'user-789';
|
||||
entity.email = 'suspended@example.com';
|
||||
entity.displayName = 'Suspended User';
|
||||
entity.roles = ['user'];
|
||||
entity.status = 'suspended';
|
||||
entity.createdAt = now;
|
||||
entity.updatedAt = now;
|
||||
|
||||
// Assert
|
||||
expect(entity.status).toBe('suspended');
|
||||
});
|
||||
|
||||
it('should handle user with many roles', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const now = new Date();
|
||||
|
||||
// Act
|
||||
entity.id = 'user-999';
|
||||
entity.email = 'multi@example.com';
|
||||
entity.displayName = 'Multi Role User';
|
||||
entity.roles = ['owner', 'admin', 'user', 'moderator', 'viewer'];
|
||||
entity.status = 'active';
|
||||
entity.createdAt = now;
|
||||
entity.updatedAt = now;
|
||||
|
||||
// Assert
|
||||
expect(entity.roles).toHaveLength(5);
|
||||
expect(entity.roles).toContain('owner');
|
||||
expect(entity.roles).toContain('admin');
|
||||
expect(entity.roles).toContain('user');
|
||||
expect(entity.roles).toContain('moderator');
|
||||
expect(entity.roles).toContain('viewer');
|
||||
});
|
||||
|
||||
it('should handle user with recent login', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const now = new Date();
|
||||
const recentLogin = new Date(now.getTime() - 60000); // 1 minute ago
|
||||
|
||||
// Act
|
||||
entity.id = 'user-111';
|
||||
entity.email = 'active@example.com';
|
||||
entity.displayName = 'Active User';
|
||||
entity.roles = ['user'];
|
||||
entity.status = 'active';
|
||||
entity.primaryDriverId = 'driver-222';
|
||||
entity.lastLoginAt = recentLogin;
|
||||
entity.createdAt = now;
|
||||
entity.updatedAt = now;
|
||||
|
||||
// Assert
|
||||
expect(entity.lastLoginAt).toBe(recentLogin);
|
||||
expect(entity.lastLoginAt!.getTime()).toBeLessThan(now.getTime());
|
||||
});
|
||||
|
||||
it('should handle user with old login', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const now = new Date();
|
||||
const oldLogin = new Date(now.getTime() - 86400000); // 1 day ago
|
||||
|
||||
// Act
|
||||
entity.id = 'user-333';
|
||||
entity.email = 'old@example.com';
|
||||
entity.displayName = 'Old Login User';
|
||||
entity.roles = ['user'];
|
||||
entity.status = 'active';
|
||||
entity.lastLoginAt = oldLogin;
|
||||
entity.createdAt = now;
|
||||
entity.updatedAt = now;
|
||||
|
||||
// Assert
|
||||
expect(entity.lastLoginAt).toBe(oldLogin);
|
||||
expect(entity.lastLoginAt!.getTime()).toBeLessThan(now.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty string values', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act
|
||||
entity.id = '';
|
||||
entity.email = '';
|
||||
entity.displayName = '';
|
||||
entity.status = '';
|
||||
|
||||
// Assert
|
||||
expect(entity.id).toBe('');
|
||||
expect(entity.email).toBe('');
|
||||
expect(entity.displayName).toBe('');
|
||||
expect(entity.status).toBe('');
|
||||
});
|
||||
|
||||
it('should handle empty roles array', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act
|
||||
entity.roles = [];
|
||||
|
||||
// Assert
|
||||
expect(entity.roles).toEqual([]);
|
||||
expect(entity.roles).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle null values for optional properties', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act
|
||||
entity.primaryDriverId = null as any;
|
||||
entity.lastLoginAt = null as any;
|
||||
|
||||
// Assert
|
||||
expect(entity.primaryDriverId).toBeNull();
|
||||
expect(entity.lastLoginAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle very long strings', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
const longString = 'a'.repeat(1000);
|
||||
|
||||
// Act
|
||||
entity.email = `${longString}@example.com`;
|
||||
entity.displayName = longString;
|
||||
|
||||
// Assert
|
||||
expect(entity.email).toBe(`${longString}@example.com`);
|
||||
expect(entity.displayName).toBe(longString);
|
||||
});
|
||||
|
||||
it('should handle unicode characters', () => {
|
||||
// Arrange
|
||||
const entity = new AdminUserOrmEntity();
|
||||
|
||||
// Act
|
||||
entity.email = '用户@例子.测试';
|
||||
entity.displayName = '用户 例子';
|
||||
|
||||
// Assert
|
||||
expect(entity.email).toBe('用户@例子.测试');
|
||||
expect(entity.displayName).toBe('用户 例子');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,521 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TypeOrmAdminSchemaError } from './TypeOrmAdminSchemaError';
|
||||
|
||||
describe('TypeOrmAdminSchemaError', () => {
|
||||
describe('TDD - Test First', () => {
|
||||
describe('constructor', () => {
|
||||
it('should create an error with all required details', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Invalid format',
|
||||
message: 'Email must be a valid email address',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.name).toBe('TypeOrmAdminSchemaError');
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: Invalid format - Email must be a valid email address');
|
||||
});
|
||||
|
||||
it('should create an error with minimal details', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'id',
|
||||
reason: 'Missing',
|
||||
message: 'ID field is required',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.id: Missing - ID field is required');
|
||||
});
|
||||
|
||||
it('should create an error with complex entity name', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUserOrmEntity',
|
||||
fieldName: 'roles',
|
||||
reason: 'Type mismatch',
|
||||
message: 'Expected simple-json but got text',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUserOrmEntity.roles: Type mismatch - Expected simple-json but got text');
|
||||
});
|
||||
|
||||
it('should create an error with long field name', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'veryLongFieldNameThatExceedsNormalLength',
|
||||
reason: 'Constraint violation',
|
||||
message: 'Field length exceeds maximum allowed',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.veryLongFieldNameThatExceedsNormalLength: Constraint violation - Field length exceeds maximum allowed');
|
||||
});
|
||||
|
||||
it('should create an error with special characters in message', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Validation failed',
|
||||
message: 'Email "test@example.com" contains invalid characters: @, ., com',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: Validation failed - Email "test@example.com" contains invalid characters: @, ., com');
|
||||
});
|
||||
|
||||
it('should create an error with empty reason', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: '',
|
||||
message: 'Email is required',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: - Email is required');
|
||||
});
|
||||
|
||||
it('should create an error with empty message', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Invalid',
|
||||
message: '',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: Invalid - ');
|
||||
});
|
||||
|
||||
it('should create an error with empty reason and message', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: '',
|
||||
message: '',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: - ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error properties', () => {
|
||||
it('should have correct error name', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Invalid',
|
||||
message: 'Test error',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.name).toBe('TypeOrmAdminSchemaError');
|
||||
});
|
||||
|
||||
it('should be instance of Error', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Invalid',
|
||||
message: 'Test error',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error instanceof Error).toBe(true);
|
||||
expect(error instanceof TypeOrmAdminSchemaError).toBe(true);
|
||||
});
|
||||
|
||||
it('should have a stack trace', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Invalid',
|
||||
message: 'Test error',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.stack).toBeDefined();
|
||||
expect(typeof error.stack).toBe('string');
|
||||
expect(error.stack).toContain('TypeOrmAdminSchemaError');
|
||||
});
|
||||
|
||||
it('should preserve details object reference', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Invalid',
|
||||
message: 'Test error',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toBe(details);
|
||||
});
|
||||
|
||||
it('should allow modification of details after creation', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Invalid',
|
||||
message: 'Test error',
|
||||
};
|
||||
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Act
|
||||
error.details.reason = 'Updated reason';
|
||||
|
||||
// Assert
|
||||
expect(error.details.reason).toBe('Updated reason');
|
||||
expect(error.message).toContain('Updated reason');
|
||||
});
|
||||
});
|
||||
|
||||
describe('message formatting', () => {
|
||||
it('should format message with all parts', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Validation failed',
|
||||
message: 'Email must be a valid email address',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: Validation failed - Email must be a valid email address');
|
||||
});
|
||||
|
||||
it('should handle multiple words in entity name', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'Admin User Entity',
|
||||
fieldName: 'email',
|
||||
reason: 'Invalid',
|
||||
message: 'Test',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] Admin User Entity.email: Invalid - Test');
|
||||
});
|
||||
|
||||
it('should handle multiple words in field name', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email address',
|
||||
reason: 'Invalid',
|
||||
message: 'Test',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email address: Invalid - Test');
|
||||
});
|
||||
|
||||
it('should handle multiple words in reason', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Validation failed completely',
|
||||
message: 'Test',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: Validation failed completely - Test');
|
||||
});
|
||||
|
||||
it('should handle multiple words in message', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Invalid',
|
||||
message: 'This is a very long error message that contains many words',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: Invalid - This is a very long error message that contains many words');
|
||||
});
|
||||
|
||||
it('should handle special characters in all parts', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'Admin_User-Entity',
|
||||
fieldName: 'email@address',
|
||||
reason: 'Validation failed: @, ., com',
|
||||
message: 'Email "test@example.com" is invalid',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] Admin_User-Entity.email@address: Validation failed: @, ., com - Email "test@example.com" is invalid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error inheritance', () => {
|
||||
it('should be instance of Error', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Invalid',
|
||||
message: 'Test error',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error instanceof Error).toBe(true);
|
||||
});
|
||||
|
||||
it('should be instance of TypeOrmAdminSchemaError', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Invalid',
|
||||
message: 'Test error',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error instanceof TypeOrmAdminSchemaError).toBe(true);
|
||||
});
|
||||
|
||||
it('should not be instance of other error types', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Invalid',
|
||||
message: 'Test error',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error instanceof TypeError).toBe(false);
|
||||
expect(error instanceof RangeError).toBe(false);
|
||||
expect(error instanceof ReferenceError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world scenarios', () => {
|
||||
it('should handle missing column error', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'primaryDriverId',
|
||||
reason: 'Column not found',
|
||||
message: 'Column "primary_driver_id" does not exist in table "admin_users"',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.primaryDriverId: Column not found - Column "primary_driver_id" does not exist in table "admin_users"');
|
||||
});
|
||||
|
||||
it('should handle type mismatch error', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'roles',
|
||||
reason: 'Type mismatch',
|
||||
message: 'Expected type "simple-json" but got "text" for column "roles"',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.roles: Type mismatch - Expected type "simple-json" but got "text" for column "roles"');
|
||||
});
|
||||
|
||||
it('should handle constraint violation error', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Constraint violation',
|
||||
message: 'UNIQUE constraint failed: admin_users.email',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: Constraint violation - UNIQUE constraint failed: admin_users.email');
|
||||
});
|
||||
|
||||
it('should handle nullable constraint error', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'displayName',
|
||||
reason: 'Constraint violation',
|
||||
message: 'NOT NULL constraint failed: admin_users.display_name',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.displayName: Constraint violation - NOT NULL constraint failed: admin_users.display_name');
|
||||
});
|
||||
|
||||
it('should handle foreign key constraint error', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'primaryDriverId',
|
||||
reason: 'Constraint violation',
|
||||
message: 'FOREIGN KEY constraint failed: admin_users.primary_driver_id references drivers.id',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.primaryDriverId: Constraint violation - FOREIGN KEY constraint failed: admin_users.primary_driver_id references drivers.id');
|
||||
});
|
||||
|
||||
it('should handle index creation error', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'email',
|
||||
reason: 'Index creation failed',
|
||||
message: 'Failed to create unique index on column "email"',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.email: Index creation failed - Failed to create unique index on column "email"');
|
||||
});
|
||||
|
||||
it('should handle default value error', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'status',
|
||||
reason: 'Default value error',
|
||||
message: 'Default value "active" is not valid for column "status"',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.status: Default value error - Default value "active" is not valid for column "status"');
|
||||
});
|
||||
|
||||
it('should handle timestamp column error', () => {
|
||||
// Arrange
|
||||
const details = {
|
||||
entityName: 'AdminUser',
|
||||
fieldName: 'createdAt',
|
||||
reason: 'Type error',
|
||||
message: 'Column "created_at" has invalid type "datetime" for PostgreSQL',
|
||||
};
|
||||
|
||||
// Act
|
||||
const error = new TypeOrmAdminSchemaError(details);
|
||||
|
||||
// Assert
|
||||
expect(error.details).toEqual(details);
|
||||
expect(error.message).toBe('[TypeOrmAdminSchemaError] AdminUser.createdAt: Type error - Column "created_at" has invalid type "datetime" for PostgreSQL');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,4 +10,4 @@ export class TypeOrmAdminSchemaError extends Error {
|
||||
super(`[TypeOrmAdminSchemaError] ${details.entityName}.${details.fieldName}: ${details.reason} - ${details.message}`);
|
||||
this.name = 'TypeOrmAdminSchemaError';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -92,4 +92,4 @@ export class AdminUserOrmMapper {
|
||||
toStored(entity: AdminUserOrmEntity): AdminUser {
|
||||
return this.toDomain(entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1016,4 +1016,4 @@ describe('TypeOrmAdminUserRepository', () => {
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -185,4 +185,4 @@ export class TypeOrmAdminUserRepository implements AdminUserRepository {
|
||||
|
||||
return AdminUser.rehydrate(props);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { TypeOrmAdminSchemaError } from '../errors/TypeOrmAdminSchemaError';
|
||||
import {
|
||||
assertNonEmptyString,
|
||||
assertStringArray,
|
||||
assertDate,
|
||||
assertOptionalDate,
|
||||
assertOptionalString,
|
||||
} from './TypeOrmAdminSchemaGuards';
|
||||
|
||||
describe('TypeOrmAdminSchemaGuards', () => {
|
||||
describe('TDD - Test First', () => {
|
||||
describe('assertNonEmptyString', () => {
|
||||
it('should pass for valid non-empty string', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'email';
|
||||
const value = 'test@example.com';
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error for empty string', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'email';
|
||||
const value = '';
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow('Field email must be a non-empty string');
|
||||
});
|
||||
|
||||
it('should throw error for string with only spaces', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'email';
|
||||
const value = ' ';
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow('Field email must be a non-empty string');
|
||||
});
|
||||
|
||||
it('should throw error for non-string value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'email';
|
||||
const value = 123;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow('Field email must be a non-empty string');
|
||||
});
|
||||
|
||||
it('should throw error for null value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'email';
|
||||
const value = null;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow('Field email must be a non-empty string');
|
||||
});
|
||||
|
||||
it('should throw error for undefined value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'email';
|
||||
const value = undefined;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertNonEmptyString(entityName, fieldName, value)).toThrow('Field email must be a non-empty string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertStringArray', () => {
|
||||
it('should pass for valid string array', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'roles';
|
||||
const value = ['admin', 'user'];
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertStringArray(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should pass for empty array', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'roles';
|
||||
const value = [];
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertStringArray(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error for non-array value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'roles';
|
||||
const value = 'admin';
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertStringArray(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertStringArray(entityName, fieldName, value)).toThrow('Field roles must be an array of strings');
|
||||
});
|
||||
|
||||
it('should throw error for array with non-string items', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'roles';
|
||||
const value = ['admin', 123];
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertStringArray(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertStringArray(entityName, fieldName, value)).toThrow('Field roles must be an array of strings');
|
||||
});
|
||||
|
||||
it('should throw error for null value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'roles';
|
||||
const value = null;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertStringArray(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertStringArray(entityName, fieldName, value)).toThrow('Field roles must be an array of strings');
|
||||
});
|
||||
|
||||
it('should throw error for undefined value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'roles';
|
||||
const value = undefined;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertStringArray(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertStringArray(entityName, fieldName, value)).toThrow('Field roles must be an array of strings');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertDate', () => {
|
||||
it('should pass for valid Date', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'createdAt';
|
||||
const value = new Date();
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertDate(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should pass for specific date', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'createdAt';
|
||||
const value = new Date('2024-01-01');
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertDate(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error for invalid date', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'createdAt';
|
||||
const value = new Date('invalid');
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertDate(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertDate(entityName, fieldName, value)).toThrow('Field createdAt must be a valid Date');
|
||||
});
|
||||
|
||||
it('should throw error for non-Date value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'createdAt';
|
||||
const value = '2024-01-01';
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertDate(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertDate(entityName, fieldName, value)).toThrow('Field createdAt must be a valid Date');
|
||||
});
|
||||
|
||||
it('should throw error for null value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'createdAt';
|
||||
const value = null;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertDate(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertDate(entityName, fieldName, value)).toThrow('Field createdAt must be a valid Date');
|
||||
});
|
||||
|
||||
it('should throw error for undefined value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'createdAt';
|
||||
const value = undefined;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertDate(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertDate(entityName, fieldName, value)).toThrow('Field createdAt must be a valid Date');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertOptionalDate', () => {
|
||||
it('should pass for valid Date', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'lastLoginAt';
|
||||
const value = new Date();
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertOptionalDate(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should pass for null value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'lastLoginAt';
|
||||
const value = null;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertOptionalDate(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should pass for undefined value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'lastLoginAt';
|
||||
const value = undefined;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertOptionalDate(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error for invalid date', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'lastLoginAt';
|
||||
const value = new Date('invalid');
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertOptionalDate(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertOptionalDate(entityName, fieldName, value)).toThrow('Field lastLoginAt must be a valid Date');
|
||||
});
|
||||
|
||||
it('should throw error for non-Date value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'lastLoginAt';
|
||||
const value = '2024-01-01';
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertOptionalDate(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertOptionalDate(entityName, fieldName, value)).toThrow('Field lastLoginAt must be a valid Date');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertOptionalString', () => {
|
||||
it('should pass for valid string', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'primaryDriverId';
|
||||
const value = 'driver-123';
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertOptionalString(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should pass for null value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'primaryDriverId';
|
||||
const value = null;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertOptionalString(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should pass for undefined value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'primaryDriverId';
|
||||
const value = undefined;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertOptionalString(entityName, fieldName, value)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error for non-string value', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'primaryDriverId';
|
||||
const value = 123;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertOptionalString(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertOptionalString(entityName, fieldName, value)).toThrow('Field primaryDriverId must be a string or undefined');
|
||||
});
|
||||
|
||||
it('should throw error for empty string', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const fieldName = 'primaryDriverId';
|
||||
const value = '';
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertOptionalString(entityName, fieldName, value)).toThrow(TypeOrmAdminSchemaError);
|
||||
expect(() => assertOptionalString(entityName, fieldName, value)).toThrow('Field primaryDriverId must be a string or undefined');
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world scenarios', () => {
|
||||
it('should validate complete admin user entity', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const id = 'user-123';
|
||||
const email = 'admin@example.com';
|
||||
const displayName = 'Admin User';
|
||||
const roles = ['owner', 'admin'];
|
||||
const status = 'active';
|
||||
const createdAt = new Date();
|
||||
const updatedAt = new Date();
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertNonEmptyString(entityName, 'id', id)).not.toThrow();
|
||||
expect(() => assertNonEmptyString(entityName, 'email', email)).not.toThrow();
|
||||
expect(() => assertNonEmptyString(entityName, 'displayName', displayName)).not.toThrow();
|
||||
expect(() => assertStringArray(entityName, 'roles', roles)).not.toThrow();
|
||||
expect(() => assertNonEmptyString(entityName, 'status', status)).not.toThrow();
|
||||
expect(() => assertDate(entityName, 'createdAt', createdAt)).not.toThrow();
|
||||
expect(() => assertDate(entityName, 'updatedAt', updatedAt)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should validate admin user with optional fields', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const primaryDriverId = 'driver-456';
|
||||
const lastLoginAt = new Date();
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertOptionalString(entityName, 'primaryDriverId', primaryDriverId)).not.toThrow();
|
||||
expect(() => assertOptionalDate(entityName, 'lastLoginAt', lastLoginAt)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should validate admin user without optional fields', () => {
|
||||
// Arrange
|
||||
const entityName = 'AdminUser';
|
||||
const primaryDriverId = undefined;
|
||||
const lastLoginAt = null;
|
||||
|
||||
// Act & Assert
|
||||
expect(() => assertOptionalString(entityName, 'primaryDriverId', primaryDriverId)).not.toThrow();
|
||||
expect(() => assertOptionalDate(entityName, 'lastLoginAt', lastLoginAt)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -52,4 +52,4 @@ export function assertOptionalString(entityName: string, fieldName: string, valu
|
||||
message: `Field ${fieldName} must be a string or undefined`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { TypeOrmAnalyticsSchemaError } from './TypeOrmAnalyticsSchemaError';
|
||||
|
||||
describe('TypeOrmAnalyticsSchemaError', () => {
|
||||
it('contains entity, field, and reason', () => {
|
||||
// Given
|
||||
const params = {
|
||||
entityName: 'AnalyticsSnapshot',
|
||||
fieldName: 'metrics.pageViews',
|
||||
reason: 'not_number' as const,
|
||||
message: 'Custom message',
|
||||
};
|
||||
|
||||
// When
|
||||
const error = new TypeOrmAnalyticsSchemaError(params);
|
||||
|
||||
// Then
|
||||
expect(error.name).toBe('TypeOrmAnalyticsSchemaError');
|
||||
expect(error.entityName).toBe(params.entityName);
|
||||
expect(error.fieldName).toBe(params.fieldName);
|
||||
expect(error.reason).toBe(params.reason);
|
||||
expect(error.message).toBe(params.message);
|
||||
});
|
||||
|
||||
it('works without optional message', () => {
|
||||
// Given
|
||||
const params = {
|
||||
entityName: 'EngagementEvent',
|
||||
fieldName: 'id',
|
||||
reason: 'missing' as const,
|
||||
};
|
||||
|
||||
// When
|
||||
const error = new TypeOrmAnalyticsSchemaError(params);
|
||||
|
||||
// Then
|
||||
expect(error.message).toBe('');
|
||||
expect(error.entityName).toBe(params.entityName);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AnalyticsSnapshot } from '@core/analytics/domain/entities/AnalyticsSnapshot';
|
||||
|
||||
import { AnalyticsSnapshotOrmEntity } from '../entities/AnalyticsSnapshotOrmEntity';
|
||||
import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError';
|
||||
import { AnalyticsSnapshotOrmMapper } from './AnalyticsSnapshotOrmMapper';
|
||||
|
||||
describe('AnalyticsSnapshotOrmMapper', () => {
|
||||
const mapper = new AnalyticsSnapshotOrmMapper();
|
||||
|
||||
it('maps domain -> orm -> domain (round-trip)', () => {
|
||||
// Given
|
||||
const domain = AnalyticsSnapshot.create({
|
||||
id: 'snap_1',
|
||||
entityType: 'league',
|
||||
entityId: 'league-1',
|
||||
period: 'daily',
|
||||
startDate: new Date('2025-01-01T00:00:00.000Z'),
|
||||
endDate: new Date('2025-01-01T23:59:59.999Z'),
|
||||
metrics: {
|
||||
pageViews: 100,
|
||||
uniqueVisitors: 50,
|
||||
avgSessionDuration: 120,
|
||||
bounceRate: 0.4,
|
||||
engagementScore: 75,
|
||||
sponsorClicks: 10,
|
||||
sponsorUrlClicks: 5,
|
||||
socialShares: 2,
|
||||
leagueJoins: 1,
|
||||
raceRegistrations: 3,
|
||||
exposureValue: 150.5,
|
||||
},
|
||||
createdAt: new Date('2025-01-02T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
// When
|
||||
const orm = mapper.toOrmEntity(domain);
|
||||
const rehydrated = mapper.toDomain(orm);
|
||||
|
||||
// Then
|
||||
expect(orm).toBeInstanceOf(AnalyticsSnapshotOrmEntity);
|
||||
expect(orm.id).toBe(domain.id);
|
||||
expect(rehydrated.id).toBe(domain.id);
|
||||
expect(rehydrated.entityType).toBe(domain.entityType);
|
||||
expect(rehydrated.entityId).toBe(domain.entityId);
|
||||
expect(rehydrated.period).toBe(domain.period);
|
||||
expect(rehydrated.startDate.toISOString()).toBe(domain.startDate.toISOString());
|
||||
expect(rehydrated.endDate.toISOString()).toBe(domain.endDate.toISOString());
|
||||
expect(rehydrated.metrics).toEqual(domain.metrics);
|
||||
expect(rehydrated.createdAt.toISOString()).toBe(domain.createdAt.toISOString());
|
||||
});
|
||||
|
||||
it('throws TypeOrmAnalyticsSchemaError for invalid persisted shape', () => {
|
||||
// Given
|
||||
const orm = new AnalyticsSnapshotOrmEntity();
|
||||
orm.id = ''; // Invalid: empty
|
||||
orm.entityType = 'league' as any;
|
||||
orm.entityId = 'league-1';
|
||||
orm.period = 'daily' as any;
|
||||
orm.startDate = new Date();
|
||||
orm.endDate = new Date();
|
||||
orm.metrics = {} as any; // Invalid: missing fields
|
||||
orm.createdAt = new Date();
|
||||
|
||||
// When / Then
|
||||
expect(() => mapper.toDomain(orm)).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
|
||||
it('throws TypeOrmAnalyticsSchemaError when metrics are missing required fields', () => {
|
||||
// Given
|
||||
const orm = new AnalyticsSnapshotOrmEntity();
|
||||
orm.id = 'snap_1';
|
||||
orm.entityType = 'league' as any;
|
||||
orm.entityId = 'league-1';
|
||||
orm.period = 'daily' as any;
|
||||
orm.startDate = new Date();
|
||||
orm.endDate = new Date();
|
||||
orm.metrics = { pageViews: 100 } as any; // Missing other metrics
|
||||
orm.createdAt = new Date();
|
||||
|
||||
// When / Then
|
||||
expect(() => mapper.toDomain(orm)).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
try {
|
||||
mapper.toDomain(orm);
|
||||
} catch (e: any) {
|
||||
expect(e.fieldName).toContain('metrics.');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { EngagementEvent } from '@core/analytics/domain/entities/EngagementEvent';
|
||||
|
||||
import { EngagementEventOrmEntity } from '../entities/EngagementEventOrmEntity';
|
||||
import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError';
|
||||
import { EngagementEventOrmMapper } from './EngagementEventOrmMapper';
|
||||
|
||||
describe('EngagementEventOrmMapper', () => {
|
||||
const mapper = new EngagementEventOrmMapper();
|
||||
|
||||
it('maps domain -> orm -> domain (round-trip)', () => {
|
||||
// Given
|
||||
const domain = EngagementEvent.create({
|
||||
id: 'eng_1',
|
||||
action: 'click_sponsor_logo',
|
||||
entityType: 'sponsor',
|
||||
entityId: 'sponsor-1',
|
||||
actorType: 'driver',
|
||||
actorId: 'driver-1',
|
||||
sessionId: 'sess-1',
|
||||
metadata: { key: 'value', num: 123, bool: true },
|
||||
timestamp: new Date('2025-01-01T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
// When
|
||||
const orm = mapper.toOrmEntity(domain);
|
||||
const rehydrated = mapper.toDomain(orm);
|
||||
|
||||
// Then
|
||||
expect(orm).toBeInstanceOf(EngagementEventOrmEntity);
|
||||
expect(orm.id).toBe(domain.id);
|
||||
expect(rehydrated.id).toBe(domain.id);
|
||||
expect(rehydrated.action).toBe(domain.action);
|
||||
expect(rehydrated.entityType).toBe(domain.entityType);
|
||||
expect(rehydrated.entityId).toBe(domain.entityId);
|
||||
expect(rehydrated.actorType).toBe(domain.actorType);
|
||||
expect(rehydrated.actorId).toBe(domain.actorId);
|
||||
expect(rehydrated.sessionId).toBe(domain.sessionId);
|
||||
expect(rehydrated.metadata).toEqual(domain.metadata);
|
||||
expect(rehydrated.timestamp.toISOString()).toBe(domain.timestamp.toISOString());
|
||||
});
|
||||
|
||||
it('maps domain -> orm -> domain with nulls', () => {
|
||||
// Given
|
||||
const domain = EngagementEvent.create({
|
||||
id: 'eng_2',
|
||||
action: 'view_standings',
|
||||
entityType: 'league',
|
||||
entityId: 'league-1',
|
||||
actorType: 'anonymous',
|
||||
sessionId: 'sess-2',
|
||||
timestamp: new Date('2025-01-01T11:00:00.000Z'),
|
||||
});
|
||||
|
||||
// When
|
||||
const orm = mapper.toOrmEntity(domain);
|
||||
const rehydrated = mapper.toDomain(orm);
|
||||
|
||||
// Then
|
||||
expect(orm.actorId).toBeNull();
|
||||
expect(orm.metadata).toBeNull();
|
||||
expect(rehydrated.actorId).toBeUndefined();
|
||||
expect(rehydrated.metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it('throws TypeOrmAnalyticsSchemaError for invalid persisted shape', () => {
|
||||
// Given
|
||||
const orm = new EngagementEventOrmEntity();
|
||||
orm.id = ''; // Invalid
|
||||
orm.action = 'invalid_action' as any;
|
||||
orm.entityType = 'league' as any;
|
||||
orm.entityId = 'league-1';
|
||||
orm.actorType = 'anonymous' as any;
|
||||
orm.sessionId = 'sess-1';
|
||||
orm.timestamp = new Date();
|
||||
|
||||
// When / Then
|
||||
expect(() => mapper.toDomain(orm)).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
|
||||
it('throws TypeOrmAnalyticsSchemaError for invalid metadata values', () => {
|
||||
// Given
|
||||
const orm = new EngagementEventOrmEntity();
|
||||
orm.id = 'eng_1';
|
||||
orm.action = 'click_sponsor_logo' as any;
|
||||
orm.entityType = 'sponsor' as any;
|
||||
orm.entityId = 'sponsor-1';
|
||||
orm.actorType = 'driver' as any;
|
||||
orm.sessionId = 'sess-1';
|
||||
orm.timestamp = new Date();
|
||||
orm.metadata = { invalid: { nested: 'object' } } as any;
|
||||
|
||||
// When / Then
|
||||
expect(() => mapper.toDomain(orm)).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
try {
|
||||
mapper.toDomain(orm);
|
||||
} catch (e: any) {
|
||||
expect(e.reason).toBe('invalid_shape');
|
||||
expect(e.fieldName).toBe('metadata');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Repository } from 'typeorm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AnalyticsSnapshot } from '@core/analytics/domain/entities/AnalyticsSnapshot';
|
||||
|
||||
import { AnalyticsSnapshotOrmEntity } from '../entities/AnalyticsSnapshotOrmEntity';
|
||||
import { AnalyticsSnapshotOrmMapper } from '../mappers/AnalyticsSnapshotOrmMapper';
|
||||
import { TypeOrmAnalyticsSnapshotRepository } from './TypeOrmAnalyticsSnapshotRepository';
|
||||
|
||||
describe('TypeOrmAnalyticsSnapshotRepository', () => {
|
||||
it('saves mapped entities via injected mapper', async () => {
|
||||
// Given
|
||||
const orm = new AnalyticsSnapshotOrmEntity();
|
||||
orm.id = 'snap_1';
|
||||
|
||||
const mapper: AnalyticsSnapshotOrmMapper = {
|
||||
toOrmEntity: vi.fn().mockReturnValue(orm),
|
||||
toDomain: vi.fn(),
|
||||
} as unknown as AnalyticsSnapshotOrmMapper;
|
||||
|
||||
const repo: Repository<AnalyticsSnapshotOrmEntity> = {
|
||||
save: vi.fn().mockResolvedValue(orm),
|
||||
} as unknown as Repository<AnalyticsSnapshotOrmEntity>;
|
||||
|
||||
const sut = new TypeOrmAnalyticsSnapshotRepository(repo, mapper);
|
||||
|
||||
const domain = AnalyticsSnapshot.create({
|
||||
id: 'snap_1',
|
||||
entityType: 'league',
|
||||
entityId: 'league-1',
|
||||
period: 'daily',
|
||||
startDate: new Date(),
|
||||
endDate: new Date(),
|
||||
metrics: {} as any,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
// When
|
||||
await sut.save(domain);
|
||||
|
||||
// Then
|
||||
expect(mapper.toOrmEntity).toHaveBeenCalledWith(domain);
|
||||
expect(repo.save).toHaveBeenCalledWith(orm);
|
||||
});
|
||||
|
||||
it('findById maps entity -> domain', async () => {
|
||||
// Given
|
||||
const orm = new AnalyticsSnapshotOrmEntity();
|
||||
orm.id = 'snap_1';
|
||||
|
||||
const domain = AnalyticsSnapshot.create({
|
||||
id: 'snap_1',
|
||||
entityType: 'league',
|
||||
entityId: 'league-1',
|
||||
period: 'daily',
|
||||
startDate: new Date(),
|
||||
endDate: new Date(),
|
||||
metrics: {} as any,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const mapper: AnalyticsSnapshotOrmMapper = {
|
||||
toOrmEntity: vi.fn(),
|
||||
toDomain: vi.fn().mockReturnValue(domain),
|
||||
} as unknown as AnalyticsSnapshotOrmMapper;
|
||||
|
||||
const repo: Repository<AnalyticsSnapshotOrmEntity> = {
|
||||
findOneBy: vi.fn().mockResolvedValue(orm),
|
||||
} as unknown as Repository<AnalyticsSnapshotOrmEntity>;
|
||||
|
||||
const sut = new TypeOrmAnalyticsSnapshotRepository(repo, mapper);
|
||||
|
||||
// When
|
||||
const result = await sut.findById('snap_1');
|
||||
|
||||
// Then
|
||||
expect(repo.findOneBy).toHaveBeenCalledWith({ id: 'snap_1' });
|
||||
expect(mapper.toDomain).toHaveBeenCalledWith(orm);
|
||||
expect(result?.id).toBe('snap_1');
|
||||
});
|
||||
|
||||
it('findLatest uses correct query options', async () => {
|
||||
// Given
|
||||
const orm = new AnalyticsSnapshotOrmEntity();
|
||||
const mapper: AnalyticsSnapshotOrmMapper = {
|
||||
toDomain: vi.fn().mockReturnValue({ id: 'snap_1' } as any),
|
||||
} as any;
|
||||
const repo: Repository<AnalyticsSnapshotOrmEntity> = {
|
||||
findOne: vi.fn().mockResolvedValue(orm),
|
||||
} as any;
|
||||
const sut = new TypeOrmAnalyticsSnapshotRepository(repo, mapper);
|
||||
|
||||
// When
|
||||
await sut.findLatest('league', 'league-1', 'daily');
|
||||
|
||||
// Then
|
||||
expect(repo.findOne).toHaveBeenCalledWith({
|
||||
where: { entityType: 'league', entityId: 'league-1', period: 'daily' },
|
||||
order: { endDate: 'DESC' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Repository } from 'typeorm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { EngagementEvent } from '@core/analytics/domain/entities/EngagementEvent';
|
||||
|
||||
import { EngagementEventOrmEntity } from '../entities/EngagementEventOrmEntity';
|
||||
import { EngagementEventOrmMapper } from '../mappers/EngagementEventOrmMapper';
|
||||
import { TypeOrmEngagementRepository } from './TypeOrmEngagementRepository';
|
||||
|
||||
describe('TypeOrmEngagementRepository', () => {
|
||||
it('saves mapped entities via injected mapper', async () => {
|
||||
// Given
|
||||
const orm = new EngagementEventOrmEntity();
|
||||
orm.id = 'eng_1';
|
||||
|
||||
const mapper: EngagementEventOrmMapper = {
|
||||
toOrmEntity: vi.fn().mockReturnValue(orm),
|
||||
toDomain: vi.fn(),
|
||||
} as unknown as EngagementEventOrmMapper;
|
||||
|
||||
const repo: Repository<EngagementEventOrmEntity> = {
|
||||
save: vi.fn().mockResolvedValue(orm),
|
||||
} as unknown as Repository<EngagementEventOrmEntity>;
|
||||
|
||||
const sut = new TypeOrmEngagementRepository(repo, mapper);
|
||||
|
||||
const domain = EngagementEvent.create({
|
||||
id: 'eng_1',
|
||||
action: 'click_sponsor_logo',
|
||||
entityType: 'sponsor',
|
||||
entityId: 'sponsor-1',
|
||||
actorType: 'anonymous',
|
||||
sessionId: 'sess-1',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
// When
|
||||
await sut.save(domain);
|
||||
|
||||
// Then
|
||||
expect(mapper.toOrmEntity).toHaveBeenCalledWith(domain);
|
||||
expect(repo.save).toHaveBeenCalledWith(orm);
|
||||
});
|
||||
|
||||
it('findById maps entity -> domain', async () => {
|
||||
// Given
|
||||
const orm = new EngagementEventOrmEntity();
|
||||
orm.id = 'eng_1';
|
||||
|
||||
const domain = EngagementEvent.create({
|
||||
id: 'eng_1',
|
||||
action: 'click_sponsor_logo',
|
||||
entityType: 'sponsor',
|
||||
entityId: 'sponsor-1',
|
||||
actorType: 'anonymous',
|
||||
sessionId: 'sess-1',
|
||||
timestamp: new Date(),
|
||||
});
|
||||
|
||||
const mapper: EngagementEventOrmMapper = {
|
||||
toOrmEntity: vi.fn(),
|
||||
toDomain: vi.fn().mockReturnValue(domain),
|
||||
} as unknown as EngagementEventOrmMapper;
|
||||
|
||||
const repo: Repository<EngagementEventOrmEntity> = {
|
||||
findOneBy: vi.fn().mockResolvedValue(orm),
|
||||
} as unknown as Repository<EngagementEventOrmEntity>;
|
||||
|
||||
const sut = new TypeOrmEngagementRepository(repo, mapper);
|
||||
|
||||
// When
|
||||
const result = await sut.findById('eng_1');
|
||||
|
||||
// Then
|
||||
expect(repo.findOneBy).toHaveBeenCalledWith({ id: 'eng_1' });
|
||||
expect(mapper.toDomain).toHaveBeenCalledWith(orm);
|
||||
expect(result?.id).toBe('eng_1');
|
||||
});
|
||||
|
||||
it('countByAction uses correct where clause', async () => {
|
||||
// Given
|
||||
const repo: Repository<EngagementEventOrmEntity> = {
|
||||
count: vi.fn().mockResolvedValue(5),
|
||||
} as any;
|
||||
const sut = new TypeOrmEngagementRepository(repo, {} as any);
|
||||
const since = new Date();
|
||||
|
||||
// When
|
||||
await sut.countByAction('click_sponsor_logo', 'sponsor-1', since);
|
||||
|
||||
// Then
|
||||
expect(repo.count).toHaveBeenCalledWith({
|
||||
where: expect.objectContaining({
|
||||
action: 'click_sponsor_logo',
|
||||
entityId: 'sponsor-1',
|
||||
timestamp: expect.anything(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { TypeOrmAnalyticsSchemaError } from '../errors/TypeOrmAnalyticsSchemaError';
|
||||
import {
|
||||
assertBoolean,
|
||||
assertDate,
|
||||
assertEnumValue,
|
||||
assertInteger,
|
||||
assertNonEmptyString,
|
||||
assertNumber,
|
||||
assertOptionalIntegerOrNull,
|
||||
assertOptionalNumberOrNull,
|
||||
assertOptionalStringOrNull,
|
||||
assertRecord,
|
||||
} from './TypeOrmAnalyticsSchemaGuards';
|
||||
|
||||
describe('TypeOrmAnalyticsSchemaGuards', () => {
|
||||
const entity = 'TestEntity';
|
||||
|
||||
describe('assertNonEmptyString', () => {
|
||||
it('accepts valid string', () => {
|
||||
expect(() => assertNonEmptyString(entity, 'field', 'valid')).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects null/undefined', () => {
|
||||
expect(() => assertNonEmptyString(entity, 'field', null)).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
expect(() => assertNonEmptyString(entity, 'field', undefined)).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
|
||||
it('rejects empty/whitespace string', () => {
|
||||
expect(() => assertNonEmptyString(entity, 'field', '')).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
expect(() => assertNonEmptyString(entity, 'field', ' ')).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
|
||||
it('rejects non-string', () => {
|
||||
expect(() => assertNonEmptyString(entity, 'field', 123)).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertOptionalStringOrNull', () => {
|
||||
it('accepts valid string, null, or undefined', () => {
|
||||
expect(() => assertOptionalStringOrNull(entity, 'field', 'valid')).not.toThrow();
|
||||
expect(() => assertOptionalStringOrNull(entity, 'field', null)).not.toThrow();
|
||||
expect(() => assertOptionalStringOrNull(entity, 'field', undefined)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects non-string', () => {
|
||||
expect(() => assertOptionalStringOrNull(entity, 'field', 123)).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertNumber', () => {
|
||||
it('accepts valid number', () => {
|
||||
expect(() => assertNumber(entity, 'field', 123.45)).not.toThrow();
|
||||
expect(() => assertNumber(entity, 'field', 0)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects NaN', () => {
|
||||
expect(() => assertNumber(entity, 'field', NaN)).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
|
||||
it('rejects non-number', () => {
|
||||
expect(() => assertNumber(entity, 'field', '123')).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertOptionalNumberOrNull', () => {
|
||||
it('accepts valid number, null, or undefined', () => {
|
||||
expect(() => assertOptionalNumberOrNull(entity, 'field', 123)).not.toThrow();
|
||||
expect(() => assertOptionalNumberOrNull(entity, 'field', null)).not.toThrow();
|
||||
expect(() => assertOptionalNumberOrNull(entity, 'field', undefined)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertInteger', () => {
|
||||
it('accepts valid integer', () => {
|
||||
expect(() => assertInteger(entity, 'field', 123)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects float', () => {
|
||||
expect(() => assertInteger(entity, 'field', 123.45)).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertOptionalIntegerOrNull', () => {
|
||||
it('accepts valid integer, null, or undefined', () => {
|
||||
expect(() => assertOptionalIntegerOrNull(entity, 'field', 123)).not.toThrow();
|
||||
expect(() => assertOptionalIntegerOrNull(entity, 'field', null)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertBoolean', () => {
|
||||
it('accepts boolean', () => {
|
||||
expect(() => assertBoolean(entity, 'field', true)).not.toThrow();
|
||||
expect(() => assertBoolean(entity, 'field', false)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects non-boolean', () => {
|
||||
expect(() => assertBoolean(entity, 'field', 'true')).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertDate', () => {
|
||||
it('accepts valid Date', () => {
|
||||
expect(() => assertDate(entity, 'field', new Date())).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects invalid Date', () => {
|
||||
expect(() => assertDate(entity, 'field', new Date('invalid'))).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
|
||||
it('rejects non-Date', () => {
|
||||
expect(() => assertDate(entity, 'field', '2025-01-01')).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertEnumValue', () => {
|
||||
const allowed = ['a', 'b'] as const;
|
||||
it('accepts allowed value', () => {
|
||||
expect(() => assertEnumValue(entity, 'field', 'a', allowed)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects disallowed value', () => {
|
||||
expect(() => assertEnumValue(entity, 'field', 'c', allowed)).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertRecord', () => {
|
||||
it('accepts object', () => {
|
||||
expect(() => assertRecord(entity, 'field', { a: 1 })).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects array', () => {
|
||||
expect(() => assertRecord(entity, 'field', [])).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
|
||||
it('rejects null', () => {
|
||||
expect(() => assertRecord(entity, 'field', null)).toThrow(TypeOrmAnalyticsSchemaError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,9 @@ export class RacingResultFactory {
|
||||
? 2
|
||||
: 3 + Math.floor(rng() * 6);
|
||||
|
||||
// Calculate points based on position
|
||||
const points = this.calculatePoints(position);
|
||||
|
||||
results.push(
|
||||
RaceResult.create({
|
||||
id: seedId(`${race.id}:${driver.id}`, this.persistence),
|
||||
@@ -60,6 +63,7 @@ export class RacingResultFactory {
|
||||
startPosition: Math.max(1, startPosition),
|
||||
fastestLap,
|
||||
incidents,
|
||||
points,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -96,4 +100,21 @@ export class RacingResultFactory {
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
private calculatePoints(position: number): number {
|
||||
// Standard F1-style points system
|
||||
const pointsMap: Record<number, number> = {
|
||||
1: 25,
|
||||
2: 18,
|
||||
3: 15,
|
||||
4: 12,
|
||||
5: 10,
|
||||
6: 8,
|
||||
7: 6,
|
||||
8: 4,
|
||||
9: 2,
|
||||
10: 1,
|
||||
};
|
||||
return pointsMap[position] || 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { InMemoryDriverRepository } from './InMemoryDriverRepository';
|
||||
import { DriverData } from '../../../../core/dashboard/application/ports/DashboardRepository';
|
||||
|
||||
describe('InMemoryDriverRepository', () => {
|
||||
let repository: InMemoryDriverRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = new InMemoryDriverRepository();
|
||||
});
|
||||
|
||||
describe('findDriverById', () => {
|
||||
it('should return null when driver does not exist', async () => {
|
||||
// Given
|
||||
const driverId = 'non-existent';
|
||||
|
||||
// When
|
||||
const result = await repository.findDriverById(driverId);
|
||||
|
||||
// Then
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return driver when it exists', async () => {
|
||||
// Given
|
||||
const driver: DriverData = {
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1500,
|
||||
rank: 10,
|
||||
starts: 100,
|
||||
wins: 10,
|
||||
podiums: 30,
|
||||
leagues: 5,
|
||||
};
|
||||
repository.addDriver(driver);
|
||||
|
||||
// When
|
||||
const result = await repository.findDriverById(driver.id);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(driver);
|
||||
});
|
||||
|
||||
it('should overwrite driver with same id (idempotency)', async () => {
|
||||
// Given
|
||||
const driverId = 'driver-1';
|
||||
const driver1: DriverData = {
|
||||
id: driverId,
|
||||
name: 'John Doe',
|
||||
rating: 1500,
|
||||
rank: 10,
|
||||
starts: 100,
|
||||
wins: 10,
|
||||
podiums: 30,
|
||||
leagues: 5,
|
||||
};
|
||||
const driver2: DriverData = {
|
||||
id: driverId,
|
||||
name: 'John Updated',
|
||||
rating: 1600,
|
||||
rank: 5,
|
||||
starts: 101,
|
||||
wins: 11,
|
||||
podiums: 31,
|
||||
leagues: 5,
|
||||
};
|
||||
|
||||
// When
|
||||
repository.addDriver(driver1);
|
||||
repository.addDriver(driver2);
|
||||
const result = await repository.findDriverById(driverId);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(driver2);
|
||||
});
|
||||
});
|
||||
});
|
||||
77
adapters/events/InMemoryEventPublisher.test.ts
Normal file
77
adapters/events/InMemoryEventPublisher.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { InMemoryEventPublisher } from './InMemoryEventPublisher';
|
||||
import { DashboardAccessedEvent } from '../../core/dashboard/application/ports/DashboardEventPublisher';
|
||||
import { LeagueCreatedEvent } from '../../core/leagues/application/ports/LeagueEventPublisher';
|
||||
|
||||
describe('InMemoryEventPublisher', () => {
|
||||
let publisher: InMemoryEventPublisher;
|
||||
|
||||
beforeEach(() => {
|
||||
publisher = new InMemoryEventPublisher();
|
||||
});
|
||||
|
||||
describe('Dashboard Events', () => {
|
||||
it('should publish and track dashboard accessed events', async () => {
|
||||
// Given
|
||||
const event: DashboardAccessedEvent = { userId: 'user-1', timestamp: new Date() };
|
||||
|
||||
// When
|
||||
await publisher.publishDashboardAccessed(event);
|
||||
|
||||
// Then
|
||||
expect(publisher.getDashboardAccessedEventCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('should throw error when configured to fail', async () => {
|
||||
// Given
|
||||
publisher.setShouldFail(true);
|
||||
const event: DashboardAccessedEvent = { userId: 'user-1', timestamp: new Date() };
|
||||
|
||||
// When & Then
|
||||
await expect(publisher.publishDashboardAccessed(event)).rejects.toThrow('Event publisher failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('League Events', () => {
|
||||
it('should publish and track league created events', async () => {
|
||||
// Given
|
||||
const event: LeagueCreatedEvent = { leagueId: 'league-1', name: 'Test League', timestamp: new Date() };
|
||||
|
||||
// When
|
||||
await publisher.emitLeagueCreated(event);
|
||||
|
||||
// Then
|
||||
expect(publisher.getLeagueCreatedEventCount()).toBe(1);
|
||||
expect(publisher.getLeagueCreatedEvents()).toContainEqual(event);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Generic Domain Events', () => {
|
||||
it('should publish and track generic domain events', async () => {
|
||||
// Given
|
||||
const event = { type: 'TestEvent', timestamp: new Date() };
|
||||
|
||||
// When
|
||||
await publisher.publish(event);
|
||||
|
||||
// Then
|
||||
expect(publisher.getEvents()).toContainEqual(event);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Maintenance', () => {
|
||||
it('should clear all events', async () => {
|
||||
// Given
|
||||
await publisher.publishDashboardAccessed({ userId: 'u1', timestamp: new Date() });
|
||||
await publisher.emitLeagueCreated({ leagueId: 'l1', name: 'L1', timestamp: new Date() });
|
||||
await publisher.publish({ type: 'Generic', timestamp: new Date() });
|
||||
|
||||
// When
|
||||
publisher.clear();
|
||||
|
||||
// Then
|
||||
expect(publisher.getDashboardAccessedEventCount()).toBe(0);
|
||||
expect(publisher.getLeagueCreatedEventCount()).toBe(0);
|
||||
expect(publisher.getEvents().length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,25 @@ import {
|
||||
DashboardAccessedEvent,
|
||||
DashboardErrorEvent,
|
||||
} from '../../core/dashboard/application/ports/DashboardEventPublisher';
|
||||
import {
|
||||
LeagueEventPublisher,
|
||||
LeagueCreatedEvent,
|
||||
LeagueUpdatedEvent,
|
||||
LeagueDeletedEvent,
|
||||
LeagueAccessedEvent,
|
||||
LeagueRosterAccessedEvent,
|
||||
} from '../../core/leagues/application/ports/LeagueEventPublisher';
|
||||
import { EventPublisher, DomainEvent } from '../../core/shared/ports/EventPublisher';
|
||||
|
||||
export class InMemoryEventPublisher implements DashboardEventPublisher {
|
||||
export class InMemoryEventPublisher implements DashboardEventPublisher, LeagueEventPublisher, EventPublisher {
|
||||
private dashboardAccessedEvents: DashboardAccessedEvent[] = [];
|
||||
private dashboardErrorEvents: DashboardErrorEvent[] = [];
|
||||
private leagueCreatedEvents: LeagueCreatedEvent[] = [];
|
||||
private leagueUpdatedEvents: LeagueUpdatedEvent[] = [];
|
||||
private leagueDeletedEvents: LeagueDeletedEvent[] = [];
|
||||
private leagueAccessedEvents: LeagueAccessedEvent[] = [];
|
||||
private leagueRosterAccessedEvents: LeagueRosterAccessedEvent[] = [];
|
||||
private events: DomainEvent[] = [];
|
||||
private shouldFail: boolean = false;
|
||||
|
||||
async publishDashboardAccessed(event: DashboardAccessedEvent): Promise<void> {
|
||||
@@ -19,6 +34,31 @@ export class InMemoryEventPublisher implements DashboardEventPublisher {
|
||||
this.dashboardErrorEvents.push(event);
|
||||
}
|
||||
|
||||
async emitLeagueCreated(event: LeagueCreatedEvent): Promise<void> {
|
||||
if (this.shouldFail) throw new Error('Event publisher failed');
|
||||
this.leagueCreatedEvents.push(event);
|
||||
}
|
||||
|
||||
async emitLeagueUpdated(event: LeagueUpdatedEvent): Promise<void> {
|
||||
if (this.shouldFail) throw new Error('Event publisher failed');
|
||||
this.leagueUpdatedEvents.push(event);
|
||||
}
|
||||
|
||||
async emitLeagueDeleted(event: LeagueDeletedEvent): Promise<void> {
|
||||
if (this.shouldFail) throw new Error('Event publisher failed');
|
||||
this.leagueDeletedEvents.push(event);
|
||||
}
|
||||
|
||||
async emitLeagueAccessed(event: LeagueAccessedEvent): Promise<void> {
|
||||
if (this.shouldFail) throw new Error('Event publisher failed');
|
||||
this.leagueAccessedEvents.push(event);
|
||||
}
|
||||
|
||||
async emitLeagueRosterAccessed(event: LeagueRosterAccessedEvent): Promise<void> {
|
||||
if (this.shouldFail) throw new Error('Event publisher failed');
|
||||
this.leagueRosterAccessedEvents.push(event);
|
||||
}
|
||||
|
||||
getDashboardAccessedEventCount(): number {
|
||||
return this.dashboardAccessedEvents.length;
|
||||
}
|
||||
@@ -27,13 +67,56 @@ export class InMemoryEventPublisher implements DashboardEventPublisher {
|
||||
return this.dashboardErrorEvents.length;
|
||||
}
|
||||
|
||||
getLeagueCreatedEventCount(): number {
|
||||
return this.leagueCreatedEvents.length;
|
||||
}
|
||||
|
||||
getLeagueUpdatedEventCount(): number {
|
||||
return this.leagueUpdatedEvents.length;
|
||||
}
|
||||
|
||||
getLeagueDeletedEventCount(): number {
|
||||
return this.leagueDeletedEvents.length;
|
||||
}
|
||||
|
||||
getLeagueAccessedEventCount(): number {
|
||||
return this.leagueAccessedEvents.length;
|
||||
}
|
||||
|
||||
getLeagueRosterAccessedEventCount(): number {
|
||||
return this.leagueRosterAccessedEvents.length;
|
||||
}
|
||||
|
||||
getLeagueRosterAccessedEvents(): LeagueRosterAccessedEvent[] {
|
||||
return [...this.leagueRosterAccessedEvents];
|
||||
}
|
||||
|
||||
getLeagueCreatedEvents(): LeagueCreatedEvent[] {
|
||||
return [...this.leagueCreatedEvents];
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.dashboardAccessedEvents = [];
|
||||
this.dashboardErrorEvents = [];
|
||||
this.leagueCreatedEvents = [];
|
||||
this.leagueUpdatedEvents = [];
|
||||
this.leagueDeletedEvents = [];
|
||||
this.leagueAccessedEvents = [];
|
||||
this.leagueRosterAccessedEvents = [];
|
||||
this.events = [];
|
||||
this.shouldFail = false;
|
||||
}
|
||||
|
||||
setShouldFail(shouldFail: boolean): void {
|
||||
this.shouldFail = shouldFail;
|
||||
}
|
||||
|
||||
async publish(event: DomainEvent): Promise<void> {
|
||||
if (this.shouldFail) throw new Error('Event publisher failed');
|
||||
this.events.push(event);
|
||||
}
|
||||
|
||||
getEvents(): DomainEvent[] {
|
||||
return [...this.events];
|
||||
}
|
||||
}
|
||||
|
||||
103
adapters/events/InMemoryHealthEventPublisher.test.ts
Normal file
103
adapters/events/InMemoryHealthEventPublisher.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { InMemoryHealthEventPublisher } from './InMemoryHealthEventPublisher';
|
||||
|
||||
describe('InMemoryHealthEventPublisher', () => {
|
||||
let publisher: InMemoryHealthEventPublisher;
|
||||
|
||||
beforeEach(() => {
|
||||
publisher = new InMemoryHealthEventPublisher();
|
||||
});
|
||||
|
||||
describe('Health Check Events', () => {
|
||||
it('should publish and track health check completed events', async () => {
|
||||
// Given
|
||||
const event = {
|
||||
healthy: true,
|
||||
responseTime: 100,
|
||||
timestamp: new Date(),
|
||||
endpoint: 'http://api.test/health',
|
||||
};
|
||||
|
||||
// When
|
||||
await publisher.publishHealthCheckCompleted(event);
|
||||
|
||||
// Then
|
||||
expect(publisher.getEventCount()).toBe(1);
|
||||
expect(publisher.getEventCountByType('HealthCheckCompleted')).toBe(1);
|
||||
const events = publisher.getEventsByType('HealthCheckCompleted');
|
||||
expect(events[0]).toMatchObject({
|
||||
type: 'HealthCheckCompleted',
|
||||
...event,
|
||||
});
|
||||
});
|
||||
|
||||
it('should publish and track health check failed events', async () => {
|
||||
// Given
|
||||
const event = {
|
||||
error: 'Connection refused',
|
||||
timestamp: new Date(),
|
||||
endpoint: 'http://api.test/health',
|
||||
};
|
||||
|
||||
// When
|
||||
await publisher.publishHealthCheckFailed(event);
|
||||
|
||||
// Then
|
||||
expect(publisher.getEventCountByType('HealthCheckFailed')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Connection Status Events', () => {
|
||||
it('should publish and track connected events', async () => {
|
||||
// Given
|
||||
const event = {
|
||||
timestamp: new Date(),
|
||||
responseTime: 50,
|
||||
};
|
||||
|
||||
// When
|
||||
await publisher.publishConnected(event);
|
||||
|
||||
// Then
|
||||
expect(publisher.getEventCountByType('Connected')).toBe(1);
|
||||
});
|
||||
|
||||
it('should publish and track disconnected events', async () => {
|
||||
// Given
|
||||
const event = {
|
||||
timestamp: new Date(),
|
||||
consecutiveFailures: 3,
|
||||
};
|
||||
|
||||
// When
|
||||
await publisher.publishDisconnected(event);
|
||||
|
||||
// Then
|
||||
expect(publisher.getEventCountByType('Disconnected')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should throw error when configured to fail', async () => {
|
||||
// Given
|
||||
publisher.setShouldFail(true);
|
||||
const event = { timestamp: new Date() };
|
||||
|
||||
// When & Then
|
||||
await expect(publisher.publishChecking(event)).rejects.toThrow('Event publisher failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Maintenance', () => {
|
||||
it('should clear all events', async () => {
|
||||
// Given
|
||||
await publisher.publishChecking({ timestamp: new Date() });
|
||||
await publisher.publishConnected({ timestamp: new Date(), responseTime: 10 });
|
||||
|
||||
// When
|
||||
publisher.clear();
|
||||
|
||||
// Then
|
||||
expect(publisher.getEventCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
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,123 @@
|
||||
import { InMemoryHealthCheckAdapter } from './InMemoryHealthCheckAdapter';
|
||||
|
||||
describe('InMemoryHealthCheckAdapter', () => {
|
||||
let adapter: InMemoryHealthCheckAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = new InMemoryHealthCheckAdapter();
|
||||
adapter.setResponseTime(0); // Speed up tests
|
||||
});
|
||||
|
||||
describe('Health Checks', () => {
|
||||
it('should return healthy by default', async () => {
|
||||
// When
|
||||
const result = await adapter.performHealthCheck();
|
||||
|
||||
// Then
|
||||
expect(result.healthy).toBe(true);
|
||||
expect(adapter.getStatus()).toBe('connected');
|
||||
});
|
||||
|
||||
it('should return unhealthy when configured to fail', async () => {
|
||||
// Given
|
||||
adapter.setShouldFail(true, 'Custom error');
|
||||
|
||||
// When
|
||||
const result = await adapter.performHealthCheck();
|
||||
|
||||
// Then
|
||||
expect(result.healthy).toBe(false);
|
||||
expect(result.error).toBe('Custom error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Status Transitions', () => {
|
||||
it('should transition to disconnected after 3 consecutive failures', async () => {
|
||||
// Given
|
||||
adapter.setShouldFail(true);
|
||||
|
||||
// When
|
||||
await adapter.performHealthCheck(); // 1
|
||||
expect(adapter.getStatus()).toBe('checking'); // Initial state is disconnected, first failure keeps it checking/disconnected
|
||||
|
||||
await adapter.performHealthCheck(); // 2
|
||||
await adapter.performHealthCheck(); // 3
|
||||
|
||||
// Then
|
||||
expect(adapter.getStatus()).toBe('disconnected');
|
||||
});
|
||||
|
||||
it('should transition to degraded if reliability is low', async () => {
|
||||
// Given
|
||||
// We need 5 requests total, and reliability < 0.7
|
||||
// 1 success, 4 failures (not consecutive)
|
||||
|
||||
await adapter.performHealthCheck(); // Success 1
|
||||
|
||||
adapter.setShouldFail(true);
|
||||
await adapter.performHealthCheck(); // Failure 1
|
||||
adapter.setShouldFail(false);
|
||||
await adapter.performHealthCheck(); // Success 2 (resets consecutive)
|
||||
adapter.setShouldFail(true);
|
||||
await adapter.performHealthCheck(); // Failure 2
|
||||
await adapter.performHealthCheck(); // Failure 3
|
||||
adapter.setShouldFail(false);
|
||||
await adapter.performHealthCheck(); // Success 3 (resets consecutive)
|
||||
adapter.setShouldFail(true);
|
||||
await adapter.performHealthCheck(); // Failure 4
|
||||
await adapter.performHealthCheck(); // Failure 5
|
||||
|
||||
// Then
|
||||
expect(adapter.getStatus()).toBe('degraded');
|
||||
expect(adapter.getReliability()).toBeLessThan(70);
|
||||
});
|
||||
|
||||
it('should recover status after a success', async () => {
|
||||
// Given
|
||||
adapter.setShouldFail(true);
|
||||
await adapter.performHealthCheck();
|
||||
await adapter.performHealthCheck();
|
||||
await adapter.performHealthCheck();
|
||||
expect(adapter.getStatus()).toBe('disconnected');
|
||||
|
||||
// When
|
||||
adapter.setShouldFail(false);
|
||||
await adapter.performHealthCheck();
|
||||
|
||||
// Then
|
||||
expect(adapter.getStatus()).toBe('connected');
|
||||
expect(adapter.isAvailable()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Metrics', () => {
|
||||
it('should track average response time', async () => {
|
||||
// Given
|
||||
adapter.setResponseTime(10);
|
||||
await adapter.performHealthCheck();
|
||||
|
||||
adapter.setResponseTime(20);
|
||||
await adapter.performHealthCheck();
|
||||
|
||||
// Then
|
||||
const health = adapter.getHealth();
|
||||
expect(health.averageResponseTime).toBe(15);
|
||||
expect(health.totalRequests).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Maintenance', () => {
|
||||
it('should clear state', async () => {
|
||||
// Given
|
||||
await adapter.performHealthCheck();
|
||||
expect(adapter.getHealth().totalRequests).toBe(1);
|
||||
|
||||
// When
|
||||
adapter.clear();
|
||||
|
||||
// Then
|
||||
expect(adapter.getHealth().totalRequests).toBe(0);
|
||||
expect(adapter.getStatus()).toBe('disconnected'); // Initial state
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 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;
|
||||
if (total === 1) {
|
||||
this.health.averageResponseTime = responseTime;
|
||||
} else {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
63
adapters/http/RequestContext.test.ts
Normal file
63
adapters/http/RequestContext.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { getHttpRequestContext, requestContextMiddleware, tryGetHttpRequestContext } from './RequestContext';
|
||||
|
||||
describe('RequestContext', () => {
|
||||
it('should return null when accessed outside of middleware', () => {
|
||||
// When
|
||||
const ctx = tryGetHttpRequestContext();
|
||||
|
||||
// Then
|
||||
expect(ctx).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw error when getHttpRequestContext is called outside of middleware', () => {
|
||||
// When & Then
|
||||
expect(() => getHttpRequestContext()).toThrow('HttpRequestContext is not available');
|
||||
});
|
||||
|
||||
it('should provide request and response within middleware scope', () => {
|
||||
// Given
|
||||
const mockReq = { id: 'req-1' } as unknown as Request;
|
||||
const mockRes = { id: 'res-1' } as unknown as Response;
|
||||
|
||||
// When
|
||||
return new Promise<void>((resolve) => {
|
||||
requestContextMiddleware(mockReq, mockRes, () => {
|
||||
// Then
|
||||
const ctx = getHttpRequestContext();
|
||||
expect(ctx.req).toBe(mockReq);
|
||||
expect(ctx.res).toBe(mockRes);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should maintain separate contexts for concurrent requests', () => {
|
||||
// Given
|
||||
const req1 = { id: '1' } as unknown as Request;
|
||||
const res1 = { id: '1' } as unknown as Response;
|
||||
const req2 = { id: '2' } as unknown as Request;
|
||||
const res2 = { id: '2' } as unknown as Response;
|
||||
|
||||
// When
|
||||
const p1 = new Promise<void>((resolve) => {
|
||||
requestContextMiddleware(req1, res1, () => {
|
||||
setTimeout(() => {
|
||||
expect(getHttpRequestContext().req).toBe(req1);
|
||||
resolve();
|
||||
}, 10);
|
||||
});
|
||||
});
|
||||
|
||||
const p2 = new Promise<void>((resolve) => {
|
||||
requestContextMiddleware(req2, res2, () => {
|
||||
setTimeout(() => {
|
||||
expect(getHttpRequestContext().req).toBe(req2);
|
||||
resolve();
|
||||
}, 5);
|
||||
});
|
||||
});
|
||||
|
||||
return Promise.all([p1, p2]);
|
||||
});
|
||||
});
|
||||
@@ -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,73 @@
|
||||
import { InMemoryLeaderboardsRepository } from './InMemoryLeaderboardsRepository';
|
||||
import { LeaderboardDriverData, LeaderboardTeamData } from '../../../../core/leaderboards/application/ports/LeaderboardsRepository';
|
||||
|
||||
describe('InMemoryLeaderboardsRepository', () => {
|
||||
let repository: InMemoryLeaderboardsRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = new InMemoryLeaderboardsRepository();
|
||||
});
|
||||
|
||||
describe('drivers', () => {
|
||||
it('should return empty array when no drivers exist', async () => {
|
||||
// When
|
||||
const result = await repository.findAllDrivers();
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should add and find all drivers', async () => {
|
||||
// Given
|
||||
const driver: LeaderboardDriverData = {
|
||||
id: 'd1',
|
||||
name: 'Driver 1',
|
||||
rating: 1500,
|
||||
raceCount: 10,
|
||||
teamId: 't1',
|
||||
teamName: 'Team 1',
|
||||
};
|
||||
repository.addDriver(driver);
|
||||
|
||||
// When
|
||||
const result = await repository.findAllDrivers();
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([driver]);
|
||||
});
|
||||
|
||||
it('should find drivers by team id', async () => {
|
||||
// Given
|
||||
const d1: LeaderboardDriverData = { id: 'd1', name: 'D1', rating: 1500, raceCount: 10, teamId: 't1', teamName: 'T1' };
|
||||
const d2: LeaderboardDriverData = { id: 'd2', name: 'D2', rating: 1400, raceCount: 5, teamId: 't2', teamName: 'T2' };
|
||||
repository.addDriver(d1);
|
||||
repository.addDriver(d2);
|
||||
|
||||
// When
|
||||
const result = await repository.findDriversByTeamId('t1');
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([d1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('teams', () => {
|
||||
it('should add and find all teams', async () => {
|
||||
// Given
|
||||
const team: LeaderboardTeamData = {
|
||||
id: 't1',
|
||||
name: 'Team 1',
|
||||
rating: 3000,
|
||||
memberCount: 2,
|
||||
raceCount: 20,
|
||||
};
|
||||
repository.addTeam(team);
|
||||
|
||||
// When
|
||||
const result = await repository.findAllTeams();
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([team]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryLeaderboardsRepository
|
||||
*
|
||||
* In-memory implementation of LeaderboardsRepository.
|
||||
* Stores data in a Map structure.
|
||||
*/
|
||||
|
||||
import {
|
||||
LeaderboardsRepository,
|
||||
LeaderboardDriverData,
|
||||
LeaderboardTeamData,
|
||||
} from '../../../../core/leaderboards/application/ports/LeaderboardsRepository';
|
||||
|
||||
export class InMemoryLeaderboardsRepository implements LeaderboardsRepository {
|
||||
private drivers: Map<string, LeaderboardDriverData> = new Map();
|
||||
private teams: Map<string, LeaderboardTeamData> = new Map();
|
||||
|
||||
async findAllDrivers(): Promise<LeaderboardDriverData[]> {
|
||||
return Array.from(this.drivers.values());
|
||||
}
|
||||
|
||||
async findAllTeams(): Promise<LeaderboardTeamData[]> {
|
||||
return Array.from(this.teams.values());
|
||||
}
|
||||
|
||||
async findDriversByTeamId(teamId: string): Promise<LeaderboardDriverData[]> {
|
||||
return Array.from(this.drivers.values()).filter(
|
||||
(driver) => driver.teamId === teamId,
|
||||
);
|
||||
}
|
||||
|
||||
addDriver(driver: LeaderboardDriverData): void {
|
||||
this.drivers.set(driver.id, driver);
|
||||
}
|
||||
|
||||
addTeam(team: LeaderboardTeamData): void {
|
||||
this.teams.set(team.id, team);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.drivers.clear();
|
||||
this.teams.clear();
|
||||
}
|
||||
}
|
||||
84
adapters/leagues/events/InMemoryLeagueEventPublisher.ts
Normal file
84
adapters/leagues/events/InMemoryLeagueEventPublisher.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
LeagueEventPublisher,
|
||||
LeagueCreatedEvent,
|
||||
LeagueUpdatedEvent,
|
||||
LeagueDeletedEvent,
|
||||
LeagueAccessedEvent,
|
||||
LeagueRosterAccessedEvent,
|
||||
} from '../../../core/leagues/application/ports/LeagueEventPublisher';
|
||||
|
||||
export class InMemoryLeagueEventPublisher implements LeagueEventPublisher {
|
||||
private leagueCreatedEvents: LeagueCreatedEvent[] = [];
|
||||
private leagueUpdatedEvents: LeagueUpdatedEvent[] = [];
|
||||
private leagueDeletedEvents: LeagueDeletedEvent[] = [];
|
||||
private leagueAccessedEvents: LeagueAccessedEvent[] = [];
|
||||
private leagueRosterAccessedEvents: LeagueRosterAccessedEvent[] = [];
|
||||
|
||||
async emitLeagueCreated(event: LeagueCreatedEvent): Promise<void> {
|
||||
this.leagueCreatedEvents.push(event);
|
||||
}
|
||||
|
||||
async emitLeagueUpdated(event: LeagueUpdatedEvent): Promise<void> {
|
||||
this.leagueUpdatedEvents.push(event);
|
||||
}
|
||||
|
||||
async emitLeagueDeleted(event: LeagueDeletedEvent): Promise<void> {
|
||||
this.leagueDeletedEvents.push(event);
|
||||
}
|
||||
|
||||
async emitLeagueAccessed(event: LeagueAccessedEvent): Promise<void> {
|
||||
this.leagueAccessedEvents.push(event);
|
||||
}
|
||||
|
||||
async emitLeagueRosterAccessed(event: LeagueRosterAccessedEvent): Promise<void> {
|
||||
this.leagueRosterAccessedEvents.push(event);
|
||||
}
|
||||
|
||||
getLeagueCreatedEventCount(): number {
|
||||
return this.leagueCreatedEvents.length;
|
||||
}
|
||||
|
||||
getLeagueUpdatedEventCount(): number {
|
||||
return this.leagueUpdatedEvents.length;
|
||||
}
|
||||
|
||||
getLeagueDeletedEventCount(): number {
|
||||
return this.leagueDeletedEvents.length;
|
||||
}
|
||||
|
||||
getLeagueAccessedEventCount(): number {
|
||||
return this.leagueAccessedEvents.length;
|
||||
}
|
||||
|
||||
getLeagueRosterAccessedEventCount(): number {
|
||||
return this.leagueRosterAccessedEvents.length;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.leagueCreatedEvents = [];
|
||||
this.leagueUpdatedEvents = [];
|
||||
this.leagueDeletedEvents = [];
|
||||
this.leagueAccessedEvents = [];
|
||||
this.leagueRosterAccessedEvents = [];
|
||||
}
|
||||
|
||||
getLeagueCreatedEvents(): LeagueCreatedEvent[] {
|
||||
return [...this.leagueCreatedEvents];
|
||||
}
|
||||
|
||||
getLeagueUpdatedEvents(): LeagueUpdatedEvent[] {
|
||||
return [...this.leagueUpdatedEvents];
|
||||
}
|
||||
|
||||
getLeagueDeletedEvents(): LeagueDeletedEvent[] {
|
||||
return [...this.leagueDeletedEvents];
|
||||
}
|
||||
|
||||
getLeagueAccessedEvents(): LeagueAccessedEvent[] {
|
||||
return [...this.leagueAccessedEvents];
|
||||
}
|
||||
|
||||
getLeagueRosterAccessedEvents(): LeagueRosterAccessedEvent[] {
|
||||
return [...this.leagueRosterAccessedEvents];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { InMemoryLeagueRepository } from './InMemoryLeagueRepository';
|
||||
import { LeagueData } from '../../../../core/leagues/application/ports/LeagueRepository';
|
||||
|
||||
describe('InMemoryLeagueRepository', () => {
|
||||
let repository: InMemoryLeagueRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = new InMemoryLeagueRepository();
|
||||
});
|
||||
|
||||
const createLeague = (id: string, name: string, ownerId: string): LeagueData => ({
|
||||
id,
|
||||
name,
|
||||
ownerId,
|
||||
description: `Description for ${name}`,
|
||||
visibility: 'public',
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
maxDrivers: 100,
|
||||
approvalRequired: false,
|
||||
lateJoinAllowed: true,
|
||||
raceFrequency: 'weekly',
|
||||
raceDay: 'Monday',
|
||||
raceTime: '20:00',
|
||||
tracks: ['Spa'],
|
||||
scoringSystem: null,
|
||||
bonusPointsEnabled: true,
|
||||
penaltiesEnabled: true,
|
||||
protestsEnabled: true,
|
||||
appealsEnabled: true,
|
||||
stewardTeam: [],
|
||||
gameType: 'iRacing',
|
||||
skillLevel: 'Intermediate',
|
||||
category: 'Road',
|
||||
tags: [],
|
||||
});
|
||||
|
||||
describe('create and findById', () => {
|
||||
it('should return null when league does not exist', async () => {
|
||||
// When
|
||||
const result = await repository.findById('non-existent');
|
||||
|
||||
// Then
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should create and retrieve a league', async () => {
|
||||
// Given
|
||||
const league = createLeague('l1', 'League 1', 'o1');
|
||||
|
||||
// When
|
||||
await repository.create(league);
|
||||
const result = await repository.findById('l1');
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(league);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByName', () => {
|
||||
it('should find a league by name', async () => {
|
||||
// Given
|
||||
const league = createLeague('l1', 'Unique Name', 'o1');
|
||||
await repository.create(league);
|
||||
|
||||
// When
|
||||
const result = await repository.findByName('Unique Name');
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(league);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update an existing league', async () => {
|
||||
// Given
|
||||
const league = createLeague('l1', 'Original Name', 'o1');
|
||||
await repository.create(league);
|
||||
|
||||
// When
|
||||
const updated = await repository.update('l1', { name: 'Updated Name' });
|
||||
|
||||
// Then
|
||||
expect(updated.name).toBe('Updated Name');
|
||||
const result = await repository.findById('l1');
|
||||
expect(result?.name).toBe('Updated Name');
|
||||
});
|
||||
|
||||
it('should throw error when updating non-existent league', async () => {
|
||||
// When & Then
|
||||
await expect(repository.update('non-existent', { name: 'New' })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete a league', async () => {
|
||||
// Given
|
||||
const league = createLeague('l1', 'To Delete', 'o1');
|
||||
await repository.create(league);
|
||||
|
||||
// When
|
||||
await repository.delete('l1');
|
||||
|
||||
// Then
|
||||
const result = await repository.findById('l1');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
it('should find leagues by name or description', async () => {
|
||||
// Given
|
||||
const l1 = createLeague('l1', 'Formula 1', 'o1');
|
||||
const l2 = createLeague('l2', 'GT3 Masters', 'o1');
|
||||
await repository.create(l1);
|
||||
await repository.create(l2);
|
||||
|
||||
// When
|
||||
const results = await repository.search('Formula');
|
||||
|
||||
// Then
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe('l1');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,64 +1,364 @@
|
||||
import {
|
||||
DashboardRepository,
|
||||
DriverData,
|
||||
RaceData,
|
||||
LeagueStandingData,
|
||||
ActivityData,
|
||||
FriendData,
|
||||
} from '../../../../core/dashboard/application/ports/DashboardRepository';
|
||||
LeagueRepository,
|
||||
LeagueData,
|
||||
LeagueStats,
|
||||
LeagueFinancials,
|
||||
LeagueStewardingMetrics,
|
||||
LeaguePerformanceMetrics,
|
||||
LeagueRatingMetrics,
|
||||
LeagueTrendMetrics,
|
||||
LeagueSuccessRateMetrics,
|
||||
LeagueResolutionTimeMetrics,
|
||||
LeagueComplexSuccessRateMetrics,
|
||||
LeagueComplexResolutionTimeMetrics,
|
||||
LeagueMember,
|
||||
LeaguePendingRequest,
|
||||
} from '../../../../core/leagues/application/ports/LeagueRepository';
|
||||
import { LeagueStandingData } from '../../../../core/dashboard/application/ports/DashboardRepository';
|
||||
|
||||
export class InMemoryLeagueRepository implements DashboardRepository {
|
||||
private drivers: Map<string, DriverData> = new Map();
|
||||
private upcomingRaces: Map<string, RaceData[]> = new Map();
|
||||
export class InMemoryLeagueRepository implements LeagueRepository {
|
||||
private leagues: Map<string, LeagueData> = new Map();
|
||||
private leagueStats: Map<string, LeagueStats> = new Map();
|
||||
private leagueFinancials: Map<string, LeagueFinancials> = new Map();
|
||||
private leagueStewardingMetrics: Map<string, LeagueStewardingMetrics> = new Map();
|
||||
private leaguePerformanceMetrics: Map<string, LeaguePerformanceMetrics> = new Map();
|
||||
private leagueRatingMetrics: Map<string, LeagueRatingMetrics> = new Map();
|
||||
private leagueTrendMetrics: Map<string, LeagueTrendMetrics> = new Map();
|
||||
private leagueSuccessRateMetrics: Map<string, LeagueSuccessRateMetrics> = new Map();
|
||||
private leagueResolutionTimeMetrics: Map<string, LeagueResolutionTimeMetrics> = new Map();
|
||||
private leagueComplexSuccessRateMetrics: Map<string, LeagueComplexSuccessRateMetrics> = new Map();
|
||||
private leagueComplexResolutionTimeMetrics: Map<string, LeagueComplexResolutionTimeMetrics> = new Map();
|
||||
private leagueStandings: Map<string, LeagueStandingData[]> = new Map();
|
||||
private recentActivity: Map<string, ActivityData[]> = new Map();
|
||||
private friends: Map<string, FriendData[]> = new Map();
|
||||
private leagueMembers: Map<string, LeagueMember[]> = new Map();
|
||||
private leaguePendingRequests: Map<string, LeaguePendingRequest[]> = new Map();
|
||||
|
||||
async findDriverById(driverId: string): Promise<DriverData | null> {
|
||||
return this.drivers.get(driverId) || null;
|
||||
async create(league: LeagueData): Promise<LeagueData> {
|
||||
this.leagues.set(league.id, league);
|
||||
return league;
|
||||
}
|
||||
|
||||
async getUpcomingRaces(driverId: string): Promise<RaceData[]> {
|
||||
return this.upcomingRaces.get(driverId) || [];
|
||||
async findById(id: string): Promise<LeagueData | null> {
|
||||
return this.leagues.get(id) || null;
|
||||
}
|
||||
|
||||
async getLeagueStandings(driverId: string): Promise<LeagueStandingData[]> {
|
||||
return this.leagueStandings.get(driverId) || [];
|
||||
async findByName(name: string): Promise<LeagueData | null> {
|
||||
for (const league of Array.from(this.leagues.values())) {
|
||||
if (league.name === name) {
|
||||
return league;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async getRecentActivity(driverId: string): Promise<ActivityData[]> {
|
||||
return this.recentActivity.get(driverId) || [];
|
||||
async findByOwner(ownerId: string): Promise<LeagueData[]> {
|
||||
const leagues: LeagueData[] = [];
|
||||
for (const league of Array.from(this.leagues.values())) {
|
||||
if (league.ownerId === ownerId) {
|
||||
leagues.push(league);
|
||||
}
|
||||
}
|
||||
return leagues;
|
||||
}
|
||||
|
||||
async getFriends(driverId: string): Promise<FriendData[]> {
|
||||
return this.friends.get(driverId) || [];
|
||||
async search(query: string): Promise<LeagueData[]> {
|
||||
const results: LeagueData[] = [];
|
||||
const lowerQuery = query.toLowerCase();
|
||||
for (const league of Array.from(this.leagues.values())) {
|
||||
if (
|
||||
league.name.toLowerCase().includes(lowerQuery) ||
|
||||
league.description?.toLowerCase().includes(lowerQuery)
|
||||
) {
|
||||
results.push(league);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
addDriver(driver: DriverData): void {
|
||||
this.drivers.set(driver.id, driver);
|
||||
async update(id: string, updates: Partial<LeagueData>): Promise<LeagueData> {
|
||||
const league = this.leagues.get(id);
|
||||
if (!league) {
|
||||
throw new Error(`League with id ${id} not found`);
|
||||
}
|
||||
const updated = { ...league, ...updates };
|
||||
this.leagues.set(id, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
addUpcomingRaces(driverId: string, races: RaceData[]): void {
|
||||
this.upcomingRaces.set(driverId, races);
|
||||
async delete(id: string): Promise<void> {
|
||||
this.leagues.delete(id);
|
||||
this.leagueStats.delete(id);
|
||||
this.leagueFinancials.delete(id);
|
||||
this.leagueStewardingMetrics.delete(id);
|
||||
this.leaguePerformanceMetrics.delete(id);
|
||||
this.leagueRatingMetrics.delete(id);
|
||||
this.leagueTrendMetrics.delete(id);
|
||||
this.leagueSuccessRateMetrics.delete(id);
|
||||
this.leagueResolutionTimeMetrics.delete(id);
|
||||
this.leagueComplexSuccessRateMetrics.delete(id);
|
||||
this.leagueComplexResolutionTimeMetrics.delete(id);
|
||||
}
|
||||
|
||||
async getStats(leagueId: string): Promise<LeagueStats> {
|
||||
return this.leagueStats.get(leagueId) || this.createDefaultStats(leagueId);
|
||||
}
|
||||
|
||||
async updateStats(leagueId: string, stats: LeagueStats): Promise<LeagueStats> {
|
||||
this.leagueStats.set(leagueId, stats);
|
||||
return stats;
|
||||
}
|
||||
|
||||
async getFinancials(leagueId: string): Promise<LeagueFinancials> {
|
||||
return this.leagueFinancials.get(leagueId) || this.createDefaultFinancials(leagueId);
|
||||
}
|
||||
|
||||
async updateFinancials(leagueId: string, financials: LeagueFinancials): Promise<LeagueFinancials> {
|
||||
this.leagueFinancials.set(leagueId, financials);
|
||||
return financials;
|
||||
}
|
||||
|
||||
async getStewardingMetrics(leagueId: string): Promise<LeagueStewardingMetrics> {
|
||||
return this.leagueStewardingMetrics.get(leagueId) || this.createDefaultStewardingMetrics(leagueId);
|
||||
}
|
||||
|
||||
async updateStewardingMetrics(leagueId: string, metrics: LeagueStewardingMetrics): Promise<LeagueStewardingMetrics> {
|
||||
this.leagueStewardingMetrics.set(leagueId, metrics);
|
||||
return metrics;
|
||||
}
|
||||
|
||||
async getPerformanceMetrics(leagueId: string): Promise<LeaguePerformanceMetrics> {
|
||||
return this.leaguePerformanceMetrics.get(leagueId) || this.createDefaultPerformanceMetrics(leagueId);
|
||||
}
|
||||
|
||||
async updatePerformanceMetrics(leagueId: string, metrics: LeaguePerformanceMetrics): Promise<LeaguePerformanceMetrics> {
|
||||
this.leaguePerformanceMetrics.set(leagueId, metrics);
|
||||
return metrics;
|
||||
}
|
||||
|
||||
async getRatingMetrics(leagueId: string): Promise<LeagueRatingMetrics> {
|
||||
return this.leagueRatingMetrics.get(leagueId) || this.createDefaultRatingMetrics(leagueId);
|
||||
}
|
||||
|
||||
async updateRatingMetrics(leagueId: string, metrics: LeagueRatingMetrics): Promise<LeagueRatingMetrics> {
|
||||
this.leagueRatingMetrics.set(leagueId, metrics);
|
||||
return metrics;
|
||||
}
|
||||
|
||||
async getTrendMetrics(leagueId: string): Promise<LeagueTrendMetrics> {
|
||||
return this.leagueTrendMetrics.get(leagueId) || this.createDefaultTrendMetrics(leagueId);
|
||||
}
|
||||
|
||||
async updateTrendMetrics(leagueId: string, metrics: LeagueTrendMetrics): Promise<LeagueTrendMetrics> {
|
||||
this.leagueTrendMetrics.set(leagueId, metrics);
|
||||
return metrics;
|
||||
}
|
||||
|
||||
async getSuccessRateMetrics(leagueId: string): Promise<LeagueSuccessRateMetrics> {
|
||||
return this.leagueSuccessRateMetrics.get(leagueId) || this.createDefaultSuccessRateMetrics(leagueId);
|
||||
}
|
||||
|
||||
async updateSuccessRateMetrics(leagueId: string, metrics: LeagueSuccessRateMetrics): Promise<LeagueSuccessRateMetrics> {
|
||||
this.leagueSuccessRateMetrics.set(leagueId, metrics);
|
||||
return metrics;
|
||||
}
|
||||
|
||||
async getResolutionTimeMetrics(leagueId: string): Promise<LeagueResolutionTimeMetrics> {
|
||||
return this.leagueResolutionTimeMetrics.get(leagueId) || this.createDefaultResolutionTimeMetrics(leagueId);
|
||||
}
|
||||
|
||||
async updateResolutionTimeMetrics(leagueId: string, metrics: LeagueResolutionTimeMetrics): Promise<LeagueResolutionTimeMetrics> {
|
||||
this.leagueResolutionTimeMetrics.set(leagueId, metrics);
|
||||
return metrics;
|
||||
}
|
||||
|
||||
async getComplexSuccessRateMetrics(leagueId: string): Promise<LeagueComplexSuccessRateMetrics> {
|
||||
return this.leagueComplexSuccessRateMetrics.get(leagueId) || this.createDefaultComplexSuccessRateMetrics(leagueId);
|
||||
}
|
||||
|
||||
async updateComplexSuccessRateMetrics(leagueId: string, metrics: LeagueComplexSuccessRateMetrics): Promise<LeagueComplexSuccessRateMetrics> {
|
||||
this.leagueComplexSuccessRateMetrics.set(leagueId, metrics);
|
||||
return metrics;
|
||||
}
|
||||
|
||||
async getComplexResolutionTimeMetrics(leagueId: string): Promise<LeagueComplexResolutionTimeMetrics> {
|
||||
return this.leagueComplexResolutionTimeMetrics.get(leagueId) || this.createDefaultComplexResolutionTimeMetrics(leagueId);
|
||||
}
|
||||
|
||||
async updateComplexResolutionTimeMetrics(leagueId: string, metrics: LeagueComplexResolutionTimeMetrics): Promise<LeagueComplexResolutionTimeMetrics> {
|
||||
this.leagueComplexResolutionTimeMetrics.set(leagueId, metrics);
|
||||
return metrics;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.leagues.clear();
|
||||
this.leagueStats.clear();
|
||||
this.leagueFinancials.clear();
|
||||
this.leagueStewardingMetrics.clear();
|
||||
this.leaguePerformanceMetrics.clear();
|
||||
this.leagueRatingMetrics.clear();
|
||||
this.leagueTrendMetrics.clear();
|
||||
this.leagueSuccessRateMetrics.clear();
|
||||
this.leagueResolutionTimeMetrics.clear();
|
||||
this.leagueComplexSuccessRateMetrics.clear();
|
||||
this.leagueComplexResolutionTimeMetrics.clear();
|
||||
this.leagueStandings.clear();
|
||||
this.leagueMembers.clear();
|
||||
this.leaguePendingRequests.clear();
|
||||
}
|
||||
|
||||
addLeagueStandings(driverId: string, standings: LeagueStandingData[]): void {
|
||||
this.leagueStandings.set(driverId, standings);
|
||||
}
|
||||
|
||||
addRecentActivity(driverId: string, activities: ActivityData[]): void {
|
||||
this.recentActivity.set(driverId, activities);
|
||||
async getLeagueStandings(driverId: string): Promise<LeagueStandingData[]> {
|
||||
return this.leagueStandings.get(driverId) || [];
|
||||
}
|
||||
|
||||
addFriends(driverId: string, friends: FriendData[]): void {
|
||||
this.friends.set(driverId, friends);
|
||||
async addLeagueMembers(leagueId: string, members: LeagueMember[]): Promise<void> {
|
||||
const current = this.leagueMembers.get(leagueId) || [];
|
||||
this.leagueMembers.set(leagueId, [...current, ...members]);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.drivers.clear();
|
||||
this.upcomingRaces.clear();
|
||||
this.leagueStandings.clear();
|
||||
this.recentActivity.clear();
|
||||
this.friends.clear();
|
||||
async getLeagueMembers(leagueId: string): Promise<LeagueMember[]> {
|
||||
return this.leagueMembers.get(leagueId) || [];
|
||||
}
|
||||
|
||||
async updateLeagueMember(leagueId: string, driverId: string, updates: Partial<LeagueMember>): Promise<void> {
|
||||
const members = this.leagueMembers.get(leagueId) || [];
|
||||
const index = members.findIndex(m => m.driverId === driverId);
|
||||
if (index !== -1) {
|
||||
members[index] = { ...members[index], ...updates } as LeagueMember;
|
||||
this.leagueMembers.set(leagueId, [...members]);
|
||||
}
|
||||
}
|
||||
|
||||
async removeLeagueMember(leagueId: string, driverId: string): Promise<void> {
|
||||
const members = this.leagueMembers.get(leagueId) || [];
|
||||
this.leagueMembers.set(leagueId, members.filter(m => m.driverId !== driverId));
|
||||
}
|
||||
|
||||
async addPendingRequests(leagueId: string, requests: LeaguePendingRequest[]): Promise<void> {
|
||||
const current = this.leaguePendingRequests.get(leagueId) || [];
|
||||
this.leaguePendingRequests.set(leagueId, [...current, ...requests]);
|
||||
}
|
||||
|
||||
async getPendingRequests(leagueId: string): Promise<LeaguePendingRequest[]> {
|
||||
return this.leaguePendingRequests.get(leagueId) || [];
|
||||
}
|
||||
|
||||
async removePendingRequest(leagueId: string, requestId: string): Promise<void> {
|
||||
const current = this.leaguePendingRequests.get(leagueId) || [];
|
||||
this.leaguePendingRequests.set(leagueId, current.filter(r => r.id !== requestId));
|
||||
}
|
||||
|
||||
private createDefaultStats(leagueId: string): LeagueStats {
|
||||
return {
|
||||
leagueId,
|
||||
memberCount: 1,
|
||||
raceCount: 0,
|
||||
sponsorCount: 0,
|
||||
prizePool: 0,
|
||||
rating: 0,
|
||||
reviewCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private createDefaultFinancials(leagueId: string): LeagueFinancials {
|
||||
return {
|
||||
leagueId,
|
||||
walletBalance: 0,
|
||||
totalRevenue: 0,
|
||||
totalFees: 0,
|
||||
pendingPayouts: 0,
|
||||
netBalance: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private createDefaultStewardingMetrics(leagueId: string): LeagueStewardingMetrics {
|
||||
return {
|
||||
leagueId,
|
||||
averageResolutionTime: 0,
|
||||
averageProtestResolutionTime: 0,
|
||||
averagePenaltyAppealSuccessRate: 0,
|
||||
averageProtestSuccessRate: 0,
|
||||
averageStewardingActionSuccessRate: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private createDefaultPerformanceMetrics(leagueId: string): LeaguePerformanceMetrics {
|
||||
return {
|
||||
leagueId,
|
||||
averageLapTime: 0,
|
||||
averageFieldSize: 0,
|
||||
averageIncidentCount: 0,
|
||||
averagePenaltyCount: 0,
|
||||
averageProtestCount: 0,
|
||||
averageStewardingActionCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private createDefaultRatingMetrics(leagueId: string): LeagueRatingMetrics {
|
||||
return {
|
||||
leagueId,
|
||||
overallRating: 0,
|
||||
ratingTrend: 0,
|
||||
rankTrend: 0,
|
||||
pointsTrend: 0,
|
||||
winRateTrend: 0,
|
||||
podiumRateTrend: 0,
|
||||
dnfRateTrend: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private createDefaultTrendMetrics(leagueId: string): LeagueTrendMetrics {
|
||||
return {
|
||||
leagueId,
|
||||
incidentRateTrend: 0,
|
||||
penaltyRateTrend: 0,
|
||||
protestRateTrend: 0,
|
||||
stewardingActionRateTrend: 0,
|
||||
stewardingTimeTrend: 0,
|
||||
protestResolutionTimeTrend: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private createDefaultSuccessRateMetrics(leagueId: string): LeagueSuccessRateMetrics {
|
||||
return {
|
||||
leagueId,
|
||||
penaltyAppealSuccessRate: 0,
|
||||
protestSuccessRate: 0,
|
||||
stewardingActionSuccessRate: 0,
|
||||
stewardingActionAppealSuccessRate: 0,
|
||||
stewardingActionPenaltySuccessRate: 0,
|
||||
stewardingActionProtestSuccessRate: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private createDefaultResolutionTimeMetrics(leagueId: string): LeagueResolutionTimeMetrics {
|
||||
return {
|
||||
leagueId,
|
||||
averageStewardingTime: 0,
|
||||
averageProtestResolutionTime: 0,
|
||||
averageStewardingActionAppealPenaltyProtestResolutionTime: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private createDefaultComplexSuccessRateMetrics(leagueId: string): LeagueComplexSuccessRateMetrics {
|
||||
return {
|
||||
leagueId,
|
||||
stewardingActionAppealPenaltyProtestSuccessRate: 0,
|
||||
stewardingActionAppealProtestSuccessRate: 0,
|
||||
stewardingActionPenaltyProtestSuccessRate: 0,
|
||||
stewardingActionAppealPenaltyProtestSuccessRate2: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private createDefaultComplexResolutionTimeMetrics(leagueId: string): LeagueComplexResolutionTimeMetrics {
|
||||
return {
|
||||
leagueId,
|
||||
stewardingActionAppealPenaltyProtestResolutionTime: 0,
|
||||
stewardingActionAppealProtestResolutionTime: 0,
|
||||
stewardingActionPenaltyProtestResolutionTime: 0,
|
||||
stewardingActionAppealPenaltyProtestResolutionTime2: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
93
adapters/media/events/InMemoryMediaEventPublisher.ts
Normal file
93
adapters/media/events/InMemoryMediaEventPublisher.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryMediaEventPublisher
|
||||
*
|
||||
* In-memory implementation of MediaEventPublisher for testing purposes.
|
||||
* Stores events in memory for verification in integration tests.
|
||||
*/
|
||||
|
||||
import type { Logger } from '@core/shared/domain/Logger';
|
||||
import type { DomainEvent } from '@core/shared/domain/DomainEvent';
|
||||
|
||||
export interface MediaEvent {
|
||||
eventType: string;
|
||||
aggregateId: string;
|
||||
eventData: unknown;
|
||||
occurredAt: Date;
|
||||
}
|
||||
|
||||
export class InMemoryMediaEventPublisher {
|
||||
private events: MediaEvent[] = [];
|
||||
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.logger.info('[InMemoryMediaEventPublisher] Initialized.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a domain event
|
||||
*/
|
||||
async publish(event: DomainEvent): Promise<void> {
|
||||
this.logger.debug(`[InMemoryMediaEventPublisher] Publishing event: ${event.eventType} for aggregate: ${event.aggregateId}`);
|
||||
|
||||
const mediaEvent: MediaEvent = {
|
||||
eventType: event.eventType,
|
||||
aggregateId: event.aggregateId,
|
||||
eventData: event.eventData,
|
||||
occurredAt: event.occurredAt,
|
||||
};
|
||||
|
||||
this.events.push(mediaEvent);
|
||||
this.logger.info(`Event ${event.eventType} published successfully.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all published events
|
||||
*/
|
||||
getEvents(): MediaEvent[] {
|
||||
return [...this.events];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events by event type
|
||||
*/
|
||||
getEventsByType(eventType: string): MediaEvent[] {
|
||||
return this.events.filter(event => event.eventType === eventType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events by aggregate ID
|
||||
*/
|
||||
getEventsByAggregateId(aggregateId: string): MediaEvent[] {
|
||||
return this.events.filter(event => event.aggregateId === aggregateId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of events
|
||||
*/
|
||||
getEventCount(): number {
|
||||
return this.events.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all events
|
||||
*/
|
||||
clear(): void {
|
||||
this.events = [];
|
||||
this.logger.info('[InMemoryMediaEventPublisher] All events cleared.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event of a specific type was published
|
||||
*/
|
||||
hasEvent(eventType: string): boolean {
|
||||
return this.events.some(event => event.eventType === eventType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event was published for a specific aggregate
|
||||
*/
|
||||
hasEventForAggregate(eventType: string, aggregateId: string): boolean {
|
||||
return this.events.some(
|
||||
event => event.eventType === eventType && event.aggregateId === aggregateId
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,12 @@ export class InMemoryAvatarGenerationRepository implements AvatarGenerationRepos
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.requests.clear();
|
||||
this.userRequests.clear();
|
||||
this.logger.info('InMemoryAvatarGenerationRepository cleared.');
|
||||
}
|
||||
|
||||
async save(request: AvatarGenerationRequest): Promise<void> {
|
||||
this.logger.debug(`[InMemoryAvatarGenerationRepository] Saving avatar generation request: ${request.id} for user ${request.userId}.`);
|
||||
this.requests.set(request.id, request);
|
||||
|
||||
121
adapters/media/persistence/inmemory/InMemoryAvatarRepository.ts
Normal file
121
adapters/media/persistence/inmemory/InMemoryAvatarRepository.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryAvatarRepository
|
||||
*
|
||||
* In-memory implementation of AvatarRepository for testing purposes.
|
||||
* Stores avatar entities in memory for fast, deterministic testing.
|
||||
*/
|
||||
|
||||
import type { Avatar } from '@core/media/domain/entities/Avatar';
|
||||
import type { AvatarRepository } from '@core/media/domain/repositories/AvatarRepository';
|
||||
import type { Logger } from '@core/shared/domain/Logger';
|
||||
|
||||
export class InMemoryAvatarRepository implements AvatarRepository {
|
||||
private avatars: Map<string, Avatar> = new Map();
|
||||
private driverAvatars: Map<string, Avatar[]> = new Map();
|
||||
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.logger.info('[InMemoryAvatarRepository] Initialized.');
|
||||
}
|
||||
|
||||
async save(avatar: Avatar): Promise<void> {
|
||||
this.logger.debug(`[InMemoryAvatarRepository] Saving avatar: ${avatar.id} for driver: ${avatar.driverId}`);
|
||||
|
||||
// Store by ID
|
||||
this.avatars.set(avatar.id, avatar);
|
||||
|
||||
// Store by driver ID
|
||||
if (!this.driverAvatars.has(avatar.driverId)) {
|
||||
this.driverAvatars.set(avatar.driverId, []);
|
||||
}
|
||||
|
||||
const driverAvatars = this.driverAvatars.get(avatar.driverId)!;
|
||||
const existingIndex = driverAvatars.findIndex(a => a.id === avatar.id);
|
||||
|
||||
if (existingIndex > -1) {
|
||||
driverAvatars[existingIndex] = avatar;
|
||||
} else {
|
||||
driverAvatars.push(avatar);
|
||||
}
|
||||
|
||||
this.logger.info(`Avatar ${avatar.id} for driver ${avatar.driverId} saved successfully.`);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Avatar | null> {
|
||||
this.logger.debug(`[InMemoryAvatarRepository] Finding avatar by ID: ${id}`);
|
||||
const avatar = this.avatars.get(id) ?? null;
|
||||
|
||||
if (avatar) {
|
||||
this.logger.info(`Found avatar by ID: ${id}`);
|
||||
} else {
|
||||
this.logger.warn(`Avatar with ID ${id} not found.`);
|
||||
}
|
||||
|
||||
return avatar;
|
||||
}
|
||||
|
||||
async findActiveByDriverId(driverId: string): Promise<Avatar | null> {
|
||||
this.logger.debug(`[InMemoryAvatarRepository] Finding active avatar for driver: ${driverId}`);
|
||||
|
||||
const driverAvatars = this.driverAvatars.get(driverId) ?? [];
|
||||
const activeAvatar = driverAvatars.find(avatar => avatar.isActive) ?? null;
|
||||
|
||||
if (activeAvatar) {
|
||||
this.logger.info(`Found active avatar for driver ${driverId}: ${activeAvatar.id}`);
|
||||
} else {
|
||||
this.logger.warn(`No active avatar found for driver: ${driverId}`);
|
||||
}
|
||||
|
||||
return activeAvatar;
|
||||
}
|
||||
|
||||
async findByDriverId(driverId: string): Promise<Avatar[]> {
|
||||
this.logger.debug(`[InMemoryAvatarRepository] Finding all avatars for driver: ${driverId}`);
|
||||
|
||||
const driverAvatars = this.driverAvatars.get(driverId) ?? [];
|
||||
this.logger.info(`Found ${driverAvatars.length} avatars for driver ${driverId}.`);
|
||||
|
||||
return driverAvatars;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
this.logger.debug(`[InMemoryAvatarRepository] Deleting avatar with ID: ${id}`);
|
||||
|
||||
const avatarToDelete = this.avatars.get(id);
|
||||
if (!avatarToDelete) {
|
||||
this.logger.warn(`Avatar with ID ${id} not found for deletion.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove from avatars map
|
||||
this.avatars.delete(id);
|
||||
|
||||
// Remove from driver avatars
|
||||
const driverAvatars = this.driverAvatars.get(avatarToDelete.driverId);
|
||||
if (driverAvatars) {
|
||||
const filtered = driverAvatars.filter(avatar => avatar.id !== id);
|
||||
if (filtered.length > 0) {
|
||||
this.driverAvatars.set(avatarToDelete.driverId, filtered);
|
||||
} else {
|
||||
this.driverAvatars.delete(avatarToDelete.driverId);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info(`Avatar ${id} deleted successfully.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all avatars from the repository
|
||||
*/
|
||||
clear(): void {
|
||||
this.avatars.clear();
|
||||
this.driverAvatars.clear();
|
||||
this.logger.info('[InMemoryAvatarRepository] All avatars cleared.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of avatars stored
|
||||
*/
|
||||
get size(): number {
|
||||
return this.avatars.size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, vi } from 'vitest';
|
||||
import { InMemoryMediaRepository } from './InMemoryMediaRepository';
|
||||
import { runMediaRepositoryContract } from '../../../../tests/contracts/media/MediaRepository.contract';
|
||||
|
||||
describe('InMemoryMediaRepository Contract Compliance', () => {
|
||||
runMediaRepositoryContract(async () => {
|
||||
const logger = {
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const repository = new InMemoryMediaRepository(logger as any);
|
||||
|
||||
return {
|
||||
repository,
|
||||
cleanup: async () => {
|
||||
repository.clear();
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, vi } from 'vitest';
|
||||
import { TypeOrmMediaRepository } from './TypeOrmMediaRepository';
|
||||
import { MediaOrmMapper } from '../mappers/MediaOrmMapper';
|
||||
import { runMediaRepositoryContract } from '../../../../../tests/contracts/media/MediaRepository.contract';
|
||||
|
||||
describe('TypeOrmMediaRepository Contract Compliance', () => {
|
||||
runMediaRepositoryContract(async () => {
|
||||
// Mocking TypeORM DataSource and Repository for a DB-free contract test
|
||||
// In a real scenario, this might use an in-memory SQLite database
|
||||
const ormEntities = new Map<string, any>();
|
||||
|
||||
const ormRepo = {
|
||||
save: vi.fn().mockImplementation(async (entity) => {
|
||||
ormEntities.set(entity.id, entity);
|
||||
return entity;
|
||||
}),
|
||||
findOne: vi.fn().mockImplementation(async ({ where: { id } }) => {
|
||||
return ormEntities.get(id) || null;
|
||||
}),
|
||||
find: vi.fn().mockImplementation(async ({ where: { uploadedBy } }) => {
|
||||
return Array.from(ormEntities.values()).filter(e => e.uploadedBy === uploadedBy);
|
||||
}),
|
||||
delete: vi.fn().mockImplementation(async ({ id }) => {
|
||||
ormEntities.delete(id);
|
||||
}),
|
||||
};
|
||||
|
||||
const dataSource = {
|
||||
getRepository: vi.fn().mockReturnValue(ormRepo),
|
||||
};
|
||||
|
||||
const mapper = new MediaOrmMapper();
|
||||
const repository = new TypeOrmMediaRepository(dataSource as any, mapper);
|
||||
|
||||
return {
|
||||
repository,
|
||||
cleanup: async () => {
|
||||
ormEntities.clear();
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
22
adapters/media/ports/InMemoryAvatarGenerationAdapter.ts
Normal file
22
adapters/media/ports/InMemoryAvatarGenerationAdapter.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { AvatarGenerationPort, AvatarGenerationOptions, AvatarGenerationResult } from '@core/media/application/ports/AvatarGenerationPort';
|
||||
import type { Logger } from '@core/shared/domain/Logger';
|
||||
|
||||
export class InMemoryAvatarGenerationAdapter implements AvatarGenerationPort {
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.logger.info('InMemoryAvatarGenerationAdapter initialized.');
|
||||
}
|
||||
|
||||
async generateAvatars(options: AvatarGenerationOptions): Promise<AvatarGenerationResult> {
|
||||
this.logger.debug('[InMemoryAvatarGenerationAdapter] Generating avatars (mock).', { options });
|
||||
|
||||
const avatars = Array.from({ length: options.count }, (_, i) => ({
|
||||
url: `https://example.com/generated-avatar-${i + 1}.png`,
|
||||
thumbnailUrl: `https://example.com/generated-avatar-${i + 1}-thumb.png`,
|
||||
}));
|
||||
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
avatars,
|
||||
});
|
||||
}
|
||||
}
|
||||
109
adapters/media/ports/InMemoryMediaStorageAdapter.ts
Normal file
109
adapters/media/ports/InMemoryMediaStorageAdapter.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InMemoryMediaStorageAdapter
|
||||
*
|
||||
* In-memory implementation of MediaStoragePort for testing purposes.
|
||||
* Simulates file storage without actual filesystem operations.
|
||||
*/
|
||||
|
||||
import type { MediaStoragePort, UploadOptions, UploadResult } from '@core/media/application/ports/MediaStoragePort';
|
||||
import type { Logger } from '@core/shared/domain/Logger';
|
||||
|
||||
export class InMemoryMediaStorageAdapter implements MediaStoragePort {
|
||||
private storage: Map<string, Buffer> = new Map();
|
||||
private metadata: Map<string, { size: number; contentType: string }> = new Map();
|
||||
|
||||
constructor(private readonly logger: Logger) {
|
||||
this.logger.info('[InMemoryMediaStorageAdapter] Initialized.');
|
||||
}
|
||||
|
||||
async uploadMedia(buffer: Buffer, options: UploadOptions): Promise<UploadResult> {
|
||||
this.logger.debug(`[InMemoryMediaStorageAdapter] Uploading media: ${options.filename}`);
|
||||
|
||||
// Validate content type
|
||||
const allowedTypes = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/gif'];
|
||||
if (!allowedTypes.includes(options.mimeType)) {
|
||||
return {
|
||||
success: false,
|
||||
errorMessage: `Content type ${options.mimeType} is not allowed`,
|
||||
};
|
||||
}
|
||||
|
||||
// Generate storage key
|
||||
const storageKey = `/media/uploaded/${Date.now()}-${options.filename.replace(/[^a-zA-Z0-9.-]/g, '_')}`;
|
||||
|
||||
// Store buffer and metadata
|
||||
this.storage.set(storageKey, buffer);
|
||||
this.metadata.set(storageKey, {
|
||||
size: buffer.length,
|
||||
contentType: options.mimeType,
|
||||
});
|
||||
|
||||
this.logger.info(`Media uploaded successfully: ${storageKey}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
filename: options.filename,
|
||||
url: storageKey,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteMedia(storageKey: string): Promise<void> {
|
||||
this.logger.debug(`[InMemoryMediaStorageAdapter] Deleting media: ${storageKey}`);
|
||||
|
||||
this.storage.delete(storageKey);
|
||||
this.metadata.delete(storageKey);
|
||||
|
||||
this.logger.info(`Media deleted successfully: ${storageKey}`);
|
||||
}
|
||||
|
||||
async getBytes(storageKey: string): Promise<Buffer | null> {
|
||||
this.logger.debug(`[InMemoryMediaStorageAdapter] Getting bytes for: ${storageKey}`);
|
||||
|
||||
const buffer = this.storage.get(storageKey) ?? null;
|
||||
|
||||
if (buffer) {
|
||||
this.logger.info(`Retrieved bytes for: ${storageKey}`);
|
||||
} else {
|
||||
this.logger.warn(`No bytes found for: ${storageKey}`);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
async getMetadata(storageKey: string): Promise<{ size: number; contentType: string } | null> {
|
||||
this.logger.debug(`[InMemoryMediaStorageAdapter] Getting metadata for: ${storageKey}`);
|
||||
|
||||
const meta = this.metadata.get(storageKey) ?? null;
|
||||
|
||||
if (meta) {
|
||||
this.logger.info(`Retrieved metadata for: ${storageKey}`);
|
||||
} else {
|
||||
this.logger.warn(`No metadata found for: ${storageKey}`);
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all stored media
|
||||
*/
|
||||
clear(): void {
|
||||
this.storage.clear();
|
||||
this.metadata.clear();
|
||||
this.logger.info('[InMemoryMediaStorageAdapter] All media cleared.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of stored media files
|
||||
*/
|
||||
get size(): number {
|
||||
return this.storage.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a storage key exists
|
||||
*/
|
||||
has(storageKey: string): boolean {
|
||||
return this.storage.has(storageKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { DiscordNotificationAdapter } from './DiscordNotificationGateway';
|
||||
import { Notification } from '@core/notifications/domain/entities/Notification';
|
||||
|
||||
describe('DiscordNotificationAdapter', () => {
|
||||
const webhookUrl = 'https://discord.com/api/webhooks/123/abc';
|
||||
let adapter: DiscordNotificationAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = new DiscordNotificationAdapter({ webhookUrl });
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
const createNotification = (overrides: any = {}) => {
|
||||
return Notification.create({
|
||||
id: 'notif-123',
|
||||
recipientId: 'driver-456',
|
||||
type: 'protest_filed',
|
||||
title: 'New Protest',
|
||||
body: 'A new protest has been filed against you.',
|
||||
channel: 'discord',
|
||||
...overrides,
|
||||
});
|
||||
};
|
||||
|
||||
describe('send', () => {
|
||||
it('should return success when configured', async () => {
|
||||
// Given
|
||||
const notification = createNotification();
|
||||
|
||||
// When
|
||||
const result = await adapter.send(notification);
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.channel).toBe('discord');
|
||||
expect(result.externalId).toContain('discord-stub-');
|
||||
expect(result.attemptedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should return failure when not configured', async () => {
|
||||
// Given
|
||||
const unconfiguredAdapter = new DiscordNotificationAdapter();
|
||||
const notification = createNotification();
|
||||
|
||||
// When
|
||||
const result = await unconfiguredAdapter.send(notification);
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Discord webhook URL not configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('supportsChannel', () => {
|
||||
it('should return true for discord channel', () => {
|
||||
expect(adapter.supportsChannel('discord')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for other channels', () => {
|
||||
expect(adapter.supportsChannel('email' as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isConfigured', () => {
|
||||
it('should return true when webhookUrl is set', () => {
|
||||
expect(adapter.isConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when webhookUrl is missing', () => {
|
||||
const unconfigured = new DiscordNotificationAdapter();
|
||||
expect(unconfigured.isConfigured()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setWebhookUrl', () => {
|
||||
it('should update the webhook URL', () => {
|
||||
const unconfigured = new DiscordNotificationAdapter();
|
||||
unconfigured.setWebhookUrl(webhookUrl);
|
||||
expect(unconfigured.isConfigured()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { EmailNotificationAdapter } from './EmailNotificationGateway';
|
||||
import { Notification } from '@core/notifications/domain/entities/Notification';
|
||||
|
||||
describe('EmailNotificationAdapter', () => {
|
||||
const config = {
|
||||
smtpHost: 'smtp.example.com',
|
||||
fromAddress: 'noreply@gridpilot.com',
|
||||
};
|
||||
let adapter: EmailNotificationAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = new EmailNotificationAdapter(config);
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
const createNotification = (overrides: any = {}) => {
|
||||
return Notification.create({
|
||||
id: 'notif-123',
|
||||
recipientId: 'driver-456',
|
||||
type: 'protest_filed',
|
||||
title: 'New Protest',
|
||||
body: 'A new protest has been filed against you.',
|
||||
channel: 'email',
|
||||
...overrides,
|
||||
});
|
||||
};
|
||||
|
||||
describe('send', () => {
|
||||
it('should return success when configured', async () => {
|
||||
// Given
|
||||
const notification = createNotification();
|
||||
|
||||
// When
|
||||
const result = await adapter.send(notification);
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.channel).toBe('email');
|
||||
expect(result.externalId).toContain('email-stub-');
|
||||
expect(result.attemptedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should return failure when not configured', async () => {
|
||||
// Given
|
||||
const unconfiguredAdapter = new EmailNotificationAdapter();
|
||||
const notification = createNotification();
|
||||
|
||||
// When
|
||||
const result = await unconfiguredAdapter.send(notification);
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Email SMTP not configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('supportsChannel', () => {
|
||||
it('should return true for email channel', () => {
|
||||
expect(adapter.supportsChannel('email')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for other channels', () => {
|
||||
expect(adapter.supportsChannel('discord' as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isConfigured', () => {
|
||||
it('should return true when smtpHost and fromAddress are set', () => {
|
||||
expect(adapter.isConfigured()).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when config is missing', () => {
|
||||
const unconfigured = new EmailNotificationAdapter();
|
||||
expect(unconfigured.isConfigured()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('configure', () => {
|
||||
it('should update the configuration', () => {
|
||||
const unconfigured = new EmailNotificationAdapter();
|
||||
unconfigured.configure(config);
|
||||
expect(unconfigured.isConfigured()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { InAppNotificationAdapter } from './InAppNotificationGateway';
|
||||
import { Notification } from '@core/notifications/domain/entities/Notification';
|
||||
|
||||
describe('InAppNotificationAdapter', () => {
|
||||
let adapter: InAppNotificationAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = new InAppNotificationAdapter();
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
const createNotification = (overrides: any = {}) => {
|
||||
return Notification.create({
|
||||
id: 'notif-123',
|
||||
recipientId: 'driver-456',
|
||||
type: 'protest_filed',
|
||||
title: 'New Protest',
|
||||
body: 'A new protest has been filed against you.',
|
||||
channel: 'in_app',
|
||||
...overrides,
|
||||
});
|
||||
};
|
||||
|
||||
describe('send', () => {
|
||||
it('should return success', async () => {
|
||||
// Given
|
||||
const notification = createNotification();
|
||||
|
||||
// When
|
||||
const result = await adapter.send(notification);
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.channel).toBe('in_app');
|
||||
expect(result.externalId).toBe('notif-123');
|
||||
expect(result.attemptedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('supportsChannel', () => {
|
||||
it('should return true for in_app channel', () => {
|
||||
expect(adapter.supportsChannel('in_app')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for other channels', () => {
|
||||
expect(adapter.supportsChannel('email' as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isConfigured', () => {
|
||||
it('should always return true', () => {
|
||||
expect(adapter.isConfigured()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { NotificationGatewayRegistry } from './NotificationGatewayRegistry';
|
||||
import { Notification } from '@core/notifications/domain/entities/Notification';
|
||||
import type { NotificationGateway, NotificationDeliveryResult } from '@core/notifications/application/ports/NotificationGateway';
|
||||
import type { NotificationChannel } from '@core/notifications/domain/types/NotificationTypes';
|
||||
|
||||
describe('NotificationGatewayRegistry', () => {
|
||||
let registry: NotificationGatewayRegistry;
|
||||
let mockGateway: NotificationGateway;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGateway = {
|
||||
send: vi.fn(),
|
||||
supportsChannel: vi.fn().mockReturnValue(true),
|
||||
isConfigured: vi.fn().mockReturnValue(true),
|
||||
getChannel: vi.fn().mockReturnValue('email'),
|
||||
};
|
||||
registry = new NotificationGatewayRegistry([mockGateway]);
|
||||
});
|
||||
|
||||
const createNotification = (overrides: any = {}) => {
|
||||
return Notification.create({
|
||||
id: 'notif-123',
|
||||
recipientId: 'driver-456',
|
||||
type: 'protest_filed',
|
||||
title: 'New Protest',
|
||||
body: 'A new protest has been filed against you.',
|
||||
channel: 'email',
|
||||
...overrides,
|
||||
});
|
||||
};
|
||||
|
||||
describe('register and get', () => {
|
||||
it('should register and retrieve a gateway', () => {
|
||||
const discordGateway = {
|
||||
...mockGateway,
|
||||
getChannel: vi.fn().mockReturnValue('discord'),
|
||||
} as any;
|
||||
|
||||
registry.register(discordGateway);
|
||||
expect(registry.getGateway('discord')).toBe(discordGateway);
|
||||
});
|
||||
|
||||
it('should return null for unregistered channel', () => {
|
||||
expect(registry.getGateway('discord')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return all registered gateways', () => {
|
||||
expect(registry.getAllGateways()).toHaveLength(1);
|
||||
expect(registry.getAllGateways()[0]).toBe(mockGateway);
|
||||
});
|
||||
});
|
||||
|
||||
describe('send', () => {
|
||||
it('should route notification to the correct gateway', async () => {
|
||||
// Given
|
||||
const notification = createNotification();
|
||||
const expectedResult: NotificationDeliveryResult = {
|
||||
success: true,
|
||||
channel: 'email',
|
||||
externalId: 'ext-123',
|
||||
attemptedAt: new Date(),
|
||||
};
|
||||
vi.mocked(mockGateway.send).mockResolvedValue(expectedResult);
|
||||
|
||||
// When
|
||||
const result = await registry.send(notification);
|
||||
|
||||
// Then
|
||||
expect(mockGateway.send).toHaveBeenCalledWith(notification);
|
||||
expect(result).toBe(expectedResult);
|
||||
});
|
||||
|
||||
it('should return failure if no gateway is registered for channel', async () => {
|
||||
// Given
|
||||
const notification = createNotification({ channel: 'discord' });
|
||||
|
||||
// When
|
||||
const result = await registry.send(notification);
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('No gateway registered for channel: discord');
|
||||
});
|
||||
|
||||
it('should return failure if gateway is not configured', async () => {
|
||||
// Given
|
||||
const notification = createNotification();
|
||||
vi.mocked(mockGateway.isConfigured).mockReturnValue(false);
|
||||
|
||||
// When
|
||||
const result = await registry.send(notification);
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Gateway for channel email is not configured');
|
||||
});
|
||||
|
||||
it('should catch and return errors from gateway.send', async () => {
|
||||
// Given
|
||||
const notification = createNotification();
|
||||
vi.mocked(mockGateway.send).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
// When
|
||||
const result = await registry.send(notification);
|
||||
|
||||
// Then
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Network error');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,34 +6,33 @@ import type { Payment, PaymentType } from '@core/payments/domain/entities/Paymen
|
||||
import type { PaymentRepository } from '@core/payments/domain/repositories/PaymentRepository';
|
||||
import type { Logger } from '@core/shared/domain/Logger';
|
||||
|
||||
const payments: Map<string, Payment> = new Map();
|
||||
|
||||
export class InMemoryPaymentRepository implements PaymentRepository {
|
||||
private payments: Map<string, Payment> = new Map();
|
||||
constructor(private readonly logger: Logger) {}
|
||||
|
||||
async findById(id: string): Promise<Payment | null> {
|
||||
this.logger.debug('[InMemoryPaymentRepository] findById', { id });
|
||||
return payments.get(id) || null;
|
||||
return this.payments.get(id) || null;
|
||||
}
|
||||
|
||||
async findByLeagueId(leagueId: string): Promise<Payment[]> {
|
||||
this.logger.debug('[InMemoryPaymentRepository] findByLeagueId', { leagueId });
|
||||
return Array.from(payments.values()).filter(p => p.leagueId === leagueId);
|
||||
return Array.from(this.payments.values()).filter(p => p.leagueId === leagueId);
|
||||
}
|
||||
|
||||
async findByPayerId(payerId: string): Promise<Payment[]> {
|
||||
this.logger.debug('[InMemoryPaymentRepository] findByPayerId', { payerId });
|
||||
return Array.from(payments.values()).filter(p => p.payerId === payerId);
|
||||
return Array.from(this.payments.values()).filter(p => p.payerId === payerId);
|
||||
}
|
||||
|
||||
async findByType(type: PaymentType): Promise<Payment[]> {
|
||||
this.logger.debug('[InMemoryPaymentRepository] findByType', { type });
|
||||
return Array.from(payments.values()).filter(p => p.type === type);
|
||||
return Array.from(this.payments.values()).filter(p => p.type === type);
|
||||
}
|
||||
|
||||
async findByFilters(filters: { leagueId?: string; payerId?: string; type?: PaymentType }): Promise<Payment[]> {
|
||||
this.logger.debug('[InMemoryPaymentRepository] findByFilters', { filters });
|
||||
let results = Array.from(payments.values());
|
||||
let results = Array.from(this.payments.values());
|
||||
|
||||
if (filters.leagueId) {
|
||||
results = results.filter(p => p.leagueId === filters.leagueId);
|
||||
@@ -50,13 +49,17 @@ export class InMemoryPaymentRepository implements PaymentRepository {
|
||||
|
||||
async create(payment: Payment): Promise<Payment> {
|
||||
this.logger.debug('[InMemoryPaymentRepository] create', { payment });
|
||||
payments.set(payment.id, payment);
|
||||
this.payments.set(payment.id, payment);
|
||||
return payment;
|
||||
}
|
||||
|
||||
async update(payment: Payment): Promise<Payment> {
|
||||
this.logger.debug('[InMemoryPaymentRepository] update', { payment });
|
||||
payments.set(payment.id, payment);
|
||||
this.payments.set(payment.id, payment);
|
||||
return payment;
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.payments.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,52 +5,86 @@
|
||||
import type { Transaction, Wallet } from '@core/payments/domain/entities/Wallet';
|
||||
import type { WalletRepository, TransactionRepository } from '@core/payments/domain/repositories/WalletRepository';
|
||||
import type { Logger } from '@core/shared/domain/Logger';
|
||||
import type { LeagueWalletRepository } from '@core/racing/domain/repositories/LeagueWalletRepository';
|
||||
|
||||
const wallets: Map<string, Wallet> = new Map();
|
||||
const transactions: Map<string, Transaction> = new Map();
|
||||
const wallets: Map<string, any> = new Map();
|
||||
const transactions: Map<string, any> = new Map();
|
||||
|
||||
export class InMemoryWalletRepository implements WalletRepository {
|
||||
export class InMemoryWalletRepository implements WalletRepository, LeagueWalletRepository {
|
||||
constructor(private readonly logger: Logger) {}
|
||||
|
||||
async findById(id: string): Promise<Wallet | null> {
|
||||
async findById(id: string): Promise<any | null> {
|
||||
this.logger.debug('[InMemoryWalletRepository] findById', { id });
|
||||
return wallets.get(id) || null;
|
||||
}
|
||||
|
||||
async findByLeagueId(leagueId: string): Promise<Wallet | null> {
|
||||
async findByLeagueId(leagueId: string): Promise<any | null> {
|
||||
this.logger.debug('[InMemoryWalletRepository] findByLeagueId', { leagueId });
|
||||
return Array.from(wallets.values()).find(w => w.leagueId === leagueId) || null;
|
||||
return Array.from(wallets.values()).find(w => w.leagueId.toString() === leagueId) || null;
|
||||
}
|
||||
|
||||
async create(wallet: Wallet): Promise<Wallet> {
|
||||
async create(wallet: any): Promise<any> {
|
||||
this.logger.debug('[InMemoryWalletRepository] create', { wallet });
|
||||
wallets.set(wallet.id, wallet);
|
||||
wallets.set(wallet.id.toString(), wallet);
|
||||
return wallet;
|
||||
}
|
||||
|
||||
async update(wallet: Wallet): Promise<Wallet> {
|
||||
async update(wallet: any): Promise<any> {
|
||||
this.logger.debug('[InMemoryWalletRepository] update', { wallet });
|
||||
wallets.set(wallet.id, wallet);
|
||||
wallets.set(wallet.id.toString(), wallet);
|
||||
return wallet;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
wallets.delete(id);
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
return wallets.has(id);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
wallets.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export class InMemoryTransactionRepository implements TransactionRepository {
|
||||
constructor(private readonly logger: Logger) {}
|
||||
|
||||
async findById(id: string): Promise<Transaction | null> {
|
||||
async findById(id: string): Promise<any | null> {
|
||||
this.logger.debug('[InMemoryTransactionRepository] findById', { id });
|
||||
return transactions.get(id) || null;
|
||||
}
|
||||
|
||||
async findByWalletId(walletId: string): Promise<Transaction[]> {
|
||||
async findByWalletId(walletId: string): Promise<any[]> {
|
||||
this.logger.debug('[InMemoryTransactionRepository] findByWalletId', { walletId });
|
||||
return Array.from(transactions.values()).filter(t => t.walletId === walletId);
|
||||
return Array.from(transactions.values()).filter(t => t.walletId.toString() === walletId);
|
||||
}
|
||||
|
||||
async create(transaction: Transaction): Promise<Transaction> {
|
||||
async create(transaction: any): Promise<any> {
|
||||
this.logger.debug('[InMemoryTransactionRepository] create', { transaction });
|
||||
transactions.set(transaction.id, transaction);
|
||||
transactions.set(transaction.id.toString(), transaction);
|
||||
return transaction;
|
||||
}
|
||||
}
|
||||
|
||||
async update(transaction: any): Promise<any> {
|
||||
transactions.set(transaction.id.toString(), transaction);
|
||||
return transaction;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
transactions.delete(id);
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
return transactions.has(id);
|
||||
}
|
||||
|
||||
findByType(type: any): Promise<any[]> {
|
||||
return Promise.resolve(Array.from(transactions.values()).filter(t => t.type === type));
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
transactions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { InMemoryRaceRepository } from './InMemoryRaceRepository';
|
||||
import { RaceData } from '../../../../core/dashboard/application/ports/DashboardRepository';
|
||||
|
||||
describe('InMemoryRaceRepository', () => {
|
||||
let repository: InMemoryRaceRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = new InMemoryRaceRepository();
|
||||
});
|
||||
|
||||
describe('getUpcomingRaces', () => {
|
||||
it('should return empty array when no races for driver', async () => {
|
||||
// When
|
||||
const result = await repository.getUpcomingRaces('driver-1');
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return races when they exist', async () => {
|
||||
// Given
|
||||
const driverId = 'driver-1';
|
||||
const races: RaceData[] = [
|
||||
{
|
||||
id: 'race-1',
|
||||
trackName: 'Spa-Francorchamps',
|
||||
carType: 'GT3',
|
||||
scheduledDate: new Date(),
|
||||
},
|
||||
];
|
||||
repository.addUpcomingRaces(driverId, races);
|
||||
|
||||
// When
|
||||
const result = await repository.getUpcomingRaces(driverId);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(races);
|
||||
});
|
||||
|
||||
it('should overwrite races for same driver (idempotency)', async () => {
|
||||
// Given
|
||||
const driverId = 'driver-1';
|
||||
const races1: RaceData[] = [{ id: 'r1', trackName: 'T1', carType: 'C1', scheduledDate: new Date() }];
|
||||
const races2: RaceData[] = [{ id: 'r2', trackName: 'T2', carType: 'C2', scheduledDate: new Date() }];
|
||||
|
||||
// When
|
||||
repository.addUpcomingRaces(driverId, races1);
|
||||
repository.addUpcomingRaces(driverId, races2);
|
||||
const result = await repository.getUpcomingRaces(driverId);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(races2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -93,6 +93,12 @@ export class InMemoryDriverRepository implements DriverRepository {
|
||||
return Promise.resolve(this.iracingIdIndex.has(iracingId));
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.logger.info('[InMemoryDriverRepository] Clearing all drivers');
|
||||
this.drivers.clear();
|
||||
this.iracingIdIndex.clear();
|
||||
}
|
||||
|
||||
// Serialization methods for persistence
|
||||
serialize(driver: Driver): Record<string, unknown> {
|
||||
return {
|
||||
|
||||
@@ -92,4 +92,9 @@ export class InMemoryLeagueMembershipRepository implements LeagueMembershipRepos
|
||||
}
|
||||
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');
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.leagues.clear();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<League | null> {
|
||||
this.logger.debug(`Attempting to find league with ID: ${id}.`);
|
||||
try {
|
||||
|
||||
@@ -105,4 +105,8 @@ export class InMemoryRaceRepository implements RaceRepository {
|
||||
this.logger.debug(`[InMemoryRaceRepository] Checking existence of race with ID: ${id}.`);
|
||||
return Promise.resolve(this.races.has(id));
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.races.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,10 +218,15 @@ export class InMemoryResultRepository implements ResultRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.logger.debug('[InMemoryResultRepository] Clearing all results.');
|
||||
this.results.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility method to generate a new UUID
|
||||
*/
|
||||
static generateId(): string {
|
||||
return uuidv4();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,4 +83,8 @@ export class InMemorySeasonRepository implements SeasonRepository {
|
||||
);
|
||||
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}`);
|
||||
return Promise.resolve(this.sponsors.has(id));
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.sponsors.clear();
|
||||
this.emailIndex.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,4 +99,12 @@ export class InMemorySponsorshipPricingRepository implements SponsorshipPricingR
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async create(pricing: any): Promise<void> {
|
||||
await this.save(pricing.entityType, pricing.entityId, pricing);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.pricings.clear();
|
||||
}
|
||||
}
|
||||
@@ -109,4 +109,8 @@ export class InMemorySponsorshipRequestRepository implements SponsorshipRequestR
|
||||
this.logger.debug(`[InMemorySponsorshipRequestRepository] Checking existence of request with ID: ${id}.`);
|
||||
return Promise.resolve(this.requests.has(id));
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.requests.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +166,11 @@ export class InMemoryStandingRepository implements StandingRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.logger.debug('Clearing all standings.');
|
||||
this.standings.clear();
|
||||
}
|
||||
|
||||
async recalculate(leagueId: string): Promise<Standing[]> {
|
||||
this.logger.debug(`Recalculating standings for league id: ${leagueId}`);
|
||||
try {
|
||||
@@ -268,4 +273,4 @@ export class InMemoryStandingRepository implements StandingRepository {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,4 +212,10 @@ async getMembership(teamId: string, driverId: string): Promise<TeamMembership |
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.logger.info('[InMemoryTeamMembershipRepository] Clearing all memberships and join requests');
|
||||
this.membershipsByTeam.clear();
|
||||
this.joinRequestsByTeam.clear();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
serialize(team: Team): Record<string, unknown> {
|
||||
return {
|
||||
|
||||
@@ -104,4 +104,9 @@ export class InMemoryDriverExtendedProfileProvider implements DriverExtendedProf
|
||||
openToRequests: hash % 2 === 0,
|
||||
};
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.logger.info('[InMemoryDriverExtendedProfileProvider] Clearing all data');
|
||||
// No data to clear as this provider generates data on-the-fly
|
||||
}
|
||||
}
|
||||
@@ -32,4 +32,9 @@ export class InMemoryDriverRatingProvider implements DriverRatingProvider {
|
||||
}
|
||||
return ratingsMap;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.logger.info('[InMemoryDriverRatingProvider] Clearing all data');
|
||||
// No data to clear as this provider generates data on-the-fly
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { InMemoryRatingRepository } from './InMemoryRatingRepository';
|
||||
import { Rating } from '../../../../core/rating/domain/Rating';
|
||||
import { DriverId } from '../../../../core/racing/domain/entities/DriverId';
|
||||
import { RaceId } from '../../../../core/racing/domain/entities/RaceId';
|
||||
|
||||
describe('InMemoryRatingRepository', () => {
|
||||
let repository: InMemoryRatingRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = new InMemoryRatingRepository();
|
||||
});
|
||||
|
||||
const createRating = (driverId: string, raceId: string, ratingValue: number) => {
|
||||
return Rating.create({
|
||||
driverId: DriverId.create(driverId),
|
||||
raceId: RaceId.create(raceId),
|
||||
rating: ratingValue,
|
||||
components: {
|
||||
resultsStrength: ratingValue,
|
||||
consistency: 0,
|
||||
cleanDriving: 0,
|
||||
racecraft: 0,
|
||||
reliability: 0,
|
||||
teamContribution: 0,
|
||||
},
|
||||
timestamp: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
describe('save and findByDriverAndRace', () => {
|
||||
it('should return null when rating does not exist', async () => {
|
||||
// When
|
||||
const result = await repository.findByDriverAndRace('d1', 'r1');
|
||||
|
||||
// Then
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should save and retrieve a rating', async () => {
|
||||
// Given
|
||||
const rating = createRating('d1', 'r1', 1500);
|
||||
|
||||
// When
|
||||
await repository.save(rating);
|
||||
const result = await repository.findByDriverAndRace('d1', 'r1');
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(rating);
|
||||
});
|
||||
|
||||
it('should overwrite rating for same driver and race (idempotency)', async () => {
|
||||
// Given
|
||||
const r1 = createRating('d1', 'r1', 1500);
|
||||
const r2 = createRating('d1', 'r1', 1600);
|
||||
|
||||
// When
|
||||
await repository.save(r1);
|
||||
await repository.save(r2);
|
||||
const result = await repository.findByDriverAndRace('d1', 'r1');
|
||||
|
||||
// Then
|
||||
expect(result?.rating).toBe(1600);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByDriver', () => {
|
||||
it('should return all ratings for a driver', async () => {
|
||||
// Given
|
||||
const r1 = createRating('d1', 'r1', 1500);
|
||||
const r2 = createRating('d1', 'r2', 1600);
|
||||
const r3 = createRating('d2', 'r1', 1400);
|
||||
|
||||
await repository.save(r1);
|
||||
await repository.save(r2);
|
||||
await repository.save(r3);
|
||||
|
||||
// When
|
||||
const result = await repository.findByDriver('d1');
|
||||
|
||||
// Then
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContainEqual(r1);
|
||||
expect(result).toContainEqual(r2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* In-Memory Rating Repository
|
||||
*
|
||||
* In-memory implementation of RatingRepository for testing purposes.
|
||||
*/
|
||||
|
||||
import { RatingRepository } from '../../../../core/rating/ports/RatingRepository';
|
||||
import { Rating } from '../../../../core/rating/domain/Rating';
|
||||
|
||||
export class InMemoryRatingRepository implements RatingRepository {
|
||||
private ratings: Map<string, Rating> = new Map();
|
||||
|
||||
async save(rating: Rating): Promise<void> {
|
||||
const key = `${rating.driverId.toString()}-${rating.raceId.toString()}`;
|
||||
this.ratings.set(key, rating);
|
||||
}
|
||||
|
||||
async findByDriverAndRace(driverId: string, raceId: string): Promise<Rating | null> {
|
||||
const key = `${driverId}-${raceId}`;
|
||||
return this.ratings.get(key) || null;
|
||||
}
|
||||
|
||||
async findByDriver(driverId: string): Promise<Rating[]> {
|
||||
return Array.from(this.ratings.values()).filter(
|
||||
rating => rating.driverId.toString() === driverId
|
||||
);
|
||||
}
|
||||
|
||||
async findByRace(raceId: string): Promise<Rating[]> {
|
||||
return Array.from(this.ratings.values()).filter(
|
||||
rating => rating.raceId.toString() === raceId
|
||||
);
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.ratings.clear();
|
||||
}
|
||||
}
|
||||
@@ -153,4 +153,10 @@ export class InMemorySocialGraphRepository implements SocialGraphRepository {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.logger.info('[InMemorySocialGraphRepository] Clearing all friendships and drivers');
|
||||
this.friendships = [];
|
||||
this.driversById.clear();
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { Provider } from '@nestjs/common';
|
||||
import {
|
||||
ANALYTICS_ENGAGEMENT_REPOSITORY_TOKEN,
|
||||
ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN,
|
||||
} from '../../../../persistence/analytics/AnalyticsPersistenceTokens';
|
||||
} from '../../persistence/analytics/AnalyticsPersistenceTokens';
|
||||
|
||||
const LOGGER_TOKEN = 'Logger';
|
||||
|
||||
|
||||
@@ -140,10 +140,9 @@ export const SponsorProviders: Provider[] = [
|
||||
useFactory: (
|
||||
paymentRepo: PaymentRepository,
|
||||
seasonSponsorshipRepo: SeasonSponsorshipRepository,
|
||||
) => {
|
||||
return new GetSponsorBillingUseCase(paymentRepo, seasonSponsorshipRepo);
|
||||
},
|
||||
inject: [PAYMENT_REPOSITORY_TOKEN, SEASON_SPONSORSHIP_REPOSITORY_TOKEN],
|
||||
sponsorRepo: SponsorRepository,
|
||||
) => new GetSponsorBillingUseCase(paymentRepo, seasonSponsorshipRepo, sponsorRepo),
|
||||
inject: [PAYMENT_REPOSITORY_TOKEN, SEASON_SPONSORSHIP_REPOSITORY_TOKEN, SPONSOR_REPOSITORY_TOKEN],
|
||||
},
|
||||
{
|
||||
provide: GET_ENTITY_SPONSORSHIP_PRICING_USE_CASE_TOKEN,
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AdminDashboardViewDataBuilder } from './AdminDashboardViewDataBuilder';
|
||||
import type { DashboardStats } from '@/lib/types/admin';
|
||||
|
||||
describe('AdminDashboardViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform DashboardStats DTO to AdminDashboardViewData correctly', () => {
|
||||
const dashboardStats: DashboardStats = {
|
||||
totalUsers: 1000,
|
||||
activeUsers: 800,
|
||||
suspendedUsers: 50,
|
||||
deletedUsers: 150,
|
||||
systemAdmins: 5,
|
||||
recentLogins: 120,
|
||||
newUsersToday: 15,
|
||||
};
|
||||
|
||||
const result = AdminDashboardViewDataBuilder.build(dashboardStats);
|
||||
|
||||
expect(result).toEqual({
|
||||
stats: {
|
||||
totalUsers: 1000,
|
||||
activeUsers: 800,
|
||||
suspendedUsers: 50,
|
||||
deletedUsers: 150,
|
||||
systemAdmins: 5,
|
||||
recentLogins: 120,
|
||||
newUsersToday: 15,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle zero values correctly', () => {
|
||||
const dashboardStats: DashboardStats = {
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
suspendedUsers: 0,
|
||||
deletedUsers: 0,
|
||||
systemAdmins: 0,
|
||||
recentLogins: 0,
|
||||
newUsersToday: 0,
|
||||
};
|
||||
|
||||
const result = AdminDashboardViewDataBuilder.build(dashboardStats);
|
||||
|
||||
expect(result).toEqual({
|
||||
stats: {
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
suspendedUsers: 0,
|
||||
deletedUsers: 0,
|
||||
systemAdmins: 0,
|
||||
recentLogins: 0,
|
||||
newUsersToday: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle large numbers correctly', () => {
|
||||
const dashboardStats: DashboardStats = {
|
||||
totalUsers: 1000000,
|
||||
activeUsers: 750000,
|
||||
suspendedUsers: 25000,
|
||||
deletedUsers: 225000,
|
||||
systemAdmins: 50,
|
||||
recentLogins: 50000,
|
||||
newUsersToday: 1000,
|
||||
};
|
||||
|
||||
const result = AdminDashboardViewDataBuilder.build(dashboardStats);
|
||||
|
||||
expect(result.stats.totalUsers).toBe(1000000);
|
||||
expect(result.stats.activeUsers).toBe(750000);
|
||||
expect(result.stats.systemAdmins).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const dashboardStats: DashboardStats = {
|
||||
totalUsers: 500,
|
||||
activeUsers: 400,
|
||||
suspendedUsers: 25,
|
||||
deletedUsers: 75,
|
||||
systemAdmins: 3,
|
||||
recentLogins: 80,
|
||||
newUsersToday: 10,
|
||||
};
|
||||
|
||||
const result = AdminDashboardViewDataBuilder.build(dashboardStats);
|
||||
|
||||
expect(result.stats.totalUsers).toBe(dashboardStats.totalUsers);
|
||||
expect(result.stats.activeUsers).toBe(dashboardStats.activeUsers);
|
||||
expect(result.stats.suspendedUsers).toBe(dashboardStats.suspendedUsers);
|
||||
expect(result.stats.deletedUsers).toBe(dashboardStats.deletedUsers);
|
||||
expect(result.stats.systemAdmins).toBe(dashboardStats.systemAdmins);
|
||||
expect(result.stats.recentLogins).toBe(dashboardStats.recentLogins);
|
||||
expect(result.stats.newUsersToday).toBe(dashboardStats.newUsersToday);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const dashboardStats: DashboardStats = {
|
||||
totalUsers: 100,
|
||||
activeUsers: 80,
|
||||
suspendedUsers: 5,
|
||||
deletedUsers: 15,
|
||||
systemAdmins: 2,
|
||||
recentLogins: 20,
|
||||
newUsersToday: 5,
|
||||
};
|
||||
|
||||
const originalStats = { ...dashboardStats };
|
||||
AdminDashboardViewDataBuilder.build(dashboardStats);
|
||||
|
||||
expect(dashboardStats).toEqual(originalStats);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle negative values (if API returns them)', () => {
|
||||
const dashboardStats: DashboardStats = {
|
||||
totalUsers: -1,
|
||||
activeUsers: -1,
|
||||
suspendedUsers: -1,
|
||||
deletedUsers: -1,
|
||||
systemAdmins: -1,
|
||||
recentLogins: -1,
|
||||
newUsersToday: -1,
|
||||
};
|
||||
|
||||
const result = AdminDashboardViewDataBuilder.build(dashboardStats);
|
||||
|
||||
expect(result.stats.totalUsers).toBe(-1);
|
||||
expect(result.stats.activeUsers).toBe(-1);
|
||||
});
|
||||
|
||||
it('should handle very large numbers', () => {
|
||||
const dashboardStats: DashboardStats = {
|
||||
totalUsers: Number.MAX_SAFE_INTEGER,
|
||||
activeUsers: Number.MAX_SAFE_INTEGER - 1000,
|
||||
suspendedUsers: 100,
|
||||
deletedUsers: 100,
|
||||
systemAdmins: 10,
|
||||
recentLogins: 1000,
|
||||
newUsersToday: 100,
|
||||
};
|
||||
|
||||
const result = AdminDashboardViewDataBuilder.build(dashboardStats);
|
||||
|
||||
expect(result.stats.totalUsers).toBe(Number.MAX_SAFE_INTEGER);
|
||||
expect(result.stats.activeUsers).toBe(Number.MAX_SAFE_INTEGER - 1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,249 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LoginViewDataBuilder } from './LoginViewDataBuilder';
|
||||
import { SignupViewDataBuilder } from './SignupViewDataBuilder';
|
||||
import { ForgotPasswordViewDataBuilder } from './ForgotPasswordViewDataBuilder';
|
||||
import { ResetPasswordViewDataBuilder } from './ResetPasswordViewDataBuilder';
|
||||
import type { LoginPageDTO } from '@/lib/services/auth/types/LoginPageDTO';
|
||||
import type { SignupPageDTO } from '@/lib/services/auth/types/SignupPageDTO';
|
||||
import type { ForgotPasswordPageDTO } from '@/lib/services/auth/types/ForgotPasswordPageDTO';
|
||||
import type { ResetPasswordPageDTO } from '@/lib/services/auth/types/ResetPasswordPageDTO';
|
||||
|
||||
describe('Auth View Data - Cross-Builder Consistency', () => {
|
||||
describe('common patterns', () => {
|
||||
it('should all initialize with isSubmitting false', () => {
|
||||
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||
|
||||
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||
|
||||
expect(loginResult.isSubmitting).toBe(false);
|
||||
expect(signupResult.isSubmitting).toBe(false);
|
||||
expect(forgotPasswordResult.isSubmitting).toBe(false);
|
||||
expect(resetPasswordResult.isSubmitting).toBe(false);
|
||||
});
|
||||
|
||||
it('should all initialize with submitError undefined', () => {
|
||||
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||
|
||||
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||
|
||||
expect(loginResult.submitError).toBeUndefined();
|
||||
expect(signupResult.submitError).toBeUndefined();
|
||||
expect(forgotPasswordResult.submitError).toBeUndefined();
|
||||
expect(resetPasswordResult.submitError).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should all initialize formState.isValid as true', () => {
|
||||
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||
|
||||
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||
|
||||
expect(loginResult.formState.isValid).toBe(true);
|
||||
expect(signupResult.formState.isValid).toBe(true);
|
||||
expect(forgotPasswordResult.formState.isValid).toBe(true);
|
||||
expect(resetPasswordResult.formState.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should all initialize formState.isSubmitting as false', () => {
|
||||
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||
|
||||
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||
|
||||
expect(loginResult.formState.isSubmitting).toBe(false);
|
||||
expect(signupResult.formState.isSubmitting).toBe(false);
|
||||
expect(forgotPasswordResult.formState.isSubmitting).toBe(false);
|
||||
expect(resetPasswordResult.formState.isSubmitting).toBe(false);
|
||||
});
|
||||
|
||||
it('should all initialize formState.submitError as undefined', () => {
|
||||
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||
|
||||
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||
|
||||
expect(loginResult.formState.submitError).toBeUndefined();
|
||||
expect(signupResult.formState.submitError).toBeUndefined();
|
||||
expect(forgotPasswordResult.formState.submitError).toBeUndefined();
|
||||
expect(resetPasswordResult.formState.submitError).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should all initialize formState.submitCount as 0', () => {
|
||||
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||
|
||||
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||
|
||||
expect(loginResult.formState.submitCount).toBe(0);
|
||||
expect(signupResult.formState.submitCount).toBe(0);
|
||||
expect(forgotPasswordResult.formState.submitCount).toBe(0);
|
||||
expect(resetPasswordResult.formState.submitCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should all initialize form fields with touched false', () => {
|
||||
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||
|
||||
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||
|
||||
expect(loginResult.formState.fields.email.touched).toBe(false);
|
||||
expect(loginResult.formState.fields.password.touched).toBe(false);
|
||||
expect(loginResult.formState.fields.rememberMe.touched).toBe(false);
|
||||
|
||||
expect(signupResult.formState.fields.firstName.touched).toBe(false);
|
||||
expect(signupResult.formState.fields.lastName.touched).toBe(false);
|
||||
expect(signupResult.formState.fields.email.touched).toBe(false);
|
||||
expect(signupResult.formState.fields.password.touched).toBe(false);
|
||||
expect(signupResult.formState.fields.confirmPassword.touched).toBe(false);
|
||||
|
||||
expect(forgotPasswordResult.formState.fields.email.touched).toBe(false);
|
||||
|
||||
expect(resetPasswordResult.formState.fields.newPassword.touched).toBe(false);
|
||||
expect(resetPasswordResult.formState.fields.confirmPassword.touched).toBe(false);
|
||||
});
|
||||
|
||||
it('should all initialize form fields with validating false', () => {
|
||||
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||
|
||||
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||
|
||||
expect(loginResult.formState.fields.email.validating).toBe(false);
|
||||
expect(loginResult.formState.fields.password.validating).toBe(false);
|
||||
expect(loginResult.formState.fields.rememberMe.validating).toBe(false);
|
||||
|
||||
expect(signupResult.formState.fields.firstName.validating).toBe(false);
|
||||
expect(signupResult.formState.fields.lastName.validating).toBe(false);
|
||||
expect(signupResult.formState.fields.email.validating).toBe(false);
|
||||
expect(signupResult.formState.fields.password.validating).toBe(false);
|
||||
expect(signupResult.formState.fields.confirmPassword.validating).toBe(false);
|
||||
|
||||
expect(forgotPasswordResult.formState.fields.email.validating).toBe(false);
|
||||
|
||||
expect(resetPasswordResult.formState.fields.newPassword.validating).toBe(false);
|
||||
expect(resetPasswordResult.formState.fields.confirmPassword.validating).toBe(false);
|
||||
});
|
||||
|
||||
it('should all initialize form fields with error undefined', () => {
|
||||
const loginDTO: LoginPageDTO = { returnTo: '/dashboard', hasInsufficientPermissions: false };
|
||||
const signupDTO: SignupPageDTO = { returnTo: '/dashboard' };
|
||||
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard' };
|
||||
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard' };
|
||||
|
||||
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||
|
||||
expect(loginResult.formState.fields.email.error).toBeUndefined();
|
||||
expect(loginResult.formState.fields.password.error).toBeUndefined();
|
||||
expect(loginResult.formState.fields.rememberMe.error).toBeUndefined();
|
||||
|
||||
expect(signupResult.formState.fields.firstName.error).toBeUndefined();
|
||||
expect(signupResult.formState.fields.lastName.error).toBeUndefined();
|
||||
expect(signupResult.formState.fields.email.error).toBeUndefined();
|
||||
expect(signupResult.formState.fields.password.error).toBeUndefined();
|
||||
expect(signupResult.formState.fields.confirmPassword.error).toBeUndefined();
|
||||
|
||||
expect(forgotPasswordResult.formState.fields.email.error).toBeUndefined();
|
||||
|
||||
expect(resetPasswordResult.formState.fields.newPassword.error).toBeUndefined();
|
||||
expect(resetPasswordResult.formState.fields.confirmPassword.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('common returnTo handling', () => {
|
||||
it('should all handle returnTo with query parameters', () => {
|
||||
const loginDTO: LoginPageDTO = { returnTo: '/dashboard?welcome=true', hasInsufficientPermissions: false };
|
||||
const signupDTO: SignupPageDTO = { returnTo: '/dashboard?welcome=true' };
|
||||
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard?welcome=true' };
|
||||
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard?welcome=true' };
|
||||
|
||||
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||
|
||||
expect(loginResult.returnTo).toBe('/dashboard?welcome=true');
|
||||
expect(signupResult.returnTo).toBe('/dashboard?welcome=true');
|
||||
expect(forgotPasswordResult.returnTo).toBe('/dashboard?welcome=true');
|
||||
expect(resetPasswordResult.returnTo).toBe('/dashboard?welcome=true');
|
||||
});
|
||||
|
||||
it('should all handle returnTo with hash fragments', () => {
|
||||
const loginDTO: LoginPageDTO = { returnTo: '/dashboard#section', hasInsufficientPermissions: false };
|
||||
const signupDTO: SignupPageDTO = { returnTo: '/dashboard#section' };
|
||||
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard#section' };
|
||||
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard#section' };
|
||||
|
||||
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||
|
||||
expect(loginResult.returnTo).toBe('/dashboard#section');
|
||||
expect(signupResult.returnTo).toBe('/dashboard#section');
|
||||
expect(forgotPasswordResult.returnTo).toBe('/dashboard#section');
|
||||
expect(resetPasswordResult.returnTo).toBe('/dashboard#section');
|
||||
});
|
||||
|
||||
it('should all handle returnTo with encoded characters', () => {
|
||||
const loginDTO: LoginPageDTO = { returnTo: '/dashboard?redirect=%2Fadmin', hasInsufficientPermissions: false };
|
||||
const signupDTO: SignupPageDTO = { returnTo: '/dashboard?redirect=%2Fadmin' };
|
||||
const forgotPasswordDTO: ForgotPasswordPageDTO = { returnTo: '/dashboard?redirect=%2Fadmin' };
|
||||
const resetPasswordDTO: ResetPasswordPageDTO = { token: 'abc123', returnTo: '/dashboard?redirect=%2Fadmin' };
|
||||
|
||||
const loginResult = LoginViewDataBuilder.build(loginDTO);
|
||||
const signupResult = SignupViewDataBuilder.build(signupDTO);
|
||||
const forgotPasswordResult = ForgotPasswordViewDataBuilder.build(forgotPasswordDTO);
|
||||
const resetPasswordResult = ResetPasswordViewDataBuilder.build(resetPasswordDTO);
|
||||
|
||||
expect(loginResult.returnTo).toBe('/dashboard?redirect=%2Fadmin');
|
||||
expect(signupResult.returnTo).toBe('/dashboard?redirect=%2Fadmin');
|
||||
expect(forgotPasswordResult.returnTo).toBe('/dashboard?redirect=%2Fadmin');
|
||||
expect(resetPasswordResult.returnTo).toBe('/dashboard?redirect=%2Fadmin');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,191 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AvatarViewDataBuilder } from './AvatarViewDataBuilder';
|
||||
import type { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
|
||||
|
||||
describe('AvatarViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform MediaBinaryDTO to AvatarViewData correctly', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle JPEG images', () => {
|
||||
const buffer = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/jpeg',
|
||||
};
|
||||
|
||||
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('should handle GIF images', () => {
|
||||
const buffer = new Uint8Array([0x47, 0x49, 0x46, 0x38]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/gif',
|
||||
};
|
||||
|
||||
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/gif');
|
||||
});
|
||||
|
||||
it('should handle SVG images', () => {
|
||||
const buffer = new TextEncoder().encode('<svg xmlns="http://www.w3.org/2000/svg"></svg>');
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/svg+xml',
|
||||
};
|
||||
|
||||
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/svg+xml');
|
||||
});
|
||||
|
||||
it('should handle WebP images', () => {
|
||||
const buffer = new Uint8Array([0x52, 0x49, 0x46, 0x46]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/webp',
|
||||
};
|
||||
|
||||
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/webp');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBeDefined();
|
||||
expect(result.contentType).toBe(mediaDto.contentType);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const originalDto = { ...mediaDto };
|
||||
AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(mediaDto).toEqual(originalDto);
|
||||
});
|
||||
|
||||
it('should convert buffer to base64 string', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(typeof result.buffer).toBe('string');
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty buffer', () => {
|
||||
const buffer = new Uint8Array([]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe('');
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle large buffer', () => {
|
||||
const buffer = new Uint8Array(1024 * 1024); // 1MB
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle buffer with all zeros', () => {
|
||||
const buffer = new Uint8Array([0x00, 0x00, 0x00, 0x00]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle buffer with all ones', () => {
|
||||
const buffer = new Uint8Array([0xff, 0xff, 0xff, 0xff]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle different content types', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const contentTypes = [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
'image/bmp',
|
||||
'image/tiff',
|
||||
];
|
||||
|
||||
contentTypes.forEach((contentType) => {
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType,
|
||||
};
|
||||
|
||||
const result = AvatarViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.contentType).toBe(contentType);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CategoryIconViewDataBuilder } from './CategoryIconViewDataBuilder';
|
||||
import type { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
|
||||
|
||||
describe('CategoryIconViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform MediaBinaryDTO to CategoryIconViewData correctly', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = CategoryIconViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle SVG icons', () => {
|
||||
const buffer = new TextEncoder().encode('<svg xmlns="http://www.w3.org/2000/svg"><circle cx="10" cy="10" r="5"/></svg>');
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/svg+xml',
|
||||
};
|
||||
|
||||
const result = CategoryIconViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/svg+xml');
|
||||
});
|
||||
|
||||
it('should handle small icon files', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = CategoryIconViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = CategoryIconViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBeDefined();
|
||||
expect(result.contentType).toBe(mediaDto.contentType);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const originalDto = { ...mediaDto };
|
||||
CategoryIconViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(mediaDto).toEqual(originalDto);
|
||||
});
|
||||
|
||||
it('should convert buffer to base64 string', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = CategoryIconViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(typeof result.buffer).toBe('string');
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty buffer', () => {
|
||||
const buffer = new Uint8Array([]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = CategoryIconViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe('');
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle buffer with special characters', () => {
|
||||
const buffer = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0xff, 0xfe, 0xfd, 0xfc]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = CategoryIconViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CompleteOnboardingViewDataBuilder } from './CompleteOnboardingViewDataBuilder';
|
||||
import type { CompleteOnboardingOutputDTO } from '@/lib/types/generated/CompleteOnboardingOutputDTO';
|
||||
|
||||
describe('CompleteOnboardingViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform successful onboarding completion DTO to ViewData correctly', () => {
|
||||
const apiDto: CompleteOnboardingOutputDTO = {
|
||||
success: true,
|
||||
driverId: 'driver-123',
|
||||
};
|
||||
|
||||
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
driverId: 'driver-123',
|
||||
errorMessage: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle onboarding completion with error message', () => {
|
||||
const apiDto: CompleteOnboardingOutputDTO = {
|
||||
success: false,
|
||||
driverId: undefined,
|
||||
errorMessage: 'Failed to complete onboarding',
|
||||
};
|
||||
|
||||
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
driverId: undefined,
|
||||
errorMessage: 'Failed to complete onboarding',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle onboarding completion with only success field', () => {
|
||||
const apiDto: CompleteOnboardingOutputDTO = {
|
||||
success: true,
|
||||
};
|
||||
|
||||
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
driverId: undefined,
|
||||
errorMessage: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const apiDto: CompleteOnboardingOutputDTO = {
|
||||
success: true,
|
||||
driverId: 'driver-123',
|
||||
errorMessage: undefined,
|
||||
};
|
||||
|
||||
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.success).toBe(apiDto.success);
|
||||
expect(result.driverId).toBe(apiDto.driverId);
|
||||
expect(result.errorMessage).toBe(apiDto.errorMessage);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const apiDto: CompleteOnboardingOutputDTO = {
|
||||
success: true,
|
||||
driverId: 'driver-123',
|
||||
errorMessage: undefined,
|
||||
};
|
||||
|
||||
const originalDto = { ...apiDto };
|
||||
CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(apiDto).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle false success value', () => {
|
||||
const apiDto: CompleteOnboardingOutputDTO = {
|
||||
success: false,
|
||||
driverId: undefined,
|
||||
errorMessage: 'Error occurred',
|
||||
};
|
||||
|
||||
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.driverId).toBeUndefined();
|
||||
expect(result.errorMessage).toBe('Error occurred');
|
||||
});
|
||||
|
||||
it('should handle empty string error message', () => {
|
||||
const apiDto: CompleteOnboardingOutputDTO = {
|
||||
success: false,
|
||||
driverId: undefined,
|
||||
errorMessage: '',
|
||||
};
|
||||
|
||||
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorMessage).toBe('');
|
||||
});
|
||||
|
||||
it('should handle very long driverId', () => {
|
||||
const longDriverId = 'driver-' + 'a'.repeat(1000);
|
||||
const apiDto: CompleteOnboardingOutputDTO = {
|
||||
success: true,
|
||||
driverId: longDriverId,
|
||||
};
|
||||
|
||||
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.driverId).toBe(longDriverId);
|
||||
});
|
||||
|
||||
it('should handle special characters in error message', () => {
|
||||
const apiDto: CompleteOnboardingOutputDTO = {
|
||||
success: false,
|
||||
driverId: undefined,
|
||||
errorMessage: 'Error: "Failed to create driver" (code: 500)',
|
||||
};
|
||||
|
||||
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.errorMessage).toBe('Error: "Failed to create driver" (code: 500)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('derived fields calculation', () => {
|
||||
it('should calculate isSuccessful derived field correctly', () => {
|
||||
const apiDto: CompleteOnboardingOutputDTO = {
|
||||
success: true,
|
||||
driverId: 'driver-123',
|
||||
};
|
||||
|
||||
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
// Note: The builder doesn't add derived fields, but we can verify the structure
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.driverId).toBe('driver-123');
|
||||
});
|
||||
|
||||
it('should handle success with no driverId', () => {
|
||||
const apiDto: CompleteOnboardingOutputDTO = {
|
||||
success: true,
|
||||
driverId: undefined,
|
||||
};
|
||||
|
||||
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.driverId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle failure with driverId', () => {
|
||||
const apiDto: CompleteOnboardingOutputDTO = {
|
||||
success: false,
|
||||
driverId: 'driver-123',
|
||||
errorMessage: 'Partial failure',
|
||||
};
|
||||
|
||||
const result = CompleteOnboardingViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.driverId).toBe('driver-123');
|
||||
expect(result.errorMessage).toBe('Partial failure');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,441 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DriverRankingsViewDataBuilder } from './DriverRankingsViewDataBuilder';
|
||||
import type { DriverLeaderboardItemDTO } from '@/lib/types/generated/DriverLeaderboardItemDTO';
|
||||
|
||||
describe('DriverRankingsViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform DriverLeaderboardItemDTO array to DriverRankingsViewData correctly', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/avatar1.jpg',
|
||||
},
|
||||
{
|
||||
id: 'driver-2',
|
||||
name: 'Jane Smith',
|
||||
rating: 1100.0,
|
||||
skillLevel: 'advanced',
|
||||
nationality: 'Canada',
|
||||
racesCompleted: 100,
|
||||
wins: 15,
|
||||
podiums: 40,
|
||||
isActive: true,
|
||||
rank: 2,
|
||||
avatarUrl: 'https://example.com/avatar2.jpg',
|
||||
},
|
||||
{
|
||||
id: 'driver-3',
|
||||
name: 'Bob Johnson',
|
||||
rating: 950.0,
|
||||
skillLevel: 'intermediate',
|
||||
nationality: 'UK',
|
||||
racesCompleted: 80,
|
||||
wins: 10,
|
||||
podiums: 30,
|
||||
isActive: true,
|
||||
rank: 3,
|
||||
avatarUrl: 'https://example.com/avatar3.jpg',
|
||||
},
|
||||
];
|
||||
|
||||
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
// Verify drivers
|
||||
expect(result.drivers).toHaveLength(3);
|
||||
expect(result.drivers[0].id).toBe('driver-1');
|
||||
expect(result.drivers[0].name).toBe('John Doe');
|
||||
expect(result.drivers[0].rating).toBe(1234.56);
|
||||
expect(result.drivers[0].skillLevel).toBe('pro');
|
||||
expect(result.drivers[0].nationality).toBe('USA');
|
||||
expect(result.drivers[0].racesCompleted).toBe(150);
|
||||
expect(result.drivers[0].wins).toBe(25);
|
||||
expect(result.drivers[0].podiums).toBe(60);
|
||||
expect(result.drivers[0].rank).toBe(1);
|
||||
expect(result.drivers[0].avatarUrl).toBe('https://example.com/avatar1.jpg');
|
||||
expect(result.drivers[0].winRate).toBe('16.7');
|
||||
expect(result.drivers[0].medalBg).toBe('bg-warning-amber');
|
||||
expect(result.drivers[0].medalColor).toBe('text-warning-amber');
|
||||
|
||||
// Verify podium (top 3 with special ordering: 2nd, 1st, 3rd)
|
||||
expect(result.podium).toHaveLength(3);
|
||||
expect(result.podium[0].id).toBe('driver-1');
|
||||
expect(result.podium[0].name).toBe('John Doe');
|
||||
expect(result.podium[0].rating).toBe(1234.56);
|
||||
expect(result.podium[0].wins).toBe(25);
|
||||
expect(result.podium[0].podiums).toBe(60);
|
||||
expect(result.podium[0].avatarUrl).toBe('https://example.com/avatar1.jpg');
|
||||
expect(result.podium[0].position).toBe(2); // 2nd place
|
||||
|
||||
expect(result.podium[1].id).toBe('driver-2');
|
||||
expect(result.podium[1].position).toBe(1); // 1st place
|
||||
|
||||
expect(result.podium[2].id).toBe('driver-3');
|
||||
expect(result.podium[2].position).toBe(3); // 3rd place
|
||||
|
||||
// Verify default values
|
||||
expect(result.searchQuery).toBe('');
|
||||
expect(result.selectedSkill).toBe('all');
|
||||
expect(result.sortBy).toBe('rank');
|
||||
expect(result.showFilters).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty driver array', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [];
|
||||
|
||||
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
expect(result.drivers).toEqual([]);
|
||||
expect(result.podium).toEqual([]);
|
||||
expect(result.searchQuery).toBe('');
|
||||
expect(result.selectedSkill).toBe('all');
|
||||
expect(result.sortBy).toBe('rank');
|
||||
expect(result.showFilters).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle less than 3 drivers for podium', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/avatar1.jpg',
|
||||
},
|
||||
{
|
||||
id: 'driver-2',
|
||||
name: 'Jane Smith',
|
||||
rating: 1100.0,
|
||||
skillLevel: 'advanced',
|
||||
nationality: 'Canada',
|
||||
racesCompleted: 100,
|
||||
wins: 15,
|
||||
podiums: 40,
|
||||
isActive: true,
|
||||
rank: 2,
|
||||
avatarUrl: 'https://example.com/avatar2.jpg',
|
||||
},
|
||||
];
|
||||
|
||||
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
expect(result.drivers).toHaveLength(2);
|
||||
expect(result.podium).toHaveLength(2);
|
||||
expect(result.podium[0].position).toBe(2); // 2nd place
|
||||
expect(result.podium[1].position).toBe(1); // 1st place
|
||||
});
|
||||
|
||||
it('should handle missing avatar URLs with empty string fallback', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
expect(result.drivers[0].avatarUrl).toBe('');
|
||||
expect(result.podium[0].avatarUrl).toBe('');
|
||||
});
|
||||
|
||||
it('should calculate win rate correctly', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 100,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
{
|
||||
id: 'driver-2',
|
||||
name: 'Jane Smith',
|
||||
rating: 1100.0,
|
||||
skillLevel: 'advanced',
|
||||
nationality: 'Canada',
|
||||
racesCompleted: 50,
|
||||
wins: 10,
|
||||
podiums: 25,
|
||||
isActive: true,
|
||||
rank: 2,
|
||||
},
|
||||
{
|
||||
id: 'driver-3',
|
||||
name: 'Bob Johnson',
|
||||
rating: 950.0,
|
||||
skillLevel: 'intermediate',
|
||||
nationality: 'UK',
|
||||
racesCompleted: 0,
|
||||
wins: 0,
|
||||
podiums: 0,
|
||||
isActive: true,
|
||||
rank: 3,
|
||||
},
|
||||
];
|
||||
|
||||
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
expect(result.drivers[0].winRate).toBe('25.0');
|
||||
expect(result.drivers[1].winRate).toBe('20.0');
|
||||
expect(result.drivers[2].winRate).toBe('0.0');
|
||||
});
|
||||
|
||||
it('should assign correct medal colors based on position', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
{
|
||||
id: 'driver-2',
|
||||
name: 'Jane Smith',
|
||||
rating: 1100.0,
|
||||
skillLevel: 'advanced',
|
||||
nationality: 'Canada',
|
||||
racesCompleted: 100,
|
||||
wins: 15,
|
||||
podiums: 40,
|
||||
isActive: true,
|
||||
rank: 2,
|
||||
},
|
||||
{
|
||||
id: 'driver-3',
|
||||
name: 'Bob Johnson',
|
||||
rating: 950.0,
|
||||
skillLevel: 'intermediate',
|
||||
nationality: 'UK',
|
||||
racesCompleted: 80,
|
||||
wins: 10,
|
||||
podiums: 30,
|
||||
isActive: true,
|
||||
rank: 3,
|
||||
},
|
||||
{
|
||||
id: 'driver-4',
|
||||
name: 'Alice Brown',
|
||||
rating: 800.0,
|
||||
skillLevel: 'beginner',
|
||||
nationality: 'Germany',
|
||||
racesCompleted: 60,
|
||||
wins: 5,
|
||||
podiums: 15,
|
||||
isActive: true,
|
||||
rank: 4,
|
||||
},
|
||||
];
|
||||
|
||||
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
expect(result.drivers[0].medalBg).toBe('bg-warning-amber');
|
||||
expect(result.drivers[0].medalColor).toBe('text-warning-amber');
|
||||
expect(result.drivers[1].medalBg).toBe('bg-gray-300');
|
||||
expect(result.drivers[1].medalColor).toBe('text-gray-300');
|
||||
expect(result.drivers[2].medalBg).toBe('bg-orange-700');
|
||||
expect(result.drivers[2].medalColor).toBe('text-orange-700');
|
||||
expect(result.drivers[3].medalBg).toBe('bg-gray-800');
|
||||
expect(result.drivers[3].medalColor).toBe('text-gray-400');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||
{
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
},
|
||||
];
|
||||
|
||||
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
expect(result.drivers[0].name).toBe(driverDTOs[0].name);
|
||||
expect(result.drivers[0].nationality).toBe(driverDTOs[0].nationality);
|
||||
expect(result.drivers[0].avatarUrl).toBe(driverDTOs[0].avatarUrl);
|
||||
expect(result.drivers[0].skillLevel).toBe(driverDTOs[0].skillLevel);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||
{
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
},
|
||||
];
|
||||
|
||||
const originalDTO = JSON.parse(JSON.stringify(driverDTOs));
|
||||
DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
expect(driverDTOs).toEqual(originalDTO);
|
||||
});
|
||||
|
||||
it('should handle large numbers correctly', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 999999.99,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 10000,
|
||||
wins: 2500,
|
||||
podiums: 5000,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
expect(result.drivers[0].rating).toBe(999999.99);
|
||||
expect(result.drivers[0].wins).toBe(2500);
|
||||
expect(result.drivers[0].podiums).toBe(5000);
|
||||
expect(result.drivers[0].racesCompleted).toBe(10000);
|
||||
expect(result.drivers[0].winRate).toBe('25.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle null/undefined avatar URLs', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: null as any,
|
||||
},
|
||||
];
|
||||
|
||||
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
expect(result.drivers[0].avatarUrl).toBe('');
|
||||
expect(result.podium[0].avatarUrl).toBe('');
|
||||
});
|
||||
|
||||
it('should handle null/undefined rating', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: null as any,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
expect(result.drivers[0].rating).toBeNull();
|
||||
expect(result.podium[0].rating).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle zero races completed for win rate calculation', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 0,
|
||||
wins: 0,
|
||||
podiums: 0,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
expect(result.drivers[0].winRate).toBe('0.0');
|
||||
});
|
||||
|
||||
it('should handle rank 0', () => {
|
||||
const driverDTOs: DriverLeaderboardItemDTO[] = [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const result = DriverRankingsViewDataBuilder.build(driverDTOs);
|
||||
|
||||
expect(result.drivers[0].rank).toBe(0);
|
||||
expect(result.drivers[0].medalBg).toBe('bg-gray-800');
|
||||
expect(result.drivers[0].medalColor).toBe('text-gray-400');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,382 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DriversViewDataBuilder } from './DriversViewDataBuilder';
|
||||
import type { DriversLeaderboardDTO } from '@/lib/types/generated/DriversLeaderboardDTO';
|
||||
|
||||
describe('DriversViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform DriversLeaderboardDTO to DriversViewData correctly', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'Pro',
|
||||
category: 'Elite',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/john.jpg',
|
||||
},
|
||||
{
|
||||
id: 'driver-2',
|
||||
name: 'Jane Smith',
|
||||
rating: 1100.75,
|
||||
skillLevel: 'Advanced',
|
||||
category: 'Pro',
|
||||
nationality: 'Canada',
|
||||
racesCompleted: 120,
|
||||
wins: 15,
|
||||
podiums: 45,
|
||||
isActive: true,
|
||||
rank: 2,
|
||||
avatarUrl: 'https://example.com/jane.jpg',
|
||||
},
|
||||
],
|
||||
totalRaces: 270,
|
||||
totalWins: 40,
|
||||
activeCount: 2,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.drivers).toHaveLength(2);
|
||||
expect(result.drivers[0].id).toBe('driver-1');
|
||||
expect(result.drivers[0].name).toBe('John Doe');
|
||||
expect(result.drivers[0].rating).toBe(1234.56);
|
||||
expect(result.drivers[0].ratingLabel).toBe('1,235');
|
||||
expect(result.drivers[0].skillLevel).toBe('Pro');
|
||||
expect(result.drivers[0].category).toBe('Elite');
|
||||
expect(result.drivers[0].nationality).toBe('USA');
|
||||
expect(result.drivers[0].racesCompleted).toBe(150);
|
||||
expect(result.drivers[0].wins).toBe(25);
|
||||
expect(result.drivers[0].podiums).toBe(60);
|
||||
expect(result.drivers[0].isActive).toBe(true);
|
||||
expect(result.drivers[0].rank).toBe(1);
|
||||
expect(result.drivers[0].avatarUrl).toBe('https://example.com/john.jpg');
|
||||
|
||||
expect(result.drivers[1].id).toBe('driver-2');
|
||||
expect(result.drivers[1].name).toBe('Jane Smith');
|
||||
expect(result.drivers[1].rating).toBe(1100.75);
|
||||
expect(result.drivers[1].ratingLabel).toBe('1,101');
|
||||
expect(result.drivers[1].skillLevel).toBe('Advanced');
|
||||
expect(result.drivers[1].category).toBe('Pro');
|
||||
expect(result.drivers[1].nationality).toBe('Canada');
|
||||
expect(result.drivers[1].racesCompleted).toBe(120);
|
||||
expect(result.drivers[1].wins).toBe(15);
|
||||
expect(result.drivers[1].podiums).toBe(45);
|
||||
expect(result.drivers[1].isActive).toBe(true);
|
||||
expect(result.drivers[1].rank).toBe(2);
|
||||
expect(result.drivers[1].avatarUrl).toBe('https://example.com/jane.jpg');
|
||||
|
||||
expect(result.totalRaces).toBe(270);
|
||||
expect(result.totalRacesLabel).toBe('270');
|
||||
expect(result.totalWins).toBe(40);
|
||||
expect(result.totalWinsLabel).toBe('40');
|
||||
expect(result.activeCount).toBe(2);
|
||||
expect(result.activeCountLabel).toBe('2');
|
||||
expect(result.totalDriversLabel).toBe('2');
|
||||
});
|
||||
|
||||
it('should handle drivers with missing optional fields', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'Pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.drivers[0].category).toBeUndefined();
|
||||
expect(result.drivers[0].avatarUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle empty drivers array', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [],
|
||||
totalRaces: 0,
|
||||
totalWins: 0,
|
||||
activeCount: 0,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.drivers).toEqual([]);
|
||||
expect(result.totalRaces).toBe(0);
|
||||
expect(result.totalRacesLabel).toBe('0');
|
||||
expect(result.totalWins).toBe(0);
|
||||
expect(result.totalWinsLabel).toBe('0');
|
||||
expect(result.activeCount).toBe(0);
|
||||
expect(result.activeCountLabel).toBe('0');
|
||||
expect(result.totalDriversLabel).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'Pro',
|
||||
category: 'Elite',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/john.jpg',
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.drivers[0].name).toBe(driversDTO.drivers[0].name);
|
||||
expect(result.drivers[0].nationality).toBe(driversDTO.drivers[0].nationality);
|
||||
expect(result.drivers[0].skillLevel).toBe(driversDTO.drivers[0].skillLevel);
|
||||
expect(result.totalRaces).toBe(driversDTO.totalRaces);
|
||||
expect(result.totalWins).toBe(driversDTO.totalWins);
|
||||
expect(result.activeCount).toBe(driversDTO.activeCount);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'Pro',
|
||||
category: 'Elite',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/john.jpg',
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
};
|
||||
|
||||
const originalDTO = JSON.parse(JSON.stringify(driversDTO));
|
||||
DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(driversDTO).toEqual(originalDTO);
|
||||
});
|
||||
|
||||
it('should transform all numeric fields to formatted strings where appropriate', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'Pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
// Rating label should be a formatted string
|
||||
expect(typeof result.drivers[0].ratingLabel).toBe('string');
|
||||
expect(result.drivers[0].ratingLabel).toBe('1,235');
|
||||
|
||||
// Total counts should be formatted strings
|
||||
expect(typeof result.totalRacesLabel).toBe('string');
|
||||
expect(result.totalRacesLabel).toBe('150');
|
||||
expect(typeof result.totalWinsLabel).toBe('string');
|
||||
expect(result.totalWinsLabel).toBe('25');
|
||||
expect(typeof result.activeCountLabel).toBe('string');
|
||||
expect(result.activeCountLabel).toBe('1');
|
||||
expect(typeof result.totalDriversLabel).toBe('string');
|
||||
expect(result.totalDriversLabel).toBe('1');
|
||||
});
|
||||
|
||||
it('should handle large numbers correctly', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 999999.99,
|
||||
skillLevel: 'Pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 10000,
|
||||
wins: 2500,
|
||||
podiums: 5000,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
],
|
||||
totalRaces: 10000,
|
||||
totalWins: 2500,
|
||||
activeCount: 1,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.drivers[0].ratingLabel).toBe('1,000,000');
|
||||
expect(result.totalRacesLabel).toBe('10,000');
|
||||
expect(result.totalWinsLabel).toBe('2,500');
|
||||
expect(result.activeCountLabel).toBe('1');
|
||||
expect(result.totalDriversLabel).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle null/undefined rating', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 0,
|
||||
skillLevel: 'Pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.drivers[0].ratingLabel).toBe('0');
|
||||
});
|
||||
|
||||
it('should handle drivers with no category', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'Pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.drivers[0].category).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle inactive drivers', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'Pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: false,
|
||||
rank: 1,
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 0,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.drivers[0].isActive).toBe(false);
|
||||
expect(result.activeCount).toBe(0);
|
||||
expect(result.activeCountLabel).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('derived fields', () => {
|
||||
it('should correctly calculate total drivers label', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{ id: 'driver-1', name: 'John Doe', rating: 1234.56, skillLevel: 'Pro', nationality: 'USA', racesCompleted: 150, wins: 25, podiums: 60, isActive: true, rank: 1 },
|
||||
{ id: 'driver-2', name: 'Jane Smith', rating: 1100.75, skillLevel: 'Advanced', nationality: 'Canada', racesCompleted: 120, wins: 15, podiums: 45, isActive: true, rank: 2 },
|
||||
{ id: 'driver-3', name: 'Bob Wilson', rating: 950.25, skillLevel: 'Intermediate', nationality: 'UK', racesCompleted: 80, wins: 5, podiums: 20, isActive: false, rank: 3 },
|
||||
],
|
||||
totalRaces: 350,
|
||||
totalWins: 45,
|
||||
activeCount: 2,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.totalDriversLabel).toBe('3');
|
||||
});
|
||||
|
||||
it('should correctly calculate active count', () => {
|
||||
const driversDTO: DriversLeaderboardDTO = {
|
||||
drivers: [
|
||||
{ id: 'driver-1', name: 'John Doe', rating: 1234.56, skillLevel: 'Pro', nationality: 'USA', racesCompleted: 150, wins: 25, podiums: 60, isActive: true, rank: 1 },
|
||||
{ id: 'driver-2', name: 'Jane Smith', rating: 1100.75, skillLevel: 'Advanced', nationality: 'Canada', racesCompleted: 120, wins: 15, podiums: 45, isActive: true, rank: 2 },
|
||||
{ id: 'driver-3', name: 'Bob Wilson', rating: 950.25, skillLevel: 'Intermediate', nationality: 'UK', racesCompleted: 80, wins: 5, podiums: 20, isActive: false, rank: 3 },
|
||||
],
|
||||
totalRaces: 350,
|
||||
totalWins: 45,
|
||||
activeCount: 2,
|
||||
};
|
||||
|
||||
const result = DriversViewDataBuilder.build(driversDTO);
|
||||
|
||||
expect(result.activeCount).toBe(2);
|
||||
expect(result.activeCountLabel).toBe('2');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ForgotPasswordViewDataBuilder } from './ForgotPasswordViewDataBuilder';
|
||||
import type { ForgotPasswordPageDTO } from '@/lib/services/auth/types/ForgotPasswordPageDTO';
|
||||
|
||||
describe('ForgotPasswordViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform ForgotPasswordPageDTO to ForgotPasswordViewData correctly', () => {
|
||||
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||
|
||||
expect(result).toEqual({
|
||||
returnTo: '/login',
|
||||
showSuccess: false,
|
||||
formState: {
|
||||
fields: {
|
||||
email: { value: '', error: undefined, touched: false, validating: false },
|
||||
},
|
||||
isValid: true,
|
||||
isSubmitting: false,
|
||||
submitError: undefined,
|
||||
submitCount: 0,
|
||||
},
|
||||
isSubmitting: false,
|
||||
submitError: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty returnTo path', () => {
|
||||
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||
returnTo: '',
|
||||
};
|
||||
|
||||
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||
|
||||
expect(result.returnTo).toBe('');
|
||||
});
|
||||
|
||||
it('should handle returnTo with query parameters', () => {
|
||||
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||
returnTo: '/login?error=expired',
|
||||
};
|
||||
|
||||
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||
|
||||
expect(result.returnTo).toBe('/login?error=expired');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||
|
||||
expect(result.returnTo).toBe(forgotPasswordPageDTO.returnTo);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const originalDTO = { ...forgotPasswordPageDTO };
|
||||
ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||
|
||||
expect(forgotPasswordPageDTO).toEqual(originalDTO);
|
||||
});
|
||||
|
||||
it('should initialize form field with default values', () => {
|
||||
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||
|
||||
expect(result.formState.fields.email.value).toBe('');
|
||||
expect(result.formState.fields.email.error).toBeUndefined();
|
||||
expect(result.formState.fields.email.touched).toBe(false);
|
||||
expect(result.formState.fields.email.validating).toBe(false);
|
||||
});
|
||||
|
||||
it('should initialize form state with default values', () => {
|
||||
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||
|
||||
expect(result.formState.isValid).toBe(true);
|
||||
expect(result.formState.isSubmitting).toBe(false);
|
||||
expect(result.formState.submitError).toBeUndefined();
|
||||
expect(result.formState.submitCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should initialize UI state flags correctly', () => {
|
||||
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||
|
||||
expect(result.showSuccess).toBe(false);
|
||||
expect(result.isSubmitting).toBe(false);
|
||||
expect(result.submitError).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle returnTo with encoded characters', () => {
|
||||
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||
returnTo: '/login?redirect=%2Fdashboard',
|
||||
};
|
||||
|
||||
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||
|
||||
expect(result.returnTo).toBe('/login?redirect=%2Fdashboard');
|
||||
});
|
||||
|
||||
it('should handle returnTo with hash fragment', () => {
|
||||
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||
returnTo: '/login#section',
|
||||
};
|
||||
|
||||
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||
|
||||
expect(result.returnTo).toBe('/login#section');
|
||||
});
|
||||
});
|
||||
|
||||
describe('form state structure', () => {
|
||||
it('should have email field', () => {
|
||||
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||
|
||||
expect(result.formState.fields).toHaveProperty('email');
|
||||
});
|
||||
|
||||
it('should have consistent field state structure', () => {
|
||||
const forgotPasswordPageDTO: ForgotPasswordPageDTO = {
|
||||
returnTo: '/login',
|
||||
};
|
||||
|
||||
const result = ForgotPasswordViewDataBuilder.build(forgotPasswordPageDTO);
|
||||
|
||||
const field = result.formState.fields.email;
|
||||
expect(field).toHaveProperty('value');
|
||||
expect(field).toHaveProperty('error');
|
||||
expect(field).toHaveProperty('touched');
|
||||
expect(field).toHaveProperty('validating');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,200 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { GenerateAvatarsViewDataBuilder } from './GenerateAvatarsViewDataBuilder';
|
||||
import type { RequestAvatarGenerationOutputDTO } from '@/lib/types/generated/RequestAvatarGenerationOutputDTO';
|
||||
|
||||
describe('GenerateAvatarsViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform RequestAvatarGenerationOutputDTO to GenerateAvatarsViewData correctly', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: true,
|
||||
avatarUrls: ['avatar-url-1', 'avatar-url-2', 'avatar-url-3'],
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
avatarUrls: ['avatar-url-1', 'avatar-url-2', 'avatar-url-3'],
|
||||
errorMessage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty avatar URLs', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: true,
|
||||
avatarUrls: [],
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result.avatarUrls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle single avatar URL', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: true,
|
||||
avatarUrls: ['avatar-url-1'],
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result.avatarUrls).toHaveLength(1);
|
||||
expect(result.avatarUrls[0]).toBe('avatar-url-1');
|
||||
});
|
||||
|
||||
it('should handle multiple avatar URLs', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: true,
|
||||
avatarUrls: ['avatar-url-1', 'avatar-url-2', 'avatar-url-3', 'avatar-url-4', 'avatar-url-5'],
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result.avatarUrls).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: true,
|
||||
avatarUrls: ['avatar-url-1', 'avatar-url-2'],
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result.success).toBe(requestAvatarGenerationOutputDto.success);
|
||||
expect(result.avatarUrls).toEqual(requestAvatarGenerationOutputDto.avatarUrls);
|
||||
expect(result.errorMessage).toBe(requestAvatarGenerationOutputDto.errorMessage);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: true,
|
||||
avatarUrls: ['avatar-url-1'],
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
const originalDto = { ...requestAvatarGenerationOutputDto };
|
||||
GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(requestAvatarGenerationOutputDto).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle success false', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: false,
|
||||
avatarUrls: [],
|
||||
errorMessage: 'Generation failed',
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle error message', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: false,
|
||||
avatarUrls: [],
|
||||
errorMessage: 'Invalid input data',
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result.errorMessage).toBe('Invalid input data');
|
||||
});
|
||||
|
||||
it('should handle null error message', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: true,
|
||||
avatarUrls: ['avatar-url-1'],
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result.errorMessage).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle undefined avatarUrls', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: true,
|
||||
avatarUrls: undefined,
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result.avatarUrls).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle empty string avatar URLs', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: true,
|
||||
avatarUrls: ['', 'avatar-url-1', ''],
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result.avatarUrls).toEqual(['', 'avatar-url-1', '']);
|
||||
});
|
||||
|
||||
it('should handle special characters in avatar URLs', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: true,
|
||||
avatarUrls: ['avatar-url-1?param=value', 'avatar-url-2#anchor', 'avatar-url-3?query=1&test=2'],
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result.avatarUrls).toEqual([
|
||||
'avatar-url-1?param=value',
|
||||
'avatar-url-2#anchor',
|
||||
'avatar-url-3?query=1&test=2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle very long avatar URLs', () => {
|
||||
const longUrl = 'https://example.com/avatars/' + 'a'.repeat(1000) + '.png';
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: true,
|
||||
avatarUrls: [longUrl],
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result.avatarUrls[0]).toBe(longUrl);
|
||||
});
|
||||
|
||||
it('should handle avatar URLs with special characters', () => {
|
||||
const requestAvatarGenerationOutputDto: RequestAvatarGenerationOutputDTO = {
|
||||
success: true,
|
||||
avatarUrls: [
|
||||
'avatar-url-1?name=John%20Doe',
|
||||
'avatar-url-2?email=test@example.com',
|
||||
'avatar-url-3?query=hello%20world',
|
||||
],
|
||||
errorMessage: null,
|
||||
};
|
||||
|
||||
const result = GenerateAvatarsViewDataBuilder.build(requestAvatarGenerationOutputDto);
|
||||
|
||||
expect(result.avatarUrls).toEqual([
|
||||
'avatar-url-1?name=John%20Doe',
|
||||
'avatar-url-2?email=test@example.com',
|
||||
'avatar-url-3?query=hello%20world',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,553 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { HealthViewDataBuilder, HealthDTO } from './HealthViewDataBuilder';
|
||||
|
||||
describe('HealthViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform HealthDTO to HealthViewData correctly', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: 99.95,
|
||||
responseTime: 150,
|
||||
errorRate: 0.05,
|
||||
lastCheck: new Date().toISOString(),
|
||||
checksPassed: 995,
|
||||
checksFailed: 5,
|
||||
components: [
|
||||
{
|
||||
name: 'Database',
|
||||
status: 'ok',
|
||||
lastCheck: new Date().toISOString(),
|
||||
responseTime: 50,
|
||||
errorRate: 0.01,
|
||||
},
|
||||
{
|
||||
name: 'API',
|
||||
status: 'ok',
|
||||
lastCheck: new Date().toISOString(),
|
||||
responseTime: 100,
|
||||
errorRate: 0.02,
|
||||
},
|
||||
],
|
||||
alerts: [
|
||||
{
|
||||
id: 'alert-1',
|
||||
type: 'info',
|
||||
title: 'System Update',
|
||||
message: 'System updated successfully',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.overallStatus.status).toBe('ok');
|
||||
expect(result.overallStatus.statusLabel).toBe('Healthy');
|
||||
expect(result.overallStatus.statusColor).toBe('#10b981');
|
||||
expect(result.overallStatus.statusIcon).toBe('✓');
|
||||
expect(result.metrics.uptime).toBe('99.95%');
|
||||
expect(result.metrics.responseTime).toBe('150ms');
|
||||
expect(result.metrics.errorRate).toBe('0.05%');
|
||||
expect(result.metrics.checksPassed).toBe(995);
|
||||
expect(result.metrics.checksFailed).toBe(5);
|
||||
expect(result.metrics.totalChecks).toBe(1000);
|
||||
expect(result.metrics.successRate).toBe('99.5%');
|
||||
expect(result.components).toHaveLength(2);
|
||||
expect(result.components[0].name).toBe('Database');
|
||||
expect(result.components[0].status).toBe('ok');
|
||||
expect(result.components[0].statusLabel).toBe('Healthy');
|
||||
expect(result.alerts).toHaveLength(1);
|
||||
expect(result.alerts[0].id).toBe('alert-1');
|
||||
expect(result.alerts[0].type).toBe('info');
|
||||
expect(result.hasAlerts).toBe(true);
|
||||
expect(result.hasDegradedComponents).toBe(false);
|
||||
expect(result.hasErrorComponents).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle missing optional fields gracefully', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.overallStatus.status).toBe('ok');
|
||||
expect(result.metrics.uptime).toBe('N/A');
|
||||
expect(result.metrics.responseTime).toBe('N/A');
|
||||
expect(result.metrics.errorRate).toBe('N/A');
|
||||
expect(result.metrics.checksPassed).toBe(0);
|
||||
expect(result.metrics.checksFailed).toBe(0);
|
||||
expect(result.metrics.totalChecks).toBe(0);
|
||||
expect(result.metrics.successRate).toBe('N/A');
|
||||
expect(result.components).toEqual([]);
|
||||
expect(result.alerts).toEqual([]);
|
||||
expect(result.hasAlerts).toBe(false);
|
||||
expect(result.hasDegradedComponents).toBe(false);
|
||||
expect(result.hasErrorComponents).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle degraded status correctly', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'degraded',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: 95.5,
|
||||
responseTime: 500,
|
||||
errorRate: 4.5,
|
||||
components: [
|
||||
{
|
||||
name: 'Database',
|
||||
status: 'degraded',
|
||||
lastCheck: new Date().toISOString(),
|
||||
responseTime: 200,
|
||||
errorRate: 2.0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.overallStatus.status).toBe('degraded');
|
||||
expect(result.overallStatus.statusLabel).toBe('Degraded');
|
||||
expect(result.overallStatus.statusColor).toBe('#f59e0b');
|
||||
expect(result.overallStatus.statusIcon).toBe('⚠');
|
||||
expect(result.metrics.uptime).toBe('95.50%');
|
||||
expect(result.metrics.responseTime).toBe('500ms');
|
||||
expect(result.metrics.errorRate).toBe('4.50%');
|
||||
expect(result.hasDegradedComponents).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle error status correctly', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'error',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: 85.2,
|
||||
responseTime: 2000,
|
||||
errorRate: 14.8,
|
||||
components: [
|
||||
{
|
||||
name: 'Database',
|
||||
status: 'error',
|
||||
lastCheck: new Date().toISOString(),
|
||||
responseTime: 1500,
|
||||
errorRate: 10.0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.overallStatus.status).toBe('error');
|
||||
expect(result.overallStatus.statusLabel).toBe('Error');
|
||||
expect(result.overallStatus.statusColor).toBe('#ef4444');
|
||||
expect(result.overallStatus.statusIcon).toBe('✕');
|
||||
expect(result.metrics.uptime).toBe('85.20%');
|
||||
expect(result.metrics.responseTime).toBe('2.00s');
|
||||
expect(result.metrics.errorRate).toBe('14.80%');
|
||||
expect(result.hasErrorComponents).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle multiple components with mixed statuses', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'degraded',
|
||||
timestamp: new Date().toISOString(),
|
||||
components: [
|
||||
{
|
||||
name: 'Database',
|
||||
status: 'ok',
|
||||
lastCheck: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
name: 'API',
|
||||
status: 'degraded',
|
||||
lastCheck: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
name: 'Cache',
|
||||
status: 'error',
|
||||
lastCheck: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.components).toHaveLength(3);
|
||||
expect(result.hasDegradedComponents).toBe(true);
|
||||
expect(result.hasErrorComponents).toBe(true);
|
||||
expect(result.components[0].statusLabel).toBe('Healthy');
|
||||
expect(result.components[1].statusLabel).toBe('Degraded');
|
||||
expect(result.components[2].statusLabel).toBe('Error');
|
||||
});
|
||||
|
||||
it('should handle multiple alerts with different severities', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
alerts: [
|
||||
{
|
||||
id: 'alert-1',
|
||||
type: 'critical',
|
||||
title: 'Critical Alert',
|
||||
message: 'Critical issue detected',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'alert-2',
|
||||
type: 'warning',
|
||||
title: 'Warning Alert',
|
||||
message: 'Warning message',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'alert-3',
|
||||
type: 'info',
|
||||
title: 'Info Alert',
|
||||
message: 'Informational message',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.alerts).toHaveLength(3);
|
||||
expect(result.hasAlerts).toBe(true);
|
||||
expect(result.alerts[0].severity).toBe('Critical');
|
||||
expect(result.alerts[0].severityColor).toBe('#ef4444');
|
||||
expect(result.alerts[1].severity).toBe('Warning');
|
||||
expect(result.alerts[1].severityColor).toBe('#f59e0b');
|
||||
expect(result.alerts[2].severity).toBe('Info');
|
||||
expect(result.alerts[2].severityColor).toBe('#3b82f6');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const now = new Date();
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: now.toISOString(),
|
||||
uptime: 99.99,
|
||||
responseTime: 100,
|
||||
errorRate: 0.01,
|
||||
lastCheck: now.toISOString(),
|
||||
checksPassed: 9999,
|
||||
checksFailed: 1,
|
||||
components: [
|
||||
{
|
||||
name: 'Test Component',
|
||||
status: 'ok',
|
||||
lastCheck: now.toISOString(),
|
||||
responseTime: 50,
|
||||
errorRate: 0.005,
|
||||
},
|
||||
],
|
||||
alerts: [
|
||||
{
|
||||
id: 'test-alert',
|
||||
type: 'info',
|
||||
title: 'Test Alert',
|
||||
message: 'Test message',
|
||||
timestamp: now.toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.overallStatus.status).toBe(healthDTO.status);
|
||||
expect(result.overallStatus.timestamp).toBe(healthDTO.timestamp);
|
||||
expect(result.metrics.uptime).toBe('99.99%');
|
||||
expect(result.metrics.responseTime).toBe('100ms');
|
||||
expect(result.metrics.errorRate).toBe('0.01%');
|
||||
expect(result.metrics.lastCheck).toBe(healthDTO.lastCheck);
|
||||
expect(result.metrics.checksPassed).toBe(healthDTO.checksPassed);
|
||||
expect(result.metrics.checksFailed).toBe(healthDTO.checksFailed);
|
||||
expect(result.components[0].name).toBe(healthDTO.components![0].name);
|
||||
expect(result.components[0].status).toBe(healthDTO.components![0].status);
|
||||
expect(result.alerts[0].id).toBe(healthDTO.alerts![0].id);
|
||||
expect(result.alerts[0].type).toBe(healthDTO.alerts![0].type);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: 99.95,
|
||||
responseTime: 150,
|
||||
errorRate: 0.05,
|
||||
components: [
|
||||
{
|
||||
name: 'Database',
|
||||
status: 'ok',
|
||||
lastCheck: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const originalDTO = JSON.parse(JSON.stringify(healthDTO));
|
||||
HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(healthDTO).toEqual(originalDTO);
|
||||
});
|
||||
|
||||
it('should transform all numeric fields to formatted strings', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: 99.95,
|
||||
responseTime: 150,
|
||||
errorRate: 0.05,
|
||||
checksPassed: 995,
|
||||
checksFailed: 5,
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(typeof result.metrics.uptime).toBe('string');
|
||||
expect(typeof result.metrics.responseTime).toBe('string');
|
||||
expect(typeof result.metrics.errorRate).toBe('string');
|
||||
expect(typeof result.metrics.successRate).toBe('string');
|
||||
});
|
||||
|
||||
it('should handle large numbers correctly', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: 99.999,
|
||||
responseTime: 5000,
|
||||
errorRate: 0.001,
|
||||
checksPassed: 999999,
|
||||
checksFailed: 1,
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.metrics.uptime).toBe('100.00%');
|
||||
expect(result.metrics.responseTime).toBe('5.00s');
|
||||
expect(result.metrics.errorRate).toBe('0.00%');
|
||||
expect(result.metrics.successRate).toBe('100.0%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle null/undefined numeric fields', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: null as any,
|
||||
responseTime: undefined,
|
||||
errorRate: null as any,
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.metrics.uptime).toBe('N/A');
|
||||
expect(result.metrics.responseTime).toBe('N/A');
|
||||
expect(result.metrics.errorRate).toBe('N/A');
|
||||
});
|
||||
|
||||
it('should handle negative numeric values', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: -1,
|
||||
responseTime: -100,
|
||||
errorRate: -0.5,
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.metrics.uptime).toBe('N/A');
|
||||
expect(result.metrics.responseTime).toBe('N/A');
|
||||
expect(result.metrics.errorRate).toBe('N/A');
|
||||
});
|
||||
|
||||
it('should handle empty components and alerts arrays', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
components: [],
|
||||
alerts: [],
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.components).toEqual([]);
|
||||
expect(result.alerts).toEqual([]);
|
||||
expect(result.hasAlerts).toBe(false);
|
||||
expect(result.hasDegradedComponents).toBe(false);
|
||||
expect(result.hasErrorComponents).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle component with missing optional fields', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
components: [
|
||||
{
|
||||
name: 'Test Component',
|
||||
status: 'ok',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.components[0].lastCheck).toBeDefined();
|
||||
expect(result.components[0].formattedLastCheck).toBeDefined();
|
||||
expect(result.components[0].responseTime).toBe('N/A');
|
||||
expect(result.components[0].errorRate).toBe('N/A');
|
||||
});
|
||||
|
||||
it('should handle alert with missing optional fields', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
alerts: [
|
||||
{
|
||||
id: 'alert-1',
|
||||
type: 'info',
|
||||
title: 'Test Alert',
|
||||
message: 'Test message',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.alerts[0].id).toBe('alert-1');
|
||||
expect(result.alerts[0].type).toBe('info');
|
||||
expect(result.alerts[0].title).toBe('Test Alert');
|
||||
expect(result.alerts[0].message).toBe('Test message');
|
||||
expect(result.alerts[0].timestamp).toBeDefined();
|
||||
expect(result.alerts[0].formattedTimestamp).toBeDefined();
|
||||
expect(result.alerts[0].relativeTime).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle unknown status', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'unknown',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.overallStatus.status).toBe('unknown');
|
||||
expect(result.overallStatus.statusLabel).toBe('Unknown');
|
||||
expect(result.overallStatus.statusColor).toBe('#6b7280');
|
||||
expect(result.overallStatus.statusIcon).toBe('?');
|
||||
});
|
||||
});
|
||||
|
||||
describe('derived fields', () => {
|
||||
it('should correctly calculate hasAlerts', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
alerts: [
|
||||
{
|
||||
id: 'alert-1',
|
||||
type: 'info',
|
||||
title: 'Test',
|
||||
message: 'Test message',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.hasAlerts).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly calculate hasDegradedComponents', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
components: [
|
||||
{
|
||||
name: 'Component 1',
|
||||
status: 'ok',
|
||||
lastCheck: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
name: 'Component 2',
|
||||
status: 'degraded',
|
||||
lastCheck: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.hasDegradedComponents).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly calculate hasErrorComponents', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
components: [
|
||||
{
|
||||
name: 'Component 1',
|
||||
status: 'ok',
|
||||
lastCheck: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
name: 'Component 2',
|
||||
status: 'error',
|
||||
lastCheck: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.hasErrorComponents).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly calculate totalChecks', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
checksPassed: 100,
|
||||
checksFailed: 20,
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.metrics.totalChecks).toBe(120);
|
||||
});
|
||||
|
||||
it('should correctly calculate successRate', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
checksPassed: 90,
|
||||
checksFailed: 10,
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.metrics.successRate).toBe('90.0%');
|
||||
});
|
||||
|
||||
it('should handle zero checks correctly', () => {
|
||||
const healthDTO: HealthDTO = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
checksPassed: 0,
|
||||
checksFailed: 0,
|
||||
};
|
||||
|
||||
const result = HealthViewDataBuilder.build(healthDTO);
|
||||
|
||||
expect(result.metrics.totalChecks).toBe(0);
|
||||
expect(result.metrics.successRate).toBe('N/A');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,167 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { HomeViewDataBuilder } from './HomeViewDataBuilder';
|
||||
import type { HomeDataDTO } from '@/lib/types/dtos/HomeDataDTO';
|
||||
|
||||
describe('HomeViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform HomeDataDTO to HomeViewData correctly', () => {
|
||||
const homeDataDto: HomeDataDTO = {
|
||||
isAlpha: true,
|
||||
upcomingRaces: [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Test Race',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
track: 'Test Track',
|
||||
},
|
||||
],
|
||||
topLeagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test Description',
|
||||
},
|
||||
],
|
||||
teams: [
|
||||
{
|
||||
id: 'team-1',
|
||||
name: 'Test Team',
|
||||
tag: 'TT',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = HomeViewDataBuilder.build(homeDataDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
isAlpha: true,
|
||||
upcomingRaces: [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Test Race',
|
||||
scheduledAt: '2024-01-01T10:00:00Z',
|
||||
track: 'Test Track',
|
||||
},
|
||||
],
|
||||
topLeagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test Description',
|
||||
},
|
||||
],
|
||||
teams: [
|
||||
{
|
||||
id: 'team-1',
|
||||
name: 'Test Team',
|
||||
tag: 'TT',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty arrays correctly', () => {
|
||||
const homeDataDto: HomeDataDTO = {
|
||||
isAlpha: false,
|
||||
upcomingRaces: [],
|
||||
topLeagues: [],
|
||||
teams: [],
|
||||
};
|
||||
|
||||
const result = HomeViewDataBuilder.build(homeDataDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
isAlpha: false,
|
||||
upcomingRaces: [],
|
||||
topLeagues: [],
|
||||
teams: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple items in arrays', () => {
|
||||
const homeDataDto: HomeDataDTO = {
|
||||
isAlpha: true,
|
||||
upcomingRaces: [
|
||||
{ id: 'race-1', name: 'Race 1', scheduledAt: '2024-01-01T10:00:00Z', track: 'Track 1' },
|
||||
{ id: 'race-2', name: 'Race 2', scheduledAt: '2024-01-02T10:00:00Z', track: 'Track 2' },
|
||||
],
|
||||
topLeagues: [
|
||||
{ id: 'league-1', name: 'League 1', description: 'Description 1' },
|
||||
{ id: 'league-2', name: 'League 2', description: 'Description 2' },
|
||||
],
|
||||
teams: [
|
||||
{ id: 'team-1', name: 'Team 1', tag: 'T1' },
|
||||
{ id: 'team-2', name: 'Team 2', tag: 'T2' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = HomeViewDataBuilder.build(homeDataDto);
|
||||
|
||||
expect(result.upcomingRaces).toHaveLength(2);
|
||||
expect(result.topLeagues).toHaveLength(2);
|
||||
expect(result.teams).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const homeDataDto: HomeDataDTO = {
|
||||
isAlpha: true,
|
||||
upcomingRaces: [{ id: 'race-1', name: 'Race', scheduledAt: '2024-01-01T10:00:00Z', track: 'Track' }],
|
||||
topLeagues: [{ id: 'league-1', name: 'League', description: 'Description' }],
|
||||
teams: [{ id: 'team-1', name: 'Team', tag: 'T' }],
|
||||
};
|
||||
|
||||
const result = HomeViewDataBuilder.build(homeDataDto);
|
||||
|
||||
expect(result.isAlpha).toBe(homeDataDto.isAlpha);
|
||||
expect(result.upcomingRaces).toEqual(homeDataDto.upcomingRaces);
|
||||
expect(result.topLeagues).toEqual(homeDataDto.topLeagues);
|
||||
expect(result.teams).toEqual(homeDataDto.teams);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const homeDataDto: HomeDataDTO = {
|
||||
isAlpha: true,
|
||||
upcomingRaces: [{ id: 'race-1', name: 'Race', scheduledAt: '2024-01-01T10:00:00Z', track: 'Track' }],
|
||||
topLeagues: [{ id: 'league-1', name: 'League', description: 'Description' }],
|
||||
teams: [{ id: 'team-1', name: 'Team', tag: 'T' }],
|
||||
};
|
||||
|
||||
const originalDto = { ...homeDataDto };
|
||||
HomeViewDataBuilder.build(homeDataDto);
|
||||
|
||||
expect(homeDataDto).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle false isAlpha value', () => {
|
||||
const homeDataDto: HomeDataDTO = {
|
||||
isAlpha: false,
|
||||
upcomingRaces: [],
|
||||
topLeagues: [],
|
||||
teams: [],
|
||||
};
|
||||
|
||||
const result = HomeViewDataBuilder.build(homeDataDto);
|
||||
|
||||
expect(result.isAlpha).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle null/undefined values in arrays', () => {
|
||||
const homeDataDto: HomeDataDTO = {
|
||||
isAlpha: true,
|
||||
upcomingRaces: [{ id: 'race-1', name: 'Race', scheduledAt: '2024-01-01T10:00:00Z', track: 'Track' }],
|
||||
topLeagues: [{ id: 'league-1', name: 'League', description: 'Description' }],
|
||||
teams: [{ id: 'team-1', name: 'Team', tag: 'T' }],
|
||||
};
|
||||
|
||||
const result = HomeViewDataBuilder.build(homeDataDto);
|
||||
|
||||
expect(result.upcomingRaces[0].id).toBe('race-1');
|
||||
expect(result.topLeagues[0].id).toBe('league-1');
|
||||
expect(result.teams[0].id).toBe('team-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,600 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LeaderboardsViewDataBuilder } from './LeaderboardsViewDataBuilder';
|
||||
|
||||
describe('LeaderboardsViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform Leaderboards DTO to LeaderboardsViewData correctly', () => {
|
||||
const leaderboardsDTO = {
|
||||
drivers: {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/avatar1.jpg',
|
||||
},
|
||||
{
|
||||
id: 'driver-2',
|
||||
name: 'Jane Smith',
|
||||
rating: 1100.0,
|
||||
skillLevel: 'advanced',
|
||||
nationality: 'Canada',
|
||||
racesCompleted: 100,
|
||||
wins: 15,
|
||||
podiums: 40,
|
||||
isActive: true,
|
||||
rank: 2,
|
||||
avatarUrl: 'https://example.com/avatar2.jpg',
|
||||
},
|
||||
],
|
||||
totalRaces: 250,
|
||||
totalWins: 40,
|
||||
activeCount: 2,
|
||||
},
|
||||
teams: {
|
||||
teams: [
|
||||
{
|
||||
id: 'team-1',
|
||||
name: 'Racing Team Alpha',
|
||||
tag: 'RTA',
|
||||
logoUrl: 'https://example.com/logo1.jpg',
|
||||
memberCount: 15,
|
||||
rating: 1500,
|
||||
totalWins: 50,
|
||||
totalRaces: 200,
|
||||
performanceLevel: 'elite',
|
||||
isRecruiting: false,
|
||||
createdAt: '2023-01-01',
|
||||
},
|
||||
{
|
||||
id: 'team-2',
|
||||
name: 'Speed Demons',
|
||||
tag: 'SD',
|
||||
logoUrl: 'https://example.com/logo2.jpg',
|
||||
memberCount: 8,
|
||||
rating: 1200,
|
||||
totalWins: 20,
|
||||
totalRaces: 150,
|
||||
performanceLevel: 'advanced',
|
||||
isRecruiting: true,
|
||||
createdAt: '2023-06-01',
|
||||
},
|
||||
],
|
||||
recruitingCount: 5,
|
||||
groupsBySkillLevel: 'pro,advanced,intermediate',
|
||||
topTeams: [
|
||||
{
|
||||
id: 'team-1',
|
||||
name: 'Racing Team Alpha',
|
||||
tag: 'RTA',
|
||||
logoUrl: 'https://example.com/logo1.jpg',
|
||||
memberCount: 15,
|
||||
rating: 1500,
|
||||
totalWins: 50,
|
||||
totalRaces: 200,
|
||||
performanceLevel: 'elite',
|
||||
isRecruiting: false,
|
||||
createdAt: '2023-01-01',
|
||||
},
|
||||
{
|
||||
id: 'team-2',
|
||||
name: 'Speed Demons',
|
||||
tag: 'SD',
|
||||
logoUrl: 'https://example.com/logo2.jpg',
|
||||
memberCount: 8,
|
||||
rating: 1200,
|
||||
totalWins: 20,
|
||||
totalRaces: 150,
|
||||
performanceLevel: 'advanced',
|
||||
isRecruiting: true,
|
||||
createdAt: '2023-06-01',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||
|
||||
// Verify drivers
|
||||
expect(result.drivers).toHaveLength(2);
|
||||
expect(result.drivers[0].id).toBe('driver-1');
|
||||
expect(result.drivers[0].name).toBe('John Doe');
|
||||
expect(result.drivers[0].rating).toBe(1234.56);
|
||||
expect(result.drivers[0].skillLevel).toBe('pro');
|
||||
expect(result.drivers[0].nationality).toBe('USA');
|
||||
expect(result.drivers[0].wins).toBe(25);
|
||||
expect(result.drivers[0].podiums).toBe(60);
|
||||
expect(result.drivers[0].racesCompleted).toBe(150);
|
||||
expect(result.drivers[0].rank).toBe(1);
|
||||
expect(result.drivers[0].avatarUrl).toBe('https://example.com/avatar1.jpg');
|
||||
expect(result.drivers[0].position).toBe(1);
|
||||
|
||||
// Verify teams
|
||||
expect(result.teams).toHaveLength(2);
|
||||
expect(result.teams[0].id).toBe('team-1');
|
||||
expect(result.teams[0].name).toBe('Racing Team Alpha');
|
||||
expect(result.teams[0].tag).toBe('RTA');
|
||||
expect(result.teams[0].memberCount).toBe(15);
|
||||
expect(result.teams[0].totalWins).toBe(50);
|
||||
expect(result.teams[0].totalRaces).toBe(200);
|
||||
expect(result.teams[0].logoUrl).toBe('https://example.com/logo1.jpg');
|
||||
expect(result.teams[0].position).toBe(1);
|
||||
expect(result.teams[0].isRecruiting).toBe(false);
|
||||
expect(result.teams[0].performanceLevel).toBe('elite');
|
||||
expect(result.teams[0].rating).toBe(1500);
|
||||
expect(result.teams[0].category).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle empty driver and team arrays', () => {
|
||||
const leaderboardsDTO = {
|
||||
drivers: {
|
||||
drivers: [],
|
||||
totalRaces: 0,
|
||||
totalWins: 0,
|
||||
activeCount: 0,
|
||||
},
|
||||
teams: {
|
||||
teams: [],
|
||||
recruitingCount: 0,
|
||||
groupsBySkillLevel: '',
|
||||
topTeams: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||
|
||||
expect(result.drivers).toEqual([]);
|
||||
expect(result.teams).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle missing avatar URLs with empty string fallback', () => {
|
||||
const leaderboardsDTO = {
|
||||
drivers: {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
},
|
||||
teams: {
|
||||
teams: [],
|
||||
recruitingCount: 0,
|
||||
groupsBySkillLevel: '',
|
||||
topTeams: [
|
||||
{
|
||||
id: 'team-1',
|
||||
name: 'Racing Team Alpha',
|
||||
tag: 'RTA',
|
||||
memberCount: 15,
|
||||
totalWins: 50,
|
||||
totalRaces: 200,
|
||||
performanceLevel: 'elite',
|
||||
isRecruiting: false,
|
||||
createdAt: '2023-01-01',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||
|
||||
expect(result.drivers[0].avatarUrl).toBe('');
|
||||
expect(result.teams[0].logoUrl).toBe('');
|
||||
});
|
||||
|
||||
it('should handle missing optional team fields with defaults', () => {
|
||||
const leaderboardsDTO = {
|
||||
drivers: {
|
||||
drivers: [],
|
||||
totalRaces: 0,
|
||||
totalWins: 0,
|
||||
activeCount: 0,
|
||||
},
|
||||
teams: {
|
||||
teams: [],
|
||||
recruitingCount: 0,
|
||||
groupsBySkillLevel: '',
|
||||
topTeams: [
|
||||
{
|
||||
id: 'team-1',
|
||||
name: 'Racing Team Alpha',
|
||||
tag: 'RTA',
|
||||
memberCount: 15,
|
||||
totalWins: 50,
|
||||
totalRaces: 200,
|
||||
performanceLevel: 'elite',
|
||||
isRecruiting: false,
|
||||
createdAt: '2023-01-01',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||
|
||||
expect(result.teams[0].rating).toBe(0);
|
||||
expect(result.teams[0].logoUrl).toBe('');
|
||||
});
|
||||
|
||||
it('should calculate position based on index', () => {
|
||||
const leaderboardsDTO = {
|
||||
drivers: {
|
||||
drivers: [
|
||||
{ id: 'driver-1', name: 'Driver 1', rating: 1000, skillLevel: 'pro', nationality: 'USA', racesCompleted: 100, wins: 10, podiums: 30, isActive: true, rank: 1 },
|
||||
{ id: 'driver-2', name: 'Driver 2', rating: 900, skillLevel: 'advanced', nationality: 'Canada', racesCompleted: 80, wins: 8, podiums: 25, isActive: true, rank: 2 },
|
||||
{ id: 'driver-3', name: 'Driver 3', rating: 800, skillLevel: 'intermediate', nationality: 'UK', racesCompleted: 60, wins: 5, podiums: 15, isActive: true, rank: 3 },
|
||||
],
|
||||
totalRaces: 240,
|
||||
totalWins: 23,
|
||||
activeCount: 3,
|
||||
},
|
||||
teams: {
|
||||
teams: [],
|
||||
recruitingCount: 1,
|
||||
groupsBySkillLevel: 'elite,advanced,intermediate',
|
||||
topTeams: [
|
||||
{ id: 'team-1', name: 'Team 1', tag: 'T1', memberCount: 10, totalWins: 30, totalRaces: 150, performanceLevel: 'elite', isRecruiting: false, createdAt: '2023-01-01' },
|
||||
{ id: 'team-2', name: 'Team 2', tag: 'T2', memberCount: 8, totalWins: 20, totalRaces: 120, performanceLevel: 'advanced', isRecruiting: true, createdAt: '2023-02-01' },
|
||||
{ id: 'team-3', name: 'Team 3', tag: 'T3', memberCount: 6, totalWins: 10, totalRaces: 80, performanceLevel: 'intermediate', isRecruiting: false, createdAt: '2023-03-01' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||
|
||||
expect(result.drivers[0].position).toBe(1);
|
||||
expect(result.drivers[1].position).toBe(2);
|
||||
expect(result.drivers[2].position).toBe(3);
|
||||
|
||||
expect(result.teams[0].position).toBe(1);
|
||||
expect(result.teams[1].position).toBe(2);
|
||||
expect(result.teams[2].position).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const leaderboardsDTO = {
|
||||
drivers: {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
},
|
||||
teams: {
|
||||
teams: [],
|
||||
recruitingCount: 5,
|
||||
groupsBySkillLevel: 'pro,advanced',
|
||||
topTeams: [
|
||||
{
|
||||
id: 'team-123',
|
||||
name: 'Racing Team Alpha',
|
||||
tag: 'RTA',
|
||||
logoUrl: 'https://example.com/logo.jpg',
|
||||
memberCount: 15,
|
||||
rating: 1500,
|
||||
totalWins: 50,
|
||||
totalRaces: 200,
|
||||
performanceLevel: 'elite',
|
||||
isRecruiting: false,
|
||||
createdAt: '2023-01-01',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||
|
||||
expect(result.drivers[0].name).toBe(leaderboardsDTO.drivers.drivers[0].name);
|
||||
expect(result.drivers[0].nationality).toBe(leaderboardsDTO.drivers.drivers[0].nationality);
|
||||
expect(result.drivers[0].avatarUrl).toBe(leaderboardsDTO.drivers.drivers[0].avatarUrl);
|
||||
expect(result.teams[0].name).toBe(leaderboardsDTO.teams.topTeams[0].name);
|
||||
expect(result.teams[0].tag).toBe(leaderboardsDTO.teams.topTeams[0].tag);
|
||||
expect(result.teams[0].logoUrl).toBe(leaderboardsDTO.teams.topTeams[0].logoUrl);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const leaderboardsDTO = {
|
||||
drivers: {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-123',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
},
|
||||
teams: {
|
||||
teams: [],
|
||||
recruitingCount: 5,
|
||||
groupsBySkillLevel: 'pro,advanced',
|
||||
topTeams: [
|
||||
{
|
||||
id: 'team-123',
|
||||
name: 'Racing Team Alpha',
|
||||
tag: 'RTA',
|
||||
logoUrl: 'https://example.com/logo.jpg',
|
||||
memberCount: 15,
|
||||
rating: 1500,
|
||||
totalWins: 50,
|
||||
totalRaces: 200,
|
||||
performanceLevel: 'elite',
|
||||
isRecruiting: false,
|
||||
createdAt: '2023-01-01',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const originalDTO = JSON.parse(JSON.stringify(leaderboardsDTO));
|
||||
LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||
|
||||
expect(leaderboardsDTO).toEqual(originalDTO);
|
||||
});
|
||||
|
||||
it('should handle large numbers correctly', () => {
|
||||
const leaderboardsDTO = {
|
||||
drivers: {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 999999.99,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 10000,
|
||||
wins: 2500,
|
||||
podiums: 5000,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
},
|
||||
],
|
||||
totalRaces: 10000,
|
||||
totalWins: 2500,
|
||||
activeCount: 1,
|
||||
},
|
||||
teams: {
|
||||
teams: [],
|
||||
recruitingCount: 0,
|
||||
groupsBySkillLevel: '',
|
||||
topTeams: [
|
||||
{
|
||||
id: 'team-1',
|
||||
name: 'Racing Team Alpha',
|
||||
tag: 'RTA',
|
||||
logoUrl: 'https://example.com/logo.jpg',
|
||||
memberCount: 100,
|
||||
rating: 999999,
|
||||
totalWins: 5000,
|
||||
totalRaces: 10000,
|
||||
performanceLevel: 'elite',
|
||||
isRecruiting: false,
|
||||
createdAt: '2023-01-01',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||
|
||||
expect(result.drivers[0].rating).toBe(999999.99);
|
||||
expect(result.drivers[0].wins).toBe(2500);
|
||||
expect(result.drivers[0].podiums).toBe(5000);
|
||||
expect(result.drivers[0].racesCompleted).toBe(10000);
|
||||
expect(result.teams[0].rating).toBe(999999);
|
||||
expect(result.teams[0].totalWins).toBe(5000);
|
||||
expect(result.teams[0].totalRaces).toBe(10000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle null/undefined avatar URLs', () => {
|
||||
const leaderboardsDTO = {
|
||||
drivers: {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: 1234.56,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: null as any,
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
},
|
||||
teams: {
|
||||
teams: [],
|
||||
recruitingCount: 0,
|
||||
groupsBySkillLevel: '',
|
||||
topTeams: [
|
||||
{
|
||||
id: 'team-1',
|
||||
name: 'Racing Team Alpha',
|
||||
tag: 'RTA',
|
||||
logoUrl: undefined as any,
|
||||
memberCount: 15,
|
||||
totalWins: 50,
|
||||
totalRaces: 200,
|
||||
performanceLevel: 'elite',
|
||||
isRecruiting: false,
|
||||
createdAt: '2023-01-01',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||
|
||||
expect(result.drivers[0].avatarUrl).toBe('');
|
||||
expect(result.teams[0].logoUrl).toBe('');
|
||||
});
|
||||
|
||||
it('should handle null/undefined rating', () => {
|
||||
const leaderboardsDTO = {
|
||||
drivers: {
|
||||
drivers: [
|
||||
{
|
||||
id: 'driver-1',
|
||||
name: 'John Doe',
|
||||
rating: null as any,
|
||||
skillLevel: 'pro',
|
||||
nationality: 'USA',
|
||||
racesCompleted: 150,
|
||||
wins: 25,
|
||||
podiums: 60,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
},
|
||||
],
|
||||
totalRaces: 150,
|
||||
totalWins: 25,
|
||||
activeCount: 1,
|
||||
},
|
||||
teams: {
|
||||
teams: [],
|
||||
recruitingCount: 0,
|
||||
groupsBySkillLevel: '',
|
||||
topTeams: [
|
||||
{
|
||||
id: 'team-1',
|
||||
name: 'Racing Team Alpha',
|
||||
tag: 'RTA',
|
||||
memberCount: 15,
|
||||
rating: null as any,
|
||||
totalWins: 50,
|
||||
totalRaces: 200,
|
||||
performanceLevel: 'elite',
|
||||
isRecruiting: false,
|
||||
createdAt: '2023-01-01',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||
|
||||
expect(result.drivers[0].rating).toBeNull();
|
||||
expect(result.teams[0].rating).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle null/undefined totalWins and totalRaces', () => {
|
||||
const leaderboardsDTO = {
|
||||
drivers: {
|
||||
drivers: [],
|
||||
totalRaces: 0,
|
||||
totalWins: 0,
|
||||
activeCount: 0,
|
||||
},
|
||||
teams: {
|
||||
teams: [],
|
||||
recruitingCount: 0,
|
||||
groupsBySkillLevel: '',
|
||||
topTeams: [
|
||||
{
|
||||
id: 'team-1',
|
||||
name: 'Racing Team Alpha',
|
||||
tag: 'RTA',
|
||||
memberCount: 15,
|
||||
totalWins: null as any,
|
||||
totalRaces: null as any,
|
||||
performanceLevel: 'elite',
|
||||
isRecruiting: false,
|
||||
createdAt: '2023-01-01',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||
|
||||
expect(result.teams[0].totalWins).toBe(0);
|
||||
expect(result.teams[0].totalRaces).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle empty performance level', () => {
|
||||
const leaderboardsDTO = {
|
||||
drivers: {
|
||||
drivers: [],
|
||||
totalRaces: 0,
|
||||
totalWins: 0,
|
||||
activeCount: 0,
|
||||
},
|
||||
teams: {
|
||||
teams: [],
|
||||
recruitingCount: 0,
|
||||
groupsBySkillLevel: '',
|
||||
topTeams: [
|
||||
{
|
||||
id: 'team-1',
|
||||
name: 'Racing Team Alpha',
|
||||
tag: 'RTA',
|
||||
memberCount: 15,
|
||||
totalWins: 50,
|
||||
totalRaces: 200,
|
||||
performanceLevel: '',
|
||||
isRecruiting: false,
|
||||
createdAt: '2023-01-01',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeaderboardsViewDataBuilder.build(leaderboardsDTO);
|
||||
|
||||
expect(result.teams[0].performanceLevel).toBe('N/A');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,141 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LeagueCoverViewDataBuilder } from './LeagueCoverViewDataBuilder';
|
||||
import type { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
|
||||
|
||||
describe('LeagueCoverViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform MediaBinaryDTO to LeagueCoverViewData correctly', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle JPEG cover images', () => {
|
||||
const buffer = new Uint8Array([0xff, 0xd8, 0xff, 0xe0]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/jpeg',
|
||||
};
|
||||
|
||||
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('should handle WebP cover images', () => {
|
||||
const buffer = new Uint8Array([0x52, 0x49, 0x46, 0x46]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/webp',
|
||||
};
|
||||
|
||||
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/webp');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBeDefined();
|
||||
expect(result.contentType).toBe(mediaDto.contentType);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const originalDto = { ...mediaDto };
|
||||
LeagueCoverViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(mediaDto).toEqual(originalDto);
|
||||
});
|
||||
|
||||
it('should convert buffer to base64 string', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(typeof result.buffer).toBe('string');
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty buffer', () => {
|
||||
const buffer = new Uint8Array([]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe('');
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle large cover images', () => {
|
||||
const buffer = new Uint8Array(2 * 1024 * 1024); // 2MB
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/jpeg',
|
||||
};
|
||||
|
||||
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('should handle buffer with all zeros', () => {
|
||||
const buffer = new Uint8Array([0x00, 0x00, 0x00, 0x00]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle buffer with all ones', () => {
|
||||
const buffer = new Uint8Array([0xff, 0xff, 0xff, 0xff]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueCoverViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,577 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LeagueDetailViewDataBuilder } from './LeagueDetailViewDataBuilder';
|
||||
import type { LeagueWithCapacityAndScoringDTO } from '@/lib/types/generated/LeagueWithCapacityAndScoringDTO';
|
||||
import type { LeagueMembershipsDTO } from '@/lib/types/generated/LeagueMembershipsDTO';
|
||||
import type { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDTO';
|
||||
import type { LeagueScoringConfigDTO } from '@/lib/types/generated/LeagueScoringConfigDTO';
|
||||
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
|
||||
|
||||
describe('LeagueDetailViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform league DTOs to LeagueDetailViewData correctly', () => {
|
||||
const league: LeagueWithCapacityAndScoringDTO = {
|
||||
id: 'league-1',
|
||||
name: 'Pro League',
|
||||
description: 'A competitive league for experienced drivers',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
qualifyingFormat: 'Solo • 32 max',
|
||||
},
|
||||
usedSlots: 25,
|
||||
category: 'competitive',
|
||||
scoring: {
|
||||
gameId: 'game-1',
|
||||
gameName: 'iRacing',
|
||||
primaryChampionshipType: 'Single Championship',
|
||||
scoringPresetId: 'preset-1',
|
||||
scoringPresetName: 'Standard',
|
||||
dropPolicySummary: 'Drop 2 worst races',
|
||||
scoringPatternSummary: 'Points based on finish position',
|
||||
},
|
||||
timingSummary: 'Weekly races on Sundays',
|
||||
logoUrl: 'https://example.com/logo.png',
|
||||
pendingJoinRequestsCount: 3,
|
||||
pendingProtestsCount: 1,
|
||||
walletBalance: 1000,
|
||||
};
|
||||
|
||||
const owner: GetDriverOutputDTO = {
|
||||
id: 'owner-1',
|
||||
name: 'John Doe',
|
||||
iracingId: '12345',
|
||||
country: 'USA',
|
||||
bio: 'Experienced driver',
|
||||
joinedAt: '2023-01-01T00:00:00.000Z',
|
||||
avatarUrl: 'https://example.com/avatar.jpg',
|
||||
};
|
||||
|
||||
const scoringConfig: LeagueScoringConfigDTO = {
|
||||
id: 'config-1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'game-1',
|
||||
gameName: 'iRacing',
|
||||
primaryChampionshipType: 'Single Championship',
|
||||
scoringPresetId: 'preset-1',
|
||||
scoringPresetName: 'Standard',
|
||||
dropPolicySummary: 'Drop 2 worst races',
|
||||
scoringPatternSummary: 'Points based on finish position',
|
||||
dropRaces: 2,
|
||||
pointsPerRace: 100,
|
||||
pointsForWin: 25,
|
||||
pointsForPodium: [20, 15, 10],
|
||||
};
|
||||
|
||||
const memberships: LeagueMembershipsDTO = {
|
||||
members: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'admin',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
driverId: 'driver-2',
|
||||
driver: {
|
||||
id: 'driver-2',
|
||||
name: 'Bob',
|
||||
iracingId: '22222',
|
||||
country: 'Germany',
|
||||
joinedAt: '2023-07-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'steward',
|
||||
joinedAt: '2023-07-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
driverId: 'driver-3',
|
||||
driver: {
|
||||
id: 'driver-3',
|
||||
name: 'Charlie',
|
||||
iracingId: '33333',
|
||||
country: 'France',
|
||||
joinedAt: '2023-08-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'member',
|
||||
joinedAt: '2023-08-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const races: RaceDTO[] = [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Race 1',
|
||||
date: '2024-01-15T14:00:00.000Z',
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
sessionType: 'race',
|
||||
strengthOfField: 1500,
|
||||
},
|
||||
{
|
||||
id: 'race-2',
|
||||
name: 'Race 2',
|
||||
date: '2024-01-22T14:00:00.000Z',
|
||||
track: 'Monza',
|
||||
car: 'Ferrari 488 GT3',
|
||||
sessionType: 'race',
|
||||
strengthOfField: 1600,
|
||||
},
|
||||
];
|
||||
|
||||
const sponsors: any[] = [
|
||||
{
|
||||
id: 'sponsor-1',
|
||||
name: 'Sponsor A',
|
||||
tier: 'main',
|
||||
logoUrl: 'https://example.com/sponsor-a.png',
|
||||
websiteUrl: 'https://sponsor-a.com',
|
||||
tagline: 'Premium racing gear',
|
||||
},
|
||||
];
|
||||
|
||||
const result = LeagueDetailViewDataBuilder.build({
|
||||
league,
|
||||
owner,
|
||||
scoringConfig,
|
||||
memberships,
|
||||
races,
|
||||
sponsors,
|
||||
});
|
||||
|
||||
expect(result.leagueId).toBe('league-1');
|
||||
expect(result.name).toBe('Pro League');
|
||||
expect(result.description).toBe('A competitive league for experienced drivers');
|
||||
expect(result.logoUrl).toBe('https://example.com/logo.png');
|
||||
expect(result.info.name).toBe('Pro League');
|
||||
expect(result.info.description).toBe('A competitive league for experienced drivers');
|
||||
expect(result.info.membersCount).toBe(3);
|
||||
expect(result.info.racesCount).toBe(2);
|
||||
expect(result.info.avgSOF).toBe(1550);
|
||||
expect(result.info.structure).toBe('Solo • 32 max');
|
||||
expect(result.info.scoring).toBe('preset-1');
|
||||
expect(result.info.createdAt).toBe('2024-01-01T00:00:00.000Z');
|
||||
expect(result.info.discordUrl).toBeUndefined();
|
||||
expect(result.info.youtubeUrl).toBeUndefined();
|
||||
expect(result.info.websiteUrl).toBeUndefined();
|
||||
expect(result.ownerSummary).not.toBeNull();
|
||||
expect(result.ownerSummary?.driverId).toBe('owner-1');
|
||||
expect(result.ownerSummary?.driverName).toBe('John Doe');
|
||||
expect(result.ownerSummary?.avatarUrl).toBe('https://example.com/avatar.jpg');
|
||||
expect(result.ownerSummary?.roleBadgeText).toBe('Owner');
|
||||
expect(result.adminSummaries).toHaveLength(1);
|
||||
expect(result.adminSummaries[0].driverId).toBe('driver-1');
|
||||
expect(result.adminSummaries[0].driverName).toBe('Alice');
|
||||
expect(result.adminSummaries[0].roleBadgeText).toBe('Admin');
|
||||
expect(result.stewardSummaries).toHaveLength(1);
|
||||
expect(result.stewardSummaries[0].driverId).toBe('driver-2');
|
||||
expect(result.stewardSummaries[0].driverName).toBe('Bob');
|
||||
expect(result.stewardSummaries[0].roleBadgeText).toBe('Steward');
|
||||
expect(result.memberSummaries).toHaveLength(1);
|
||||
expect(result.memberSummaries[0].driverId).toBe('driver-3');
|
||||
expect(result.memberSummaries[0].driverName).toBe('Charlie');
|
||||
expect(result.memberSummaries[0].roleBadgeText).toBe('Member');
|
||||
expect(result.sponsors).toHaveLength(1);
|
||||
expect(result.sponsors[0].id).toBe('sponsor-1');
|
||||
expect(result.sponsors[0].name).toBe('Sponsor A');
|
||||
expect(result.sponsors[0].tier).toBe('main');
|
||||
expect(result.walletBalance).toBe(1000);
|
||||
expect(result.pendingProtestsCount).toBe(1);
|
||||
expect(result.pendingJoinRequestsCount).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle league with no owner', () => {
|
||||
const league: LeagueWithCapacityAndScoringDTO = {
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test description',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 10,
|
||||
};
|
||||
|
||||
const result = LeagueDetailViewDataBuilder.build({
|
||||
league,
|
||||
owner: null,
|
||||
scoringConfig: null,
|
||||
memberships: { members: [] },
|
||||
races: [],
|
||||
sponsors: [],
|
||||
});
|
||||
|
||||
expect(result.ownerSummary).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle league with no scoring config', () => {
|
||||
const league: LeagueWithCapacityAndScoringDTO = {
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test description',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 10,
|
||||
};
|
||||
|
||||
const result = LeagueDetailViewDataBuilder.build({
|
||||
league,
|
||||
owner: null,
|
||||
scoringConfig: null,
|
||||
memberships: { members: [] },
|
||||
races: [],
|
||||
sponsors: [],
|
||||
});
|
||||
|
||||
expect(result.info.scoring).toBe('Standard');
|
||||
});
|
||||
|
||||
it('should handle league with no races', () => {
|
||||
const league: LeagueWithCapacityAndScoringDTO = {
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test description',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 10,
|
||||
};
|
||||
|
||||
const result = LeagueDetailViewDataBuilder.build({
|
||||
league,
|
||||
owner: null,
|
||||
scoringConfig: null,
|
||||
memberships: { members: [] },
|
||||
races: [],
|
||||
sponsors: [],
|
||||
});
|
||||
|
||||
expect(result.info.racesCount).toBe(0);
|
||||
expect(result.info.avgSOF).toBeNull();
|
||||
expect(result.runningRaces).toEqual([]);
|
||||
expect(result.nextRace).toBeUndefined();
|
||||
expect(result.seasonProgress).toEqual({
|
||||
completedRaces: 0,
|
||||
totalRaces: 0,
|
||||
percentage: 0,
|
||||
});
|
||||
expect(result.recentResults).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const league: LeagueWithCapacityAndScoringDTO = {
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test description',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
qualifyingFormat: 'Solo • 32 max',
|
||||
},
|
||||
usedSlots: 20,
|
||||
category: 'test',
|
||||
scoring: {
|
||||
gameId: 'game-1',
|
||||
gameName: 'Test Game',
|
||||
primaryChampionshipType: 'Test Type',
|
||||
scoringPresetId: 'preset-1',
|
||||
scoringPresetName: 'Test Preset',
|
||||
dropPolicySummary: 'Test drop policy',
|
||||
scoringPatternSummary: 'Test pattern',
|
||||
},
|
||||
timingSummary: 'Test timing',
|
||||
logoUrl: 'https://example.com/test.png',
|
||||
pendingJoinRequestsCount: 5,
|
||||
pendingProtestsCount: 2,
|
||||
walletBalance: 500,
|
||||
};
|
||||
|
||||
const result = LeagueDetailViewDataBuilder.build({
|
||||
league,
|
||||
owner: null,
|
||||
scoringConfig: null,
|
||||
memberships: { members: [] },
|
||||
races: [],
|
||||
sponsors: [],
|
||||
});
|
||||
|
||||
expect(result.leagueId).toBe(league.id);
|
||||
expect(result.name).toBe(league.name);
|
||||
expect(result.description).toBe(league.description);
|
||||
expect(result.logoUrl).toBe(league.logoUrl);
|
||||
expect(result.walletBalance).toBe(league.walletBalance);
|
||||
expect(result.pendingProtestsCount).toBe(league.pendingProtestsCount);
|
||||
expect(result.pendingJoinRequestsCount).toBe(league.pendingJoinRequestsCount);
|
||||
});
|
||||
|
||||
it('should not modify the input DTOs', () => {
|
||||
const league: LeagueWithCapacityAndScoringDTO = {
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test description',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 20,
|
||||
};
|
||||
|
||||
const originalLeague = JSON.parse(JSON.stringify(league));
|
||||
LeagueDetailViewDataBuilder.build({
|
||||
league,
|
||||
owner: null,
|
||||
scoringConfig: null,
|
||||
memberships: { members: [] },
|
||||
races: [],
|
||||
sponsors: [],
|
||||
});
|
||||
|
||||
expect(league).toEqual(originalLeague);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle league with missing optional fields', () => {
|
||||
const league: LeagueWithCapacityAndScoringDTO = {
|
||||
id: 'league-1',
|
||||
name: 'Minimal League',
|
||||
description: '',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 10,
|
||||
};
|
||||
|
||||
const result = LeagueDetailViewDataBuilder.build({
|
||||
league,
|
||||
owner: null,
|
||||
scoringConfig: null,
|
||||
memberships: { members: [] },
|
||||
races: [],
|
||||
sponsors: [],
|
||||
});
|
||||
|
||||
expect(result.description).toBe('');
|
||||
expect(result.logoUrl).toBeUndefined();
|
||||
expect(result.info.description).toBe('');
|
||||
expect(result.info.discordUrl).toBeUndefined();
|
||||
expect(result.info.youtubeUrl).toBeUndefined();
|
||||
expect(result.info.websiteUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle races with missing strengthOfField', () => {
|
||||
const league: LeagueWithCapacityAndScoringDTO = {
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test description',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 10,
|
||||
};
|
||||
|
||||
const races: RaceDTO[] = [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Race 1',
|
||||
date: '2024-01-15T14:00:00.000Z',
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
sessionType: 'race',
|
||||
},
|
||||
];
|
||||
|
||||
const result = LeagueDetailViewDataBuilder.build({
|
||||
league,
|
||||
owner: null,
|
||||
scoringConfig: null,
|
||||
memberships: { members: [] },
|
||||
races,
|
||||
sponsors: [],
|
||||
});
|
||||
|
||||
expect(result.info.avgSOF).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle races with zero strengthOfField', () => {
|
||||
const league: LeagueWithCapacityAndScoringDTO = {
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test description',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 10,
|
||||
};
|
||||
|
||||
const races: RaceDTO[] = [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Race 1',
|
||||
date: '2024-01-15T14:00:00.000Z',
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
sessionType: 'race',
|
||||
strengthOfField: 0,
|
||||
},
|
||||
];
|
||||
|
||||
const result = LeagueDetailViewDataBuilder.build({
|
||||
league,
|
||||
owner: null,
|
||||
scoringConfig: null,
|
||||
memberships: { members: [] },
|
||||
races,
|
||||
sponsors: [],
|
||||
});
|
||||
|
||||
expect(result.info.avgSOF).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle races with different dates for next race calculation', () => {
|
||||
const now = new Date();
|
||||
const pastDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 1 day ago
|
||||
const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 1 day from now
|
||||
|
||||
const league: LeagueWithCapacityAndScoringDTO = {
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test description',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 10,
|
||||
};
|
||||
|
||||
const races: RaceDTO[] = [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Past Race',
|
||||
date: pastDate.toISOString(),
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
sessionType: 'race',
|
||||
},
|
||||
{
|
||||
id: 'race-2',
|
||||
name: 'Future Race',
|
||||
date: futureDate.toISOString(),
|
||||
track: 'Monza',
|
||||
car: 'Ferrari 488 GT3',
|
||||
sessionType: 'race',
|
||||
},
|
||||
];
|
||||
|
||||
const result = LeagueDetailViewDataBuilder.build({
|
||||
league,
|
||||
owner: null,
|
||||
scoringConfig: null,
|
||||
memberships: { members: [] },
|
||||
races,
|
||||
sponsors: [],
|
||||
});
|
||||
|
||||
expect(result.nextRace).toBeDefined();
|
||||
expect(result.nextRace?.id).toBe('race-2');
|
||||
expect(result.nextRace?.name).toBe('Future Race');
|
||||
expect(result.seasonProgress.completedRaces).toBe(1);
|
||||
expect(result.seasonProgress.totalRaces).toBe(2);
|
||||
expect(result.seasonProgress.percentage).toBe(50);
|
||||
expect(result.recentResults).toHaveLength(1);
|
||||
expect(result.recentResults[0].raceId).toBe('race-1');
|
||||
});
|
||||
|
||||
it('should handle members with different roles', () => {
|
||||
const league: LeagueWithCapacityAndScoringDTO = {
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test description',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 10,
|
||||
};
|
||||
|
||||
const memberships: LeagueMembershipsDTO = {
|
||||
members: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Admin',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'admin',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
driverId: 'driver-2',
|
||||
driver: {
|
||||
id: 'driver-2',
|
||||
name: 'Steward',
|
||||
iracingId: '22222',
|
||||
country: 'Germany',
|
||||
joinedAt: '2023-07-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'steward',
|
||||
joinedAt: '2023-07-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
driverId: 'driver-3',
|
||||
driver: {
|
||||
id: 'driver-3',
|
||||
name: 'Member',
|
||||
iracingId: '33333',
|
||||
country: 'France',
|
||||
joinedAt: '2023-08-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'member',
|
||||
joinedAt: '2023-08-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueDetailViewDataBuilder.build({
|
||||
league,
|
||||
owner: null,
|
||||
scoringConfig: null,
|
||||
memberships,
|
||||
races: [],
|
||||
sponsors: [],
|
||||
});
|
||||
|
||||
expect(result.adminSummaries).toHaveLength(1);
|
||||
expect(result.stewardSummaries).toHaveLength(1);
|
||||
expect(result.memberSummaries).toHaveLength(1);
|
||||
expect(result.info.membersCount).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,128 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LeagueLogoViewDataBuilder } from './LeagueLogoViewDataBuilder';
|
||||
import type { MediaBinaryDTO } from '@/lib/types/MediaBinaryDTO';
|
||||
|
||||
describe('LeagueLogoViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform MediaBinaryDTO to LeagueLogoViewData correctly', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueLogoViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle SVG league logos', () => {
|
||||
const buffer = new TextEncoder().encode('<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>');
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/svg+xml',
|
||||
};
|
||||
|
||||
const result = LeagueLogoViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/svg+xml');
|
||||
});
|
||||
|
||||
it('should handle transparent PNG logos', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueLogoViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueLogoViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBeDefined();
|
||||
expect(result.contentType).toBe(mediaDto.contentType);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const originalDto = { ...mediaDto };
|
||||
LeagueLogoViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(mediaDto).toEqual(originalDto);
|
||||
});
|
||||
|
||||
it('should convert buffer to base64 string', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueLogoViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(typeof result.buffer).toBe('string');
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty buffer', () => {
|
||||
const buffer = new Uint8Array([]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueLogoViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe('');
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle small logo files', () => {
|
||||
const buffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueLogoViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
|
||||
it('should handle buffer with special characters', () => {
|
||||
const buffer = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0xff, 0xfe, 0xfd, 0xfc]);
|
||||
const mediaDto: MediaBinaryDTO = {
|
||||
buffer: buffer.buffer,
|
||||
contentType: 'image/png',
|
||||
};
|
||||
|
||||
const result = LeagueLogoViewDataBuilder.build(mediaDto);
|
||||
|
||||
expect(result.buffer).toBe(Buffer.from(buffer).toString('base64'));
|
||||
expect(result.contentType).toBe('image/png');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,255 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LeagueRosterAdminViewDataBuilder } from './LeagueRosterAdminViewDataBuilder';
|
||||
import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO';
|
||||
import type { LeagueRosterJoinRequestDTO } from '@/lib/types/generated/LeagueRosterJoinRequestDTO';
|
||||
|
||||
describe('LeagueRosterAdminViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform roster DTOs to LeagueRosterAdminViewData correctly', () => {
|
||||
const members: LeagueRosterMemberDTO[] = [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'admin',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
driverId: 'driver-2',
|
||||
driver: {
|
||||
id: 'driver-2',
|
||||
name: 'Bob',
|
||||
iracingId: '22222',
|
||||
country: 'Germany',
|
||||
joinedAt: '2023-07-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'member',
|
||||
joinedAt: '2023-07-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const joinRequests: LeagueRosterJoinRequestDTO[] = [
|
||||
{
|
||||
id: 'request-1',
|
||||
leagueId: 'league-1',
|
||||
driverId: 'driver-3',
|
||||
requestedAt: '2024-01-15T10:00:00.000Z',
|
||||
message: 'I would like to join this league',
|
||||
driver: {},
|
||||
},
|
||||
];
|
||||
|
||||
const result = LeagueRosterAdminViewDataBuilder.build({
|
||||
leagueId: 'league-1',
|
||||
members,
|
||||
joinRequests,
|
||||
});
|
||||
|
||||
expect(result.leagueId).toBe('league-1');
|
||||
expect(result.members).toHaveLength(2);
|
||||
expect(result.members[0].driverId).toBe('driver-1');
|
||||
expect(result.members[0].driver.id).toBe('driver-1');
|
||||
expect(result.members[0].driver.name).toBe('Alice');
|
||||
expect(result.members[0].role).toBe('admin');
|
||||
expect(result.members[0].joinedAt).toBe('2023-06-01T00:00:00.000Z');
|
||||
expect(result.members[0].formattedJoinedAt).toBeDefined();
|
||||
expect(result.members[1].driverId).toBe('driver-2');
|
||||
expect(result.members[1].driver.id).toBe('driver-2');
|
||||
expect(result.members[1].driver.name).toBe('Bob');
|
||||
expect(result.members[1].role).toBe('member');
|
||||
expect(result.members[1].joinedAt).toBe('2023-07-01T00:00:00.000Z');
|
||||
expect(result.members[1].formattedJoinedAt).toBeDefined();
|
||||
expect(result.joinRequests).toHaveLength(1);
|
||||
expect(result.joinRequests[0].id).toBe('request-1');
|
||||
expect(result.joinRequests[0].driver.id).toBe('driver-3');
|
||||
expect(result.joinRequests[0].driver.name).toBe('Unknown Driver');
|
||||
expect(result.joinRequests[0].requestedAt).toBe('2024-01-15T10:00:00.000Z');
|
||||
expect(result.joinRequests[0].formattedRequestedAt).toBeDefined();
|
||||
expect(result.joinRequests[0].message).toBe('I would like to join this league');
|
||||
});
|
||||
|
||||
it('should handle empty members and join requests', () => {
|
||||
const result = LeagueRosterAdminViewDataBuilder.build({
|
||||
leagueId: 'league-1',
|
||||
members: [],
|
||||
joinRequests: [],
|
||||
});
|
||||
|
||||
expect(result.leagueId).toBe('league-1');
|
||||
expect(result.members).toHaveLength(0);
|
||||
expect(result.joinRequests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle members without driver details', () => {
|
||||
const members: LeagueRosterMemberDTO[] = [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: undefined as any,
|
||||
role: 'member',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const result = LeagueRosterAdminViewDataBuilder.build({
|
||||
leagueId: 'league-1',
|
||||
members,
|
||||
joinRequests: [],
|
||||
});
|
||||
|
||||
expect(result.members[0].driver.name).toBe('Unknown Driver');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const members: LeagueRosterMemberDTO[] = [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'admin',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const joinRequests: LeagueRosterJoinRequestDTO[] = [
|
||||
{
|
||||
id: 'request-1',
|
||||
leagueId: 'league-1',
|
||||
driverId: 'driver-3',
|
||||
requestedAt: '2024-01-15T10:00:00.000Z',
|
||||
message: 'I would like to join this league',
|
||||
driver: {},
|
||||
},
|
||||
];
|
||||
|
||||
const result = LeagueRosterAdminViewDataBuilder.build({
|
||||
leagueId: 'league-1',
|
||||
members,
|
||||
joinRequests,
|
||||
});
|
||||
|
||||
expect(result.leagueId).toBe('league-1');
|
||||
expect(result.members[0].driverId).toBe(members[0].driverId);
|
||||
expect(result.members[0].driver.id).toBe(members[0].driver.id);
|
||||
expect(result.members[0].driver.name).toBe(members[0].driver.name);
|
||||
expect(result.members[0].role).toBe(members[0].role);
|
||||
expect(result.members[0].joinedAt).toBe(members[0].joinedAt);
|
||||
expect(result.joinRequests[0].id).toBe(joinRequests[0].id);
|
||||
expect(result.joinRequests[0].requestedAt).toBe(joinRequests[0].requestedAt);
|
||||
expect(result.joinRequests[0].message).toBe(joinRequests[0].message);
|
||||
});
|
||||
|
||||
it('should not modify the input DTOs', () => {
|
||||
const members: LeagueRosterMemberDTO[] = [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'admin',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const joinRequests: LeagueRosterJoinRequestDTO[] = [
|
||||
{
|
||||
id: 'request-1',
|
||||
leagueId: 'league-1',
|
||||
driverId: 'driver-3',
|
||||
requestedAt: '2024-01-15T10:00:00.000Z',
|
||||
message: 'I would like to join this league',
|
||||
driver: {},
|
||||
},
|
||||
];
|
||||
|
||||
const originalMembers = JSON.parse(JSON.stringify(members));
|
||||
const originalRequests = JSON.parse(JSON.stringify(joinRequests));
|
||||
|
||||
LeagueRosterAdminViewDataBuilder.build({
|
||||
leagueId: 'league-1',
|
||||
members,
|
||||
joinRequests,
|
||||
});
|
||||
|
||||
expect(members).toEqual(originalMembers);
|
||||
expect(joinRequests).toEqual(originalRequests);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle members with missing driver field', () => {
|
||||
const members: LeagueRosterMemberDTO[] = [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: undefined as any,
|
||||
role: 'member',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
const result = LeagueRosterAdminViewDataBuilder.build({
|
||||
leagueId: 'league-1',
|
||||
members,
|
||||
joinRequests: [],
|
||||
});
|
||||
|
||||
expect(result.members[0].driver.name).toBe('Unknown Driver');
|
||||
});
|
||||
|
||||
it('should handle join requests with missing driver field', () => {
|
||||
const joinRequests: LeagueRosterJoinRequestDTO[] = [
|
||||
{
|
||||
id: 'request-1',
|
||||
leagueId: 'league-1',
|
||||
driverId: 'driver-3',
|
||||
requestedAt: '2024-01-15T10:00:00.000Z',
|
||||
message: 'I would like to join this league',
|
||||
driver: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const result = LeagueRosterAdminViewDataBuilder.build({
|
||||
leagueId: 'league-1',
|
||||
members: [],
|
||||
joinRequests,
|
||||
});
|
||||
|
||||
expect(result.joinRequests[0].driver.name).toBe('Unknown Driver');
|
||||
});
|
||||
|
||||
it('should handle join requests without message', () => {
|
||||
const joinRequests: LeagueRosterJoinRequestDTO[] = [
|
||||
{
|
||||
id: 'request-1',
|
||||
leagueId: 'league-1',
|
||||
driverId: 'driver-3',
|
||||
requestedAt: '2024-01-15T10:00:00.000Z',
|
||||
driver: {},
|
||||
},
|
||||
];
|
||||
|
||||
const result = LeagueRosterAdminViewDataBuilder.build({
|
||||
leagueId: 'league-1',
|
||||
members: [],
|
||||
joinRequests,
|
||||
});
|
||||
|
||||
expect(result.joinRequests[0].message).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,211 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LeagueScheduleViewDataBuilder } from './LeagueScheduleViewDataBuilder';
|
||||
|
||||
describe('LeagueScheduleViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform schedule DTO to LeagueScheduleViewData correctly', () => {
|
||||
const now = new Date();
|
||||
const pastDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 1 day ago
|
||||
const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 1 day from now
|
||||
|
||||
const apiDto = {
|
||||
leagueId: 'league-1',
|
||||
races: [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Past Race',
|
||||
date: pastDate.toISOString(),
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
sessionType: 'race',
|
||||
},
|
||||
{
|
||||
id: 'race-2',
|
||||
name: 'Future Race',
|
||||
date: futureDate.toISOString(),
|
||||
track: 'Monza',
|
||||
car: 'Ferrari 488 GT3',
|
||||
sessionType: 'race',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueScheduleViewDataBuilder.build(apiDto, 'driver-1', true);
|
||||
|
||||
expect(result.leagueId).toBe('league-1');
|
||||
expect(result.races).toHaveLength(2);
|
||||
expect(result.races[0].id).toBe('race-1');
|
||||
expect(result.races[0].name).toBe('Past Race');
|
||||
expect(result.races[0].scheduledAt).toBe(pastDate.toISOString());
|
||||
expect(result.races[0].track).toBe('Spa');
|
||||
expect(result.races[0].car).toBe('Porsche 911 GT3');
|
||||
expect(result.races[0].sessionType).toBe('race');
|
||||
expect(result.races[0].isPast).toBe(true);
|
||||
expect(result.races[0].isUpcoming).toBe(false);
|
||||
expect(result.races[0].status).toBe('completed');
|
||||
expect(result.races[0].isUserRegistered).toBe(false);
|
||||
expect(result.races[0].canRegister).toBe(false);
|
||||
expect(result.races[0].canEdit).toBe(true);
|
||||
expect(result.races[0].canReschedule).toBe(true);
|
||||
expect(result.races[1].id).toBe('race-2');
|
||||
expect(result.races[1].name).toBe('Future Race');
|
||||
expect(result.races[1].scheduledAt).toBe(futureDate.toISOString());
|
||||
expect(result.races[1].track).toBe('Monza');
|
||||
expect(result.races[1].car).toBe('Ferrari 488 GT3');
|
||||
expect(result.races[1].sessionType).toBe('race');
|
||||
expect(result.races[1].isPast).toBe(false);
|
||||
expect(result.races[1].isUpcoming).toBe(true);
|
||||
expect(result.races[1].status).toBe('scheduled');
|
||||
expect(result.races[1].isUserRegistered).toBe(false);
|
||||
expect(result.races[1].canRegister).toBe(true);
|
||||
expect(result.races[1].canEdit).toBe(true);
|
||||
expect(result.races[1].canReschedule).toBe(true);
|
||||
expect(result.currentDriverId).toBe('driver-1');
|
||||
expect(result.isAdmin).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty races list', () => {
|
||||
const apiDto = {
|
||||
leagueId: 'league-1',
|
||||
races: [],
|
||||
};
|
||||
|
||||
const result = LeagueScheduleViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.leagueId).toBe('league-1');
|
||||
expect(result.races).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle non-admin user', () => {
|
||||
const now = new Date();
|
||||
const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
|
||||
const apiDto = {
|
||||
leagueId: 'league-1',
|
||||
races: [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Future Race',
|
||||
date: futureDate.toISOString(),
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
sessionType: 'race',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueScheduleViewDataBuilder.build(apiDto, 'driver-1', false);
|
||||
|
||||
expect(result.races[0].canEdit).toBe(false);
|
||||
expect(result.races[0].canReschedule).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const now = new Date();
|
||||
const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
|
||||
const apiDto = {
|
||||
leagueId: 'league-1',
|
||||
races: [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Test Race',
|
||||
date: futureDate.toISOString(),
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
sessionType: 'race',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueScheduleViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.leagueId).toBe(apiDto.leagueId);
|
||||
expect(result.races[0].id).toBe(apiDto.races[0].id);
|
||||
expect(result.races[0].name).toBe(apiDto.races[0].name);
|
||||
expect(result.races[0].scheduledAt).toBe(apiDto.races[0].date);
|
||||
expect(result.races[0].track).toBe(apiDto.races[0].track);
|
||||
expect(result.races[0].car).toBe(apiDto.races[0].car);
|
||||
expect(result.races[0].sessionType).toBe(apiDto.races[0].sessionType);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const now = new Date();
|
||||
const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
|
||||
const apiDto = {
|
||||
leagueId: 'league-1',
|
||||
races: [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Test Race',
|
||||
date: futureDate.toISOString(),
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
sessionType: 'race',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const originalDto = JSON.parse(JSON.stringify(apiDto));
|
||||
LeagueScheduleViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(apiDto).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle races with missing optional fields', () => {
|
||||
const now = new Date();
|
||||
const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
|
||||
const apiDto = {
|
||||
leagueId: 'league-1',
|
||||
races: [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Test Race',
|
||||
date: futureDate.toISOString(),
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
sessionType: 'race',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueScheduleViewDataBuilder.build(apiDto);
|
||||
|
||||
expect(result.races[0].track).toBe('Spa');
|
||||
expect(result.races[0].car).toBe('Porsche 911 GT3');
|
||||
expect(result.races[0].sessionType).toBe('race');
|
||||
});
|
||||
|
||||
it('should handle races at exactly the current time', () => {
|
||||
const now = new Date();
|
||||
const currentRaceDate = new Date(now.getTime());
|
||||
|
||||
const apiDto = {
|
||||
leagueId: 'league-1',
|
||||
races: [
|
||||
{
|
||||
id: 'race-1',
|
||||
name: 'Current Race',
|
||||
date: currentRaceDate.toISOString(),
|
||||
track: 'Spa',
|
||||
car: 'Porsche 911 GT3',
|
||||
sessionType: 'race',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueScheduleViewDataBuilder.build(apiDto);
|
||||
|
||||
// Race at current time should be considered past
|
||||
expect(result.races[0].isPast).toBe(true);
|
||||
expect(result.races[0].isUpcoming).toBe(false);
|
||||
expect(result.races[0].status).toBe('completed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ export class LeagueScheduleViewDataBuilder {
|
||||
leagueId: apiDto.leagueId,
|
||||
races: apiDto.races.map((race) => {
|
||||
const scheduledAt = new Date(race.date);
|
||||
const isPast = scheduledAt.getTime() <= now.getTime();
|
||||
const isPast = scheduledAt.getTime() < now.getTime();
|
||||
const isUpcoming = !isPast;
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LeagueSettingsViewDataBuilder } from './LeagueSettingsViewDataBuilder';
|
||||
import type { LeagueSettingsApiDto } from '@/lib/types/tbd/LeagueSettingsApiDto';
|
||||
|
||||
describe('LeagueSettingsViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform LeagueSettingsApiDto to LeagueSettingsViewData correctly', () => {
|
||||
const leagueSettingsApiDto: LeagueSettingsApiDto = {
|
||||
leagueId: 'league-123',
|
||||
league: {
|
||||
id: 'league-123',
|
||||
name: 'Test League',
|
||||
description: 'Test Description',
|
||||
},
|
||||
config: {
|
||||
maxDrivers: 32,
|
||||
qualifyingFormat: 'Open',
|
||||
raceLength: 30,
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeagueSettingsViewDataBuilder.build(leagueSettingsApiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
leagueId: 'league-123',
|
||||
league: {
|
||||
id: 'league-123',
|
||||
name: 'Test League',
|
||||
description: 'Test Description',
|
||||
},
|
||||
config: {
|
||||
maxDrivers: 32,
|
||||
qualifyingFormat: 'Open',
|
||||
raceLength: 30,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle minimal configuration', () => {
|
||||
const leagueSettingsApiDto: LeagueSettingsApiDto = {
|
||||
leagueId: 'league-456',
|
||||
league: {
|
||||
id: 'league-456',
|
||||
name: 'Minimal League',
|
||||
description: '',
|
||||
},
|
||||
config: {
|
||||
maxDrivers: 16,
|
||||
qualifyingFormat: 'Open',
|
||||
raceLength: 20,
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeagueSettingsViewDataBuilder.build(leagueSettingsApiDto);
|
||||
|
||||
expect(result.leagueId).toBe('league-456');
|
||||
expect(result.league.name).toBe('Minimal League');
|
||||
expect(result.config.maxDrivers).toBe(16);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const leagueSettingsApiDto: LeagueSettingsApiDto = {
|
||||
leagueId: 'league-789',
|
||||
league: {
|
||||
id: 'league-789',
|
||||
name: 'Full League',
|
||||
description: 'Full Description',
|
||||
},
|
||||
config: {
|
||||
maxDrivers: 24,
|
||||
qualifyingFormat: 'Open',
|
||||
raceLength: 45,
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeagueSettingsViewDataBuilder.build(leagueSettingsApiDto);
|
||||
|
||||
expect(result.leagueId).toBe(leagueSettingsApiDto.leagueId);
|
||||
expect(result.league).toEqual(leagueSettingsApiDto.league);
|
||||
expect(result.config).toEqual(leagueSettingsApiDto.config);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const leagueSettingsApiDto: LeagueSettingsApiDto = {
|
||||
leagueId: 'league-101',
|
||||
league: {
|
||||
id: 'league-101',
|
||||
name: 'Test League',
|
||||
description: 'Test',
|
||||
},
|
||||
config: {
|
||||
maxDrivers: 20,
|
||||
qualifyingFormat: 'Open',
|
||||
raceLength: 25,
|
||||
},
|
||||
};
|
||||
|
||||
const originalDto = { ...leagueSettingsApiDto };
|
||||
LeagueSettingsViewDataBuilder.build(leagueSettingsApiDto);
|
||||
|
||||
expect(leagueSettingsApiDto).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle different qualifying formats', () => {
|
||||
const leagueSettingsApiDto: LeagueSettingsApiDto = {
|
||||
leagueId: 'league-102',
|
||||
league: {
|
||||
id: 'league-102',
|
||||
name: 'Test League',
|
||||
description: 'Test',
|
||||
},
|
||||
config: {
|
||||
maxDrivers: 20,
|
||||
qualifyingFormat: 'Closed',
|
||||
raceLength: 30,
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeagueSettingsViewDataBuilder.build(leagueSettingsApiDto);
|
||||
|
||||
expect(result.config.qualifyingFormat).toBe('Closed');
|
||||
});
|
||||
|
||||
it('should handle large driver counts', () => {
|
||||
const leagueSettingsApiDto: LeagueSettingsApiDto = {
|
||||
leagueId: 'league-103',
|
||||
league: {
|
||||
id: 'league-103',
|
||||
name: 'Test League',
|
||||
description: 'Test',
|
||||
},
|
||||
config: {
|
||||
maxDrivers: 100,
|
||||
qualifyingFormat: 'Open',
|
||||
raceLength: 60,
|
||||
},
|
||||
};
|
||||
|
||||
const result = LeagueSettingsViewDataBuilder.build(leagueSettingsApiDto);
|
||||
|
||||
expect(result.config.maxDrivers).toBe(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,235 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LeagueSponsorshipsViewDataBuilder } from './LeagueSponsorshipsViewDataBuilder';
|
||||
import type { LeagueSponsorshipsApiDto } from '@/lib/types/tbd/LeagueSponsorshipsApiDto';
|
||||
|
||||
describe('LeagueSponsorshipsViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform LeagueSponsorshipsApiDto to LeagueSponsorshipsViewData correctly', () => {
|
||||
const leagueSponsorshipsApiDto: LeagueSponsorshipsApiDto = {
|
||||
leagueId: 'league-123',
|
||||
league: {
|
||||
id: 'league-123',
|
||||
name: 'Test League',
|
||||
},
|
||||
sponsorshipSlots: [
|
||||
{
|
||||
id: 'slot-1',
|
||||
name: 'Primary Sponsor',
|
||||
price: 1000,
|
||||
status: 'available',
|
||||
},
|
||||
],
|
||||
sponsorshipRequests: [
|
||||
{
|
||||
id: 'request-1',
|
||||
sponsorId: 'sponsor-1',
|
||||
sponsorName: 'Test Sponsor',
|
||||
sponsorLogo: 'logo-url',
|
||||
message: 'Test message',
|
||||
requestedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'pending',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueSponsorshipsViewDataBuilder.build(leagueSponsorshipsApiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
leagueId: 'league-123',
|
||||
activeTab: 'overview',
|
||||
onTabChange: expect.any(Function),
|
||||
league: {
|
||||
id: 'league-123',
|
||||
name: 'Test League',
|
||||
},
|
||||
sponsorshipSlots: [
|
||||
{
|
||||
id: 'slot-1',
|
||||
name: 'Primary Sponsor',
|
||||
price: 1000,
|
||||
status: 'available',
|
||||
},
|
||||
],
|
||||
sponsorshipRequests: [
|
||||
{
|
||||
id: 'request-1',
|
||||
sponsorId: 'sponsor-1',
|
||||
sponsorName: 'Test Sponsor',
|
||||
sponsorLogo: 'logo-url',
|
||||
message: 'Test message',
|
||||
requestedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'pending',
|
||||
formattedRequestedAt: expect.any(String),
|
||||
statusLabel: expect.any(String),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty sponsorship requests', () => {
|
||||
const leagueSponsorshipsApiDto: LeagueSponsorshipsApiDto = {
|
||||
leagueId: 'league-456',
|
||||
league: {
|
||||
id: 'league-456',
|
||||
name: 'Test League',
|
||||
},
|
||||
sponsorshipSlots: [
|
||||
{
|
||||
id: 'slot-1',
|
||||
name: 'Primary Sponsor',
|
||||
price: 1000,
|
||||
status: 'available',
|
||||
},
|
||||
],
|
||||
sponsorshipRequests: [],
|
||||
};
|
||||
|
||||
const result = LeagueSponsorshipsViewDataBuilder.build(leagueSponsorshipsApiDto);
|
||||
|
||||
expect(result.sponsorshipRequests).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle multiple sponsorship requests', () => {
|
||||
const leagueSponsorshipsApiDto: LeagueSponsorshipsApiDto = {
|
||||
leagueId: 'league-789',
|
||||
league: {
|
||||
id: 'league-789',
|
||||
name: 'Test League',
|
||||
},
|
||||
sponsorshipSlots: [],
|
||||
sponsorshipRequests: [
|
||||
{
|
||||
id: 'request-1',
|
||||
sponsorId: 'sponsor-1',
|
||||
sponsorName: 'Sponsor 1',
|
||||
sponsorLogo: 'logo-1',
|
||||
message: 'Message 1',
|
||||
requestedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'request-2',
|
||||
sponsorId: 'sponsor-2',
|
||||
sponsorName: 'Sponsor 2',
|
||||
sponsorLogo: 'logo-2',
|
||||
message: 'Message 2',
|
||||
requestedAt: '2024-01-02T10:00:00Z',
|
||||
status: 'approved',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueSponsorshipsViewDataBuilder.build(leagueSponsorshipsApiDto);
|
||||
|
||||
expect(result.sponsorshipRequests).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const leagueSponsorshipsApiDto: LeagueSponsorshipsApiDto = {
|
||||
leagueId: 'league-101',
|
||||
league: {
|
||||
id: 'league-101',
|
||||
name: 'Test League',
|
||||
},
|
||||
sponsorshipSlots: [
|
||||
{
|
||||
id: 'slot-1',
|
||||
name: 'Primary Sponsor',
|
||||
price: 1000,
|
||||
status: 'available',
|
||||
},
|
||||
],
|
||||
sponsorshipRequests: [
|
||||
{
|
||||
id: 'request-1',
|
||||
sponsorId: 'sponsor-1',
|
||||
sponsorName: 'Test Sponsor',
|
||||
sponsorLogo: 'logo-url',
|
||||
message: 'Test message',
|
||||
requestedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'pending',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueSponsorshipsViewDataBuilder.build(leagueSponsorshipsApiDto);
|
||||
|
||||
expect(result.leagueId).toBe(leagueSponsorshipsApiDto.leagueId);
|
||||
expect(result.league).toEqual(leagueSponsorshipsApiDto.league);
|
||||
expect(result.sponsorshipSlots).toEqual(leagueSponsorshipsApiDto.sponsorshipSlots);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const leagueSponsorshipsApiDto: LeagueSponsorshipsApiDto = {
|
||||
leagueId: 'league-102',
|
||||
league: {
|
||||
id: 'league-102',
|
||||
name: 'Test League',
|
||||
},
|
||||
sponsorshipSlots: [],
|
||||
sponsorshipRequests: [],
|
||||
};
|
||||
|
||||
const originalDto = { ...leagueSponsorshipsApiDto };
|
||||
LeagueSponsorshipsViewDataBuilder.build(leagueSponsorshipsApiDto);
|
||||
|
||||
expect(leagueSponsorshipsApiDto).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle requests without sponsor logo', () => {
|
||||
const leagueSponsorshipsApiDto: LeagueSponsorshipsApiDto = {
|
||||
leagueId: 'league-103',
|
||||
league: {
|
||||
id: 'league-103',
|
||||
name: 'Test League',
|
||||
},
|
||||
sponsorshipSlots: [],
|
||||
sponsorshipRequests: [
|
||||
{
|
||||
id: 'request-1',
|
||||
sponsorId: 'sponsor-1',
|
||||
sponsorName: 'Test Sponsor',
|
||||
sponsorLogo: null,
|
||||
message: 'Test message',
|
||||
requestedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'pending',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueSponsorshipsViewDataBuilder.build(leagueSponsorshipsApiDto);
|
||||
|
||||
expect(result.sponsorshipRequests[0].sponsorLogoUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle requests without message', () => {
|
||||
const leagueSponsorshipsApiDto: LeagueSponsorshipsApiDto = {
|
||||
leagueId: 'league-104',
|
||||
league: {
|
||||
id: 'league-104',
|
||||
name: 'Test League',
|
||||
},
|
||||
sponsorshipSlots: [],
|
||||
sponsorshipRequests: [
|
||||
{
|
||||
id: 'request-1',
|
||||
sponsorId: 'sponsor-1',
|
||||
sponsorName: 'Test Sponsor',
|
||||
sponsorLogo: 'logo-url',
|
||||
message: null,
|
||||
requestedAt: '2024-01-01T10:00:00Z',
|
||||
status: 'pending',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueSponsorshipsViewDataBuilder.build(leagueSponsorshipsApiDto);
|
||||
|
||||
expect(result.sponsorshipRequests[0].message).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,464 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LeagueStandingsViewDataBuilder } from './LeagueStandingsViewDataBuilder';
|
||||
|
||||
describe('LeagueStandingsViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform standings DTOs to LeagueStandingsViewData correctly', () => {
|
||||
const standingsDto = {
|
||||
standings: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
},
|
||||
points: 1250,
|
||||
position: 1,
|
||||
wins: 5,
|
||||
podiums: 10,
|
||||
races: 15,
|
||||
positionChange: 2,
|
||||
lastRacePoints: 25,
|
||||
droppedRaceIds: ['race-1', 'race-2'],
|
||||
},
|
||||
{
|
||||
driverId: 'driver-2',
|
||||
driver: {
|
||||
id: 'driver-2',
|
||||
name: 'Bob',
|
||||
iracingId: '22222',
|
||||
country: 'Germany',
|
||||
},
|
||||
points: 1100,
|
||||
position: 2,
|
||||
wins: 3,
|
||||
podiums: 8,
|
||||
races: 15,
|
||||
positionChange: -1,
|
||||
lastRacePoints: 15,
|
||||
droppedRaceIds: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const membershipsDto = {
|
||||
members: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'member',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
driverId: 'driver-2',
|
||||
driver: {
|
||||
id: 'driver-2',
|
||||
name: 'Bob',
|
||||
iracingId: '22222',
|
||||
country: 'Germany',
|
||||
joinedAt: '2023-07-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'member',
|
||||
joinedAt: '2023-07-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueStandingsViewDataBuilder.build(
|
||||
standingsDto,
|
||||
membershipsDto,
|
||||
'league-1',
|
||||
false
|
||||
);
|
||||
|
||||
expect(result.leagueId).toBe('league-1');
|
||||
expect(result.isTeamChampionship).toBe(false);
|
||||
expect(result.currentDriverId).toBeNull();
|
||||
expect(result.isAdmin).toBe(false);
|
||||
expect(result.standings).toHaveLength(2);
|
||||
expect(result.standings[0].driverId).toBe('driver-1');
|
||||
expect(result.standings[0].position).toBe(1);
|
||||
expect(result.standings[0].totalPoints).toBe(1250);
|
||||
expect(result.standings[0].racesFinished).toBe(15);
|
||||
expect(result.standings[0].racesStarted).toBe(15);
|
||||
expect(result.standings[0].avgFinish).toBeNull();
|
||||
expect(result.standings[0].penaltyPoints).toBe(0);
|
||||
expect(result.standings[0].bonusPoints).toBe(0);
|
||||
expect(result.standings[0].positionChange).toBe(2);
|
||||
expect(result.standings[0].lastRacePoints).toBe(25);
|
||||
expect(result.standings[0].droppedRaceIds).toEqual(['race-1', 'race-2']);
|
||||
expect(result.standings[0].wins).toBe(5);
|
||||
expect(result.standings[0].podiums).toBe(10);
|
||||
expect(result.standings[1].driverId).toBe('driver-2');
|
||||
expect(result.standings[1].position).toBe(2);
|
||||
expect(result.standings[1].totalPoints).toBe(1100);
|
||||
expect(result.standings[1].racesFinished).toBe(15);
|
||||
expect(result.standings[1].racesStarted).toBe(15);
|
||||
expect(result.standings[1].avgFinish).toBeNull();
|
||||
expect(result.standings[1].penaltyPoints).toBe(0);
|
||||
expect(result.standings[1].bonusPoints).toBe(0);
|
||||
expect(result.standings[1].positionChange).toBe(-1);
|
||||
expect(result.standings[1].lastRacePoints).toBe(15);
|
||||
expect(result.standings[1].droppedRaceIds).toEqual([]);
|
||||
expect(result.standings[1].wins).toBe(3);
|
||||
expect(result.standings[1].podiums).toBe(8);
|
||||
expect(result.drivers).toHaveLength(2);
|
||||
expect(result.drivers[0].id).toBe('driver-1');
|
||||
expect(result.drivers[0].name).toBe('Alice');
|
||||
expect(result.drivers[0].iracingId).toBe('11111');
|
||||
expect(result.drivers[0].country).toBe('UK');
|
||||
expect(result.drivers[0].avatarUrl).toBeNull();
|
||||
expect(result.drivers[1].id).toBe('driver-2');
|
||||
expect(result.drivers[1].name).toBe('Bob');
|
||||
expect(result.drivers[1].iracingId).toBe('22222');
|
||||
expect(result.drivers[1].country).toBe('Germany');
|
||||
expect(result.drivers[1].avatarUrl).toBeNull();
|
||||
expect(result.memberships).toHaveLength(2);
|
||||
expect(result.memberships[0].driverId).toBe('driver-1');
|
||||
expect(result.memberships[0].leagueId).toBe('league-1');
|
||||
expect(result.memberships[0].role).toBe('member');
|
||||
expect(result.memberships[0].joinedAt).toBe('2023-06-01T00:00:00.000Z');
|
||||
expect(result.memberships[0].status).toBe('active');
|
||||
expect(result.memberships[1].driverId).toBe('driver-2');
|
||||
expect(result.memberships[1].leagueId).toBe('league-1');
|
||||
expect(result.memberships[1].role).toBe('member');
|
||||
expect(result.memberships[1].joinedAt).toBe('2023-07-01T00:00:00.000Z');
|
||||
expect(result.memberships[1].status).toBe('active');
|
||||
});
|
||||
|
||||
it('should handle empty standings and memberships', () => {
|
||||
const standingsDto = {
|
||||
standings: [],
|
||||
};
|
||||
|
||||
const membershipsDto = {
|
||||
members: [],
|
||||
};
|
||||
|
||||
const result = LeagueStandingsViewDataBuilder.build(
|
||||
standingsDto,
|
||||
membershipsDto,
|
||||
'league-1',
|
||||
false
|
||||
);
|
||||
|
||||
expect(result.standings).toHaveLength(0);
|
||||
expect(result.drivers).toHaveLength(0);
|
||||
expect(result.memberships).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle team championship mode', () => {
|
||||
const standingsDto = {
|
||||
standings: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
},
|
||||
points: 1250,
|
||||
position: 1,
|
||||
wins: 5,
|
||||
podiums: 10,
|
||||
races: 15,
|
||||
positionChange: 2,
|
||||
lastRacePoints: 25,
|
||||
droppedRaceIds: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const membershipsDto = {
|
||||
members: [],
|
||||
};
|
||||
|
||||
const result = LeagueStandingsViewDataBuilder.build(
|
||||
standingsDto,
|
||||
membershipsDto,
|
||||
'league-1',
|
||||
true
|
||||
);
|
||||
|
||||
expect(result.isTeamChampionship).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const standingsDto = {
|
||||
standings: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
},
|
||||
points: 1250,
|
||||
position: 1,
|
||||
wins: 5,
|
||||
podiums: 10,
|
||||
races: 15,
|
||||
positionChange: 2,
|
||||
lastRacePoints: 25,
|
||||
droppedRaceIds: ['race-1'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const membershipsDto = {
|
||||
members: [],
|
||||
};
|
||||
|
||||
const result = LeagueStandingsViewDataBuilder.build(
|
||||
standingsDto,
|
||||
membershipsDto,
|
||||
'league-1',
|
||||
false
|
||||
);
|
||||
|
||||
expect(result.standings[0].driverId).toBe(standingsDto.standings[0].driverId);
|
||||
expect(result.standings[0].position).toBe(standingsDto.standings[0].position);
|
||||
expect(result.standings[0].totalPoints).toBe(standingsDto.standings[0].points);
|
||||
expect(result.standings[0].racesFinished).toBe(standingsDto.standings[0].races);
|
||||
expect(result.standings[0].racesStarted).toBe(standingsDto.standings[0].races);
|
||||
expect(result.standings[0].positionChange).toBe(standingsDto.standings[0].positionChange);
|
||||
expect(result.standings[0].lastRacePoints).toBe(standingsDto.standings[0].lastRacePoints);
|
||||
expect(result.standings[0].droppedRaceIds).toEqual(standingsDto.standings[0].droppedRaceIds);
|
||||
expect(result.standings[0].wins).toBe(standingsDto.standings[0].wins);
|
||||
expect(result.standings[0].podiums).toBe(standingsDto.standings[0].podiums);
|
||||
expect(result.drivers[0].id).toBe(standingsDto.standings[0].driver.id);
|
||||
expect(result.drivers[0].name).toBe(standingsDto.standings[0].driver.name);
|
||||
expect(result.drivers[0].iracingId).toBe(standingsDto.standings[0].driver.iracingId);
|
||||
expect(result.drivers[0].country).toBe(standingsDto.standings[0].driver.country);
|
||||
});
|
||||
|
||||
it('should not modify the input DTOs', () => {
|
||||
const standingsDto = {
|
||||
standings: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
},
|
||||
points: 1250,
|
||||
position: 1,
|
||||
wins: 5,
|
||||
podiums: 10,
|
||||
races: 15,
|
||||
positionChange: 2,
|
||||
lastRacePoints: 25,
|
||||
droppedRaceIds: ['race-1'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const membershipsDto = {
|
||||
members: [],
|
||||
};
|
||||
|
||||
const originalStandings = JSON.parse(JSON.stringify(standingsDto));
|
||||
const originalMemberships = JSON.parse(JSON.stringify(membershipsDto));
|
||||
|
||||
LeagueStandingsViewDataBuilder.build(
|
||||
standingsDto,
|
||||
membershipsDto,
|
||||
'league-1',
|
||||
false
|
||||
);
|
||||
|
||||
expect(standingsDto).toEqual(originalStandings);
|
||||
expect(membershipsDto).toEqual(originalMemberships);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle standings with missing optional fields', () => {
|
||||
const standingsDto = {
|
||||
standings: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
},
|
||||
points: 1250,
|
||||
position: 1,
|
||||
wins: 5,
|
||||
podiums: 10,
|
||||
races: 15,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const membershipsDto = {
|
||||
members: [],
|
||||
};
|
||||
|
||||
const result = LeagueStandingsViewDataBuilder.build(
|
||||
standingsDto,
|
||||
membershipsDto,
|
||||
'league-1',
|
||||
false
|
||||
);
|
||||
|
||||
expect(result.standings[0].positionChange).toBe(0);
|
||||
expect(result.standings[0].lastRacePoints).toBe(0);
|
||||
expect(result.standings[0].droppedRaceIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle standings with missing driver field', () => {
|
||||
const standingsDto = {
|
||||
standings: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: undefined as any,
|
||||
points: 1250,
|
||||
position: 1,
|
||||
wins: 5,
|
||||
podiums: 10,
|
||||
races: 15,
|
||||
positionChange: 2,
|
||||
lastRacePoints: 25,
|
||||
droppedRaceIds: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const membershipsDto = {
|
||||
members: [],
|
||||
};
|
||||
|
||||
const result = LeagueStandingsViewDataBuilder.build(
|
||||
standingsDto,
|
||||
membershipsDto,
|
||||
'league-1',
|
||||
false
|
||||
);
|
||||
|
||||
expect(result.drivers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle duplicate drivers in standings', () => {
|
||||
const standingsDto = {
|
||||
standings: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
},
|
||||
points: 1250,
|
||||
position: 1,
|
||||
wins: 5,
|
||||
podiums: 10,
|
||||
races: 15,
|
||||
positionChange: 2,
|
||||
lastRacePoints: 25,
|
||||
droppedRaceIds: [],
|
||||
},
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
},
|
||||
points: 1100,
|
||||
position: 2,
|
||||
wins: 3,
|
||||
podiums: 8,
|
||||
races: 15,
|
||||
positionChange: -1,
|
||||
lastRacePoints: 15,
|
||||
droppedRaceIds: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const membershipsDto = {
|
||||
members: [],
|
||||
};
|
||||
|
||||
const result = LeagueStandingsViewDataBuilder.build(
|
||||
standingsDto,
|
||||
membershipsDto,
|
||||
'league-1',
|
||||
false
|
||||
);
|
||||
|
||||
// Should only have one driver entry
|
||||
expect(result.drivers).toHaveLength(1);
|
||||
expect(result.drivers[0].id).toBe('driver-1');
|
||||
});
|
||||
|
||||
it('should handle members with different roles', () => {
|
||||
const standingsDto = {
|
||||
standings: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
},
|
||||
points: 1250,
|
||||
position: 1,
|
||||
wins: 5,
|
||||
podiums: 10,
|
||||
races: 15,
|
||||
positionChange: 2,
|
||||
lastRacePoints: 25,
|
||||
droppedRaceIds: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const membershipsDto = {
|
||||
members: [
|
||||
{
|
||||
driverId: 'driver-1',
|
||||
driver: {
|
||||
id: 'driver-1',
|
||||
name: 'Alice',
|
||||
iracingId: '11111',
|
||||
country: 'UK',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
role: 'admin',
|
||||
joinedAt: '2023-06-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueStandingsViewDataBuilder.build(
|
||||
standingsDto,
|
||||
membershipsDto,
|
||||
'league-1',
|
||||
false
|
||||
);
|
||||
|
||||
expect(result.memberships[0].role).toBe('admin');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,213 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LeagueWalletViewDataBuilder } from './LeagueWalletViewDataBuilder';
|
||||
import type { LeagueWalletApiDto } from '@/lib/types/tbd/LeagueWalletApiDto';
|
||||
|
||||
describe('LeagueWalletViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform LeagueWalletApiDto to LeagueWalletViewData correctly', () => {
|
||||
const leagueWalletApiDto: LeagueWalletApiDto = {
|
||||
leagueId: 'league-123',
|
||||
balance: 5000,
|
||||
currency: 'USD',
|
||||
transactions: [
|
||||
{
|
||||
id: 'txn-1',
|
||||
amount: 1000,
|
||||
status: 'completed',
|
||||
createdAt: '2024-01-01T10:00:00Z',
|
||||
description: 'Sponsorship payment',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueWalletViewDataBuilder.build(leagueWalletApiDto);
|
||||
|
||||
expect(result).toEqual({
|
||||
leagueId: 'league-123',
|
||||
balance: 5000,
|
||||
formattedBalance: expect.any(String),
|
||||
totalRevenue: 5000,
|
||||
formattedTotalRevenue: expect.any(String),
|
||||
totalFees: 0,
|
||||
formattedTotalFees: expect.any(String),
|
||||
pendingPayouts: 0,
|
||||
formattedPendingPayouts: expect.any(String),
|
||||
currency: 'USD',
|
||||
transactions: [
|
||||
{
|
||||
id: 'txn-1',
|
||||
amount: 1000,
|
||||
status: 'completed',
|
||||
createdAt: '2024-01-01T10:00:00Z',
|
||||
description: 'Sponsorship payment',
|
||||
formattedAmount: expect.any(String),
|
||||
amountColor: 'green',
|
||||
formattedDate: expect.any(String),
|
||||
statusColor: 'green',
|
||||
typeColor: 'blue',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty transactions', () => {
|
||||
const leagueWalletApiDto: LeagueWalletApiDto = {
|
||||
leagueId: 'league-456',
|
||||
balance: 0,
|
||||
currency: 'USD',
|
||||
transactions: [],
|
||||
};
|
||||
|
||||
const result = LeagueWalletViewDataBuilder.build(leagueWalletApiDto);
|
||||
|
||||
expect(result.transactions).toHaveLength(0);
|
||||
expect(result.balance).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle multiple transactions', () => {
|
||||
const leagueWalletApiDto: LeagueWalletApiDto = {
|
||||
leagueId: 'league-789',
|
||||
balance: 10000,
|
||||
currency: 'USD',
|
||||
transactions: [
|
||||
{
|
||||
id: 'txn-1',
|
||||
amount: 5000,
|
||||
status: 'completed',
|
||||
createdAt: '2024-01-01T10:00:00Z',
|
||||
description: 'Sponsorship payment',
|
||||
},
|
||||
{
|
||||
id: 'txn-2',
|
||||
amount: -1000,
|
||||
status: 'completed',
|
||||
createdAt: '2024-01-02T10:00:00Z',
|
||||
description: 'Payout',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueWalletViewDataBuilder.build(leagueWalletApiDto);
|
||||
|
||||
expect(result.transactions).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const leagueWalletApiDto: LeagueWalletApiDto = {
|
||||
leagueId: 'league-101',
|
||||
balance: 7500,
|
||||
currency: 'EUR',
|
||||
transactions: [
|
||||
{
|
||||
id: 'txn-1',
|
||||
amount: 2500,
|
||||
status: 'completed',
|
||||
createdAt: '2024-01-01T10:00:00Z',
|
||||
description: 'Test transaction',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueWalletViewDataBuilder.build(leagueWalletApiDto);
|
||||
|
||||
expect(result.leagueId).toBe(leagueWalletApiDto.leagueId);
|
||||
expect(result.balance).toBe(leagueWalletApiDto.balance);
|
||||
expect(result.currency).toBe(leagueWalletApiDto.currency);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const leagueWalletApiDto: LeagueWalletApiDto = {
|
||||
leagueId: 'league-102',
|
||||
balance: 5000,
|
||||
currency: 'USD',
|
||||
transactions: [],
|
||||
};
|
||||
|
||||
const originalDto = { ...leagueWalletApiDto };
|
||||
LeagueWalletViewDataBuilder.build(leagueWalletApiDto);
|
||||
|
||||
expect(leagueWalletApiDto).toEqual(originalDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle negative balance', () => {
|
||||
const leagueWalletApiDto: LeagueWalletApiDto = {
|
||||
leagueId: 'league-103',
|
||||
balance: -500,
|
||||
currency: 'USD',
|
||||
transactions: [
|
||||
{
|
||||
id: 'txn-1',
|
||||
amount: -500,
|
||||
status: 'completed',
|
||||
createdAt: '2024-01-01T10:00:00Z',
|
||||
description: 'Overdraft',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueWalletViewDataBuilder.build(leagueWalletApiDto);
|
||||
|
||||
expect(result.balance).toBe(-500);
|
||||
expect(result.transactions[0].amountColor).toBe('red');
|
||||
});
|
||||
|
||||
it('should handle pending transactions', () => {
|
||||
const leagueWalletApiDto: LeagueWalletApiDto = {
|
||||
leagueId: 'league-104',
|
||||
balance: 1000,
|
||||
currency: 'USD',
|
||||
transactions: [
|
||||
{
|
||||
id: 'txn-1',
|
||||
amount: 500,
|
||||
status: 'pending',
|
||||
createdAt: '2024-01-01T10:00:00Z',
|
||||
description: 'Pending payment',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueWalletViewDataBuilder.build(leagueWalletApiDto);
|
||||
|
||||
expect(result.transactions[0].statusColor).toBe('yellow');
|
||||
});
|
||||
|
||||
it('should handle failed transactions', () => {
|
||||
const leagueWalletApiDto: LeagueWalletApiDto = {
|
||||
leagueId: 'league-105',
|
||||
balance: 1000,
|
||||
currency: 'USD',
|
||||
transactions: [
|
||||
{
|
||||
id: 'txn-1',
|
||||
amount: 500,
|
||||
status: 'failed',
|
||||
createdAt: '2024-01-01T10:00:00Z',
|
||||
description: 'Failed payment',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = LeagueWalletViewDataBuilder.build(leagueWalletApiDto);
|
||||
|
||||
expect(result.transactions[0].statusColor).toBe('red');
|
||||
});
|
||||
|
||||
it('should handle different currencies', () => {
|
||||
const leagueWalletApiDto: LeagueWalletApiDto = {
|
||||
leagueId: 'league-106',
|
||||
balance: 1000,
|
||||
currency: 'EUR',
|
||||
transactions: [],
|
||||
};
|
||||
|
||||
const result = LeagueWalletViewDataBuilder.build(leagueWalletApiDto);
|
||||
|
||||
expect(result.currency).toBe('EUR');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,351 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LeaguesViewDataBuilder } from './LeaguesViewDataBuilder';
|
||||
import type { AllLeaguesWithCapacityAndScoringDTO } from '@/lib/types/generated/AllLeaguesWithCapacityAndScoringDTO';
|
||||
|
||||
describe('LeaguesViewDataBuilder', () => {
|
||||
describe('happy paths', () => {
|
||||
it('should transform AllLeaguesWithCapacityAndScoringDTO to LeaguesViewData correctly', () => {
|
||||
const leaguesDTO: AllLeaguesWithCapacityAndScoringDTO = {
|
||||
leagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Pro League',
|
||||
description: 'A competitive league for experienced drivers',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
qualifyingFormat: 'Solo • 32 max',
|
||||
},
|
||||
usedSlots: 25,
|
||||
category: 'competitive',
|
||||
scoring: {
|
||||
gameId: 'game-1',
|
||||
gameName: 'iRacing',
|
||||
primaryChampionshipType: 'Single Championship',
|
||||
scoringPresetId: 'preset-1',
|
||||
scoringPresetName: 'Standard',
|
||||
dropPolicySummary: 'Drop 2 worst races',
|
||||
scoringPatternSummary: 'Points based on finish position',
|
||||
},
|
||||
timingSummary: 'Weekly races on Sundays',
|
||||
logoUrl: 'https://example.com/logo.png',
|
||||
pendingJoinRequestsCount: 3,
|
||||
pendingProtestsCount: 1,
|
||||
walletBalance: 1000,
|
||||
},
|
||||
{
|
||||
id: 'league-2',
|
||||
name: 'Rookie League',
|
||||
description: null,
|
||||
ownerId: 'owner-2',
|
||||
createdAt: '2024-02-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 16,
|
||||
qualifyingFormat: 'Solo • 16 max',
|
||||
},
|
||||
usedSlots: 10,
|
||||
category: 'rookie',
|
||||
scoring: {
|
||||
gameId: 'game-1',
|
||||
gameName: 'iRacing',
|
||||
primaryChampionshipType: 'Single Championship',
|
||||
scoringPresetId: 'preset-2',
|
||||
scoringPresetName: 'Rookie',
|
||||
dropPolicySummary: 'No drops',
|
||||
scoringPatternSummary: 'Points based on finish position',
|
||||
},
|
||||
timingSummary: 'Bi-weekly races',
|
||||
logoUrl: null,
|
||||
pendingJoinRequestsCount: 0,
|
||||
pendingProtestsCount: 0,
|
||||
walletBalance: 0,
|
||||
},
|
||||
],
|
||||
totalCount: 2,
|
||||
};
|
||||
|
||||
const result = LeaguesViewDataBuilder.build(leaguesDTO);
|
||||
|
||||
expect(result.leagues).toHaveLength(2);
|
||||
expect(result.leagues[0]).toEqual({
|
||||
id: 'league-1',
|
||||
name: 'Pro League',
|
||||
description: 'A competitive league for experienced drivers',
|
||||
logoUrl: 'https://example.com/logo.png',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
maxDrivers: 32,
|
||||
usedDriverSlots: 25,
|
||||
activeDriversCount: undefined,
|
||||
nextRaceAt: undefined,
|
||||
maxTeams: undefined,
|
||||
usedTeamSlots: undefined,
|
||||
structureSummary: 'Solo • 32 max',
|
||||
timingSummary: 'Weekly races on Sundays',
|
||||
category: 'competitive',
|
||||
scoring: {
|
||||
gameId: 'game-1',
|
||||
gameName: 'iRacing',
|
||||
primaryChampionshipType: 'Single Championship',
|
||||
scoringPresetId: 'preset-1',
|
||||
scoringPresetName: 'Standard',
|
||||
dropPolicySummary: 'Drop 2 worst races',
|
||||
scoringPatternSummary: 'Points based on finish position',
|
||||
},
|
||||
});
|
||||
expect(result.leagues[1]).toEqual({
|
||||
id: 'league-2',
|
||||
name: 'Rookie League',
|
||||
description: null,
|
||||
logoUrl: null,
|
||||
ownerId: 'owner-2',
|
||||
createdAt: '2024-02-01T00:00:00.000Z',
|
||||
maxDrivers: 16,
|
||||
usedDriverSlots: 10,
|
||||
activeDriversCount: undefined,
|
||||
nextRaceAt: undefined,
|
||||
maxTeams: undefined,
|
||||
usedTeamSlots: undefined,
|
||||
structureSummary: 'Solo • 16 max',
|
||||
timingSummary: 'Bi-weekly races',
|
||||
category: 'rookie',
|
||||
scoring: {
|
||||
gameId: 'game-1',
|
||||
gameName: 'iRacing',
|
||||
primaryChampionshipType: 'Single Championship',
|
||||
scoringPresetId: 'preset-2',
|
||||
scoringPresetName: 'Rookie',
|
||||
dropPolicySummary: 'No drops',
|
||||
scoringPatternSummary: 'Points based on finish position',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty leagues list', () => {
|
||||
const leaguesDTO: AllLeaguesWithCapacityAndScoringDTO = {
|
||||
leagues: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
|
||||
const result = LeaguesViewDataBuilder.build(leaguesDTO);
|
||||
|
||||
expect(result.leagues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle leagues with missing optional fields', () => {
|
||||
const leaguesDTO: AllLeaguesWithCapacityAndScoringDTO = {
|
||||
leagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Minimal League',
|
||||
description: '',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 20,
|
||||
},
|
||||
usedSlots: 5,
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
};
|
||||
|
||||
const result = LeaguesViewDataBuilder.build(leaguesDTO);
|
||||
|
||||
expect(result.leagues[0].description).toBe(null);
|
||||
expect(result.leagues[0].logoUrl).toBe(null);
|
||||
expect(result.leagues[0].category).toBe(null);
|
||||
expect(result.leagues[0].scoring).toBeUndefined();
|
||||
expect(result.leagues[0].timingSummary).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('data transformation', () => {
|
||||
it('should preserve all DTO fields in the output', () => {
|
||||
const leaguesDTO: AllLeaguesWithCapacityAndScoringDTO = {
|
||||
leagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test description',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
qualifyingFormat: 'Solo • 32 max',
|
||||
},
|
||||
usedSlots: 20,
|
||||
category: 'test',
|
||||
scoring: {
|
||||
gameId: 'game-1',
|
||||
gameName: 'Test Game',
|
||||
primaryChampionshipType: 'Test Type',
|
||||
scoringPresetId: 'preset-1',
|
||||
scoringPresetName: 'Test Preset',
|
||||
dropPolicySummary: 'Test drop policy',
|
||||
scoringPatternSummary: 'Test pattern',
|
||||
},
|
||||
timingSummary: 'Test timing',
|
||||
logoUrl: 'https://example.com/test.png',
|
||||
pendingJoinRequestsCount: 5,
|
||||
pendingProtestsCount: 2,
|
||||
walletBalance: 500,
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
};
|
||||
|
||||
const result = LeaguesViewDataBuilder.build(leaguesDTO);
|
||||
|
||||
expect(result.leagues[0].id).toBe(leaguesDTO.leagues[0].id);
|
||||
expect(result.leagues[0].name).toBe(leaguesDTO.leagues[0].name);
|
||||
expect(result.leagues[0].description).toBe(leaguesDTO.leagues[0].description);
|
||||
expect(result.leagues[0].logoUrl).toBe(leaguesDTO.leagues[0].logoUrl);
|
||||
expect(result.leagues[0].ownerId).toBe(leaguesDTO.leagues[0].ownerId);
|
||||
expect(result.leagues[0].createdAt).toBe(leaguesDTO.leagues[0].createdAt);
|
||||
expect(result.leagues[0].maxDrivers).toBe(leaguesDTO.leagues[0].settings.maxDrivers);
|
||||
expect(result.leagues[0].usedDriverSlots).toBe(leaguesDTO.leagues[0].usedSlots);
|
||||
expect(result.leagues[0].structureSummary).toBe(leaguesDTO.leagues[0].settings.qualifyingFormat);
|
||||
expect(result.leagues[0].timingSummary).toBe(leaguesDTO.leagues[0].timingSummary);
|
||||
expect(result.leagues[0].category).toBe(leaguesDTO.leagues[0].category);
|
||||
expect(result.leagues[0].scoring).toEqual(leaguesDTO.leagues[0].scoring);
|
||||
});
|
||||
|
||||
it('should not modify the input DTO', () => {
|
||||
const leaguesDTO: AllLeaguesWithCapacityAndScoringDTO = {
|
||||
leagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: 'Test description',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
qualifyingFormat: 'Solo • 32 max',
|
||||
},
|
||||
usedSlots: 20,
|
||||
category: 'test',
|
||||
scoring: {
|
||||
gameId: 'game-1',
|
||||
gameName: 'Test Game',
|
||||
primaryChampionshipType: 'Test Type',
|
||||
scoringPresetId: 'preset-1',
|
||||
scoringPresetName: 'Test Preset',
|
||||
dropPolicySummary: 'Test drop policy',
|
||||
scoringPatternSummary: 'Test pattern',
|
||||
},
|
||||
timingSummary: 'Test timing',
|
||||
logoUrl: 'https://example.com/test.png',
|
||||
pendingJoinRequestsCount: 5,
|
||||
pendingProtestsCount: 2,
|
||||
walletBalance: 500,
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
};
|
||||
|
||||
const originalDTO = JSON.parse(JSON.stringify(leaguesDTO));
|
||||
LeaguesViewDataBuilder.build(leaguesDTO);
|
||||
|
||||
expect(leaguesDTO).toEqual(originalDTO);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle leagues with very long descriptions', () => {
|
||||
const longDescription = 'A'.repeat(1000);
|
||||
const leaguesDTO: AllLeaguesWithCapacityAndScoringDTO = {
|
||||
leagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Test League',
|
||||
description: longDescription,
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 20,
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
};
|
||||
|
||||
const result = LeaguesViewDataBuilder.build(leaguesDTO);
|
||||
|
||||
expect(result.leagues[0].description).toBe(longDescription);
|
||||
});
|
||||
|
||||
it('should handle leagues with special characters in name', () => {
|
||||
const leaguesDTO: AllLeaguesWithCapacityAndScoringDTO = {
|
||||
leagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'League & Co. (2024)',
|
||||
description: 'Test league',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 20,
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
};
|
||||
|
||||
const result = LeaguesViewDataBuilder.build(leaguesDTO);
|
||||
|
||||
expect(result.leagues[0].name).toBe('League & Co. (2024)');
|
||||
});
|
||||
|
||||
it('should handle leagues with zero used slots', () => {
|
||||
const leaguesDTO: AllLeaguesWithCapacityAndScoringDTO = {
|
||||
leagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Empty League',
|
||||
description: 'No members yet',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 0,
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
};
|
||||
|
||||
const result = LeaguesViewDataBuilder.build(leaguesDTO);
|
||||
|
||||
expect(result.leagues[0].usedDriverSlots).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle leagues with maximum capacity', () => {
|
||||
const leaguesDTO: AllLeaguesWithCapacityAndScoringDTO = {
|
||||
leagues: [
|
||||
{
|
||||
id: 'league-1',
|
||||
name: 'Full League',
|
||||
description: 'At maximum capacity',
|
||||
ownerId: 'owner-1',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
settings: {
|
||||
maxDrivers: 32,
|
||||
},
|
||||
usedSlots: 32,
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
};
|
||||
|
||||
const result = LeaguesViewDataBuilder.build(leaguesDTO);
|
||||
|
||||
expect(result.leagues[0].usedDriverSlots).toBe(32);
|
||||
expect(result.leagues[0].maxDrivers).toBe(32);
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user