Compare commits
17 Commits
69319ce1d4
...
tests/core
| Author | SHA1 | Date | |
|---|---|---|---|
| 648dce2193 | |||
| 280d6fc199 | |||
| 093eece3d7 | |||
| 35cc7cf12b | |||
| 0a37454171 | |||
| a165ac9b65 | |||
| f61ebda9b7 | |||
| fb1221701d | |||
| 40bc15ff61 | |||
| 152926e4c7 | |||
| b04604ae60 | |||
| b0ad702165 | |||
| c117331e65 | |||
| 3c9b846f1d | |||
| 959b99cb58 | |||
| 5ed958281d | |||
| ea58909070 |
11
README.md
11
README.md
@@ -14,6 +14,17 @@ GridPilot streamlines the organization and administration of iRacing racing leag
|
|||||||
- **Docker** and **Docker Compose**
|
- **Docker** and **Docker Compose**
|
||||||
- Git for version control
|
- Git for version control
|
||||||
|
|
||||||
|
### Windows Compatibility
|
||||||
|
|
||||||
|
This project is fully compatible with Windows 11, macOS, and Linux. All development scripts have been updated to work across all platforms:
|
||||||
|
|
||||||
|
- ✅ Cross-platform npm scripts
|
||||||
|
- ✅ Windows-compatible Docker commands
|
||||||
|
- ✅ Universal test commands
|
||||||
|
- ✅ Cross-platform cleanup scripts
|
||||||
|
|
||||||
|
For detailed information, see [Windows Compatibility Guide](docs/WINDOWS_COMPATIBILITY.md).
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
DashboardRepository,
|
||||||
|
DriverData,
|
||||||
|
RaceData,
|
||||||
|
LeagueStandingData,
|
||||||
|
ActivityData,
|
||||||
|
FriendData,
|
||||||
|
} from '../../../../core/dashboard/application/ports/DashboardRepository';
|
||||||
|
|
||||||
|
export class InMemoryActivityRepository implements DashboardRepository {
|
||||||
|
private drivers: Map<string, DriverData> = new Map();
|
||||||
|
private upcomingRaces: Map<string, RaceData[]> = new Map();
|
||||||
|
private leagueStandings: Map<string, LeagueStandingData[]> = new Map();
|
||||||
|
private recentActivity: Map<string, ActivityData[]> = new Map();
|
||||||
|
private friends: Map<string, FriendData[]> = new Map();
|
||||||
|
|
||||||
|
async findDriverById(driverId: string): Promise<DriverData | null> {
|
||||||
|
return this.drivers.get(driverId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUpcomingRaces(driverId: string): Promise<RaceData[]> {
|
||||||
|
return this.upcomingRaces.get(driverId) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLeagueStandings(driverId: string): Promise<LeagueStandingData[]> {
|
||||||
|
return this.leagueStandings.get(driverId) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecentActivity(driverId: string): Promise<ActivityData[]> {
|
||||||
|
return this.recentActivity.get(driverId) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFriends(driverId: string): Promise<FriendData[]> {
|
||||||
|
return this.friends.get(driverId) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
addDriver(driver: DriverData): void {
|
||||||
|
this.drivers.set(driver.id, driver);
|
||||||
|
}
|
||||||
|
|
||||||
|
addUpcomingRaces(driverId: string, races: RaceData[]): void {
|
||||||
|
this.upcomingRaces.set(driverId, races);
|
||||||
|
}
|
||||||
|
|
||||||
|
addLeagueStandings(driverId: string, standings: LeagueStandingData[]): void {
|
||||||
|
this.leagueStandings.set(driverId, standings);
|
||||||
|
}
|
||||||
|
|
||||||
|
addRecentActivity(driverId: string, activities: ActivityData[]): void {
|
||||||
|
this.recentActivity.set(driverId, activities);
|
||||||
|
}
|
||||||
|
|
||||||
|
addFriends(driverId: string, friends: FriendData[]): void {
|
||||||
|
this.friends.set(driverId, friends);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.drivers.clear();
|
||||||
|
this.upcomingRaces.clear();
|
||||||
|
this.leagueStandings.clear();
|
||||||
|
this.recentActivity.clear();
|
||||||
|
this.friends.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
DashboardRepository,
|
||||||
|
DriverData,
|
||||||
|
RaceData,
|
||||||
|
LeagueStandingData,
|
||||||
|
ActivityData,
|
||||||
|
FriendData,
|
||||||
|
} from '../../../../core/dashboard/application/ports/DashboardRepository';
|
||||||
|
|
||||||
|
export class InMemoryDriverRepository implements DashboardRepository {
|
||||||
|
private drivers: Map<string, DriverData> = new Map();
|
||||||
|
private upcomingRaces: Map<string, RaceData[]> = new Map();
|
||||||
|
private leagueStandings: Map<string, LeagueStandingData[]> = new Map();
|
||||||
|
private recentActivity: Map<string, ActivityData[]> = new Map();
|
||||||
|
private friends: Map<string, FriendData[]> = new Map();
|
||||||
|
|
||||||
|
async findDriverById(driverId: string): Promise<DriverData | null> {
|
||||||
|
return this.drivers.get(driverId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUpcomingRaces(driverId: string): Promise<RaceData[]> {
|
||||||
|
return this.upcomingRaces.get(driverId) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLeagueStandings(driverId: string): Promise<LeagueStandingData[]> {
|
||||||
|
return this.leagueStandings.get(driverId) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecentActivity(driverId: string): Promise<ActivityData[]> {
|
||||||
|
return this.recentActivity.get(driverId) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFriends(driverId: string): Promise<FriendData[]> {
|
||||||
|
return this.friends.get(driverId) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
addDriver(driver: DriverData): void {
|
||||||
|
this.drivers.set(driver.id, driver);
|
||||||
|
}
|
||||||
|
|
||||||
|
addUpcomingRaces(driverId: string, races: RaceData[]): void {
|
||||||
|
this.upcomingRaces.set(driverId, races);
|
||||||
|
}
|
||||||
|
|
||||||
|
addLeagueStandings(driverId: string, standings: LeagueStandingData[]): void {
|
||||||
|
this.leagueStandings.set(driverId, standings);
|
||||||
|
}
|
||||||
|
|
||||||
|
addRecentActivity(driverId: string, activities: ActivityData[]): void {
|
||||||
|
this.recentActivity.set(driverId, activities);
|
||||||
|
}
|
||||||
|
|
||||||
|
addFriends(driverId: string, friends: FriendData[]): void {
|
||||||
|
this.friends.set(driverId, friends);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.drivers.clear();
|
||||||
|
this.upcomingRaces.clear();
|
||||||
|
this.leagueStandings.clear();
|
||||||
|
this.recentActivity.clear();
|
||||||
|
this.friends.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
39
adapters/events/InMemoryEventPublisher.ts
Normal file
39
adapters/events/InMemoryEventPublisher.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import {
|
||||||
|
DashboardEventPublisher,
|
||||||
|
DashboardAccessedEvent,
|
||||||
|
DashboardErrorEvent,
|
||||||
|
} from '../../core/dashboard/application/ports/DashboardEventPublisher';
|
||||||
|
|
||||||
|
export class InMemoryEventPublisher implements DashboardEventPublisher {
|
||||||
|
private dashboardAccessedEvents: DashboardAccessedEvent[] = [];
|
||||||
|
private dashboardErrorEvents: DashboardErrorEvent[] = [];
|
||||||
|
private shouldFail: boolean = false;
|
||||||
|
|
||||||
|
async publishDashboardAccessed(event: DashboardAccessedEvent): Promise<void> {
|
||||||
|
if (this.shouldFail) throw new Error('Event publisher failed');
|
||||||
|
this.dashboardAccessedEvents.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishDashboardError(event: DashboardErrorEvent): Promise<void> {
|
||||||
|
if (this.shouldFail) throw new Error('Event publisher failed');
|
||||||
|
this.dashboardErrorEvents.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDashboardAccessedEventCount(): number {
|
||||||
|
return this.dashboardAccessedEvents.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDashboardErrorEventCount(): number {
|
||||||
|
return this.dashboardErrorEvents.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.dashboardAccessedEvents = [];
|
||||||
|
this.dashboardErrorEvents = [];
|
||||||
|
this.shouldFail = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShouldFail(shouldFail: boolean): void {
|
||||||
|
this.shouldFail = shouldFail;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
DashboardRepository,
|
||||||
|
DriverData,
|
||||||
|
RaceData,
|
||||||
|
LeagueStandingData,
|
||||||
|
ActivityData,
|
||||||
|
FriendData,
|
||||||
|
} 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();
|
||||||
|
private leagueStandings: Map<string, LeagueStandingData[]> = new Map();
|
||||||
|
private recentActivity: Map<string, ActivityData[]> = new Map();
|
||||||
|
private friends: Map<string, FriendData[]> = new Map();
|
||||||
|
|
||||||
|
async findDriverById(driverId: string): Promise<DriverData | null> {
|
||||||
|
return this.drivers.get(driverId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUpcomingRaces(driverId: string): Promise<RaceData[]> {
|
||||||
|
return this.upcomingRaces.get(driverId) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLeagueStandings(driverId: string): Promise<LeagueStandingData[]> {
|
||||||
|
return this.leagueStandings.get(driverId) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecentActivity(driverId: string): Promise<ActivityData[]> {
|
||||||
|
return this.recentActivity.get(driverId) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFriends(driverId: string): Promise<FriendData[]> {
|
||||||
|
return this.friends.get(driverId) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
addDriver(driver: DriverData): void {
|
||||||
|
this.drivers.set(driver.id, driver);
|
||||||
|
}
|
||||||
|
|
||||||
|
addUpcomingRaces(driverId: string, races: RaceData[]): void {
|
||||||
|
this.upcomingRaces.set(driverId, races);
|
||||||
|
}
|
||||||
|
|
||||||
|
addLeagueStandings(driverId: string, standings: LeagueStandingData[]): void {
|
||||||
|
this.leagueStandings.set(driverId, standings);
|
||||||
|
}
|
||||||
|
|
||||||
|
addRecentActivity(driverId: string, activities: ActivityData[]): void {
|
||||||
|
this.recentActivity.set(driverId, activities);
|
||||||
|
}
|
||||||
|
|
||||||
|
addFriends(driverId: string, friends: FriendData[]): void {
|
||||||
|
this.friends.set(driverId, friends);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.drivers.clear();
|
||||||
|
this.upcomingRaces.clear();
|
||||||
|
this.leagueStandings.clear();
|
||||||
|
this.recentActivity.clear();
|
||||||
|
this.friends.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
DashboardRepository,
|
||||||
|
DriverData,
|
||||||
|
RaceData,
|
||||||
|
LeagueStandingData,
|
||||||
|
ActivityData,
|
||||||
|
FriendData,
|
||||||
|
} from '../../../../core/dashboard/application/ports/DashboardRepository';
|
||||||
|
|
||||||
|
export class InMemoryRaceRepository implements DashboardRepository {
|
||||||
|
private drivers: Map<string, DriverData> = new Map();
|
||||||
|
private upcomingRaces: Map<string, RaceData[]> = new Map();
|
||||||
|
private leagueStandings: Map<string, LeagueStandingData[]> = new Map();
|
||||||
|
private recentActivity: Map<string, ActivityData[]> = new Map();
|
||||||
|
private friends: Map<string, FriendData[]> = new Map();
|
||||||
|
|
||||||
|
async findDriverById(driverId: string): Promise<DriverData | null> {
|
||||||
|
return this.drivers.get(driverId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUpcomingRaces(driverId: string): Promise<RaceData[]> {
|
||||||
|
return this.upcomingRaces.get(driverId) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLeagueStandings(driverId: string): Promise<LeagueStandingData[]> {
|
||||||
|
return this.leagueStandings.get(driverId) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRecentActivity(driverId: string): Promise<ActivityData[]> {
|
||||||
|
return this.recentActivity.get(driverId) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFriends(driverId: string): Promise<FriendData[]> {
|
||||||
|
return this.friends.get(driverId) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
addDriver(driver: DriverData): void {
|
||||||
|
this.drivers.set(driver.id, driver);
|
||||||
|
}
|
||||||
|
|
||||||
|
addUpcomingRaces(driverId: string, races: RaceData[]): void {
|
||||||
|
this.upcomingRaces.set(driverId, races);
|
||||||
|
}
|
||||||
|
|
||||||
|
addLeagueStandings(driverId: string, standings: LeagueStandingData[]): void {
|
||||||
|
this.leagueStandings.set(driverId, standings);
|
||||||
|
}
|
||||||
|
|
||||||
|
addRecentActivity(driverId: string, activities: ActivityData[]): void {
|
||||||
|
this.recentActivity.set(driverId, activities);
|
||||||
|
}
|
||||||
|
|
||||||
|
addFriends(driverId: string, friends: FriendData[]): void {
|
||||||
|
this.friends.set(driverId, friends);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.drivers.clear();
|
||||||
|
this.upcomingRaces.clear();
|
||||||
|
this.leagueStandings.clear();
|
||||||
|
this.recentActivity.clear();
|
||||||
|
this.friends.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"timestamp": "2026-01-18T00:40:18.010Z",
|
"timestamp": "2026-01-21T18:46:59.984Z",
|
||||||
"summary": {
|
"summary": {
|
||||||
"total": 0,
|
"total": 0,
|
||||||
"success": 0,
|
"success": 0,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# API Smoke Test Report
|
# API Smoke Test Report
|
||||||
|
|
||||||
**Generated:** 2026-01-18T00:40:18.011Z
|
**Generated:** 2026-01-21T18:46:59.986Z
|
||||||
**API Base URL:** http://localhost:3101
|
**API Base URL:** http://localhost:3101
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|||||||
30
apps/api/src/domain/admin/AdminModule.test.ts
Normal file
30
apps/api/src/domain/admin/AdminModule.test.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AdminController } from './AdminController';
|
||||||
|
import { AdminModule } from './AdminModule';
|
||||||
|
import { AdminService } from './AdminService';
|
||||||
|
|
||||||
|
describe('AdminModule', () => {
|
||||||
|
let module: TestingModule;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
module = await Test.createTestingModule({
|
||||||
|
imports: [AdminModule],
|
||||||
|
}).compile();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should compile the module', () => {
|
||||||
|
expect(module).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide AdminController', () => {
|
||||||
|
const controller = module.get<AdminController>(AdminController);
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
expect(controller).toBeInstanceOf(AdminController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide AdminService', () => {
|
||||||
|
const service = module.get<AdminService>(AdminService);
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
expect(service).toBeInstanceOf(AdminService);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
apps/api/src/domain/admin/RequireSystemAdmin.test.ts
Normal file
40
apps/api/src/domain/admin/RequireSystemAdmin.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { RequireSystemAdmin, REQUIRE_SYSTEM_ADMIN_METADATA_KEY } from './RequireSystemAdmin';
|
||||||
|
|
||||||
|
// Mock SetMetadata
|
||||||
|
vi.mock('@nestjs/common', () => ({
|
||||||
|
SetMetadata: vi.fn(() => () => {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('RequireSystemAdmin', () => {
|
||||||
|
it('should return a method decorator', () => {
|
||||||
|
const decorator = RequireSystemAdmin();
|
||||||
|
expect(typeof decorator).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call SetMetadata with correct key and value', () => {
|
||||||
|
RequireSystemAdmin();
|
||||||
|
|
||||||
|
expect(SetMetadata).toHaveBeenCalledWith(REQUIRE_SYSTEM_ADMIN_METADATA_KEY, {
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a decorator that can be used as both method and class decorator', () => {
|
||||||
|
const decorator = RequireSystemAdmin();
|
||||||
|
|
||||||
|
// Test as method decorator
|
||||||
|
const mockTarget = {};
|
||||||
|
const mockPropertyKey = 'testMethod';
|
||||||
|
const mockDescriptor = { value: () => {} };
|
||||||
|
|
||||||
|
const result = decorator(mockTarget, mockPropertyKey, mockDescriptor);
|
||||||
|
|
||||||
|
// The decorator should return the descriptor
|
||||||
|
expect(result).toBe(mockDescriptor);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct metadata key', () => {
|
||||||
|
expect(REQUIRE_SYSTEM_ADMIN_METADATA_KEY).toBe('gridpilot:requireSystemAdmin');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,680 @@
|
|||||||
|
import { AdminUser } from '@core/admin/domain/entities/AdminUser';
|
||||||
|
import { AuthorizationService } from '@core/admin/domain/services/AuthorizationService';
|
||||||
|
import { Result } from '@core/shared/domain/Result';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { GetDashboardStatsUseCase } from './GetDashboardStatsUseCase';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
const mockAdminUserRepo = {
|
||||||
|
findById: vi.fn(),
|
||||||
|
list: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('GetDashboardStatsUseCase', () => {
|
||||||
|
describe('TDD - Test First', () => {
|
||||||
|
let useCase: GetDashboardStatsUseCase;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useCase = new GetDashboardStatsUseCase(mockAdminUserRepo as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('execute', () => {
|
||||||
|
it('should return error when actor is not found', async () => {
|
||||||
|
// Arrange
|
||||||
|
mockAdminUserRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await useCase.execute({ actorId: 'actor-1' });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.isErr()).toBe(true);
|
||||||
|
const error = result.unwrapErr();
|
||||||
|
expect(error.code).toBe('AUTHORIZATION_ERROR');
|
||||||
|
expect(error.details.message).toBe('Actor not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error when actor is not authorized to view dashboard', async () => {
|
||||||
|
// Arrange
|
||||||
|
const actor = AdminUser.create({
|
||||||
|
id: 'actor-1',
|
||||||
|
email: 'actor@example.com',
|
||||||
|
displayName: 'Actor',
|
||||||
|
roles: ['user'],
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAdminUserRepo.findById.mockResolvedValue(actor);
|
||||||
|
mockAdminUserRepo.list.mockResolvedValue({ users: [] });
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await useCase.execute({ actorId: 'actor-1' });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.isErr()).toBe(true);
|
||||||
|
const error = result.unwrapErr();
|
||||||
|
expect(error.code).toBe('AUTHORIZATION_ERROR');
|
||||||
|
expect(error.details.message).toBe('User is not authorized to view dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty stats when no users exist', async () => {
|
||||||
|
// Arrange
|
||||||
|
const actor = AdminUser.create({
|
||||||
|
id: 'actor-1',
|
||||||
|
email: 'actor@example.com',
|
||||||
|
displayName: 'Actor',
|
||||||
|
roles: ['owner'],
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAdminUserRepo.findById.mockResolvedValue(actor);
|
||||||
|
mockAdminUserRepo.list.mockResolvedValue({ users: [] });
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await useCase.execute({ actorId: 'actor-1' });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
const stats = result.unwrap();
|
||||||
|
expect(stats.totalUsers).toBe(0);
|
||||||
|
expect(stats.activeUsers).toBe(0);
|
||||||
|
expect(stats.suspendedUsers).toBe(0);
|
||||||
|
expect(stats.deletedUsers).toBe(0);
|
||||||
|
expect(stats.systemAdmins).toBe(0);
|
||||||
|
expect(stats.recentLogins).toBe(0);
|
||||||
|
expect(stats.newUsersToday).toBe(0);
|
||||||
|
expect(stats.userGrowth).toEqual([]);
|
||||||
|
expect(stats.roleDistribution).toEqual([]);
|
||||||
|
expect(stats.statusDistribution).toEqual({
|
||||||
|
active: 0,
|
||||||
|
suspended: 0,
|
||||||
|
deleted: 0,
|
||||||
|
});
|
||||||
|
expect(stats.activityTimeline).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct stats when users exist', async () => {
|
||||||
|
// Arrange
|
||||||
|
const actor = AdminUser.create({
|
||||||
|
id: 'actor-1',
|
||||||
|
email: 'actor@example.com',
|
||||||
|
displayName: 'Actor',
|
||||||
|
roles: ['owner'],
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
const user1 = AdminUser.create({
|
||||||
|
id: 'user-1',
|
||||||
|
email: 'user1@example.com',
|
||||||
|
displayName: 'User 1',
|
||||||
|
roles: ['user'],
|
||||||
|
status: 'active',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const user2 = AdminUser.create({
|
||||||
|
id: 'user-2',
|
||||||
|
email: 'user2@example.com',
|
||||||
|
displayName: 'User 2',
|
||||||
|
roles: ['admin'],
|
||||||
|
status: 'suspended',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const user3 = AdminUser.create({
|
||||||
|
id: 'user-3',
|
||||||
|
email: 'user3@example.com',
|
||||||
|
displayName: 'User 3',
|
||||||
|
roles: ['owner'],
|
||||||
|
status: 'deleted',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAdminUserRepo.findById.mockResolvedValue(actor);
|
||||||
|
mockAdminUserRepo.list.mockResolvedValue({ users: [user1, user2, user3] });
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await useCase.execute({ actorId: 'actor-1' });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
const stats = result.unwrap();
|
||||||
|
expect(stats.totalUsers).toBe(3);
|
||||||
|
expect(stats.activeUsers).toBe(1);
|
||||||
|
expect(stats.suspendedUsers).toBe(1);
|
||||||
|
expect(stats.deletedUsers).toBe(1);
|
||||||
|
expect(stats.systemAdmins).toBe(2); // actor + user3
|
||||||
|
expect(stats.recentLogins).toBe(0); // no recent logins
|
||||||
|
expect(stats.newUsersToday).toBe(3); // all created today
|
||||||
|
expect(stats.userGrowth).toHaveLength(7);
|
||||||
|
expect(stats.roleDistribution).toHaveLength(3);
|
||||||
|
expect(stats.statusDistribution).toEqual({
|
||||||
|
active: 1,
|
||||||
|
suspended: 1,
|
||||||
|
deleted: 1,
|
||||||
|
});
|
||||||
|
expect(stats.activityTimeline).toHaveLength(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should count recent logins correctly', async () => {
|
||||||
|
// Arrange
|
||||||
|
const actor = AdminUser.create({
|
||||||
|
id: 'actor-1',
|
||||||
|
email: 'actor@example.com',
|
||||||
|
displayName: 'Actor',
|
||||||
|
roles: ['owner'],
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
const recentLoginUser = AdminUser.create({
|
||||||
|
id: 'user-1',
|
||||||
|
email: 'user1@example.com',
|
||||||
|
displayName: 'User 1',
|
||||||
|
roles: ['user'],
|
||||||
|
status: 'active',
|
||||||
|
createdAt: new Date(Date.now() - 86400000 * 2), // 2 days ago
|
||||||
|
updatedAt: new Date(Date.now() - 86400000 * 2),
|
||||||
|
lastLoginAt: new Date(Date.now() - 3600000), // 1 hour ago
|
||||||
|
});
|
||||||
|
|
||||||
|
const oldLoginUser = AdminUser.create({
|
||||||
|
id: 'user-2',
|
||||||
|
email: 'user2@example.com',
|
||||||
|
displayName: 'User 2',
|
||||||
|
roles: ['user'],
|
||||||
|
status: 'active',
|
||||||
|
createdAt: new Date(Date.now() - 86400000 * 2),
|
||||||
|
updatedAt: new Date(Date.now() - 86400000 * 2),
|
||||||
|
lastLoginAt: new Date(Date.now() - 86400000 * 2), // 2 days ago
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAdminUserRepo.findById.mockResolvedValue(actor);
|
||||||
|
mockAdminUserRepo.list.mockResolvedValue({ users: [recentLoginUser, oldLoginUser] });
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await useCase.execute({ actorId: 'actor-1' });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
const stats = result.unwrap();
|
||||||
|
expect(stats.recentLogins).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should count new users today correctly', async () => {
|
||||||
|
// Arrange
|
||||||
|
const actor = AdminUser.create({
|
||||||
|
id: 'actor-1',
|
||||||
|
email: 'actor@example.com',
|
||||||
|
displayName: 'Actor',
|
||||||
|
roles: ['owner'],
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
const todayUser = AdminUser.create({
|
||||||
|
id: 'user-1',
|
||||||
|
email: 'user1@example.com',
|
||||||
|
displayName: 'User 1',
|
||||||
|
roles: ['user'],
|
||||||
|
status: 'active',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const yesterdayUser = AdminUser.create({
|
||||||
|
id: 'user-2',
|
||||||
|
email: 'user2@example.com',
|
||||||
|
displayName: 'User 2',
|
||||||
|
roles: ['user'],
|
||||||
|
status: 'active',
|
||||||
|
createdAt: new Date(Date.now() - 86400000),
|
||||||
|
updatedAt: new Date(Date.now() - 86400000),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAdminUserRepo.findById.mockResolvedValue(actor);
|
||||||
|
mockAdminUserRepo.list.mockResolvedValue({ users: [todayUser, yesterdayUser] });
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await useCase.execute({ actorId: 'actor-1' });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
const stats = result.unwrap();
|
||||||
|
expect(stats.newUsersToday).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate role distribution correctly', async () => {
|
||||||
|
// Arrange
|
||||||
|
const actor = AdminUser.create({
|
||||||
|
id: 'actor-1',
|
||||||
|
email: 'actor@example.com',
|
||||||
|
displayName: 'Actor',
|
||||||
|
roles: ['owner'],
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
const user1 = AdminUser.create({
|
||||||
|
id: 'user-1',
|
||||||
|
email: 'user1@example.com',
|
||||||
|
displayName: 'User 1',
|
||||||
|
roles: ['user'],
|
||||||
|
status: 'active',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const user2 = AdminUser.create({
|
||||||
|
id: 'user-2',
|
||||||
|
email: 'user2@example.com',
|
||||||
|
displayName: 'User 2',
|
||||||
|
roles: ['admin'],
|
||||||
|
status: 'active',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const user3 = AdminUser.create({
|
||||||
|
id: 'user-3',
|
||||||
|
email: 'user3@example.com',
|
||||||
|
displayName: 'User 3',
|
||||||
|
roles: ['owner'],
|
||||||
|
status: 'active',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAdminUserRepo.findById.mockResolvedValue(actor);
|
||||||
|
mockAdminUserRepo.list.mockResolvedValue({ users: [user1, user2, user3] });
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await useCase.execute({ actorId: 'actor-1' });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
const stats = result.unwrap();
|
||||||
|
expect(stats.roleDistribution).toHaveLength(3);
|
||||||
|
expect(stats.roleDistribution).toContainEqual({
|
||||||
|
label: 'Owner',
|
||||||
|
value: 2,
|
||||||
|
color: 'text-purple-500',
|
||||||
|
});
|
||||||
|
expect(stats.roleDistribution).toContainEqual({
|
||||||
|
label: 'Admin',
|
||||||
|
value: 1,
|
||||||
|
color: 'text-blue-500',
|
||||||
|
});
|
||||||
|
expect(stats.roleDistribution).toContainEqual({
|
||||||
|
label: 'User',
|
||||||
|
value: 1,
|
||||||
|
color: 'text-gray-500',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle users with multiple roles', async () => {
|
||||||
|
// Arrange
|
||||||
|
const actor = AdminUser.create({
|
||||||
|
id: 'actor-1',
|
||||||
|
email: 'actor@example.com',
|
||||||
|
displayName: 'Actor',
|
||||||
|
roles: ['owner'],
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
const user1 = AdminUser.create({
|
||||||
|
id: 'user-1',
|
||||||
|
email: 'user1@example.com',
|
||||||
|
displayName: 'User 1',
|
||||||
|
roles: ['user', 'admin'],
|
||||||
|
status: 'active',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAdminUserRepo.findById.mockResolvedValue(actor);
|
||||||
|
mockAdminUserRepo.list.mockResolvedValue({ users: [user1] });
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await useCase.execute({ actorId: 'actor-1' });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
const stats = result.unwrap();
|
||||||
|
expect(stats.roleDistribution).toHaveLength(2);
|
||||||
|
expect(stats.roleDistribution).toContainEqual({
|
||||||
|
label: 'User',
|
||||||
|
value: 1,
|
||||||
|
color: 'text-gray-500',
|
||||||
|
});
|
||||||
|
expect(stats.roleDistribution).toContainEqual({
|
||||||
|
label: 'Admin',
|
||||||
|
value: 1,
|
||||||
|
color: 'text-blue-500',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate user growth for last 7 days', async () => {
|
||||||
|
// Arrange
|
||||||
|
const actor = AdminUser.create({
|
||||||
|
id: 'actor-1',
|
||||||
|
email: 'actor@example.com',
|
||||||
|
displayName: 'Actor',
|
||||||
|
roles: ['owner'],
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const yesterday = new Date(today);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
const twoDaysAgo = new Date(today);
|
||||||
|
twoDaysAgo.setDate(twoDaysAgo.getDate() - 2);
|
||||||
|
|
||||||
|
const user1 = AdminUser.create({
|
||||||
|
id: 'user-1',
|
||||||
|
email: 'user1@example.com',
|
||||||
|
displayName: 'User 1',
|
||||||
|
roles: ['user'],
|
||||||
|
status: 'active',
|
||||||
|
createdAt: today,
|
||||||
|
updatedAt: today,
|
||||||
|
});
|
||||||
|
|
||||||
|
const user2 = AdminUser.create({
|
||||||
|
id: 'user-2',
|
||||||
|
email: 'user2@example.com',
|
||||||
|
displayName: 'User 2',
|
||||||
|
roles: ['user'],
|
||||||
|
status: 'active',
|
||||||
|
createdAt: yesterday,
|
||||||
|
updatedAt: yesterday,
|
||||||
|
});
|
||||||
|
|
||||||
|
const user3 = AdminUser.create({
|
||||||
|
id: 'user-3',
|
||||||
|
email: 'user3@example.com',
|
||||||
|
displayName: 'User 3',
|
||||||
|
roles: ['user'],
|
||||||
|
status: 'active',
|
||||||
|
createdAt: twoDaysAgo,
|
||||||
|
updatedAt: twoDaysAgo,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAdminUserRepo.findById.mockResolvedValue(actor);
|
||||||
|
mockAdminUserRepo.list.mockResolvedValue({ users: [user1, user2, user3] });
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await useCase.execute({ actorId: 'actor-1' });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
const stats = result.unwrap();
|
||||||
|
expect(stats.userGrowth).toHaveLength(7);
|
||||||
|
|
||||||
|
// Check that today has 1 user
|
||||||
|
const todayEntry = stats.userGrowth[6];
|
||||||
|
expect(todayEntry.value).toBe(1);
|
||||||
|
|
||||||
|
// Check that yesterday has 1 user
|
||||||
|
const yesterdayEntry = stats.userGrowth[5];
|
||||||
|
expect(yesterdayEntry.value).toBe(1);
|
||||||
|
|
||||||
|
// Check that two days ago has 1 user
|
||||||
|
const twoDaysAgoEntry = stats.userGrowth[4];
|
||||||
|
expect(twoDaysAgoEntry.value).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate activity timeline for last 7 days', async () => {
|
||||||
|
// Arrange
|
||||||
|
const actor = AdminUser.create({
|
||||||
|
id: 'actor-1',
|
||||||
|
email: 'actor@example.com',
|
||||||
|
displayName: 'Actor',
|
||||||
|
roles: ['owner'],
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const yesterday = new Date(today);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
|
||||||
|
const newUser = AdminUser.create({
|
||||||
|
id: 'user-1',
|
||||||
|
email: 'user1@example.com',
|
||||||
|
displayName: 'User 1',
|
||||||
|
roles: ['user'],
|
||||||
|
status: 'active',
|
||||||
|
createdAt: today,
|
||||||
|
updatedAt: today,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recentLoginUser = AdminUser.create({
|
||||||
|
id: 'user-2',
|
||||||
|
email: 'user2@example.com',
|
||||||
|
displayName: 'User 2',
|
||||||
|
roles: ['user'],
|
||||||
|
status: 'active',
|
||||||
|
createdAt: yesterday,
|
||||||
|
updatedAt: yesterday,
|
||||||
|
lastLoginAt: new Date(Date.now() - 3600000), // 1 hour ago
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAdminUserRepo.findById.mockResolvedValue(actor);
|
||||||
|
mockAdminUserRepo.list.mockResolvedValue({ users: [newUser, recentLoginUser] });
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await useCase.execute({ actorId: 'actor-1' });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
const stats = result.unwrap();
|
||||||
|
expect(stats.activityTimeline).toHaveLength(7);
|
||||||
|
|
||||||
|
// Check today's entry
|
||||||
|
const todayEntry = stats.activityTimeline[6];
|
||||||
|
expect(todayEntry.newUsers).toBe(1);
|
||||||
|
expect(todayEntry.logins).toBe(1);
|
||||||
|
|
||||||
|
// Check yesterday's entry
|
||||||
|
const yesterdayEntry = stats.activityTimeline[5];
|
||||||
|
expect(yesterdayEntry.newUsers).toBe(0);
|
||||||
|
expect(yesterdayEntry.logins).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle repository errors gracefully', async () => {
|
||||||
|
// Arrange
|
||||||
|
const actor = AdminUser.create({
|
||||||
|
id: 'actor-1',
|
||||||
|
email: 'actor@example.com',
|
||||||
|
displayName: 'Actor',
|
||||||
|
roles: ['owner'],
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAdminUserRepo.findById.mockResolvedValue(actor);
|
||||||
|
mockAdminUserRepo.list.mockRejectedValue(new Error('Database connection failed'));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await useCase.execute({ actorId: 'actor-1' });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.isErr()).toBe(true);
|
||||||
|
const error = result.unwrapErr();
|
||||||
|
expect(error.code).toBe('REPOSITORY_ERROR');
|
||||||
|
expect(error.details.message).toBe('Database connection failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-Error exceptions', async () => {
|
||||||
|
// Arrange
|
||||||
|
const actor = AdminUser.create({
|
||||||
|
id: 'actor-1',
|
||||||
|
email: 'actor@example.com',
|
||||||
|
displayName: 'Actor',
|
||||||
|
roles: ['owner'],
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAdminUserRepo.findById.mockResolvedValue(actor);
|
||||||
|
mockAdminUserRepo.list.mockRejectedValue('String error');
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await useCase.execute({ actorId: 'actor-1' });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.isErr()).toBe(true);
|
||||||
|
const error = result.unwrapErr();
|
||||||
|
expect(error.code).toBe('REPOSITORY_ERROR');
|
||||||
|
expect(error.details.message).toBe('Failed to get dashboard stats');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with owner role', async () => {
|
||||||
|
// Arrange
|
||||||
|
const actor = AdminUser.create({
|
||||||
|
id: 'actor-1',
|
||||||
|
email: 'actor@example.com',
|
||||||
|
displayName: 'Actor',
|
||||||
|
roles: ['owner'],
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAdminUserRepo.findById.mockResolvedValue(actor);
|
||||||
|
mockAdminUserRepo.list.mockResolvedValue({ users: [] });
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await useCase.execute({ actorId: 'actor-1' });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with admin role', async () => {
|
||||||
|
// Arrange
|
||||||
|
const actor = AdminUser.create({
|
||||||
|
id: 'actor-1',
|
||||||
|
email: 'actor@example.com',
|
||||||
|
displayName: 'Actor',
|
||||||
|
roles: ['admin'],
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAdminUserRepo.findById.mockResolvedValue(actor);
|
||||||
|
mockAdminUserRepo.list.mockResolvedValue({ users: [] });
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await useCase.execute({ actorId: 'actor-1' });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject user role', async () => {
|
||||||
|
// Arrange
|
||||||
|
const actor = AdminUser.create({
|
||||||
|
id: 'actor-1',
|
||||||
|
email: 'actor@example.com',
|
||||||
|
displayName: 'Actor',
|
||||||
|
roles: ['user'],
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAdminUserRepo.findById.mockResolvedValue(actor);
|
||||||
|
mockAdminUserRepo.list.mockResolvedValue({ users: [] });
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await useCase.execute({ actorId: 'actor-1' });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.isErr()).toBe(true);
|
||||||
|
const error = result.unwrapErr();
|
||||||
|
expect(error.code).toBe('AUTHORIZATION_ERROR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle suspended actor', async () => {
|
||||||
|
// Arrange
|
||||||
|
const actor = AdminUser.create({
|
||||||
|
id: 'actor-1',
|
||||||
|
email: 'actor@example.com',
|
||||||
|
displayName: 'Actor',
|
||||||
|
roles: ['owner'],
|
||||||
|
status: 'suspended',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAdminUserRepo.findById.mockResolvedValue(actor);
|
||||||
|
mockAdminUserRepo.list.mockResolvedValue({ users: [] });
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await useCase.execute({ actorId: 'actor-1' });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.isErr()).toBe(true);
|
||||||
|
const error = result.unwrapErr();
|
||||||
|
expect(error.code).toBe('AUTHORIZATION_ERROR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle deleted actor', async () => {
|
||||||
|
// Arrange
|
||||||
|
const actor = AdminUser.create({
|
||||||
|
id: 'actor-1',
|
||||||
|
email: 'actor@example.com',
|
||||||
|
displayName: 'Actor',
|
||||||
|
roles: ['owner'],
|
||||||
|
status: 'deleted',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockAdminUserRepo.findById.mockResolvedValue(actor);
|
||||||
|
mockAdminUserRepo.list.mockResolvedValue({ users: [] });
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await useCase.execute({ actorId: 'actor-1' });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.isErr()).toBe(true);
|
||||||
|
const error = result.unwrapErr();
|
||||||
|
expect(error.code).toBe('AUTHORIZATION_ERROR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle large number of users efficiently', async () => {
|
||||||
|
// Arrange
|
||||||
|
const actor = AdminUser.create({
|
||||||
|
id: 'actor-1',
|
||||||
|
email: 'actor@example.com',
|
||||||
|
displayName: 'Actor',
|
||||||
|
roles: ['owner'],
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
const users = Array.from({ length: 1000 }, (_, i) =>
|
||||||
|
AdminUser.create({
|
||||||
|
id: `user-${i}`,
|
||||||
|
email: `user${i}@example.com`,
|
||||||
|
displayName: `User ${i}`,
|
||||||
|
roles: i % 3 === 0 ? ['owner'] : i % 3 === 1 ? ['admin'] : ['user'],
|
||||||
|
status: i % 4 === 0 ? 'suspended' : i % 4 === 1 ? 'deleted' : 'active',
|
||||||
|
createdAt: new Date(Date.now() - i * 3600000),
|
||||||
|
updatedAt: new Date(Date.now() - i * 3600000),
|
||||||
|
lastLoginAt: i % 10 === 0 ? new Date(Date.now() - i * 3600000) : undefined,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
mockAdminUserRepo.findById.mockResolvedValue(actor);
|
||||||
|
mockAdminUserRepo.list.mockResolvedValue({ users });
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const result = await useCase.execute({ actorId: 'actor-1' });
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
const stats = result.unwrap();
|
||||||
|
expect(stats.totalUsers).toBe(1000);
|
||||||
|
expect(stats.activeUsers).toBe(500);
|
||||||
|
expect(stats.suspendedUsers).toBe(250);
|
||||||
|
expect(stats.deletedUsers).toBe(250);
|
||||||
|
expect(stats.systemAdmins).toBe(334); // owner + admin
|
||||||
|
expect(stats.recentLogins).toBe(100); // 10% of users
|
||||||
|
expect(stats.userGrowth).toHaveLength(7);
|
||||||
|
expect(stats.roleDistribution).toHaveLength(3);
|
||||||
|
expect(stats.activityTimeline).toHaveLength(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
234
apps/api/src/domain/analytics/AnalyticsProviders.test.ts
Normal file
234
apps/api/src/domain/analytics/AnalyticsProviders.test.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { AnalyticsProviders } from './AnalyticsProviders';
|
||||||
|
import { AnalyticsService } from './AnalyticsService';
|
||||||
|
import { RecordPageViewPresenter } from './presenters/RecordPageViewPresenter';
|
||||||
|
import { RecordEngagementPresenter } from './presenters/RecordEngagementPresenter';
|
||||||
|
import { GetDashboardDataPresenter } from './presenters/GetDashboardDataPresenter';
|
||||||
|
import { GetAnalyticsMetricsPresenter } from './presenters/GetAnalyticsMetricsPresenter';
|
||||||
|
import { RecordPageViewUseCase } from '@core/analytics/application/use-cases/RecordPageViewUseCase';
|
||||||
|
import { RecordEngagementUseCase } from '@core/analytics/application/use-cases/RecordEngagementUseCase';
|
||||||
|
import { GetDashboardDataUseCase } from '@core/analytics/application/use-cases/GetDashboardDataUseCase';
|
||||||
|
import { GetAnalyticsMetricsUseCase } from '@core/analytics/application/use-cases/GetAnalyticsMetricsUseCase';
|
||||||
|
import { ANALYTICS_ENGAGEMENT_REPOSITORY_TOKEN, ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN } from '../../persistence/analytics/AnalyticsPersistenceTokens';
|
||||||
|
|
||||||
|
describe('AnalyticsProviders', () => {
|
||||||
|
describe('AnalyticsService', () => {
|
||||||
|
it('should be defined as a provider', () => {
|
||||||
|
const provider = AnalyticsProviders.find(p => p === AnalyticsService);
|
||||||
|
expect(provider).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RecordPageViewPresenter', () => {
|
||||||
|
it('should be defined as a provider', () => {
|
||||||
|
const provider = AnalyticsProviders.find(p => p === RecordPageViewPresenter);
|
||||||
|
expect(provider).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RecordEngagementPresenter', () => {
|
||||||
|
it('should be defined as a provider', () => {
|
||||||
|
const provider = AnalyticsProviders.find(p => p === RecordEngagementPresenter);
|
||||||
|
expect(provider).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GetDashboardDataPresenter', () => {
|
||||||
|
it('should be defined as a provider', () => {
|
||||||
|
const provider = AnalyticsProviders.find(p => p === GetDashboardDataPresenter);
|
||||||
|
expect(provider).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GetAnalyticsMetricsPresenter', () => {
|
||||||
|
it('should be defined as a provider', () => {
|
||||||
|
const provider = AnalyticsProviders.find(p => p === GetAnalyticsMetricsPresenter);
|
||||||
|
expect(provider).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RecordPageViewUseCase', () => {
|
||||||
|
it('should be defined as a provider with useFactory', () => {
|
||||||
|
const provider = AnalyticsProviders.find(
|
||||||
|
p => typeof p === 'object' && 'provide' in p && p.provide === RecordPageViewUseCase,
|
||||||
|
);
|
||||||
|
expect(provider).toBeDefined();
|
||||||
|
expect(provider).toHaveProperty('useFactory');
|
||||||
|
expect(provider).toHaveProperty('inject');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should inject correct dependencies', () => {
|
||||||
|
const provider = AnalyticsProviders.find(
|
||||||
|
p => typeof p === 'object' && 'provide' in p && p.provide === RecordPageViewUseCase,
|
||||||
|
) as { inject: string[] };
|
||||||
|
|
||||||
|
expect(provider.inject).toEqual([ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN, 'Logger']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RecordEngagementUseCase', () => {
|
||||||
|
it('should be defined as a provider with useFactory', () => {
|
||||||
|
const provider = AnalyticsProviders.find(
|
||||||
|
p => typeof p === 'object' && 'provide' in p && p.provide === RecordEngagementUseCase,
|
||||||
|
);
|
||||||
|
expect(provider).toBeDefined();
|
||||||
|
expect(provider).toHaveProperty('useFactory');
|
||||||
|
expect(provider).toHaveProperty('inject');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should inject correct dependencies', () => {
|
||||||
|
const provider = AnalyticsProviders.find(
|
||||||
|
p => typeof p === 'object' && 'provide' in p && p.provide === RecordEngagementUseCase,
|
||||||
|
) as { inject: string[] };
|
||||||
|
|
||||||
|
expect(provider.inject).toEqual([ANALYTICS_ENGAGEMENT_REPOSITORY_TOKEN, 'Logger']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GetDashboardDataUseCase', () => {
|
||||||
|
it('should be defined as a provider with useFactory', () => {
|
||||||
|
const provider = AnalyticsProviders.find(
|
||||||
|
p => typeof p === 'object' && 'provide' in p && p.provide === GetDashboardDataUseCase,
|
||||||
|
);
|
||||||
|
expect(provider).toBeDefined();
|
||||||
|
expect(provider).toHaveProperty('useFactory');
|
||||||
|
expect(provider).toHaveProperty('inject');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should inject correct dependencies', () => {
|
||||||
|
const provider = AnalyticsProviders.find(
|
||||||
|
p => typeof p === 'object' && 'provide' in p && p.provide === GetDashboardDataUseCase,
|
||||||
|
) as { inject: string[] };
|
||||||
|
|
||||||
|
expect(provider.inject).toEqual(['Logger']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GetAnalyticsMetricsUseCase', () => {
|
||||||
|
it('should be defined as a provider with useFactory', () => {
|
||||||
|
const provider = AnalyticsProviders.find(
|
||||||
|
p => typeof p === 'object' && 'provide' in p && p.provide === GetAnalyticsMetricsUseCase,
|
||||||
|
);
|
||||||
|
expect(provider).toBeDefined();
|
||||||
|
expect(provider).toHaveProperty('useFactory');
|
||||||
|
expect(provider).toHaveProperty('inject');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should inject correct dependencies', () => {
|
||||||
|
const provider = AnalyticsProviders.find(
|
||||||
|
p => typeof p === 'object' && 'provide' in p && p.provide === GetAnalyticsMetricsUseCase,
|
||||||
|
) as { inject: string[] };
|
||||||
|
|
||||||
|
expect(provider.inject).toEqual(['Logger', ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useFactory functions', () => {
|
||||||
|
it('should create RecordPageViewUseCase with correct dependencies', async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
...AnalyticsProviders,
|
||||||
|
{
|
||||||
|
provide: ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN,
|
||||||
|
useValue: {
|
||||||
|
save: vi.fn(),
|
||||||
|
findById: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: 'Logger',
|
||||||
|
useValue: {
|
||||||
|
info: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
const useCase = module.get<RecordPageViewUseCase>(RecordPageViewUseCase);
|
||||||
|
expect(useCase).toBeDefined();
|
||||||
|
expect(useCase).toBeInstanceOf(RecordPageViewUseCase);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create RecordEngagementUseCase with correct dependencies', async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
...AnalyticsProviders,
|
||||||
|
{
|
||||||
|
provide: ANALYTICS_ENGAGEMENT_REPOSITORY_TOKEN,
|
||||||
|
useValue: {
|
||||||
|
save: vi.fn(),
|
||||||
|
findById: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: 'Logger',
|
||||||
|
useValue: {
|
||||||
|
info: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
const useCase = module.get<RecordEngagementUseCase>(RecordEngagementUseCase);
|
||||||
|
expect(useCase).toBeDefined();
|
||||||
|
expect(useCase).toBeInstanceOf(RecordEngagementUseCase);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create GetDashboardDataUseCase with correct dependencies', async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
...AnalyticsProviders,
|
||||||
|
{
|
||||||
|
provide: 'Logger',
|
||||||
|
useValue: {
|
||||||
|
info: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
const useCase = module.get<GetDashboardDataUseCase>(GetDashboardDataUseCase);
|
||||||
|
expect(useCase).toBeDefined();
|
||||||
|
expect(useCase).toBeInstanceOf(GetDashboardDataUseCase);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create GetAnalyticsMetricsUseCase with correct dependencies', async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
...AnalyticsProviders,
|
||||||
|
{
|
||||||
|
provide: ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN,
|
||||||
|
useValue: {
|
||||||
|
save: vi.fn(),
|
||||||
|
findById: vi.fn(),
|
||||||
|
findAll: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: 'Logger',
|
||||||
|
useValue: {
|
||||||
|
info: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
const useCase = module.get<GetAnalyticsMetricsUseCase>(GetAnalyticsMetricsUseCase);
|
||||||
|
expect(useCase).toBeDefined();
|
||||||
|
expect(useCase).toBeInstanceOf(GetAnalyticsMetricsUseCase);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@ import { Provider } from '@nestjs/common';
|
|||||||
import {
|
import {
|
||||||
ANALYTICS_ENGAGEMENT_REPOSITORY_TOKEN,
|
ANALYTICS_ENGAGEMENT_REPOSITORY_TOKEN,
|
||||||
ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN,
|
ANALYTICS_PAGE_VIEW_REPOSITORY_TOKEN,
|
||||||
} from '../../persistence/analytics/AnalyticsPersistenceTokens';
|
} from '../../../../persistence/analytics/AnalyticsPersistenceTokens';
|
||||||
|
|
||||||
const LOGGER_TOKEN = 'Logger';
|
const LOGGER_TOKEN = 'Logger';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import { GetAnalyticsMetricsOutputDTO } from './GetAnalyticsMetricsOutputDTO';
|
||||||
|
|
||||||
|
describe('GetAnalyticsMetricsOutputDTO', () => {
|
||||||
|
describe('TDD - Test First', () => {
|
||||||
|
it('should create valid DTO with all fields', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetAnalyticsMetricsOutputDTO();
|
||||||
|
dto.pageViews = 1000;
|
||||||
|
dto.uniqueVisitors = 500;
|
||||||
|
dto.averageSessionDuration = 300;
|
||||||
|
dto.bounceRate = 0.4;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViews).toBe(1000);
|
||||||
|
expect(dto.uniqueVisitors).toBe(500);
|
||||||
|
expect(dto.averageSessionDuration).toBe(300);
|
||||||
|
expect(dto.bounceRate).toBe(0.4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero values', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetAnalyticsMetricsOutputDTO();
|
||||||
|
dto.pageViews = 0;
|
||||||
|
dto.uniqueVisitors = 0;
|
||||||
|
dto.averageSessionDuration = 0;
|
||||||
|
dto.bounceRate = 0;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViews).toBe(0);
|
||||||
|
expect(dto.uniqueVisitors).toBe(0);
|
||||||
|
expect(dto.averageSessionDuration).toBe(0);
|
||||||
|
expect(dto.bounceRate).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle large numbers', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetAnalyticsMetricsOutputDTO();
|
||||||
|
dto.pageViews = 1000000;
|
||||||
|
dto.uniqueVisitors = 500000;
|
||||||
|
dto.averageSessionDuration = 3600;
|
||||||
|
dto.bounceRate = 0.95;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViews).toBe(1000000);
|
||||||
|
expect(dto.uniqueVisitors).toBe(500000);
|
||||||
|
expect(dto.averageSessionDuration).toBe(3600);
|
||||||
|
expect(dto.bounceRate).toBe(0.95);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single digit values', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetAnalyticsMetricsOutputDTO();
|
||||||
|
dto.pageViews = 1;
|
||||||
|
dto.uniqueVisitors = 1;
|
||||||
|
dto.averageSessionDuration = 1;
|
||||||
|
dto.bounceRate = 0.1;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViews).toBe(1);
|
||||||
|
expect(dto.uniqueVisitors).toBe(1);
|
||||||
|
expect(dto.averageSessionDuration).toBe(1);
|
||||||
|
expect(dto.bounceRate).toBe(0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle unique visitors greater than page views', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetAnalyticsMetricsOutputDTO();
|
||||||
|
dto.pageViews = 100;
|
||||||
|
dto.uniqueVisitors = 150;
|
||||||
|
dto.averageSessionDuration = 300;
|
||||||
|
dto.bounceRate = 0.4;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViews).toBe(100);
|
||||||
|
expect(dto.uniqueVisitors).toBe(150);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero unique visitors', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetAnalyticsMetricsOutputDTO();
|
||||||
|
dto.pageViews = 100;
|
||||||
|
dto.uniqueVisitors = 0;
|
||||||
|
dto.averageSessionDuration = 300;
|
||||||
|
dto.bounceRate = 0.4;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.uniqueVisitors).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero page views', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetAnalyticsMetricsOutputDTO();
|
||||||
|
dto.pageViews = 0;
|
||||||
|
dto.uniqueVisitors = 0;
|
||||||
|
dto.averageSessionDuration = 300;
|
||||||
|
dto.bounceRate = 0.4;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViews).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero average session duration', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetAnalyticsMetricsOutputDTO();
|
||||||
|
dto.pageViews = 100;
|
||||||
|
dto.uniqueVisitors = 50;
|
||||||
|
dto.averageSessionDuration = 0;
|
||||||
|
dto.bounceRate = 0.4;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.averageSessionDuration).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero bounce rate', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetAnalyticsMetricsOutputDTO();
|
||||||
|
dto.pageViews = 100;
|
||||||
|
dto.uniqueVisitors = 50;
|
||||||
|
dto.averageSessionDuration = 300;
|
||||||
|
dto.bounceRate = 0;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.bounceRate).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle bounce rate of 1.0', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetAnalyticsMetricsOutputDTO();
|
||||||
|
dto.pageViews = 100;
|
||||||
|
dto.uniqueVisitors = 50;
|
||||||
|
dto.averageSessionDuration = 300;
|
||||||
|
dto.bounceRate = 1.0;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.bounceRate).toBe(1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very large numbers', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetAnalyticsMetricsOutputDTO();
|
||||||
|
dto.pageViews = 999999999;
|
||||||
|
dto.uniqueVisitors = 888888888;
|
||||||
|
dto.averageSessionDuration = 777777777;
|
||||||
|
dto.bounceRate = 0.999999;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViews).toBe(999999999);
|
||||||
|
expect(dto.uniqueVisitors).toBe(888888888);
|
||||||
|
expect(dto.averageSessionDuration).toBe(777777777);
|
||||||
|
expect(dto.bounceRate).toBe(0.999999);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle decimal numbers', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetAnalyticsMetricsOutputDTO();
|
||||||
|
dto.pageViews = 100.5;
|
||||||
|
dto.uniqueVisitors = 50.7;
|
||||||
|
dto.averageSessionDuration = 300.3;
|
||||||
|
dto.bounceRate = 0.45;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViews).toBe(100.5);
|
||||||
|
expect(dto.uniqueVisitors).toBe(50.7);
|
||||||
|
expect(dto.averageSessionDuration).toBe(300.3);
|
||||||
|
expect(dto.bounceRate).toBe(0.45);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle negative numbers', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetAnalyticsMetricsOutputDTO();
|
||||||
|
dto.pageViews = -100;
|
||||||
|
dto.uniqueVisitors = -50;
|
||||||
|
dto.averageSessionDuration = -300;
|
||||||
|
dto.bounceRate = -0.4;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViews).toBe(-100);
|
||||||
|
expect(dto.uniqueVisitors).toBe(-50);
|
||||||
|
expect(dto.averageSessionDuration).toBe(-300);
|
||||||
|
expect(dto.bounceRate).toBe(-0.4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle scientific notation', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetAnalyticsMetricsOutputDTO();
|
||||||
|
dto.pageViews = 1e6;
|
||||||
|
dto.uniqueVisitors = 5e5;
|
||||||
|
dto.averageSessionDuration = 3e2;
|
||||||
|
dto.bounceRate = 4e-1;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViews).toBe(1000000);
|
||||||
|
expect(dto.uniqueVisitors).toBe(500000);
|
||||||
|
expect(dto.averageSessionDuration).toBe(300);
|
||||||
|
expect(dto.bounceRate).toBe(0.4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle maximum safe integer', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetAnalyticsMetricsOutputDTO();
|
||||||
|
dto.pageViews = Number.MAX_SAFE_INTEGER;
|
||||||
|
dto.uniqueVisitors = Number.MAX_SAFE_INTEGER;
|
||||||
|
dto.averageSessionDuration = Number.MAX_SAFE_INTEGER;
|
||||||
|
dto.bounceRate = 0.99;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViews).toBe(Number.MAX_SAFE_INTEGER);
|
||||||
|
expect(dto.uniqueVisitors).toBe(Number.MAX_SAFE_INTEGER);
|
||||||
|
expect(dto.averageSessionDuration).toBe(Number.MAX_SAFE_INTEGER);
|
||||||
|
expect(dto.bounceRate).toBe(0.99);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle minimum safe integer', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetAnalyticsMetricsOutputDTO();
|
||||||
|
dto.pageViews = Number.MIN_SAFE_INTEGER;
|
||||||
|
dto.uniqueVisitors = Number.MIN_SAFE_INTEGER;
|
||||||
|
dto.averageSessionDuration = Number.MIN_SAFE_INTEGER;
|
||||||
|
dto.bounceRate = -0.99;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViews).toBe(Number.MIN_SAFE_INTEGER);
|
||||||
|
expect(dto.uniqueVisitors).toBe(Number.MIN_SAFE_INTEGER);
|
||||||
|
expect(dto.averageSessionDuration).toBe(Number.MIN_SAFE_INTEGER);
|
||||||
|
expect(dto.bounceRate).toBe(-0.99);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Infinity for page views', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetAnalyticsMetricsOutputDTO();
|
||||||
|
dto.pageViews = Infinity;
|
||||||
|
dto.uniqueVisitors = 500;
|
||||||
|
dto.averageSessionDuration = 300;
|
||||||
|
dto.bounceRate = 0.4;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViews).toBe(Infinity);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Infinity for unique visitors', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetAnalyticsMetricsOutputDTO();
|
||||||
|
dto.pageViews = 1000;
|
||||||
|
dto.uniqueVisitors = Infinity;
|
||||||
|
dto.averageSessionDuration = 300;
|
||||||
|
dto.bounceRate = 0.4;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.uniqueVisitors).toBe(Infinity);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Infinity for average session duration', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetAnalyticsMetricsOutputDTO();
|
||||||
|
dto.pageViews = 1000;
|
||||||
|
dto.uniqueVisitors = 500;
|
||||||
|
dto.averageSessionDuration = Infinity;
|
||||||
|
dto.bounceRate = 0.4;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.averageSessionDuration).toBe(Infinity);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle NaN for page views', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetAnalyticsMetricsOutputDTO();
|
||||||
|
dto.pageViews = NaN;
|
||||||
|
dto.uniqueVisitors = 500;
|
||||||
|
dto.averageSessionDuration = 300;
|
||||||
|
dto.bounceRate = 0.4;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViews).toBeNaN();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle NaN for unique visitors', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetAnalyticsMetricsOutputDTO();
|
||||||
|
dto.pageViews = 1000;
|
||||||
|
dto.uniqueVisitors = NaN;
|
||||||
|
dto.averageSessionDuration = 300;
|
||||||
|
dto.bounceRate = 0.4;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.uniqueVisitors).toBeNaN();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle NaN for average session duration', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetAnalyticsMetricsOutputDTO();
|
||||||
|
dto.pageViews = 1000;
|
||||||
|
dto.uniqueVisitors = 500;
|
||||||
|
dto.averageSessionDuration = NaN;
|
||||||
|
dto.bounceRate = 0.4;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.averageSessionDuration).toBeNaN();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle NaN for bounce rate', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetAnalyticsMetricsOutputDTO();
|
||||||
|
dto.pageViews = 1000;
|
||||||
|
dto.uniqueVisitors = 500;
|
||||||
|
dto.averageSessionDuration = 300;
|
||||||
|
dto.bounceRate = NaN;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.bounceRate).toBeNaN();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import { GetDashboardDataOutputDTO } from './GetDashboardDataOutputDTO';
|
||||||
|
|
||||||
|
describe('GetDashboardDataOutputDTO', () => {
|
||||||
|
describe('TDD - Test First', () => {
|
||||||
|
it('should create valid DTO with all fields', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetDashboardDataOutputDTO();
|
||||||
|
dto.totalUsers = 100;
|
||||||
|
dto.activeUsers = 50;
|
||||||
|
dto.totalRaces = 20;
|
||||||
|
dto.totalLeagues = 5;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.totalUsers).toBe(100);
|
||||||
|
expect(dto.activeUsers).toBe(50);
|
||||||
|
expect(dto.totalRaces).toBe(20);
|
||||||
|
expect(dto.totalLeagues).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero values', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetDashboardDataOutputDTO();
|
||||||
|
dto.totalUsers = 0;
|
||||||
|
dto.activeUsers = 0;
|
||||||
|
dto.totalRaces = 0;
|
||||||
|
dto.totalLeagues = 0;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.totalUsers).toBe(0);
|
||||||
|
expect(dto.activeUsers).toBe(0);
|
||||||
|
expect(dto.totalRaces).toBe(0);
|
||||||
|
expect(dto.totalLeagues).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle large numbers', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetDashboardDataOutputDTO();
|
||||||
|
dto.totalUsers = 1000000;
|
||||||
|
dto.activeUsers = 500000;
|
||||||
|
dto.totalRaces = 100000;
|
||||||
|
dto.totalLeagues = 10000;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.totalUsers).toBe(1000000);
|
||||||
|
expect(dto.activeUsers).toBe(500000);
|
||||||
|
expect(dto.totalRaces).toBe(100000);
|
||||||
|
expect(dto.totalLeagues).toBe(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single digit values', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetDashboardDataOutputDTO();
|
||||||
|
dto.totalUsers = 1;
|
||||||
|
dto.activeUsers = 1;
|
||||||
|
dto.totalRaces = 1;
|
||||||
|
dto.totalLeagues = 1;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.totalUsers).toBe(1);
|
||||||
|
expect(dto.activeUsers).toBe(1);
|
||||||
|
expect(dto.totalRaces).toBe(1);
|
||||||
|
expect(dto.totalLeagues).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle active users greater than total users', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetDashboardDataOutputDTO();
|
||||||
|
dto.totalUsers = 100;
|
||||||
|
dto.activeUsers = 150;
|
||||||
|
dto.totalRaces = 20;
|
||||||
|
dto.totalLeagues = 5;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.totalUsers).toBe(100);
|
||||||
|
expect(dto.activeUsers).toBe(150);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero active users', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetDashboardDataOutputDTO();
|
||||||
|
dto.totalUsers = 100;
|
||||||
|
dto.activeUsers = 0;
|
||||||
|
dto.totalRaces = 20;
|
||||||
|
dto.totalLeagues = 5;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.activeUsers).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero total users', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetDashboardDataOutputDTO();
|
||||||
|
dto.totalUsers = 0;
|
||||||
|
dto.activeUsers = 0;
|
||||||
|
dto.totalRaces = 20;
|
||||||
|
dto.totalLeagues = 5;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.totalUsers).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero races', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetDashboardDataOutputDTO();
|
||||||
|
dto.totalUsers = 100;
|
||||||
|
dto.activeUsers = 50;
|
||||||
|
dto.totalRaces = 0;
|
||||||
|
dto.totalLeagues = 5;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.totalRaces).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero leagues', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetDashboardDataOutputDTO();
|
||||||
|
dto.totalUsers = 100;
|
||||||
|
dto.activeUsers = 50;
|
||||||
|
dto.totalRaces = 20;
|
||||||
|
dto.totalLeagues = 0;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.totalLeagues).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very large numbers', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetDashboardDataOutputDTO();
|
||||||
|
dto.totalUsers = 999999999;
|
||||||
|
dto.activeUsers = 888888888;
|
||||||
|
dto.totalRaces = 777777777;
|
||||||
|
dto.totalLeagues = 666666666;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.totalUsers).toBe(999999999);
|
||||||
|
expect(dto.activeUsers).toBe(888888888);
|
||||||
|
expect(dto.totalRaces).toBe(777777777);
|
||||||
|
expect(dto.totalLeagues).toBe(666666666);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle decimal numbers', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetDashboardDataOutputDTO();
|
||||||
|
dto.totalUsers = 100.5;
|
||||||
|
dto.activeUsers = 50.7;
|
||||||
|
dto.totalRaces = 20.3;
|
||||||
|
dto.totalLeagues = 5.9;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.totalUsers).toBe(100.5);
|
||||||
|
expect(dto.activeUsers).toBe(50.7);
|
||||||
|
expect(dto.totalRaces).toBe(20.3);
|
||||||
|
expect(dto.totalLeagues).toBe(5.9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle negative numbers', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetDashboardDataOutputDTO();
|
||||||
|
dto.totalUsers = -100;
|
||||||
|
dto.activeUsers = -50;
|
||||||
|
dto.totalRaces = -20;
|
||||||
|
dto.totalLeagues = -5;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.totalUsers).toBe(-100);
|
||||||
|
expect(dto.activeUsers).toBe(-50);
|
||||||
|
expect(dto.totalRaces).toBe(-20);
|
||||||
|
expect(dto.totalLeagues).toBe(-5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle scientific notation', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetDashboardDataOutputDTO();
|
||||||
|
dto.totalUsers = 1e6;
|
||||||
|
dto.activeUsers = 5e5;
|
||||||
|
dto.totalRaces = 2e4;
|
||||||
|
dto.totalLeagues = 5e3;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.totalUsers).toBe(1000000);
|
||||||
|
expect(dto.activeUsers).toBe(500000);
|
||||||
|
expect(dto.totalRaces).toBe(20000);
|
||||||
|
expect(dto.totalLeagues).toBe(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle maximum safe integer', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetDashboardDataOutputDTO();
|
||||||
|
dto.totalUsers = Number.MAX_SAFE_INTEGER;
|
||||||
|
dto.activeUsers = Number.MAX_SAFE_INTEGER;
|
||||||
|
dto.totalRaces = Number.MAX_SAFE_INTEGER;
|
||||||
|
dto.totalLeagues = Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.totalUsers).toBe(Number.MAX_SAFE_INTEGER);
|
||||||
|
expect(dto.activeUsers).toBe(Number.MAX_SAFE_INTEGER);
|
||||||
|
expect(dto.totalRaces).toBe(Number.MAX_SAFE_INTEGER);
|
||||||
|
expect(dto.totalLeagues).toBe(Number.MAX_SAFE_INTEGER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle minimum safe integer', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetDashboardDataOutputDTO();
|
||||||
|
dto.totalUsers = Number.MIN_SAFE_INTEGER;
|
||||||
|
dto.activeUsers = Number.MIN_SAFE_INTEGER;
|
||||||
|
dto.totalRaces = Number.MIN_SAFE_INTEGER;
|
||||||
|
dto.totalLeagues = Number.MIN_SAFE_INTEGER;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.totalUsers).toBe(Number.MIN_SAFE_INTEGER);
|
||||||
|
expect(dto.activeUsers).toBe(Number.MIN_SAFE_INTEGER);
|
||||||
|
expect(dto.totalRaces).toBe(Number.MIN_SAFE_INTEGER);
|
||||||
|
expect(dto.totalLeagues).toBe(Number.MIN_SAFE_INTEGER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Infinity', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetDashboardDataOutputDTO();
|
||||||
|
dto.totalUsers = Infinity;
|
||||||
|
dto.activeUsers = Infinity;
|
||||||
|
dto.totalRaces = Infinity;
|
||||||
|
dto.totalLeagues = Infinity;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.totalUsers).toBe(Infinity);
|
||||||
|
expect(dto.activeUsers).toBe(Infinity);
|
||||||
|
expect(dto.totalRaces).toBe(Infinity);
|
||||||
|
expect(dto.totalLeagues).toBe(Infinity);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle NaN', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new GetDashboardDataOutputDTO();
|
||||||
|
dto.totalUsers = NaN;
|
||||||
|
dto.activeUsers = NaN;
|
||||||
|
dto.totalRaces = NaN;
|
||||||
|
dto.totalLeagues = NaN;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.totalUsers).toBeNaN();
|
||||||
|
expect(dto.activeUsers).toBeNaN();
|
||||||
|
expect(dto.totalRaces).toBeNaN();
|
||||||
|
expect(dto.totalLeagues).toBeNaN();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import { RecordEngagementInputDTO } from './RecordEngagementInputDTO';
|
||||||
|
import { EngagementAction, EngagementEntityType } from '@core/analytics/domain/types/EngagementEvent';
|
||||||
|
|
||||||
|
describe('RecordEngagementInputDTO', () => {
|
||||||
|
describe('TDD - Test First', () => {
|
||||||
|
it('should create valid DTO with all fields', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementInputDTO();
|
||||||
|
dto.action = EngagementAction.CLICK_SPONSOR_LOGO;
|
||||||
|
dto.entityType = EngagementEntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.actorId = 'actor-456';
|
||||||
|
dto.actorType = 'driver';
|
||||||
|
dto.sessionId = 'session-789';
|
||||||
|
dto.metadata = { key: 'value', count: 5 };
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.action).toBe(EngagementAction.CLICK_SPONSOR_LOGO);
|
||||||
|
expect(dto.entityType).toBe(EngagementEntityType.RACE);
|
||||||
|
expect(dto.entityId).toBe('race-123');
|
||||||
|
expect(dto.actorId).toBe('actor-456');
|
||||||
|
expect(dto.actorType).toBe('driver');
|
||||||
|
expect(dto.sessionId).toBe('session-789');
|
||||||
|
expect(dto.metadata).toEqual({ key: 'value', count: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create DTO with required fields only', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementInputDTO();
|
||||||
|
dto.action = EngagementAction.CLICK_SPONSOR_LOGO;
|
||||||
|
dto.entityType = EngagementEntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.actorType = 'anonymous';
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.action).toBe(EngagementAction.CLICK_SPONSOR_LOGO);
|
||||||
|
expect(dto.entityType).toBe(EngagementEntityType.RACE);
|
||||||
|
expect(dto.entityId).toBe('race-123');
|
||||||
|
expect(dto.actorType).toBe('anonymous');
|
||||||
|
expect(dto.sessionId).toBe('session-456');
|
||||||
|
expect(dto.actorId).toBeUndefined();
|
||||||
|
expect(dto.metadata).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle CLICK_SPONSOR_LOGO action', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementInputDTO();
|
||||||
|
dto.action = EngagementAction.CLICK_SPONSOR_LOGO;
|
||||||
|
dto.entityType = EngagementEntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.actorType = 'anonymous';
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.action).toBe(EngagementAction.CLICK_SPONSOR_LOGO);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle CLICK_SPONSOR_URL action', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementInputDTO();
|
||||||
|
dto.action = EngagementAction.CLICK_SPONSOR_URL;
|
||||||
|
dto.entityType = EngagementEntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.actorType = 'anonymous';
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.action).toBe(EngagementAction.CLICK_SPONSOR_URL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle RACE entity type', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementInputDTO();
|
||||||
|
dto.action = EngagementAction.CLICK_SPONSOR_LOGO;
|
||||||
|
dto.entityType = EngagementEntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.actorType = 'anonymous';
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.entityType).toBe(EngagementEntityType.RACE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle LEAGUE entity type', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementInputDTO();
|
||||||
|
dto.action = EngagementAction.CLICK_SPONSOR_LOGO;
|
||||||
|
dto.entityType = EngagementEntityType.LEAGUE;
|
||||||
|
dto.entityId = 'league-123';
|
||||||
|
dto.actorType = 'anonymous';
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.entityType).toBe(EngagementEntityType.LEAGUE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle DRIVER entity type', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementInputDTO();
|
||||||
|
dto.action = EngagementAction.CLICK_SPONSOR_LOGO;
|
||||||
|
dto.entityType = EngagementEntityType.DRIVER;
|
||||||
|
dto.entityId = 'driver-123';
|
||||||
|
dto.actorType = 'anonymous';
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.entityType).toBe(EngagementEntityType.DRIVER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle TEAM entity type', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementInputDTO();
|
||||||
|
dto.action = EngagementAction.CLICK_SPONSOR_LOGO;
|
||||||
|
dto.entityType = EngagementEntityType.TEAM;
|
||||||
|
dto.entityId = 'team-123';
|
||||||
|
dto.actorType = 'anonymous';
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.entityType).toBe(EngagementEntityType.TEAM);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle anonymous actor type', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementInputDTO();
|
||||||
|
dto.action = EngagementAction.CLICK_SPONSOR_LOGO;
|
||||||
|
dto.entityType = EngagementEntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.actorType = 'anonymous';
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.actorType).toBe('anonymous');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle driver actor type', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementInputDTO();
|
||||||
|
dto.action = EngagementAction.CLICK_SPONSOR_LOGO;
|
||||||
|
dto.entityType = EngagementEntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.actorType = 'driver';
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.actorType).toBe('driver');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle sponsor actor type', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementInputDTO();
|
||||||
|
dto.action = EngagementAction.CLICK_SPONSOR_LOGO;
|
||||||
|
dto.entityType = EngagementEntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.actorType = 'sponsor';
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.actorType).toBe('sponsor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty metadata', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementInputDTO();
|
||||||
|
dto.action = EngagementAction.CLICK_SPONSOR_LOGO;
|
||||||
|
dto.entityType = EngagementEntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.actorType = 'anonymous';
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
dto.metadata = {};
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.metadata).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle metadata with multiple keys', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementInputDTO();
|
||||||
|
dto.action = EngagementAction.CLICK_SPONSOR_LOGO;
|
||||||
|
dto.entityType = EngagementEntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.actorType = 'anonymous';
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
dto.metadata = {
|
||||||
|
key1: 'value1',
|
||||||
|
key2: 'value2',
|
||||||
|
key3: 'value3',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.metadata).toEqual({
|
||||||
|
key1: 'value1',
|
||||||
|
key2: 'value2',
|
||||||
|
key3: 'value3',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle metadata with numeric values', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementInputDTO();
|
||||||
|
dto.action = EngagementAction.CLICK_SPONSOR_LOGO;
|
||||||
|
dto.entityType = EngagementEntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.actorType = 'anonymous';
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
dto.metadata = { count: 10, score: 95.5 };
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.metadata).toEqual({ count: 10, score: 95.5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle metadata with boolean values', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementInputDTO();
|
||||||
|
dto.action = EngagementAction.CLICK_SPONSOR_LOGO;
|
||||||
|
dto.entityType = EngagementEntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.actorType = 'anonymous';
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
dto.metadata = { isFeatured: true, isPremium: false };
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.metadata).toEqual({ isFeatured: true, isPremium: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very long entity ID', () => {
|
||||||
|
// Arrange
|
||||||
|
const longId = 'a'.repeat(100);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const dto = new RecordEngagementInputDTO();
|
||||||
|
dto.action = EngagementAction.CLICK_SPONSOR_LOGO;
|
||||||
|
dto.entityType = EngagementEntityType.RACE;
|
||||||
|
dto.entityId = longId;
|
||||||
|
dto.actorType = 'anonymous';
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.entityId).toBe(longId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very long session ID', () => {
|
||||||
|
// Arrange
|
||||||
|
const longSessionId = 's'.repeat(100);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const dto = new RecordEngagementInputDTO();
|
||||||
|
dto.action = EngagementAction.CLICK_SPONSOR_LOGO;
|
||||||
|
dto.entityType = EngagementEntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.actorType = 'anonymous';
|
||||||
|
dto.sessionId = longSessionId;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.sessionId).toBe(longSessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very long actor ID', () => {
|
||||||
|
// Arrange
|
||||||
|
const longActorId = 'a'.repeat(100);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const dto = new RecordEngagementInputDTO();
|
||||||
|
dto.action = EngagementAction.CLICK_SPONSOR_LOGO;
|
||||||
|
dto.entityType = EngagementEntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.actorType = 'driver';
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
dto.actorId = longActorId;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.actorId).toBe(longActorId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle special characters in entity ID', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementInputDTO();
|
||||||
|
dto.action = EngagementAction.CLICK_SPONSOR_LOGO;
|
||||||
|
dto.entityType = EngagementEntityType.RACE;
|
||||||
|
dto.entityId = 'race-123-test-456';
|
||||||
|
dto.actorType = 'anonymous';
|
||||||
|
dto.sessionId = 'session-789';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.entityId).toBe('race-123-test-456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle UUID format for entity ID', () => {
|
||||||
|
// Arrange
|
||||||
|
const uuid = '550e8400-e29b-41d4-a716-446655440000';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const dto = new RecordEngagementInputDTO();
|
||||||
|
dto.action = EngagementAction.CLICK_SPONSOR_LOGO;
|
||||||
|
dto.entityType = EngagementEntityType.RACE;
|
||||||
|
dto.entityId = uuid;
|
||||||
|
dto.actorType = 'anonymous';
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.entityId).toBe(uuid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle numeric entity ID', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementInputDTO();
|
||||||
|
dto.action = EngagementAction.CLICK_SPONSOR_LOGO;
|
||||||
|
dto.entityType = EngagementEntityType.RACE;
|
||||||
|
dto.entityId = '123456';
|
||||||
|
dto.actorType = 'anonymous';
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.entityId).toBe('123456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex metadata with string values', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementInputDTO();
|
||||||
|
dto.action = EngagementAction.CLICK_SPONSOR_LOGO;
|
||||||
|
dto.entityType = EngagementEntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.actorType = 'anonymous';
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
dto.metadata = {
|
||||||
|
position: '100,200',
|
||||||
|
timestamp: '2024-01-01T00:00:00Z',
|
||||||
|
isValid: 'true',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.metadata).toEqual({
|
||||||
|
position: '100,200',
|
||||||
|
timestamp: '2024-01-01T00:00:00Z',
|
||||||
|
isValid: 'true',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { RecordEngagementOutputDTO } from './RecordEngagementOutputDTO';
|
||||||
|
|
||||||
|
describe('RecordEngagementOutputDTO', () => {
|
||||||
|
describe('TDD - Test First', () => {
|
||||||
|
it('should create valid DTO with all fields', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementOutputDTO();
|
||||||
|
dto.eventId = 'event-123';
|
||||||
|
dto.engagementWeight = 10;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.eventId).toBe('event-123');
|
||||||
|
expect(dto.engagementWeight).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero engagement weight', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementOutputDTO();
|
||||||
|
dto.eventId = 'event-123';
|
||||||
|
dto.engagementWeight = 0;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.engagementWeight).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single digit engagement weight', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementOutputDTO();
|
||||||
|
dto.eventId = 'event-123';
|
||||||
|
dto.engagementWeight = 1;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.engagementWeight).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle large engagement weight', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementOutputDTO();
|
||||||
|
dto.eventId = 'event-123';
|
||||||
|
dto.engagementWeight = 1000;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.engagementWeight).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very large engagement weight', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementOutputDTO();
|
||||||
|
dto.eventId = 'event-123';
|
||||||
|
dto.engagementWeight = 999999;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.engagementWeight).toBe(999999);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle negative engagement weight', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementOutputDTO();
|
||||||
|
dto.eventId = 'event-123';
|
||||||
|
dto.engagementWeight = -10;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.engagementWeight).toBe(-10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle decimal engagement weight', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementOutputDTO();
|
||||||
|
dto.eventId = 'event-123';
|
||||||
|
dto.engagementWeight = 10.5;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.engagementWeight).toBe(10.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very small decimal engagement weight', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementOutputDTO();
|
||||||
|
dto.eventId = 'event-123';
|
||||||
|
dto.engagementWeight = 0.001;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.engagementWeight).toBe(0.001);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle scientific notation for engagement weight', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementOutputDTO();
|
||||||
|
dto.eventId = 'event-123';
|
||||||
|
dto.engagementWeight = 1e3;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.engagementWeight).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle UUID format for event ID', () => {
|
||||||
|
// Arrange
|
||||||
|
const uuid = '550e8400-e29b-41d4-a716-446655440000';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const dto = new RecordEngagementOutputDTO();
|
||||||
|
dto.eventId = uuid;
|
||||||
|
dto.engagementWeight = 10;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.eventId).toBe(uuid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle numeric event ID', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementOutputDTO();
|
||||||
|
dto.eventId = '123456';
|
||||||
|
dto.engagementWeight = 10;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.eventId).toBe('123456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle special characters in event ID', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementOutputDTO();
|
||||||
|
dto.eventId = 'event-123-test-456';
|
||||||
|
dto.engagementWeight = 10;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.eventId).toBe('event-123-test-456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very long event ID', () => {
|
||||||
|
// Arrange
|
||||||
|
const longId = 'e'.repeat(100);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const dto = new RecordEngagementOutputDTO();
|
||||||
|
dto.eventId = longId;
|
||||||
|
dto.engagementWeight = 10;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.eventId).toBe(longId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle maximum safe integer for engagement weight', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementOutputDTO();
|
||||||
|
dto.eventId = 'event-123';
|
||||||
|
dto.engagementWeight = Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.engagementWeight).toBe(Number.MAX_SAFE_INTEGER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle minimum safe integer for engagement weight', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementOutputDTO();
|
||||||
|
dto.eventId = 'event-123';
|
||||||
|
dto.engagementWeight = Number.MIN_SAFE_INTEGER;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.engagementWeight).toBe(Number.MIN_SAFE_INTEGER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Infinity for engagement weight', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementOutputDTO();
|
||||||
|
dto.eventId = 'event-123';
|
||||||
|
dto.engagementWeight = Infinity;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.engagementWeight).toBe(Infinity);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle NaN for engagement weight', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementOutputDTO();
|
||||||
|
dto.eventId = 'event-123';
|
||||||
|
dto.engagementWeight = NaN;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.engagementWeight).toBeNaN();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very small event ID', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementOutputDTO();
|
||||||
|
dto.eventId = 'e';
|
||||||
|
dto.engagementWeight = 10;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.eventId).toBe('e');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle event ID with spaces', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementOutputDTO();
|
||||||
|
dto.eventId = 'event 123 test';
|
||||||
|
dto.engagementWeight = 10;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.eventId).toBe('event 123 test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle event ID with special characters', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementOutputDTO();
|
||||||
|
dto.eventId = 'event@123#test$456';
|
||||||
|
dto.engagementWeight = 10;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.eventId).toBe('event@123#test$456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle event ID with unicode characters', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordEngagementOutputDTO();
|
||||||
|
dto.eventId = 'event-123-测试-456';
|
||||||
|
dto.engagementWeight = 10;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.eventId).toBe('event-123-测试-456');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import { RecordPageViewInputDTO } from './RecordPageViewInputDTO';
|
||||||
|
import { EntityType, VisitorType } from '@core/analytics/domain/types/PageView';
|
||||||
|
|
||||||
|
describe('RecordPageViewInputDTO', () => {
|
||||||
|
describe('TDD - Test First', () => {
|
||||||
|
it('should create valid DTO with all fields', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewInputDTO();
|
||||||
|
dto.entityType = EntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.visitorId = 'visitor-456';
|
||||||
|
dto.visitorType = VisitorType.ANONYMOUS;
|
||||||
|
dto.sessionId = 'session-789';
|
||||||
|
dto.referrer = 'https://example.com';
|
||||||
|
dto.userAgent = 'Mozilla/5.0';
|
||||||
|
dto.country = 'US';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.entityType).toBe(EntityType.RACE);
|
||||||
|
expect(dto.entityId).toBe('race-123');
|
||||||
|
expect(dto.visitorId).toBe('visitor-456');
|
||||||
|
expect(dto.visitorType).toBe(VisitorType.ANONYMOUS);
|
||||||
|
expect(dto.sessionId).toBe('session-789');
|
||||||
|
expect(dto.referrer).toBe('https://example.com');
|
||||||
|
expect(dto.userAgent).toBe('Mozilla/5.0');
|
||||||
|
expect(dto.country).toBe('US');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create DTO with required fields only', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewInputDTO();
|
||||||
|
dto.entityType = EntityType.LEAGUE;
|
||||||
|
dto.entityId = 'league-123';
|
||||||
|
dto.visitorType = VisitorType.AUTHENTICATED;
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.entityType).toBe(EntityType.LEAGUE);
|
||||||
|
expect(dto.entityId).toBe('league-123');
|
||||||
|
expect(dto.visitorType).toBe(VisitorType.AUTHENTICATED);
|
||||||
|
expect(dto.sessionId).toBe('session-456');
|
||||||
|
expect(dto.visitorId).toBeUndefined();
|
||||||
|
expect(dto.referrer).toBeUndefined();
|
||||||
|
expect(dto.userAgent).toBeUndefined();
|
||||||
|
expect(dto.country).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle RACE entity type', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewInputDTO();
|
||||||
|
dto.entityType = EntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.visitorType = VisitorType.ANONYMOUS;
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.entityType).toBe(EntityType.RACE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle LEAGUE entity type', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewInputDTO();
|
||||||
|
dto.entityType = EntityType.LEAGUE;
|
||||||
|
dto.entityId = 'league-123';
|
||||||
|
dto.visitorType = VisitorType.ANONYMOUS;
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.entityType).toBe(EntityType.LEAGUE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle DRIVER entity type', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewInputDTO();
|
||||||
|
dto.entityType = EntityType.DRIVER;
|
||||||
|
dto.entityId = 'driver-123';
|
||||||
|
dto.visitorType = VisitorType.ANONYMOUS;
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.entityType).toBe(EntityType.DRIVER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle TEAM entity type', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewInputDTO();
|
||||||
|
dto.entityType = EntityType.TEAM;
|
||||||
|
dto.entityId = 'team-123';
|
||||||
|
dto.visitorType = VisitorType.ANONYMOUS;
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.entityType).toBe(EntityType.TEAM);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle ANONYMOUS visitor type', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewInputDTO();
|
||||||
|
dto.entityType = EntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.visitorType = VisitorType.ANONYMOUS;
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.visitorType).toBe(VisitorType.ANONYMOUS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle AUTHENTICATED visitor type', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewInputDTO();
|
||||||
|
dto.entityType = EntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.visitorType = VisitorType.AUTHENTICATED;
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.visitorType).toBe(VisitorType.AUTHENTICATED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty referrer', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewInputDTO();
|
||||||
|
dto.entityType = EntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.visitorType = VisitorType.ANONYMOUS;
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
dto.referrer = '';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.referrer).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty userAgent', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewInputDTO();
|
||||||
|
dto.entityType = EntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.visitorType = VisitorType.ANONYMOUS;
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
dto.userAgent = '';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.userAgent).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty country', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewInputDTO();
|
||||||
|
dto.entityType = EntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.visitorType = VisitorType.ANONYMOUS;
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
dto.country = '';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.country).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very long entity ID', () => {
|
||||||
|
// Arrange
|
||||||
|
const longId = 'a'.repeat(100);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const dto = new RecordPageViewInputDTO();
|
||||||
|
dto.entityType = EntityType.RACE;
|
||||||
|
dto.entityId = longId;
|
||||||
|
dto.visitorType = VisitorType.ANONYMOUS;
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.entityId).toBe(longId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very long session ID', () => {
|
||||||
|
// Arrange
|
||||||
|
const longSessionId = 's'.repeat(100);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const dto = new RecordPageViewInputDTO();
|
||||||
|
dto.entityType = EntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.visitorType = VisitorType.ANONYMOUS;
|
||||||
|
dto.sessionId = longSessionId;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.sessionId).toBe(longSessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very long visitor ID', () => {
|
||||||
|
// Arrange
|
||||||
|
const longVisitorId = 'v'.repeat(100);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const dto = new RecordPageViewInputDTO();
|
||||||
|
dto.entityType = EntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.visitorType = VisitorType.AUTHENTICATED;
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
dto.visitorId = longVisitorId;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.visitorId).toBe(longVisitorId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle special characters in entity ID', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewInputDTO();
|
||||||
|
dto.entityType = EntityType.RACE;
|
||||||
|
dto.entityId = 'race-123-test-456';
|
||||||
|
dto.visitorType = VisitorType.ANONYMOUS;
|
||||||
|
dto.sessionId = 'session-789';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.entityId).toBe('race-123-test-456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle UUID format for entity ID', () => {
|
||||||
|
// Arrange
|
||||||
|
const uuid = '550e8400-e29b-41d4-a716-446655440000';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const dto = new RecordPageViewInputDTO();
|
||||||
|
dto.entityType = EntityType.RACE;
|
||||||
|
dto.entityId = uuid;
|
||||||
|
dto.visitorType = VisitorType.ANONYMOUS;
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.entityId).toBe(uuid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle numeric entity ID', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewInputDTO();
|
||||||
|
dto.entityType = EntityType.RACE;
|
||||||
|
dto.entityId = '123456';
|
||||||
|
dto.visitorType = VisitorType.ANONYMOUS;
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.entityId).toBe('123456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle URL in referrer', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewInputDTO();
|
||||||
|
dto.entityType = EntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.visitorType = VisitorType.ANONYMOUS;
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
dto.referrer = 'https://www.example.com/path/to/page?query=value';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.referrer).toBe('https://www.example.com/path/to/page?query=value');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex user agent string', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewInputDTO();
|
||||||
|
dto.entityType = EntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.visitorType = VisitorType.ANONYMOUS;
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
dto.userAgent =
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.userAgent).toBe(
|
||||||
|
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle country codes', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewInputDTO();
|
||||||
|
dto.entityType = EntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.visitorType = VisitorType.ANONYMOUS;
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
dto.country = 'GB';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.country).toBe('GB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle country with region', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewInputDTO();
|
||||||
|
dto.entityType = EntityType.RACE;
|
||||||
|
dto.entityId = 'race-123';
|
||||||
|
dto.visitorType = VisitorType.ANONYMOUS;
|
||||||
|
dto.sessionId = 'session-456';
|
||||||
|
dto.country = 'US-CA';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.country).toBe('US-CA');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
import { RecordPageViewOutputDTO } from './RecordPageViewOutputDTO';
|
||||||
|
|
||||||
|
describe('RecordPageViewOutputDTO', () => {
|
||||||
|
describe('TDD - Test First', () => {
|
||||||
|
it('should create valid DTO with all fields', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv-123';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle UUID format for page view ID', () => {
|
||||||
|
// Arrange
|
||||||
|
const uuid = '550e8400-e29b-41d4-a716-446655440000';
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = uuid;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe(uuid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle numeric page view ID', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = '123456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('123456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle special characters in page view ID', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv-123-test-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv-123-test-456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very long page view ID', () => {
|
||||||
|
// Arrange
|
||||||
|
const longId = 'p'.repeat(100);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = longId;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe(longId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very small page view ID', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'p';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('p');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with spaces', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv 123 test';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv 123 test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with special characters', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv@123#test$456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv@123#test$456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with unicode characters', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv-123-测试-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv-123-测试-456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with leading zeros', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = '000123';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('000123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with trailing zeros', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = '123000';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('123000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with mixed case', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'Pv-123-Test-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('Pv-123-Test-456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with underscores', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv_123_test_456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv_123_test_456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with dots', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv.123.test.456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv.123.test.456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with hyphens', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv-123-test-456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv-123-test-456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with colons', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv:123:test:456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv:123:test:456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with slashes', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv/123/test/456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv/123/test/456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with backslashes', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv\\123\\test\\456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv\\123\\test\\456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with pipes', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv|123|test|456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv|123|test|456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with ampersands', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv&123&test&456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv&123&test&456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with percent signs', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv%123%test%456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv%123%test%456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with dollar signs', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv$123$test$456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv$123$test$456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with exclamation marks', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv!123!test!456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv!123!test!456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with question marks', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv?123?test?456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv?123?test?456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with plus signs', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv+123+test+456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv+123+test+456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with equals signs', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv=123=test=456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv=123=test=456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with asterisks', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv*123*test*456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv*123*test*456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with parentheses', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv(123)test(456)';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv(123)test(456)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with brackets', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv[123]test[456]';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv[123]test[456]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with curly braces', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv{123}test{456}';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv{123}test{456}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with angle brackets', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv<123>test<456>';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv<123>test<456>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with quotes', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv"123"test"456"';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv"123"test"456"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with single quotes', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = "pv'123'test'456'";
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe("pv'123'test'456'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with backticks', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv`123`test`456`';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv`123`test`456`');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with tildes', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv~123~test~456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv~123~test~456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with at signs', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv@123@test@456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv@123@test@456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle page view ID with hash signs', () => {
|
||||||
|
// Arrange & Act
|
||||||
|
const dto = new RecordPageViewOutputDTO();
|
||||||
|
dto.pageViewId = 'pv#123#test#456';
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(dto.pageViewId).toBe('pv#123#test#456');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
153
apps/api/src/domain/auth/AuthorizationService.test.ts
Normal file
153
apps/api/src/domain/auth/AuthorizationService.test.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { AuthorizationService } from './AuthorizationService';
|
||||||
|
|
||||||
|
describe('AuthorizationService', () => {
|
||||||
|
let service: AuthorizationService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear environment variables
|
||||||
|
delete process.env.GRIDPILOT_AUTHZ_CACHE_MS;
|
||||||
|
delete process.env.GRIDPILOT_USER_ROLES_JSON;
|
||||||
|
|
||||||
|
service = new AuthorizationService();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRolesForUser', () => {
|
||||||
|
it('should return empty array when no roles are configured', () => {
|
||||||
|
const roles = service.getRolesForUser('user-123');
|
||||||
|
expect(roles).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return roles from environment variable', () => {
|
||||||
|
process.env.GRIDPILOT_USER_ROLES_JSON = JSON.stringify({
|
||||||
|
'user-123': ['admin', 'owner'],
|
||||||
|
'user-456': ['user'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const roles = service.getRolesForUser('user-123');
|
||||||
|
expect(roles).toEqual(['admin', 'owner']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array for user not in roles config', () => {
|
||||||
|
process.env.GRIDPILOT_USER_ROLES_JSON = JSON.stringify({
|
||||||
|
'user-123': ['admin'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const roles = service.getRolesForUser('user-456');
|
||||||
|
expect(roles).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cache roles and return cached values', () => {
|
||||||
|
process.env.GRIDPILOT_USER_ROLES_JSON = JSON.stringify({
|
||||||
|
'user-123': ['admin'],
|
||||||
|
});
|
||||||
|
process.env.GRIDPILOT_AUTHZ_CACHE_MS = '10000';
|
||||||
|
|
||||||
|
// First call
|
||||||
|
const roles1 = service.getRolesForUser('user-123');
|
||||||
|
expect(roles1).toEqual(['admin']);
|
||||||
|
|
||||||
|
// Second call should return cached value
|
||||||
|
const roles2 = service.getRolesForUser('user-123');
|
||||||
|
expect(roles2).toEqual(['admin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle invalid JSON gracefully', () => {
|
||||||
|
process.env.GRIDPILOT_USER_ROLES_JSON = 'invalid json';
|
||||||
|
|
||||||
|
const roles = service.getRolesForUser('user-123');
|
||||||
|
expect(roles).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-object JSON gracefully', () => {
|
||||||
|
process.env.GRIDPILOT_USER_ROLES_JSON = JSON.stringify('not an object');
|
||||||
|
|
||||||
|
const roles = service.getRolesForUser('user-123');
|
||||||
|
expect(roles).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out non-string roles', () => {
|
||||||
|
process.env.GRIDPILOT_USER_ROLES_JSON = JSON.stringify({
|
||||||
|
'user-123': ['admin', 123, null, 'owner', undefined, 'user'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const roles = service.getRolesForUser('user-123');
|
||||||
|
expect(roles).toEqual(['admin', 'owner', 'user']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace from roles', () => {
|
||||||
|
process.env.GRIDPILOT_USER_ROLES_JSON = JSON.stringify({
|
||||||
|
'user-123': [' admin ', ' owner '],
|
||||||
|
});
|
||||||
|
|
||||||
|
const roles = service.getRolesForUser('user-123');
|
||||||
|
expect(roles).toEqual(['admin', 'owner']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out empty strings after trimming', () => {
|
||||||
|
process.env.GRIDPILOT_USER_ROLES_JSON = JSON.stringify({
|
||||||
|
'user-123': ['admin', ' ', 'owner'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const roles = service.getRolesForUser('user-123');
|
||||||
|
expect(roles).toEqual(['admin', 'owner']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default cache time when not configured', () => {
|
||||||
|
process.env.GRIDPILOT_USER_ROLES_JSON = JSON.stringify({
|
||||||
|
'user-123': ['admin'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const roles = service.getRolesForUser('user-123');
|
||||||
|
expect(roles).toEqual(['admin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use configured cache time', () => {
|
||||||
|
process.env.GRIDPILOT_USER_ROLES_JSON = JSON.stringify({
|
||||||
|
'user-123': ['admin'],
|
||||||
|
});
|
||||||
|
process.env.GRIDPILOT_AUTHZ_CACHE_MS = '5000';
|
||||||
|
|
||||||
|
const roles = service.getRolesForUser('user-123');
|
||||||
|
expect(roles).toEqual(['admin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle invalid cache time gracefully', () => {
|
||||||
|
process.env.GRIDPILOT_USER_ROLES_JSON = JSON.stringify({
|
||||||
|
'user-123': ['admin'],
|
||||||
|
});
|
||||||
|
process.env.GRIDPILOT_AUTHZ_CACHE_MS = 'invalid';
|
||||||
|
|
||||||
|
const roles = service.getRolesForUser('user-123');
|
||||||
|
expect(roles).toEqual(['admin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle negative cache time gracefully', () => {
|
||||||
|
process.env.GRIDPILOT_USER_ROLES_JSON = JSON.stringify({
|
||||||
|
'user-123': ['admin'],
|
||||||
|
});
|
||||||
|
process.env.GRIDPILOT_AUTHZ_CACHE_MS = '-1000';
|
||||||
|
|
||||||
|
const roles = service.getRolesForUser('user-123');
|
||||||
|
expect(roles).toEqual(['admin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty roles array', () => {
|
||||||
|
process.env.GRIDPILOT_USER_ROLES_JSON = JSON.stringify({
|
||||||
|
'user-123': [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const roles = service.getRolesForUser('user-123');
|
||||||
|
expect(roles).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle user ID with special characters', () => {
|
||||||
|
process.env.GRIDPILOT_USER_ROLES_JSON = JSON.stringify({
|
||||||
|
'user-123@example.com': ['admin'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const roles = service.getRolesForUser('user-123@example.com');
|
||||||
|
expect(roles).toEqual(['admin']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
40
apps/api/src/domain/auth/Public.test.ts
Normal file
40
apps/api/src/domain/auth/Public.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { Public, PUBLIC_ROUTE_METADATA_KEY } from './Public';
|
||||||
|
|
||||||
|
// Mock SetMetadata
|
||||||
|
vi.mock('@nestjs/common', () => ({
|
||||||
|
SetMetadata: vi.fn(() => () => {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Public', () => {
|
||||||
|
it('should return a method decorator', () => {
|
||||||
|
const decorator = Public();
|
||||||
|
expect(typeof decorator).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call SetMetadata with correct key and value', () => {
|
||||||
|
Public();
|
||||||
|
|
||||||
|
expect(SetMetadata).toHaveBeenCalledWith(PUBLIC_ROUTE_METADATA_KEY, {
|
||||||
|
public: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a decorator that can be used as both method and class decorator', () => {
|
||||||
|
const decorator = Public();
|
||||||
|
|
||||||
|
// Test as method decorator
|
||||||
|
const mockTarget = {};
|
||||||
|
const mockPropertyKey = 'testMethod';
|
||||||
|
const mockDescriptor = { value: () => {} };
|
||||||
|
|
||||||
|
const result = decorator(mockTarget, mockPropertyKey, mockDescriptor);
|
||||||
|
|
||||||
|
// The decorator should return the descriptor
|
||||||
|
expect(result).toBe(mockDescriptor);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct metadata key', () => {
|
||||||
|
expect(PUBLIC_ROUTE_METADATA_KEY).toBe('gridpilot:publicRoute');
|
||||||
|
});
|
||||||
|
});
|
||||||
40
apps/api/src/domain/auth/RequireAuthenticatedUser.test.ts
Normal file
40
apps/api/src/domain/auth/RequireAuthenticatedUser.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { RequireAuthenticatedUser, REQUIRE_AUTHENTICATED_USER_METADATA_KEY } from './RequireAuthenticatedUser';
|
||||||
|
|
||||||
|
// Mock SetMetadata
|
||||||
|
vi.mock('@nestjs/common', () => ({
|
||||||
|
SetMetadata: vi.fn(() => () => {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('RequireAuthenticatedUser', () => {
|
||||||
|
it('should return a method decorator', () => {
|
||||||
|
const decorator = RequireAuthenticatedUser();
|
||||||
|
expect(typeof decorator).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call SetMetadata with correct key and value', () => {
|
||||||
|
RequireAuthenticatedUser();
|
||||||
|
|
||||||
|
expect(SetMetadata).toHaveBeenCalledWith(REQUIRE_AUTHENTICATED_USER_METADATA_KEY, {
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a decorator that can be used as both method and class decorator', () => {
|
||||||
|
const decorator = RequireAuthenticatedUser();
|
||||||
|
|
||||||
|
// Test as method decorator
|
||||||
|
const mockTarget = {};
|
||||||
|
const mockPropertyKey = 'testMethod';
|
||||||
|
const mockDescriptor = { value: () => {} };
|
||||||
|
|
||||||
|
const result = decorator(mockTarget, mockPropertyKey, mockDescriptor);
|
||||||
|
|
||||||
|
// The decorator should return the descriptor
|
||||||
|
expect(result).toBe(mockDescriptor);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct metadata key', () => {
|
||||||
|
expect(REQUIRE_AUTHENTICATED_USER_METADATA_KEY).toBe('gridpilot:requireAuthenticatedUser');
|
||||||
|
});
|
||||||
|
});
|
||||||
69
apps/api/src/domain/auth/RequireRoles.test.ts
Normal file
69
apps/api/src/domain/auth/RequireRoles.test.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { RequireRoles, REQUIRE_ROLES_METADATA_KEY } from './RequireRoles';
|
||||||
|
|
||||||
|
// Mock SetMetadata
|
||||||
|
vi.mock('@nestjs/common', () => ({
|
||||||
|
SetMetadata: vi.fn(() => () => {}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('RequireRoles', () => {
|
||||||
|
it('should return a method decorator', () => {
|
||||||
|
const decorator = RequireRoles('admin');
|
||||||
|
expect(typeof decorator).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call SetMetadata with correct key and value for single role', () => {
|
||||||
|
RequireRoles('admin');
|
||||||
|
|
||||||
|
expect(SetMetadata).toHaveBeenCalledWith(REQUIRE_ROLES_METADATA_KEY, {
|
||||||
|
anyOf: ['admin'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call SetMetadata with correct key and value for multiple roles', () => {
|
||||||
|
RequireRoles('admin', 'owner', 'moderator');
|
||||||
|
|
||||||
|
expect(SetMetadata).toHaveBeenCalledWith(REQUIRE_ROLES_METADATA_KEY, {
|
||||||
|
anyOf: ['admin', 'owner', 'moderator'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a decorator that can be used as both method and class decorator', () => {
|
||||||
|
const decorator = RequireRoles('admin');
|
||||||
|
|
||||||
|
// Test as method decorator
|
||||||
|
const mockTarget = {};
|
||||||
|
const mockPropertyKey = 'testMethod';
|
||||||
|
const mockDescriptor = { value: () => {} };
|
||||||
|
|
||||||
|
const result = decorator(mockTarget, mockPropertyKey, mockDescriptor);
|
||||||
|
|
||||||
|
// The decorator should return the descriptor
|
||||||
|
expect(result).toBe(mockDescriptor);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct metadata key', () => {
|
||||||
|
expect(REQUIRE_ROLES_METADATA_KEY).toBe('gridpilot:requireRoles');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty roles array', () => {
|
||||||
|
const decorator = RequireRoles();
|
||||||
|
|
||||||
|
// Test as method decorator
|
||||||
|
const mockTarget = {};
|
||||||
|
const mockPropertyKey = 'testMethod';
|
||||||
|
const mockDescriptor = { value: () => {} };
|
||||||
|
|
||||||
|
const result = decorator(mockTarget, mockPropertyKey, mockDescriptor);
|
||||||
|
|
||||||
|
expect(result).toBe(mockDescriptor);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle roles with special characters', () => {
|
||||||
|
RequireRoles('admin-user', 'owner@company');
|
||||||
|
|
||||||
|
expect(SetMetadata).toHaveBeenCalledWith(REQUIRE_ROLES_METADATA_KEY, {
|
||||||
|
anyOf: ['admin-user', 'owner@company'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
156
apps/api/src/domain/auth/getActorFromRequestContext.test.ts
Normal file
156
apps/api/src/domain/auth/getActorFromRequestContext.test.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { getActorFromRequestContext } from './getActorFromRequestContext';
|
||||||
|
|
||||||
|
// Mock the http adapter
|
||||||
|
vi.mock('@adapters/http/RequestContext', () => ({
|
||||||
|
getHttpRequestContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { getHttpRequestContext } from '@adapters/http/RequestContext';
|
||||||
|
|
||||||
|
describe('getActorFromRequestContext', () => {
|
||||||
|
const mockGetHttpRequestContext = vi.mocked(getHttpRequestContext);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return actor with userId and driverId from request', () => {
|
||||||
|
const mockContext = {
|
||||||
|
req: {
|
||||||
|
user: {
|
||||||
|
userId: 'user-123',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGetHttpRequestContext.mockReturnValue(mockContext as never);
|
||||||
|
|
||||||
|
const actor = getActorFromRequestContext();
|
||||||
|
|
||||||
|
expect(actor).toEqual({
|
||||||
|
userId: 'user-123',
|
||||||
|
driverId: 'user-123',
|
||||||
|
role: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include role from request when available', () => {
|
||||||
|
const mockContext = {
|
||||||
|
req: {
|
||||||
|
user: {
|
||||||
|
userId: 'user-123',
|
||||||
|
role: 'admin',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGetHttpRequestContext.mockReturnValue(mockContext as never);
|
||||||
|
|
||||||
|
const actor = getActorFromRequestContext();
|
||||||
|
|
||||||
|
expect(actor).toEqual({
|
||||||
|
userId: 'user-123',
|
||||||
|
driverId: 'user-123',
|
||||||
|
role: 'admin',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when userId is missing', () => {
|
||||||
|
const mockContext = {
|
||||||
|
req: {
|
||||||
|
user: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGetHttpRequestContext.mockReturnValue(mockContext as never);
|
||||||
|
|
||||||
|
expect(() => getActorFromRequestContext()).toThrow('Unauthorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when user object is missing', () => {
|
||||||
|
const mockContext = {
|
||||||
|
req: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGetHttpRequestContext.mockReturnValue(mockContext as never);
|
||||||
|
|
||||||
|
expect(() => getActorFromRequestContext()).toThrow('Unauthorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when request is missing', () => {
|
||||||
|
const mockContext = {};
|
||||||
|
|
||||||
|
mockGetHttpRequestContext.mockReturnValue(mockContext as never);
|
||||||
|
|
||||||
|
expect(() => getActorFromRequestContext()).toThrow('Unauthorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle userId as empty string', () => {
|
||||||
|
const mockContext = {
|
||||||
|
req: {
|
||||||
|
user: {
|
||||||
|
userId: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGetHttpRequestContext.mockReturnValue(mockContext as never);
|
||||||
|
|
||||||
|
expect(() => getActorFromRequestContext()).toThrow('Unauthorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should map userId to driverId correctly', () => {
|
||||||
|
const mockContext = {
|
||||||
|
req: {
|
||||||
|
user: {
|
||||||
|
userId: 'driver-456',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGetHttpRequestContext.mockReturnValue(mockContext as never);
|
||||||
|
|
||||||
|
const actor = getActorFromRequestContext();
|
||||||
|
|
||||||
|
expect(actor.driverId).toBe('driver-456');
|
||||||
|
expect(actor.userId).toBe('driver-456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle role as undefined when not provided', () => {
|
||||||
|
const mockContext = {
|
||||||
|
req: {
|
||||||
|
user: {
|
||||||
|
userId: 'user-123',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGetHttpRequestContext.mockReturnValue(mockContext as never);
|
||||||
|
|
||||||
|
const actor = getActorFromRequestContext();
|
||||||
|
|
||||||
|
expect(actor.role).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle role as null', () => {
|
||||||
|
const mockContext = {
|
||||||
|
req: {
|
||||||
|
user: {
|
||||||
|
userId: 'user-123',
|
||||||
|
role: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGetHttpRequestContext.mockReturnValue(mockContext as never);
|
||||||
|
|
||||||
|
const actor = getActorFromRequestContext();
|
||||||
|
|
||||||
|
expect(actor.role).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
89
apps/api/src/domain/database/DatabaseModule.test.ts
Normal file
89
apps/api/src/domain/database/DatabaseModule.test.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { DatabaseModule } from './DatabaseModule';
|
||||||
|
|
||||||
|
describe('DatabaseModule', () => {
|
||||||
|
let module: TestingModule;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Clear environment variables to ensure consistent test behavior
|
||||||
|
delete process.env.DATABASE_URL;
|
||||||
|
delete process.env.DATABASE_HOST;
|
||||||
|
delete process.env.DATABASE_PORT;
|
||||||
|
delete process.env.DATABASE_USER;
|
||||||
|
delete process.env.DATABASE_PASSWORD;
|
||||||
|
delete process.env.DATABASE_NAME;
|
||||||
|
delete process.env.NODE_ENV;
|
||||||
|
|
||||||
|
module = await Test.createTestingModule({
|
||||||
|
imports: [DatabaseModule],
|
||||||
|
}).compile();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should compile the module', () => {
|
||||||
|
expect(module).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should configure TypeORM with DATABASE_URL when provided', async () => {
|
||||||
|
process.env.DATABASE_URL = 'postgres://user:pass@localhost:5432/testdb';
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
|
||||||
|
const testModule = await Test.createTestingModule({
|
||||||
|
imports: [DatabaseModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
expect(testModule).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should configure TypeORM with individual connection parameters when DATABASE_URL is not provided', async () => {
|
||||||
|
process.env.DATABASE_HOST = 'localhost';
|
||||||
|
process.env.DATABASE_PORT = '5432';
|
||||||
|
process.env.DATABASE_USER = 'testuser';
|
||||||
|
process.env.DATABASE_PASSWORD = 'testpass';
|
||||||
|
process.env.DATABASE_NAME = 'testdb';
|
||||||
|
process.env.NODE_ENV = 'development';
|
||||||
|
|
||||||
|
const testModule = await Test.createTestingModule({
|
||||||
|
imports: [DatabaseModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
expect(testModule).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default values when connection parameters are not provided', async () => {
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
|
||||||
|
const testModule = await Test.createTestingModule({
|
||||||
|
imports: [DatabaseModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
expect(testModule).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enable synchronization in non-production environments', async () => {
|
||||||
|
process.env.NODE_ENV = 'development';
|
||||||
|
|
||||||
|
const testModule = await Test.createTestingModule({
|
||||||
|
imports: [DatabaseModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
expect(testModule).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable synchronization in production environment', async () => {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
|
||||||
|
const testModule = await Test.createTestingModule({
|
||||||
|
imports: [DatabaseModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
expect(testModule).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto load entities', async () => {
|
||||||
|
const testModule = await Test.createTestingModule({
|
||||||
|
imports: [DatabaseModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
expect(testModule).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
30
apps/api/src/domain/hello/HelloModule.test.ts
Normal file
30
apps/api/src/domain/hello/HelloModule.test.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { HelloController } from './HelloController';
|
||||||
|
import { HelloModule } from './HelloModule';
|
||||||
|
import { HelloService } from './HelloService';
|
||||||
|
|
||||||
|
describe('HelloModule', () => {
|
||||||
|
let module: TestingModule;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
module = await Test.createTestingModule({
|
||||||
|
imports: [HelloModule],
|
||||||
|
}).compile();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should compile the module', () => {
|
||||||
|
expect(module).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide HelloController', () => {
|
||||||
|
const controller = module.get<HelloController>(HelloController);
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
expect(controller).toBeInstanceOf(HelloController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide HelloService', () => {
|
||||||
|
const service = module.get<HelloService>(HelloService);
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
expect(service).toBeInstanceOf(HelloService);
|
||||||
|
});
|
||||||
|
});
|
||||||
210
apps/api/src/domain/league/LeagueAuthorization.test.ts
Normal file
210
apps/api/src/domain/league/LeagueAuthorization.test.ts
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
|
import { requireLeagueAdminOrOwner } from './LeagueAuthorization';
|
||||||
|
|
||||||
|
// Mock the auth module
|
||||||
|
vi.mock('../auth/getActorFromRequestContext', () => ({
|
||||||
|
getActorFromRequestContext: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { getActorFromRequestContext } from '../auth/getActorFromRequestContext';
|
||||||
|
|
||||||
|
describe('requireLeagueAdminOrOwner', () => {
|
||||||
|
const mockGetActorFromRequestContext = vi.mocked(getActorFromRequestContext);
|
||||||
|
const mockGetLeagueAdminPermissionsUseCase = {
|
||||||
|
execute: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow access for demo session role "league-admin"', async () => {
|
||||||
|
mockGetActorFromRequestContext.mockReturnValue({
|
||||||
|
userId: 'user-123',
|
||||||
|
driverId: 'driver-123',
|
||||||
|
role: 'league-admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
requireLeagueAdminOrOwner('league-123', mockGetLeagueAdminPermissionsUseCase)
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(mockGetLeagueAdminPermissionsUseCase.execute).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow access for demo session role "league-owner"', async () => {
|
||||||
|
mockGetActorFromRequestContext.mockReturnValue({
|
||||||
|
userId: 'user-123',
|
||||||
|
driverId: 'driver-123',
|
||||||
|
role: 'league-owner',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
requireLeagueAdminOrOwner('league-123', mockGetLeagueAdminPermissionsUseCase)
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(mockGetLeagueAdminPermissionsUseCase.execute).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow access for demo session role "super-admin"', async () => {
|
||||||
|
mockGetActorFromRequestContext.mockReturnValue({
|
||||||
|
userId: 'user-123',
|
||||||
|
driverId: 'driver-123',
|
||||||
|
role: 'super-admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
requireLeagueAdminOrOwner('league-123', mockGetLeagueAdminPermissionsUseCase)
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(mockGetLeagueAdminPermissionsUseCase.execute).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow access for demo session role "system-owner"', async () => {
|
||||||
|
mockGetActorFromRequestContext.mockReturnValue({
|
||||||
|
userId: 'user-123',
|
||||||
|
driverId: 'driver-123',
|
||||||
|
role: 'system-owner',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
requireLeagueAdminOrOwner('league-123', mockGetLeagueAdminPermissionsUseCase)
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(mockGetLeagueAdminPermissionsUseCase.execute).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should check permissions for non-demo roles', async () => {
|
||||||
|
mockGetActorFromRequestContext.mockReturnValue({
|
||||||
|
userId: 'user-123',
|
||||||
|
driverId: 'driver-123',
|
||||||
|
role: 'user',
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockResult = {
|
||||||
|
isErr: () => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGetLeagueAdminPermissionsUseCase.execute.mockResolvedValue(mockResult);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
requireLeagueAdminOrOwner('league-123', mockGetLeagueAdminPermissionsUseCase)
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(mockGetLeagueAdminPermissionsUseCase.execute).toHaveBeenCalledWith({
|
||||||
|
leagueId: 'league-123',
|
||||||
|
performerDriverId: 'driver-123',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ForbiddenException when permission check fails', async () => {
|
||||||
|
mockGetActorFromRequestContext.mockReturnValue({
|
||||||
|
userId: 'user-123',
|
||||||
|
driverId: 'driver-123',
|
||||||
|
role: 'user',
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockResult = {
|
||||||
|
isErr: () => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGetLeagueAdminPermissionsUseCase.execute.mockResolvedValue(mockResult);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
requireLeagueAdminOrOwner('league-123', mockGetLeagueAdminPermissionsUseCase)
|
||||||
|
).rejects.toThrow(ForbiddenException);
|
||||||
|
|
||||||
|
expect(mockGetLeagueAdminPermissionsUseCase.execute).toHaveBeenCalledWith({
|
||||||
|
leagueId: 'league-123',
|
||||||
|
performerDriverId: 'driver-123',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ForbiddenException with correct message', async () => {
|
||||||
|
mockGetActorFromRequestContext.mockReturnValue({
|
||||||
|
userId: 'user-123',
|
||||||
|
driverId: 'driver-123',
|
||||||
|
role: 'user',
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockResult = {
|
||||||
|
isErr: () => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGetLeagueAdminPermissionsUseCase.execute.mockResolvedValue(mockResult);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await requireLeagueAdminOrOwner('league-123', mockGetLeagueAdminPermissionsUseCase);
|
||||||
|
expect(true).toBe(false); // Should not reach here
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(ForbiddenException);
|
||||||
|
expect(error.message).toBe('Forbidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle different league IDs', async () => {
|
||||||
|
mockGetActorFromRequestContext.mockReturnValue({
|
||||||
|
userId: 'user-123',
|
||||||
|
driverId: 'driver-123',
|
||||||
|
role: 'user',
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockResult = {
|
||||||
|
isErr: () => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGetLeagueAdminPermissionsUseCase.execute.mockResolvedValue(mockResult);
|
||||||
|
|
||||||
|
await requireLeagueAdminOrOwner('league-456', mockGetLeagueAdminPermissionsUseCase);
|
||||||
|
|
||||||
|
expect(mockGetLeagueAdminPermissionsUseCase.execute).toHaveBeenCalledWith({
|
||||||
|
leagueId: 'league-456',
|
||||||
|
performerDriverId: 'driver-123',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle actor without role', async () => {
|
||||||
|
mockGetActorFromRequestContext.mockReturnValue({
|
||||||
|
userId: 'user-123',
|
||||||
|
driverId: 'driver-123',
|
||||||
|
role: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockResult = {
|
||||||
|
isErr: () => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGetLeagueAdminPermissionsUseCase.execute.mockResolvedValue(mockResult);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
requireLeagueAdminOrOwner('league-123', mockGetLeagueAdminPermissionsUseCase)
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(mockGetLeagueAdminPermissionsUseCase.execute).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle actor with null role', async () => {
|
||||||
|
mockGetActorFromRequestContext.mockReturnValue({
|
||||||
|
userId: 'user-123',
|
||||||
|
driverId: 'driver-123',
|
||||||
|
role: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockResult = {
|
||||||
|
isErr: () => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGetLeagueAdminPermissionsUseCase.execute.mockResolvedValue(mockResult);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
requireLeagueAdminOrOwner('league-123', mockGetLeagueAdminPermissionsUseCase)
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
|
expect(mockGetLeagueAdminPermissionsUseCase.execute).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
398
apps/api/src/domain/league/LeagueController.detail.test.ts
Normal file
398
apps/api/src/domain/league/LeagueController.detail.test.ts
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { LeagueController } from './LeagueController';
|
||||||
|
import { LeagueService } from './LeagueService';
|
||||||
|
|
||||||
|
describe('LeagueController - Detail Endpoints', () => {
|
||||||
|
let controller: LeagueController;
|
||||||
|
let mockService: ReturnType<typeof vi.mocked<LeagueService>>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockService = {
|
||||||
|
getLeagueOwnerSummary: vi.fn(),
|
||||||
|
getLeagueSeasons: vi.fn(),
|
||||||
|
getLeagueStats: vi.fn(),
|
||||||
|
getLeagueMemberships: vi.fn(),
|
||||||
|
} as never;
|
||||||
|
|
||||||
|
controller = new LeagueController(mockService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLeague', () => {
|
||||||
|
it('should return league details by ID', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
driver: {
|
||||||
|
id: 'driver-1',
|
||||||
|
iracingId: '12345',
|
||||||
|
name: 'John Doe',
|
||||||
|
country: 'US',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
rating: 1500,
|
||||||
|
rank: 10,
|
||||||
|
};
|
||||||
|
mockService.getLeagueOwnerSummary.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeague('league-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(mockService.getLeagueOwnerSummary).toHaveBeenCalledWith({
|
||||||
|
ownerId: 'unknown',
|
||||||
|
leagueId: 'league-1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle league not found gracefully', async () => {
|
||||||
|
mockService.getLeagueOwnerSummary.mockRejectedValue(new Error('League not found'));
|
||||||
|
|
||||||
|
await expect(controller.getLeague('non-existent-league')).rejects.toThrow('League not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return league with minimal information', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
driver: {
|
||||||
|
id: 'driver-1',
|
||||||
|
iracingId: '12345',
|
||||||
|
name: 'Simple Driver',
|
||||||
|
country: 'DE',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
rating: null,
|
||||||
|
rank: null,
|
||||||
|
};
|
||||||
|
mockService.getLeagueOwnerSummary.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeague('league-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result.driver.name).toBe('Simple Driver');
|
||||||
|
expect(result.rating).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLeagueSeasons', () => {
|
||||||
|
it('should return seasons for a league', async () => {
|
||||||
|
const mockResult = [
|
||||||
|
{
|
||||||
|
seasonId: 'season-1',
|
||||||
|
name: 'Season 1',
|
||||||
|
status: 'active',
|
||||||
|
startDate: new Date('2024-01-01'),
|
||||||
|
endDate: new Date('2024-06-30'),
|
||||||
|
isPrimary: true,
|
||||||
|
isParallelActive: false,
|
||||||
|
totalRaces: 12,
|
||||||
|
completedRaces: 6,
|
||||||
|
nextRaceAt: new Date('2024-03-15'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seasonId: 'season-2',
|
||||||
|
name: 'Season 2',
|
||||||
|
status: 'upcoming',
|
||||||
|
startDate: new Date('2024-07-01'),
|
||||||
|
endDate: new Date('2024-12-31'),
|
||||||
|
isPrimary: false,
|
||||||
|
isParallelActive: false,
|
||||||
|
totalRaces: 12,
|
||||||
|
completedRaces: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockService.getLeagueSeasons.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueSeasons('league-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(mockService.getLeagueSeasons).toHaveBeenCalledWith({ leagueId: 'league-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when league has no seasons', async () => {
|
||||||
|
const mockResult: never[] = [];
|
||||||
|
mockService.getLeagueSeasons.mockResolvedValue(mockResult);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueSeasons('league-1');
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle league with single season', async () => {
|
||||||
|
const mockResult = [
|
||||||
|
{
|
||||||
|
seasonId: 'season-1',
|
||||||
|
name: 'Season 1',
|
||||||
|
status: 'active',
|
||||||
|
startDate: new Date('2024-01-01'),
|
||||||
|
endDate: new Date('2024-12-31'),
|
||||||
|
isPrimary: true,
|
||||||
|
isParallelActive: false,
|
||||||
|
totalRaces: 24,
|
||||||
|
completedRaces: 12,
|
||||||
|
nextRaceAt: new Date('2024-06-15'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockService.getLeagueSeasons.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueSeasons('league-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.totalRaces).toBe(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle seasons with different statuses', async () => {
|
||||||
|
const mockResult = [
|
||||||
|
{
|
||||||
|
seasonId: 'season-1',
|
||||||
|
name: 'Season 1',
|
||||||
|
status: 'completed',
|
||||||
|
startDate: new Date('2024-01-01'),
|
||||||
|
endDate: new Date('2024-06-30'),
|
||||||
|
isPrimary: true,
|
||||||
|
isParallelActive: false,
|
||||||
|
totalRaces: 12,
|
||||||
|
completedRaces: 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seasonId: 'season-2',
|
||||||
|
name: 'Season 2',
|
||||||
|
status: 'active',
|
||||||
|
startDate: new Date('2024-07-01'),
|
||||||
|
endDate: new Date('2024-12-31'),
|
||||||
|
isPrimary: false,
|
||||||
|
isParallelActive: false,
|
||||||
|
totalRaces: 12,
|
||||||
|
completedRaces: 6,
|
||||||
|
nextRaceAt: new Date('2024-10-15'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seasonId: 'season-3',
|
||||||
|
name: 'Season 3',
|
||||||
|
status: 'upcoming',
|
||||||
|
startDate: new Date('2025-01-01'),
|
||||||
|
endDate: new Date('2025-06-30'),
|
||||||
|
isPrimary: false,
|
||||||
|
isParallelActive: false,
|
||||||
|
totalRaces: 12,
|
||||||
|
completedRaces: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockService.getLeagueSeasons.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueSeasons('league-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result[0]?.status).toBe('completed');
|
||||||
|
expect(result[1]?.status).toBe('active');
|
||||||
|
expect(result[2]?.status).toBe('upcoming');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLeagueStats', () => {
|
||||||
|
it('should return league statistics', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
totalMembers: 25,
|
||||||
|
totalRaces: 150,
|
||||||
|
averageRating: 1450.5,
|
||||||
|
};
|
||||||
|
mockService.getLeagueStats.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueStats('league-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(mockService.getLeagueStats).toHaveBeenCalledWith('league-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty stats for new league', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
totalMembers: 0,
|
||||||
|
totalRaces: 0,
|
||||||
|
averageRating: 0,
|
||||||
|
};
|
||||||
|
mockService.getLeagueStats.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueStats('league-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result.totalMembers).toBe(0);
|
||||||
|
expect(result.totalRaces).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle league with extensive statistics', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
totalMembers: 100,
|
||||||
|
totalRaces: 500,
|
||||||
|
averageRating: 1650.75,
|
||||||
|
};
|
||||||
|
mockService.getLeagueStats.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueStats('league-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result.totalRaces).toBe(500);
|
||||||
|
expect(result.totalMembers).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLeagueMemberships', () => {
|
||||||
|
it('should return league memberships', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
driverId: 'driver-1',
|
||||||
|
driver: {
|
||||||
|
id: 'driver-1',
|
||||||
|
iracingId: '12345',
|
||||||
|
name: 'John Doe',
|
||||||
|
country: 'US',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
role: 'owner',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driverId: 'driver-2',
|
||||||
|
driver: {
|
||||||
|
id: 'driver-2',
|
||||||
|
iracingId: '67890',
|
||||||
|
name: 'Jane Smith',
|
||||||
|
country: 'UK',
|
||||||
|
joinedAt: '2024-01-15T00:00:00Z',
|
||||||
|
},
|
||||||
|
role: 'admin',
|
||||||
|
joinedAt: '2024-01-15T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driverId: 'driver-3',
|
||||||
|
driver: {
|
||||||
|
id: 'driver-3',
|
||||||
|
iracingId: '11111',
|
||||||
|
name: 'Bob Johnson',
|
||||||
|
country: 'CA',
|
||||||
|
joinedAt: '2024-02-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
role: 'member',
|
||||||
|
joinedAt: '2024-02-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockService.getLeagueMemberships.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueMemberships('league-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(mockService.getLeagueMemberships).toHaveBeenCalledWith('league-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty memberships for league with no members', async () => {
|
||||||
|
const mockResult = { members: [] };
|
||||||
|
mockService.getLeagueMemberships.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueMemberships('league-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result.members).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle league with only owner', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
driverId: 'driver-1',
|
||||||
|
driver: {
|
||||||
|
id: 'driver-1',
|
||||||
|
iracingId: '12345',
|
||||||
|
name: 'Owner',
|
||||||
|
country: 'US',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
role: 'owner',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockService.getLeagueMemberships.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueMemberships('league-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result.members).toHaveLength(1);
|
||||||
|
expect(result.members[0]?.role).toBe('owner');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle league with mixed roles', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
driverId: 'driver-1',
|
||||||
|
driver: {
|
||||||
|
id: 'driver-1',
|
||||||
|
iracingId: '12345',
|
||||||
|
name: 'Owner',
|
||||||
|
country: 'US',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
role: 'owner',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driverId: 'driver-2',
|
||||||
|
driver: {
|
||||||
|
id: 'driver-2',
|
||||||
|
iracingId: '67890',
|
||||||
|
name: 'Admin 1',
|
||||||
|
country: 'UK',
|
||||||
|
joinedAt: '2024-01-02T00:00:00Z',
|
||||||
|
},
|
||||||
|
role: 'admin',
|
||||||
|
joinedAt: '2024-01-02T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driverId: 'driver-3',
|
||||||
|
driver: {
|
||||||
|
id: 'driver-3',
|
||||||
|
iracingId: '11111',
|
||||||
|
name: 'Admin 2',
|
||||||
|
country: 'CA',
|
||||||
|
joinedAt: '2024-01-03T00:00:00Z',
|
||||||
|
},
|
||||||
|
role: 'admin',
|
||||||
|
joinedAt: '2024-01-03T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driverId: 'driver-4',
|
||||||
|
driver: {
|
||||||
|
id: 'driver-4',
|
||||||
|
iracingId: '22222',
|
||||||
|
name: 'Member 1',
|
||||||
|
country: 'DE',
|
||||||
|
joinedAt: '2024-01-04T00:00:00Z',
|
||||||
|
},
|
||||||
|
role: 'member',
|
||||||
|
joinedAt: '2024-01-04T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driverId: 'driver-5',
|
||||||
|
driver: {
|
||||||
|
id: 'driver-5',
|
||||||
|
iracingId: '33333',
|
||||||
|
name: 'Member 2',
|
||||||
|
country: 'FR',
|
||||||
|
joinedAt: '2024-01-05T00:00:00Z',
|
||||||
|
},
|
||||||
|
role: 'member',
|
||||||
|
joinedAt: '2024-01-05T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockService.getLeagueMemberships.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueMemberships('league-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result.members).toHaveLength(5);
|
||||||
|
expect(result.members.filter(m => m.role === 'owner')).toHaveLength(1);
|
||||||
|
expect(result.members.filter(m => m.role === 'admin')).toHaveLength(2);
|
||||||
|
expect(result.members.filter(m => m.role === 'member')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
205
apps/api/src/domain/league/LeagueController.discovery.test.ts
Normal file
205
apps/api/src/domain/league/LeagueController.discovery.test.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { LeagueController } from './LeagueController';
|
||||||
|
import { LeagueService } from './LeagueService';
|
||||||
|
|
||||||
|
describe('LeagueController - Discovery Endpoints', () => {
|
||||||
|
let controller: LeagueController;
|
||||||
|
let mockService: ReturnType<typeof vi.mocked<LeagueService>>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockService = {
|
||||||
|
getAllLeaguesWithCapacity: vi.fn(),
|
||||||
|
getAllLeaguesWithCapacityAndScoring: vi.fn(),
|
||||||
|
getTotalLeagues: vi.fn(),
|
||||||
|
} as never;
|
||||||
|
|
||||||
|
controller = new LeagueController(mockService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllLeaguesWithCapacity', () => {
|
||||||
|
it('should return leagues with capacity information', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
leagues: [
|
||||||
|
{
|
||||||
|
id: 'league-1',
|
||||||
|
name: 'GT3 Masters',
|
||||||
|
description: 'A GT3 racing league',
|
||||||
|
ownerId: 'owner-1',
|
||||||
|
maxDrivers: 32,
|
||||||
|
currentDrivers: 25,
|
||||||
|
isPublic: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalCount: 1,
|
||||||
|
};
|
||||||
|
mockService.getAllLeaguesWithCapacity.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getAllLeaguesWithCapacity();
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(mockService.getAllLeaguesWithCapacity).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when no leagues exist', async () => {
|
||||||
|
const mockResult = { leagues: [], totalCount: 0 };
|
||||||
|
mockService.getAllLeaguesWithCapacity.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getAllLeaguesWithCapacity();
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result.leagues).toHaveLength(0);
|
||||||
|
expect(result.totalCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple leagues with different capacities', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
leagues: [
|
||||||
|
{
|
||||||
|
id: 'league-1',
|
||||||
|
name: 'Small League',
|
||||||
|
description: 'Small league',
|
||||||
|
ownerId: 'owner-1',
|
||||||
|
maxDrivers: 10,
|
||||||
|
currentDrivers: 8,
|
||||||
|
isPublic: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'league-2',
|
||||||
|
name: 'Large League',
|
||||||
|
description: 'Large league',
|
||||||
|
ownerId: 'owner-2',
|
||||||
|
maxDrivers: 50,
|
||||||
|
currentDrivers: 45,
|
||||||
|
isPublic: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalCount: 2,
|
||||||
|
};
|
||||||
|
mockService.getAllLeaguesWithCapacity.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getAllLeaguesWithCapacity();
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result.leagues).toHaveLength(2);
|
||||||
|
expect(result.leagues[0]?.maxDrivers).toBe(10);
|
||||||
|
expect(result.leagues[1]?.maxDrivers).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllLeaguesWithCapacityAndScoring', () => {
|
||||||
|
it('should return leagues with capacity and scoring information', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
leagues: [
|
||||||
|
{
|
||||||
|
id: 'league-1',
|
||||||
|
name: 'GT3 Masters',
|
||||||
|
description: 'A GT3 racing league',
|
||||||
|
ownerId: 'owner-1',
|
||||||
|
maxDrivers: 32,
|
||||||
|
currentDrivers: 25,
|
||||||
|
isPublic: true,
|
||||||
|
scoringConfig: {
|
||||||
|
pointsSystem: 'standard',
|
||||||
|
pointsPerRace: 25,
|
||||||
|
bonusPoints: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalCount: 1,
|
||||||
|
};
|
||||||
|
mockService.getAllLeaguesWithCapacityAndScoring.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getAllLeaguesWithCapacityAndScoring();
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(mockService.getAllLeaguesWithCapacityAndScoring).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when no leagues exist', async () => {
|
||||||
|
const mockResult = { leagues: [], totalCount: 0 };
|
||||||
|
mockService.getAllLeaguesWithCapacityAndScoring.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getAllLeaguesWithCapacityAndScoring();
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result.leagues).toHaveLength(0);
|
||||||
|
expect(result.totalCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle leagues with different scoring configurations', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
leagues: [
|
||||||
|
{
|
||||||
|
id: 'league-1',
|
||||||
|
name: 'Standard League',
|
||||||
|
description: 'Standard scoring',
|
||||||
|
ownerId: 'owner-1',
|
||||||
|
maxDrivers: 32,
|
||||||
|
currentDrivers: 20,
|
||||||
|
isPublic: true,
|
||||||
|
scoringConfig: {
|
||||||
|
pointsSystem: 'standard',
|
||||||
|
pointsPerRace: 25,
|
||||||
|
bonusPoints: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'league-2',
|
||||||
|
name: 'Custom League',
|
||||||
|
description: 'Custom scoring',
|
||||||
|
ownerId: 'owner-2',
|
||||||
|
maxDrivers: 20,
|
||||||
|
currentDrivers: 15,
|
||||||
|
isPublic: true,
|
||||||
|
scoringConfig: {
|
||||||
|
pointsSystem: 'custom',
|
||||||
|
pointsPerRace: 50,
|
||||||
|
bonusPoints: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalCount: 2,
|
||||||
|
};
|
||||||
|
mockService.getAllLeaguesWithCapacityAndScoring.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getAllLeaguesWithCapacityAndScoring();
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result.leagues).toHaveLength(2);
|
||||||
|
expect(result.leagues[0]?.scoringConfig.pointsSystem).toBe('standard');
|
||||||
|
expect(result.leagues[1]?.scoringConfig.pointsSystem).toBe('custom');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTotalLeagues', () => {
|
||||||
|
it('should return total leagues count', async () => {
|
||||||
|
const mockResult = { totalLeagues: 42 };
|
||||||
|
mockService.getTotalLeagues.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getTotalLeagues();
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(mockService.getTotalLeagues).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return zero when no leagues exist', async () => {
|
||||||
|
const mockResult = { totalLeagues: 0 };
|
||||||
|
mockService.getTotalLeagues.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getTotalLeagues();
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result.totalLeagues).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle large league counts', async () => {
|
||||||
|
const mockResult = { totalLeagues: 1000 };
|
||||||
|
mockService.getTotalLeagues.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getTotalLeagues();
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result.totalLeagues).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
231
apps/api/src/domain/league/LeagueController.schedule.test.ts
Normal file
231
apps/api/src/domain/league/LeagueController.schedule.test.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { LeagueController } from './LeagueController';
|
||||||
|
import { LeagueService } from './LeagueService';
|
||||||
|
|
||||||
|
describe('LeagueController - Schedule Endpoints', () => {
|
||||||
|
let controller: LeagueController;
|
||||||
|
let mockService: ReturnType<typeof vi.mocked<LeagueService>>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockService = {
|
||||||
|
getLeagueSchedule: vi.fn(),
|
||||||
|
} as never;
|
||||||
|
|
||||||
|
controller = new LeagueController(mockService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLeagueSchedule', () => {
|
||||||
|
it('should return league schedule', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
seasonId: 'season-1',
|
||||||
|
published: true,
|
||||||
|
races: [
|
||||||
|
{
|
||||||
|
id: 'race-1',
|
||||||
|
name: 'Spa Endurance',
|
||||||
|
date: '2024-03-15T14:00:00Z',
|
||||||
|
leagueName: 'GT3 Masters',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'race-2',
|
||||||
|
name: 'Monza Sprint',
|
||||||
|
date: '2024-03-22T14:00:00Z',
|
||||||
|
leagueName: 'GT3 Masters',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'race-3',
|
||||||
|
name: 'Nürburgring Endurance',
|
||||||
|
date: '2024-03-29T14:00:00Z',
|
||||||
|
leagueName: 'GT3 Masters',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockService.getLeagueSchedule.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueSchedule('league-1', {});
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(mockService.getLeagueSchedule).toHaveBeenCalledWith('league-1', {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty schedule for league with no races', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
seasonId: 'season-1',
|
||||||
|
published: false,
|
||||||
|
races: [],
|
||||||
|
};
|
||||||
|
mockService.getLeagueSchedule.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueSchedule('league-1', {});
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result.races).toHaveLength(0);
|
||||||
|
expect(result.published).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle schedule with specific season ID', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
seasonId: 'season-2',
|
||||||
|
published: true,
|
||||||
|
races: [
|
||||||
|
{
|
||||||
|
id: 'race-10',
|
||||||
|
name: 'Silverstone Endurance',
|
||||||
|
date: '2024-08-01T14:00:00Z',
|
||||||
|
leagueName: 'GT3 Masters',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockService.getLeagueSchedule.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueSchedule('league-1', { seasonId: 'season-2' });
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(mockService.getLeagueSchedule).toHaveBeenCalledWith('league-1', { seasonId: 'season-2' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle schedule with multiple races on same track', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
seasonId: 'season-1',
|
||||||
|
published: true,
|
||||||
|
races: [
|
||||||
|
{
|
||||||
|
id: 'race-1',
|
||||||
|
name: 'Spa Endurance',
|
||||||
|
date: '2024-03-15T14:00:00Z',
|
||||||
|
leagueName: 'GT3 Masters',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'race-2',
|
||||||
|
name: 'Spa Sprint',
|
||||||
|
date: '2024-04-15T14:00:00Z',
|
||||||
|
leagueName: 'GT3 Masters',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockService.getLeagueSchedule.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueSchedule('league-1', {});
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result.races).toHaveLength(2);
|
||||||
|
expect(result.races[0]?.name).toContain('Spa');
|
||||||
|
expect(result.races[1]?.name).toContain('Spa');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle schedule with different race names', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
seasonId: 'season-1',
|
||||||
|
published: true,
|
||||||
|
races: [
|
||||||
|
{
|
||||||
|
id: 'race-1',
|
||||||
|
name: 'Spa Endurance',
|
||||||
|
date: '2024-01-15T14:00:00Z',
|
||||||
|
leagueName: 'GT3 Masters',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'race-2',
|
||||||
|
name: 'Monza Sprint',
|
||||||
|
date: '2024-02-15T14:00:00Z',
|
||||||
|
leagueName: 'GT3 Masters',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'race-3',
|
||||||
|
name: 'Nürburgring Endurance',
|
||||||
|
date: '2024-03-15T14:00:00Z',
|
||||||
|
leagueName: 'GT3 Masters',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'race-4',
|
||||||
|
name: 'Silverstone Sprint',
|
||||||
|
date: '2024-04-15T14:00:00Z',
|
||||||
|
leagueName: 'GT3 Masters',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockService.getLeagueSchedule.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueSchedule('league-1', {});
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result.races).toHaveLength(4);
|
||||||
|
expect(result.races[0]?.name).toBe('Spa Endurance');
|
||||||
|
expect(result.races[1]?.name).toBe('Monza Sprint');
|
||||||
|
expect(result.races[2]?.name).toBe('Nürburgring Endurance');
|
||||||
|
expect(result.races[3]?.name).toBe('Silverstone Sprint');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle schedule with different dates', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
seasonId: 'season-1',
|
||||||
|
published: true,
|
||||||
|
races: [
|
||||||
|
{
|
||||||
|
id: 'race-1',
|
||||||
|
name: 'Race 1',
|
||||||
|
date: '2024-01-15T14:00:00Z',
|
||||||
|
leagueName: 'GT3 Masters',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'race-2',
|
||||||
|
name: 'Race 2',
|
||||||
|
date: '2024-02-15T14:00:00Z',
|
||||||
|
leagueName: 'GT3 Masters',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'race-3',
|
||||||
|
name: 'Race 3',
|
||||||
|
date: '2024-03-15T14:00:00Z',
|
||||||
|
leagueName: 'GT3 Masters',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockService.getLeagueSchedule.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueSchedule('league-1', {});
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result.races).toHaveLength(3);
|
||||||
|
expect(result.races[0]?.date).toBe('2024-01-15T14:00:00Z');
|
||||||
|
expect(result.races[1]?.date).toBe('2024-02-15T14:00:00Z');
|
||||||
|
expect(result.races[2]?.date).toBe('2024-03-15T14:00:00Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle schedule with league name variations', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
seasonId: 'season-1',
|
||||||
|
published: true,
|
||||||
|
races: [
|
||||||
|
{
|
||||||
|
id: 'race-1',
|
||||||
|
name: 'Race 1',
|
||||||
|
date: '2024-03-15T14:00:00Z',
|
||||||
|
leagueName: 'GT3 Masters',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'race-2',
|
||||||
|
name: 'Race 2',
|
||||||
|
date: '2024-03-22T14:00:00Z',
|
||||||
|
leagueName: 'GT3 Masters',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'race-3',
|
||||||
|
name: 'Race 3',
|
||||||
|
date: '2024-03-29T14:00:00Z',
|
||||||
|
leagueName: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockService.getLeagueSchedule.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueSchedule('league-1', {});
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result.races).toHaveLength(3);
|
||||||
|
expect(result.races[0]?.leagueName).toBe('GT3 Masters');
|
||||||
|
expect(result.races[1]?.leagueName).toBe('GT3 Masters');
|
||||||
|
expect(result.races[2]?.leagueName).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
388
apps/api/src/domain/league/LeagueController.standings.test.ts
Normal file
388
apps/api/src/domain/league/LeagueController.standings.test.ts
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { LeagueController } from './LeagueController';
|
||||||
|
import { LeagueService } from './LeagueService';
|
||||||
|
|
||||||
|
describe('LeagueController - Standings Endpoints', () => {
|
||||||
|
let controller: LeagueController;
|
||||||
|
let mockService: ReturnType<typeof vi.mocked<LeagueService>>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockService = {
|
||||||
|
getLeagueStandings: vi.fn(),
|
||||||
|
} as never;
|
||||||
|
|
||||||
|
controller = new LeagueController(mockService);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLeagueStandings', () => {
|
||||||
|
it('should return league standings', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
standings: [
|
||||||
|
{
|
||||||
|
driverId: 'driver-1',
|
||||||
|
driver: {
|
||||||
|
id: 'driver-1',
|
||||||
|
iracingId: '12345',
|
||||||
|
name: 'John Doe',
|
||||||
|
country: 'US',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
position: 1,
|
||||||
|
points: 150,
|
||||||
|
races: 12,
|
||||||
|
wins: 5,
|
||||||
|
podiums: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driverId: 'driver-2',
|
||||||
|
driver: {
|
||||||
|
id: 'driver-2',
|
||||||
|
iracingId: '67890',
|
||||||
|
name: 'Jane Smith',
|
||||||
|
country: 'UK',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
position: 2,
|
||||||
|
points: 145,
|
||||||
|
races: 12,
|
||||||
|
wins: 4,
|
||||||
|
podiums: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driverId: 'driver-3',
|
||||||
|
driver: {
|
||||||
|
id: 'driver-3',
|
||||||
|
iracingId: '11111',
|
||||||
|
name: 'Bob Johnson',
|
||||||
|
country: 'CA',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
position: 3,
|
||||||
|
points: 140,
|
||||||
|
races: 12,
|
||||||
|
wins: 3,
|
||||||
|
podiums: 6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockService.getLeagueStandings.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueStandings('league-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(mockService.getLeagueStandings).toHaveBeenCalledWith('league-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty standings for league with no races', async () => {
|
||||||
|
const mockResult = { standings: [] };
|
||||||
|
mockService.getLeagueStandings.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueStandings('league-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result.standings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle standings with single driver', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
standings: [
|
||||||
|
{
|
||||||
|
driverId: 'driver-1',
|
||||||
|
driver: {
|
||||||
|
id: 'driver-1',
|
||||||
|
iracingId: '12345',
|
||||||
|
name: 'John Doe',
|
||||||
|
country: 'US',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
position: 1,
|
||||||
|
points: 100,
|
||||||
|
races: 10,
|
||||||
|
wins: 10,
|
||||||
|
podiums: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockService.getLeagueStandings.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueStandings('league-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result.standings).toHaveLength(1);
|
||||||
|
expect(result.standings[0]?.position).toBe(1);
|
||||||
|
expect(result.standings[0]?.points).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle standings with many drivers', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
standings: Array.from({ length: 20 }, (_, i) => ({
|
||||||
|
driverId: `driver-${i + 1}`,
|
||||||
|
driver: {
|
||||||
|
id: `driver-${i + 1}`,
|
||||||
|
iracingId: `${10000 + i}`,
|
||||||
|
name: `Driver ${i + 1}`,
|
||||||
|
country: 'US',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
position: i + 1,
|
||||||
|
points: 100 - i,
|
||||||
|
races: 12,
|
||||||
|
wins: Math.max(0, 5 - i),
|
||||||
|
podiums: Math.max(0, 10 - i),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
mockService.getLeagueStandings.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueStandings('league-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result.standings).toHaveLength(20);
|
||||||
|
expect(result.standings[0]?.position).toBe(1);
|
||||||
|
expect(result.standings[19]?.position).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle standings with tied points', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
standings: [
|
||||||
|
{
|
||||||
|
driverId: 'driver-1',
|
||||||
|
driver: {
|
||||||
|
id: 'driver-1',
|
||||||
|
iracingId: '12345',
|
||||||
|
name: 'John Doe',
|
||||||
|
country: 'US',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
position: 1,
|
||||||
|
points: 150,
|
||||||
|
races: 12,
|
||||||
|
wins: 5,
|
||||||
|
podiums: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driverId: 'driver-2',
|
||||||
|
driver: {
|
||||||
|
id: 'driver-2',
|
||||||
|
iracingId: '67890',
|
||||||
|
name: 'Jane Smith',
|
||||||
|
country: 'UK',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
position: 2,
|
||||||
|
points: 150,
|
||||||
|
races: 12,
|
||||||
|
wins: 5,
|
||||||
|
podiums: 7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driverId: 'driver-3',
|
||||||
|
driver: {
|
||||||
|
id: 'driver-3',
|
||||||
|
iracingId: '11111',
|
||||||
|
name: 'Bob Johnson',
|
||||||
|
country: 'CA',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
position: 3,
|
||||||
|
points: 145,
|
||||||
|
races: 12,
|
||||||
|
wins: 4,
|
||||||
|
podiums: 6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockService.getLeagueStandings.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueStandings('league-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result.standings).toHaveLength(3);
|
||||||
|
expect(result.standings[0]?.points).toBe(150);
|
||||||
|
expect(result.standings[1]?.points).toBe(150);
|
||||||
|
expect(result.standings[2]?.points).toBe(145);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle standings with varying race counts', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
standings: [
|
||||||
|
{
|
||||||
|
driverId: 'driver-1',
|
||||||
|
driver: {
|
||||||
|
id: 'driver-1',
|
||||||
|
iracingId: '12345',
|
||||||
|
name: 'John Doe',
|
||||||
|
country: 'US',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
position: 1,
|
||||||
|
points: 150,
|
||||||
|
races: 12,
|
||||||
|
wins: 5,
|
||||||
|
podiums: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driverId: 'driver-2',
|
||||||
|
driver: {
|
||||||
|
id: 'driver-2',
|
||||||
|
iracingId: '67890',
|
||||||
|
name: 'Jane Smith',
|
||||||
|
country: 'UK',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
position: 2,
|
||||||
|
points: 140,
|
||||||
|
races: 10,
|
||||||
|
wins: 4,
|
||||||
|
podiums: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driverId: 'driver-3',
|
||||||
|
driver: {
|
||||||
|
id: 'driver-3',
|
||||||
|
iracingId: '11111',
|
||||||
|
name: 'Bob Johnson',
|
||||||
|
country: 'CA',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
position: 3,
|
||||||
|
points: 130,
|
||||||
|
races: 8,
|
||||||
|
wins: 3,
|
||||||
|
podiums: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockService.getLeagueStandings.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueStandings('league-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result.standings).toHaveLength(3);
|
||||||
|
expect(result.standings[0]?.races).toBe(12);
|
||||||
|
expect(result.standings[1]?.races).toBe(10);
|
||||||
|
expect(result.standings[2]?.races).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle standings with varying win counts', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
standings: [
|
||||||
|
{
|
||||||
|
driverId: 'driver-1',
|
||||||
|
driver: {
|
||||||
|
id: 'driver-1',
|
||||||
|
iracingId: '12345',
|
||||||
|
name: 'John Doe',
|
||||||
|
country: 'US',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
position: 1,
|
||||||
|
points: 150,
|
||||||
|
races: 12,
|
||||||
|
wins: 10,
|
||||||
|
podiums: 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driverId: 'driver-2',
|
||||||
|
driver: {
|
||||||
|
id: 'driver-2',
|
||||||
|
iracingId: '67890',
|
||||||
|
name: 'Jane Smith',
|
||||||
|
country: 'UK',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
position: 2,
|
||||||
|
points: 140,
|
||||||
|
races: 12,
|
||||||
|
wins: 2,
|
||||||
|
podiums: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driverId: 'driver-3',
|
||||||
|
driver: {
|
||||||
|
id: 'driver-3',
|
||||||
|
iracingId: '11111',
|
||||||
|
name: 'Bob Johnson',
|
||||||
|
country: 'CA',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
position: 3,
|
||||||
|
points: 130,
|
||||||
|
races: 12,
|
||||||
|
wins: 0,
|
||||||
|
podiums: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockService.getLeagueStandings.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueStandings('league-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result.standings).toHaveLength(3);
|
||||||
|
expect(result.standings[0]?.wins).toBe(10);
|
||||||
|
expect(result.standings[1]?.wins).toBe(2);
|
||||||
|
expect(result.standings[2]?.wins).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle standings with varying podium counts', async () => {
|
||||||
|
const mockResult = {
|
||||||
|
standings: [
|
||||||
|
{
|
||||||
|
driverId: 'driver-1',
|
||||||
|
driver: {
|
||||||
|
id: 'driver-1',
|
||||||
|
iracingId: '12345',
|
||||||
|
name: 'John Doe',
|
||||||
|
country: 'US',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
position: 1,
|
||||||
|
points: 150,
|
||||||
|
races: 12,
|
||||||
|
wins: 5,
|
||||||
|
podiums: 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driverId: 'driver-2',
|
||||||
|
driver: {
|
||||||
|
id: 'driver-2',
|
||||||
|
iracingId: '67890',
|
||||||
|
name: 'Jane Smith',
|
||||||
|
country: 'UK',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
position: 2,
|
||||||
|
points: 140,
|
||||||
|
races: 12,
|
||||||
|
wins: 4,
|
||||||
|
podiums: 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driverId: 'driver-3',
|
||||||
|
driver: {
|
||||||
|
id: 'driver-3',
|
||||||
|
iracingId: '11111',
|
||||||
|
name: 'Bob Johnson',
|
||||||
|
country: 'CA',
|
||||||
|
joinedAt: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
position: 3,
|
||||||
|
points: 130,
|
||||||
|
races: 12,
|
||||||
|
wins: 3,
|
||||||
|
podiums: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
mockService.getLeagueStandings.mockResolvedValue(mockResult as never);
|
||||||
|
|
||||||
|
const result = await controller.getLeagueStandings('league-1');
|
||||||
|
|
||||||
|
expect(result).toEqual(mockResult);
|
||||||
|
expect(result.standings).toHaveLength(3);
|
||||||
|
expect(result.standings[0]?.podiums).toBe(12);
|
||||||
|
expect(result.standings[1]?.podiums).toBe(8);
|
||||||
|
expect(result.standings[2]?.podiums).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
30
apps/api/src/domain/league/LeagueModule.test.ts
Normal file
30
apps/api/src/domain/league/LeagueModule.test.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { LeagueController } from './LeagueController';
|
||||||
|
import { LeagueModule } from './LeagueModule';
|
||||||
|
import { LeagueService } from './LeagueService';
|
||||||
|
|
||||||
|
describe('LeagueModule', () => {
|
||||||
|
let module: TestingModule;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
module = await Test.createTestingModule({
|
||||||
|
imports: [LeagueModule],
|
||||||
|
}).compile();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should compile the module', () => {
|
||||||
|
expect(module).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide LeagueController', () => {
|
||||||
|
const controller = module.get<LeagueController>(LeagueController);
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
expect(controller).toBeInstanceOf(LeagueController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide LeagueService', () => {
|
||||||
|
const service = module.get<LeagueService>(LeagueService);
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
expect(service).toBeInstanceOf(LeagueService);
|
||||||
|
});
|
||||||
|
});
|
||||||
257
apps/api/src/domain/league/LeagueService.endpoints.test.ts
Normal file
257
apps/api/src/domain/league/LeagueService.endpoints.test.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import { requestContextMiddleware } from '@adapters/http/RequestContext';
|
||||||
|
import { Result } from '@core/shared/domain/Result';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { LeagueService } from './LeagueService';
|
||||||
|
|
||||||
|
async function withUserId<T>(userId: string, fn: () => Promise<T>): Promise<T> {
|
||||||
|
const req = { user: { userId } };
|
||||||
|
const res = {};
|
||||||
|
|
||||||
|
return await new Promise<T>((resolve, reject) => {
|
||||||
|
requestContextMiddleware(req as never, res as never, () => {
|
||||||
|
fn().then(resolve, reject);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LeagueService - All Endpoints', () => {
|
||||||
|
it('covers all league endpoint happy paths and error branches', async () => {
|
||||||
|
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||||
|
|
||||||
|
const ok = async () => Result.ok(undefined);
|
||||||
|
const err = async () => Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } } as never);
|
||||||
|
|
||||||
|
// Discovery use cases
|
||||||
|
const getAllLeaguesWithCapacityUseCase = { execute: vi.fn(async () => Result.ok({ leagues: [] })) };
|
||||||
|
const getAllLeaguesWithCapacityAndScoringUseCase = { execute: vi.fn(ok) };
|
||||||
|
const getTotalLeaguesUseCase = { execute: vi.fn(ok) };
|
||||||
|
|
||||||
|
// Detail use cases
|
||||||
|
const getLeagueOwnerSummaryUseCase = { execute: vi.fn(ok) };
|
||||||
|
const getLeagueSeasonsUseCase = { execute: vi.fn(ok) };
|
||||||
|
const getLeagueStatsUseCase = { execute: vi.fn(ok) };
|
||||||
|
const getLeagueMembershipsUseCase = { execute: vi.fn(ok) };
|
||||||
|
|
||||||
|
// Schedule use case
|
||||||
|
const getLeagueScheduleUseCase = { execute: vi.fn(ok) };
|
||||||
|
|
||||||
|
// Standings use case
|
||||||
|
const getLeagueStandingsUseCase = { execute: vi.fn(ok) };
|
||||||
|
|
||||||
|
// Other use cases (for completeness)
|
||||||
|
const getLeagueFullConfigUseCase = { execute: vi.fn(ok) };
|
||||||
|
const getLeagueScoringConfigUseCase = { execute: vi.fn(ok) };
|
||||||
|
const listLeagueScoringPresetsUseCase = { execute: vi.fn(ok) };
|
||||||
|
const joinLeagueUseCase = { execute: vi.fn(ok) };
|
||||||
|
const transferLeagueOwnershipUseCase = { execute: vi.fn(ok) };
|
||||||
|
const createLeagueWithSeasonAndScoringUseCase = { execute: vi.fn(ok) };
|
||||||
|
const getLeagueJoinRequestsUseCase = { execute: vi.fn(ok) };
|
||||||
|
const approveLeagueJoinRequestUseCase = { execute: vi.fn(ok) };
|
||||||
|
const rejectLeagueJoinRequestUseCase = { execute: vi.fn(ok) };
|
||||||
|
const removeLeagueMemberUseCase = { execute: vi.fn(ok) };
|
||||||
|
const updateLeagueMemberRoleUseCase = { execute: vi.fn(ok) };
|
||||||
|
const getLeagueProtestsUseCase = { execute: vi.fn(ok) };
|
||||||
|
const getLeagueAdminPermissionsUseCase = { execute: vi.fn(ok) };
|
||||||
|
const getLeagueWalletUseCase = { execute: vi.fn(ok) };
|
||||||
|
const withdrawFromLeagueWalletUseCase = { execute: vi.fn(ok) };
|
||||||
|
const getSeasonSponsorshipsUseCase = { execute: vi.fn(ok) };
|
||||||
|
|
||||||
|
const getLeagueRosterMembersUseCase = { execute: vi.fn(ok) };
|
||||||
|
const getLeagueRosterJoinRequestsUseCase = { execute: vi.fn(ok) };
|
||||||
|
|
||||||
|
// Schedule mutation use cases
|
||||||
|
const createLeagueSeasonScheduleRaceUseCase = { execute: vi.fn(ok) };
|
||||||
|
const updateLeagueSeasonScheduleRaceUseCase = { execute: vi.fn(ok) };
|
||||||
|
const deleteLeagueSeasonScheduleRaceUseCase = { execute: vi.fn(ok) };
|
||||||
|
const publishLeagueSeasonScheduleUseCase = { execute: vi.fn(ok) };
|
||||||
|
const unpublishLeagueSeasonScheduleUseCase = { execute: vi.fn(ok) };
|
||||||
|
|
||||||
|
// Presenters
|
||||||
|
const allLeaguesWithCapacityPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ leagues: [] })) };
|
||||||
|
const allLeaguesWithCapacityAndScoringPresenter = {
|
||||||
|
present: vi.fn(),
|
||||||
|
getViewModel: vi.fn(() => ({ leagues: [], totalCount: 0 })),
|
||||||
|
};
|
||||||
|
const leagueStandingsPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ standings: [] })) };
|
||||||
|
const leagueProtestsPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ protests: [] })) };
|
||||||
|
const seasonSponsorshipsPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ sponsorships: [] })) };
|
||||||
|
const leagueScoringPresetsPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ presets: [] })) };
|
||||||
|
const approveLeagueJoinRequestPresenter = {
|
||||||
|
present: vi.fn(),
|
||||||
|
getViewModel: vi.fn(() => ({ success: true }))
|
||||||
|
};
|
||||||
|
const createLeaguePresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ id: 'l1' })) };
|
||||||
|
const getLeagueAdminPermissionsPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ canManage: true })) };
|
||||||
|
const getLeagueMembershipsPresenter = {
|
||||||
|
reset: vi.fn(),
|
||||||
|
present: vi.fn(),
|
||||||
|
getViewModel: vi.fn(() => ({ memberships: { members: [] } })),
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLeagueRosterMembersPresenter = {
|
||||||
|
reset: vi.fn(),
|
||||||
|
present: vi.fn(),
|
||||||
|
getViewModel: vi.fn(() => ([])),
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLeagueRosterJoinRequestsPresenter = {
|
||||||
|
reset: vi.fn(),
|
||||||
|
present: vi.fn(),
|
||||||
|
getViewModel: vi.fn(() => ([])),
|
||||||
|
};
|
||||||
|
const getLeagueOwnerSummaryPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ driver: { id: 'd1', iracingId: '12345', name: 'Driver', country: 'US', joinedAt: '2024-01-01T00:00:00Z' }, rating: 1500, rank: 10 })) };
|
||||||
|
const getLeagueSeasonsPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ([])) };
|
||||||
|
const joinLeaguePresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ success: true })) };
|
||||||
|
const leagueSchedulePresenter = { reset: vi.fn(), present: vi.fn(), getViewModel: vi.fn(() => ({ seasonId: 'season-1', published: false, races: [] })) };
|
||||||
|
const leagueStatsPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ totalMembers: 0, totalRaces: 0, averageRating: 0 })) };
|
||||||
|
const rejectLeagueJoinRequestPresenter = {
|
||||||
|
present: vi.fn(),
|
||||||
|
getViewModel: vi.fn(() => ({ success: true }))
|
||||||
|
};
|
||||||
|
const removeLeagueMemberPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ success: true })) };
|
||||||
|
const totalLeaguesPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ totalLeagues: 0 })) };
|
||||||
|
const transferLeagueOwnershipPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ success: true })) };
|
||||||
|
const updateLeagueMemberRolePresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ success: true })) };
|
||||||
|
const leagueConfigPresenter = {
|
||||||
|
present: vi.fn(),
|
||||||
|
getViewModel: vi.fn(() => ({ form: {} }))
|
||||||
|
};
|
||||||
|
const leagueScoringConfigPresenter = {
|
||||||
|
present: vi.fn(),
|
||||||
|
getViewModel: vi.fn(() => ({ config: {} }))
|
||||||
|
};
|
||||||
|
const getLeagueWalletPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ balance: 0 })) };
|
||||||
|
const withdrawFromLeagueWalletPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ success: true })) };
|
||||||
|
const leagueJoinRequestsPresenter = { reset: vi.fn(), present: vi.fn(), getViewModel: vi.fn(() => ({ joinRequests: [] })) };
|
||||||
|
|
||||||
|
const createLeagueSeasonScheduleRacePresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ raceId: 'race-1' })) };
|
||||||
|
const updateLeagueSeasonScheduleRacePresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ success: true })) };
|
||||||
|
const deleteLeagueSeasonScheduleRacePresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ success: true })) };
|
||||||
|
const publishLeagueSeasonSchedulePresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ success: true, published: true })) };
|
||||||
|
const unpublishLeagueSeasonSchedulePresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ success: true, published: false })) };
|
||||||
|
|
||||||
|
const service = new (LeagueService as unknown as { new (...args: never[]): LeagueService })(
|
||||||
|
getAllLeaguesWithCapacityUseCase as never,
|
||||||
|
getAllLeaguesWithCapacityAndScoringUseCase as never,
|
||||||
|
getLeagueStandingsUseCase as never,
|
||||||
|
getLeagueStatsUseCase as never,
|
||||||
|
getLeagueFullConfigUseCase as never,
|
||||||
|
getLeagueScoringConfigUseCase as never,
|
||||||
|
listLeagueScoringPresetsUseCase as never,
|
||||||
|
joinLeagueUseCase as never,
|
||||||
|
transferLeagueOwnershipUseCase as never,
|
||||||
|
createLeagueWithSeasonAndScoringUseCase as never,
|
||||||
|
getTotalLeaguesUseCase as never,
|
||||||
|
getLeagueJoinRequestsUseCase as never,
|
||||||
|
approveLeagueJoinRequestUseCase as never,
|
||||||
|
rejectLeagueJoinRequestUseCase as never,
|
||||||
|
removeLeagueMemberUseCase as never,
|
||||||
|
updateLeagueMemberRoleUseCase as never,
|
||||||
|
getLeagueOwnerSummaryUseCase as never,
|
||||||
|
getLeagueProtestsUseCase as never,
|
||||||
|
getLeagueSeasonsUseCase as never,
|
||||||
|
getLeagueMembershipsUseCase as never,
|
||||||
|
getLeagueScheduleUseCase as never,
|
||||||
|
getLeagueAdminPermissionsUseCase as never,
|
||||||
|
getLeagueWalletUseCase as never,
|
||||||
|
withdrawFromLeagueWalletUseCase as never,
|
||||||
|
getSeasonSponsorshipsUseCase as never,
|
||||||
|
createLeagueSeasonScheduleRaceUseCase as never,
|
||||||
|
updateLeagueSeasonScheduleRaceUseCase as never,
|
||||||
|
deleteLeagueSeasonScheduleRaceUseCase as never,
|
||||||
|
publishLeagueSeasonScheduleUseCase as never,
|
||||||
|
unpublishLeagueSeasonScheduleUseCase as never,
|
||||||
|
logger as never,
|
||||||
|
allLeaguesWithCapacityPresenter as never,
|
||||||
|
allLeaguesWithCapacityAndScoringPresenter as never,
|
||||||
|
leagueStandingsPresenter as never,
|
||||||
|
leagueProtestsPresenter as never,
|
||||||
|
seasonSponsorshipsPresenter as never,
|
||||||
|
leagueScoringPresetsPresenter as never,
|
||||||
|
approveLeagueJoinRequestPresenter as never,
|
||||||
|
createLeaguePresenter as never,
|
||||||
|
getLeagueAdminPermissionsPresenter as never,
|
||||||
|
getLeagueMembershipsPresenter as never,
|
||||||
|
getLeagueOwnerSummaryPresenter as never,
|
||||||
|
getLeagueSeasonsPresenter as never,
|
||||||
|
joinLeaguePresenter as never,
|
||||||
|
leagueSchedulePresenter as never,
|
||||||
|
leagueStatsPresenter as never,
|
||||||
|
rejectLeagueJoinRequestPresenter as never,
|
||||||
|
removeLeagueMemberPresenter as never,
|
||||||
|
totalLeaguesPresenter as never,
|
||||||
|
transferLeagueOwnershipPresenter as never,
|
||||||
|
updateLeagueMemberRolePresenter as never,
|
||||||
|
leagueConfigPresenter as never,
|
||||||
|
leagueScoringConfigPresenter as never,
|
||||||
|
getLeagueWalletPresenter as never,
|
||||||
|
withdrawFromLeagueWalletPresenter as never,
|
||||||
|
leagueJoinRequestsPresenter as never,
|
||||||
|
createLeagueSeasonScheduleRacePresenter as never,
|
||||||
|
updateLeagueSeasonScheduleRacePresenter as never,
|
||||||
|
deleteLeagueSeasonScheduleRacePresenter as never,
|
||||||
|
publishLeagueSeasonSchedulePresenter as never,
|
||||||
|
unpublishLeagueSeasonSchedulePresenter as never,
|
||||||
|
|
||||||
|
// Roster admin read delegation (added for strict TDD)
|
||||||
|
getLeagueRosterMembersUseCase as never,
|
||||||
|
getLeagueRosterJoinRequestsUseCase as never,
|
||||||
|
getLeagueRosterMembersPresenter as never,
|
||||||
|
getLeagueRosterJoinRequestsPresenter as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Discovery endpoints
|
||||||
|
await expect(service.getAllLeaguesWithCapacity()).resolves.toEqual({ leagues: [] });
|
||||||
|
await expect(service.getAllLeaguesWithCapacityAndScoring()).resolves.toEqual({ leagues: [], totalCount: 0 });
|
||||||
|
await expect(service.getTotalLeagues()).resolves.toEqual({ totalLeagues: 0 });
|
||||||
|
|
||||||
|
// Detail endpoints
|
||||||
|
await expect(service.getLeagueOwnerSummary({ leagueId: 'l1' } as never)).resolves.toEqual({ driver: { id: 'd1', iracingId: '12345', name: 'Driver', country: 'US', joinedAt: '2024-01-01T00:00:00Z' }, rating: 1500, rank: 10 });
|
||||||
|
await expect(service.getLeagueSeasons({ leagueId: 'l1' } as never)).resolves.toEqual([]);
|
||||||
|
await expect(service.getLeagueStats('l1')).resolves.toEqual({ totalMembers: 0, totalRaces: 0, averageRating: 0 });
|
||||||
|
await expect(service.getLeagueMemberships('l1')).resolves.toEqual({ members: [] });
|
||||||
|
|
||||||
|
// Schedule endpoint
|
||||||
|
await expect(service.getLeagueSchedule('l1')).resolves.toEqual({ seasonId: 'season-1', published: false, races: [] });
|
||||||
|
expect(getLeagueScheduleUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1' });
|
||||||
|
|
||||||
|
getLeagueScheduleUseCase.execute.mockClear();
|
||||||
|
await expect(service.getLeagueSchedule('l1', { seasonId: 'season-x' } as never)).resolves.toEqual({
|
||||||
|
seasonId: 'season-1',
|
||||||
|
published: false,
|
||||||
|
races: [],
|
||||||
|
});
|
||||||
|
expect(getLeagueScheduleUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1', seasonId: 'season-x' });
|
||||||
|
|
||||||
|
// Standings endpoint
|
||||||
|
await expect(service.getLeagueStandings('l1')).resolves.toEqual({ standings: [] });
|
||||||
|
|
||||||
|
// Error branches: use case returns error result
|
||||||
|
getAllLeaguesWithCapacityUseCase.execute.mockResolvedValueOnce(Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } } as never));
|
||||||
|
await expect(service.getAllLeaguesWithCapacity()).rejects.toThrow('REPOSITORY_ERROR');
|
||||||
|
|
||||||
|
getLeagueFullConfigUseCase.execute.mockResolvedValueOnce(
|
||||||
|
Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } } as never)
|
||||||
|
);
|
||||||
|
await expect(service.getLeagueFullConfig({ leagueId: 'l1' } as never)).resolves.toBeNull();
|
||||||
|
|
||||||
|
getLeagueScoringConfigUseCase.execute.mockImplementationOnce(async () => {
|
||||||
|
throw new Error('boom');
|
||||||
|
});
|
||||||
|
await expect(service.getLeagueScoringConfig('l1')).resolves.toBeNull();
|
||||||
|
|
||||||
|
// Cover non-Error throw branches for logger.error wrapping
|
||||||
|
getLeagueFullConfigUseCase.execute.mockResolvedValueOnce(
|
||||||
|
Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } } as never)
|
||||||
|
);
|
||||||
|
await expect(service.getLeagueFullConfig({ leagueId: 'l1' } as never)).resolves.toBeNull();
|
||||||
|
|
||||||
|
getLeagueScoringConfigUseCase.execute.mockImplementationOnce(async () => {
|
||||||
|
throw 'boom';
|
||||||
|
});
|
||||||
|
await expect(service.getLeagueScoringConfig('l1')).resolves.toBeNull();
|
||||||
|
|
||||||
|
// keep lint happy (ensures err() used)
|
||||||
|
await err();
|
||||||
|
});
|
||||||
|
});
|
||||||
32
apps/api/src/domain/logging/LoggingModule.test.ts
Normal file
32
apps/api/src/domain/logging/LoggingModule.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { LoggingModule } from './LoggingModule';
|
||||||
|
|
||||||
|
describe('LoggingModule', () => {
|
||||||
|
let module: TestingModule;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
module = await Test.createTestingModule({
|
||||||
|
imports: [LoggingModule],
|
||||||
|
}).compile();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should compile the module', () => {
|
||||||
|
expect(module).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide Logger provider', () => {
|
||||||
|
const logger = module.get('Logger');
|
||||||
|
expect(logger).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should export Logger provider', () => {
|
||||||
|
const logger = module.get('Logger');
|
||||||
|
expect(logger).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be a global module', () => {
|
||||||
|
// Check if the module has the @Global() decorator by verifying it's registered globally
|
||||||
|
// In NestJS, global modules are automatically available to all other modules
|
||||||
|
expect(module).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { NotificationsController } from './NotificationsController';
|
||||||
|
import { NotificationsService } from './NotificationsService';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
|
||||||
|
describe('NotificationsController', () => {
|
||||||
|
let controller: NotificationsController;
|
||||||
|
let service: ReturnType<typeof vi.mocked<NotificationsService>>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [NotificationsController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: NotificationsService,
|
||||||
|
useValue: {
|
||||||
|
getUnreadNotifications: vi.fn(),
|
||||||
|
getAllNotifications: vi.fn(),
|
||||||
|
markAsRead: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<NotificationsController>(NotificationsController);
|
||||||
|
service = vi.mocked(module.get(NotificationsService));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getUnreadNotifications', () => {
|
||||||
|
it('should return unread notifications for authenticated user', async () => {
|
||||||
|
const mockNotifications = [
|
||||||
|
{ id: '1', message: 'Test notification 1' },
|
||||||
|
{ id: '2', message: 'Test notification 2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
service.getUnreadNotifications.mockResolvedValue(mockNotifications);
|
||||||
|
|
||||||
|
const mockReq = {
|
||||||
|
user: { userId: 'user-123' },
|
||||||
|
} as unknown as Request;
|
||||||
|
|
||||||
|
const mockRes = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
json: vi.fn(),
|
||||||
|
} as unknown as Response;
|
||||||
|
|
||||||
|
await controller.getUnreadNotifications(mockReq, mockRes);
|
||||||
|
|
||||||
|
expect(service.getUnreadNotifications).toHaveBeenCalledWith('user-123');
|
||||||
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith({ notifications: mockNotifications });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when user is not authenticated', async () => {
|
||||||
|
const mockReq = {} as unknown as Request;
|
||||||
|
const mockRes = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
json: vi.fn(),
|
||||||
|
} as unknown as Response;
|
||||||
|
|
||||||
|
await controller.getUnreadNotifications(mockReq, mockRes);
|
||||||
|
|
||||||
|
expect(service.getUnreadNotifications).not.toHaveBeenCalled();
|
||||||
|
expect(mockRes.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith({ error: 'Unauthorized' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when userId is missing', async () => {
|
||||||
|
const mockReq = {
|
||||||
|
user: {},
|
||||||
|
} as unknown as Request;
|
||||||
|
|
||||||
|
const mockRes = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
json: vi.fn(),
|
||||||
|
} as unknown as Response;
|
||||||
|
|
||||||
|
await controller.getUnreadNotifications(mockReq, mockRes);
|
||||||
|
|
||||||
|
expect(service.getUnreadNotifications).not.toHaveBeenCalled();
|
||||||
|
expect(mockRes.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith({ error: 'Unauthorized' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markAsRead', () => {
|
||||||
|
it('should mark notification as read for authenticated user', async () => {
|
||||||
|
service.markAsRead.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const mockReq = {
|
||||||
|
user: { userId: 'user-123' },
|
||||||
|
} as unknown as Request;
|
||||||
|
|
||||||
|
const mockRes = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
json: vi.fn(),
|
||||||
|
} as unknown as Response;
|
||||||
|
|
||||||
|
await controller.markAsRead('notification-123', mockReq, mockRes);
|
||||||
|
|
||||||
|
expect(service.markAsRead).toHaveBeenCalledWith('notification-123', 'user-123');
|
||||||
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when user is not authenticated', async () => {
|
||||||
|
const mockReq = {} as unknown as Request;
|
||||||
|
const mockRes = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
json: vi.fn(),
|
||||||
|
} as unknown as Response;
|
||||||
|
|
||||||
|
await controller.markAsRead('notification-123', mockReq, mockRes);
|
||||||
|
|
||||||
|
expect(service.markAsRead).not.toHaveBeenCalled();
|
||||||
|
expect(mockRes.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith({ error: 'Unauthorized' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when userId is missing', async () => {
|
||||||
|
const mockReq = {
|
||||||
|
user: {},
|
||||||
|
} as unknown as Request;
|
||||||
|
|
||||||
|
const mockRes = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
json: vi.fn(),
|
||||||
|
} as unknown as Response;
|
||||||
|
|
||||||
|
await controller.markAsRead('notification-123', mockReq, mockRes);
|
||||||
|
|
||||||
|
expect(service.markAsRead).not.toHaveBeenCalled();
|
||||||
|
expect(mockRes.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith({ error: 'Unauthorized' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllNotifications', () => {
|
||||||
|
it('should return all notifications for authenticated user', async () => {
|
||||||
|
const mockNotifications = [
|
||||||
|
{ id: '1', message: 'Test notification 1' },
|
||||||
|
{ id: '2', message: 'Test notification 2' },
|
||||||
|
{ id: '3', message: 'Test notification 3' },
|
||||||
|
];
|
||||||
|
|
||||||
|
service.getAllNotifications.mockResolvedValue(mockNotifications);
|
||||||
|
|
||||||
|
const mockReq = {
|
||||||
|
user: { userId: 'user-123' },
|
||||||
|
} as unknown as Request;
|
||||||
|
|
||||||
|
const mockRes = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
json: vi.fn(),
|
||||||
|
} as unknown as Response;
|
||||||
|
|
||||||
|
await controller.getAllNotifications(mockReq, mockRes);
|
||||||
|
|
||||||
|
expect(service.getAllNotifications).toHaveBeenCalledWith('user-123');
|
||||||
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith({ notifications: mockNotifications });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when user is not authenticated', async () => {
|
||||||
|
const mockReq = {} as unknown as Request;
|
||||||
|
const mockRes = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
json: vi.fn(),
|
||||||
|
} as unknown as Response;
|
||||||
|
|
||||||
|
await controller.getAllNotifications(mockReq, mockRes);
|
||||||
|
|
||||||
|
expect(service.getAllNotifications).not.toHaveBeenCalled();
|
||||||
|
expect(mockRes.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith({ error: 'Unauthorized' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 401 when userId is missing', async () => {
|
||||||
|
const mockReq = {
|
||||||
|
user: {},
|
||||||
|
} as unknown as Request;
|
||||||
|
|
||||||
|
const mockRes = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
json: vi.fn(),
|
||||||
|
} as unknown as Response;
|
||||||
|
|
||||||
|
await controller.getAllNotifications(mockReq, mockRes);
|
||||||
|
|
||||||
|
expect(service.getAllNotifications).not.toHaveBeenCalled();
|
||||||
|
expect(mockRes.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith({ error: 'Unauthorized' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty notifications list', async () => {
|
||||||
|
service.getAllNotifications.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const mockReq = {
|
||||||
|
user: { userId: 'user-123' },
|
||||||
|
} as unknown as Request;
|
||||||
|
|
||||||
|
const mockRes = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
json: vi.fn(),
|
||||||
|
} as unknown as Response;
|
||||||
|
|
||||||
|
await controller.getAllNotifications(mockReq, mockRes);
|
||||||
|
|
||||||
|
expect(service.getAllNotifications).toHaveBeenCalledWith('user-123');
|
||||||
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith({ notifications: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { NotificationsController } from './NotificationsController';
|
||||||
|
import { NotificationsModule } from './NotificationsModule';
|
||||||
|
import { NotificationsService } from './NotificationsService';
|
||||||
|
|
||||||
|
describe('NotificationsModule', () => {
|
||||||
|
let module: TestingModule;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
module = await Test.createTestingModule({
|
||||||
|
imports: [NotificationsModule],
|
||||||
|
}).compile();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should compile the module', () => {
|
||||||
|
expect(module).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide NotificationsController', () => {
|
||||||
|
const controller = module.get<NotificationsController>(NotificationsController);
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
expect(controller).toBeInstanceOf(NotificationsController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide NotificationsService', () => {
|
||||||
|
const service = module.get<NotificationsService>(NotificationsService);
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
expect(service).toBeInstanceOf(NotificationsService);
|
||||||
|
});
|
||||||
|
});
|
||||||
211
apps/api/src/domain/notifications/NotificationsService.test.ts
Normal file
211
apps/api/src/domain/notifications/NotificationsService.test.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { Result } from '@core/shared/domain/Result';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { NotificationsService } from './NotificationsService';
|
||||||
|
|
||||||
|
describe('NotificationsService', () => {
|
||||||
|
const mockGetUnreadNotificationsUseCase = { execute: vi.fn() };
|
||||||
|
const mockGetAllNotificationsUseCase = { execute: vi.fn() };
|
||||||
|
const mockMarkNotificationReadUseCase = { execute: vi.fn() };
|
||||||
|
|
||||||
|
let service: NotificationsService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
service = new NotificationsService(
|
||||||
|
mockGetUnreadNotificationsUseCase as never,
|
||||||
|
mockGetAllNotificationsUseCase as never,
|
||||||
|
mockMarkNotificationReadUseCase as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getUnreadNotifications', () => {
|
||||||
|
it('should return unread notifications on success', async () => {
|
||||||
|
const mockNotification = {
|
||||||
|
toJSON: () => ({ id: '1', message: 'Test notification' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGetUnreadNotificationsUseCase.execute.mockResolvedValue(
|
||||||
|
Result.ok({ notifications: [mockNotification] })
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.getUnreadNotifications('user-123');
|
||||||
|
|
||||||
|
expect(mockGetUnreadNotificationsUseCase.execute).toHaveBeenCalledWith({
|
||||||
|
recipientId: 'user-123',
|
||||||
|
});
|
||||||
|
expect(result).toEqual([{ id: '1', message: 'Test notification' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when use case fails', async () => {
|
||||||
|
mockGetUnreadNotificationsUseCase.execute.mockResolvedValue(
|
||||||
|
Result.err({ code: 'ERROR', details: { message: 'Failed to get notifications' } })
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.getUnreadNotifications('user-123')).rejects.toThrow(
|
||||||
|
'Failed to get notifications'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw generic error when no message provided', async () => {
|
||||||
|
mockGetUnreadNotificationsUseCase.execute.mockResolvedValue(
|
||||||
|
Result.err({ code: 'ERROR', details: {} })
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.getUnreadNotifications('user-123')).rejects.toThrow(
|
||||||
|
'Failed to get unread notifications'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty notifications list', async () => {
|
||||||
|
mockGetUnreadNotificationsUseCase.execute.mockResolvedValue(
|
||||||
|
Result.ok({ notifications: [] })
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.getUnreadNotifications('user-123');
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple notifications', async () => {
|
||||||
|
const mockNotifications = [
|
||||||
|
{ toJSON: () => ({ id: '1', message: 'Notification 1' }) },
|
||||||
|
{ toJSON: () => ({ id: '2', message: 'Notification 2' }) },
|
||||||
|
{ toJSON: () => ({ id: '3', message: 'Notification 3' }) },
|
||||||
|
];
|
||||||
|
|
||||||
|
mockGetUnreadNotificationsUseCase.execute.mockResolvedValue(
|
||||||
|
Result.ok({ notifications: mockNotifications })
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.getUnreadNotifications('user-123');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result[0]).toEqual({ id: '1', message: 'Notification 1' });
|
||||||
|
expect(result[1]).toEqual({ id: '2', message: 'Notification 2' });
|
||||||
|
expect(result[2]).toEqual({ id: '3', message: 'Notification 3' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllNotifications', () => {
|
||||||
|
it('should return all notifications on success', async () => {
|
||||||
|
const mockNotification = {
|
||||||
|
toJSON: () => ({ id: '1', message: 'Test notification' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockGetAllNotificationsUseCase.execute.mockResolvedValue(
|
||||||
|
Result.ok({ notifications: [mockNotification] })
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.getAllNotifications('user-123');
|
||||||
|
|
||||||
|
expect(mockGetAllNotificationsUseCase.execute).toHaveBeenCalledWith({
|
||||||
|
recipientId: 'user-123',
|
||||||
|
});
|
||||||
|
expect(result).toEqual([{ id: '1', message: 'Test notification' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when use case fails', async () => {
|
||||||
|
mockGetAllNotificationsUseCase.execute.mockResolvedValue(
|
||||||
|
Result.err({ code: 'ERROR', details: { message: 'Failed to get notifications' } })
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.getAllNotifications('user-123')).rejects.toThrow(
|
||||||
|
'Failed to get notifications'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw generic error when no message provided', async () => {
|
||||||
|
mockGetAllNotificationsUseCase.execute.mockResolvedValue(
|
||||||
|
Result.err({ code: 'ERROR', details: {} })
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.getAllNotifications('user-123')).rejects.toThrow(
|
||||||
|
'Failed to get all notifications'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty notifications list', async () => {
|
||||||
|
mockGetAllNotificationsUseCase.execute.mockResolvedValue(
|
||||||
|
Result.ok({ notifications: [] })
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.getAllNotifications('user-123');
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple notifications', async () => {
|
||||||
|
const mockNotifications = [
|
||||||
|
{ toJSON: () => ({ id: '1', message: 'Notification 1' }) },
|
||||||
|
{ toJSON: () => ({ id: '2', message: 'Notification 2' }) },
|
||||||
|
{ toJSON: () => ({ id: '3', message: 'Notification 3' }) },
|
||||||
|
];
|
||||||
|
|
||||||
|
mockGetAllNotificationsUseCase.execute.mockResolvedValue(
|
||||||
|
Result.ok({ notifications: mockNotifications })
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.getAllNotifications('user-123');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(result[0]).toEqual({ id: '1', message: 'Notification 1' });
|
||||||
|
expect(result[1]).toEqual({ id: '2', message: 'Notification 2' });
|
||||||
|
expect(result[2]).toEqual({ id: '3', message: 'Notification 3' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markAsRead', () => {
|
||||||
|
it('should mark notification as read on success', async () => {
|
||||||
|
mockMarkNotificationReadUseCase.execute.mockResolvedValue(Result.ok(undefined));
|
||||||
|
|
||||||
|
await service.markAsRead('notification-123', 'user-123');
|
||||||
|
|
||||||
|
expect(mockMarkNotificationReadUseCase.execute).toHaveBeenCalledWith({
|
||||||
|
notificationId: 'notification-123',
|
||||||
|
recipientId: 'user-123',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when use case fails', async () => {
|
||||||
|
mockMarkNotificationReadUseCase.execute.mockResolvedValue(
|
||||||
|
Result.err({ code: 'ERROR', details: { message: 'Failed to mark as read' } })
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.markAsRead('notification-123', 'user-123')).rejects.toThrow(
|
||||||
|
'Failed to mark as read'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw generic error when no message provided', async () => {
|
||||||
|
mockMarkNotificationReadUseCase.execute.mockResolvedValue(
|
||||||
|
Result.err({ code: 'ERROR', details: {} })
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.markAsRead('notification-123', 'user-123')).rejects.toThrow(
|
||||||
|
'Failed to mark notification as read'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle different notification IDs', async () => {
|
||||||
|
mockMarkNotificationReadUseCase.execute.mockResolvedValue(Result.ok(undefined));
|
||||||
|
|
||||||
|
await service.markAsRead('notification-456', 'user-123');
|
||||||
|
|
||||||
|
expect(mockMarkNotificationReadUseCase.execute).toHaveBeenCalledWith({
|
||||||
|
notificationId: 'notification-456',
|
||||||
|
recipientId: 'user-123',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle different user IDs', async () => {
|
||||||
|
mockMarkNotificationReadUseCase.execute.mockResolvedValue(Result.ok(undefined));
|
||||||
|
|
||||||
|
await service.markAsRead('notification-123', 'user-456');
|
||||||
|
|
||||||
|
expect(mockMarkNotificationReadUseCase.execute).toHaveBeenCalledWith({
|
||||||
|
notificationId: 'notification-123',
|
||||||
|
recipientId: 'user-456',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { AwardPrizePresenter } from './AwardPrizePresenter';
|
||||||
|
import { AwardPrizeResultDTO } from '../dtos/AwardPrizeDTO';
|
||||||
|
import { PrizeType } from '../dtos/PaymentsDto';
|
||||||
|
|
||||||
|
describe('AwardPrizePresenter', () => {
|
||||||
|
let presenter: AwardPrizePresenter;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
presenter = new AwardPrizePresenter();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('present', () => {
|
||||||
|
it('should store the result', () => {
|
||||||
|
const result: AwardPrizeResultDTO = {
|
||||||
|
prize: {
|
||||||
|
id: 'prize-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
seasonId: 'season-123',
|
||||||
|
position: 1,
|
||||||
|
name: 'Test Prize',
|
||||||
|
amount: 100,
|
||||||
|
type: PrizeType.CASH,
|
||||||
|
description: 'Test Description',
|
||||||
|
awarded: false,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should overwrite previous result', () => {
|
||||||
|
const firstResult: AwardPrizeResultDTO = {
|
||||||
|
prize: {
|
||||||
|
id: 'prize-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
seasonId: 'season-123',
|
||||||
|
position: 1,
|
||||||
|
name: 'Test Prize',
|
||||||
|
amount: 100,
|
||||||
|
type: PrizeType.CASH,
|
||||||
|
description: 'Test Description',
|
||||||
|
awarded: false,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondResult: AwardPrizeResultDTO = {
|
||||||
|
prize: {
|
||||||
|
id: 'prize-456',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
seasonId: 'season-456',
|
||||||
|
position: 2,
|
||||||
|
name: 'Another Prize',
|
||||||
|
amount: 200,
|
||||||
|
type: PrizeType.MERCHANDISE,
|
||||||
|
description: 'Another Description',
|
||||||
|
awarded: false,
|
||||||
|
createdAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(firstResult);
|
||||||
|
presenter.present(secondResult);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(secondResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getResponseModel', () => {
|
||||||
|
it('should return null when not presented', () => {
|
||||||
|
expect(presenter.getResponseModel()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the result after present()', () => {
|
||||||
|
const result: AwardPrizeResultDTO = {
|
||||||
|
prize: {
|
||||||
|
id: 'prize-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
seasonId: 'season-123',
|
||||||
|
position: 1,
|
||||||
|
name: 'Test Prize',
|
||||||
|
amount: 100,
|
||||||
|
type: PrizeType.CASH,
|
||||||
|
description: 'Test Description',
|
||||||
|
awarded: false,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('should clear the result', () => {
|
||||||
|
const result: AwardPrizeResultDTO = {
|
||||||
|
prize: {
|
||||||
|
id: 'prize-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
seasonId: 'season-123',
|
||||||
|
position: 1,
|
||||||
|
name: 'Test Prize',
|
||||||
|
amount: 100,
|
||||||
|
type: PrizeType.CASH,
|
||||||
|
description: 'Test Description',
|
||||||
|
awarded: false,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
presenter.reset();
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow presenting again after reset', () => {
|
||||||
|
const firstResult: AwardPrizeResultDTO = {
|
||||||
|
prize: {
|
||||||
|
id: 'prize-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
seasonId: 'season-123',
|
||||||
|
position: 1,
|
||||||
|
name: 'Test Prize',
|
||||||
|
amount: 100,
|
||||||
|
type: PrizeType.CASH,
|
||||||
|
description: 'Test Description',
|
||||||
|
awarded: false,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondResult: AwardPrizeResultDTO = {
|
||||||
|
prize: {
|
||||||
|
id: 'prize-456',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
seasonId: 'season-456',
|
||||||
|
position: 2,
|
||||||
|
name: 'Another Prize',
|
||||||
|
amount: 200,
|
||||||
|
type: PrizeType.MERCHANDISE,
|
||||||
|
description: 'Another Description',
|
||||||
|
awarded: false,
|
||||||
|
createdAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(firstResult);
|
||||||
|
presenter.reset();
|
||||||
|
presenter.present(secondResult);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(secondResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('viewModel', () => {
|
||||||
|
it('should return the result', () => {
|
||||||
|
const result: AwardPrizeResultDTO = {
|
||||||
|
prize: {
|
||||||
|
id: 'prize-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
seasonId: 'season-123',
|
||||||
|
position: 1,
|
||||||
|
name: 'Test Prize',
|
||||||
|
amount: 100,
|
||||||
|
type: PrizeType.CASH,
|
||||||
|
description: 'Test Description',
|
||||||
|
awarded: false,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.viewModel).toEqual(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when accessed before present()', () => {
|
||||||
|
expect(() => presenter.viewModel).toThrow('Presenter not presented');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error after reset', () => {
|
||||||
|
const result: AwardPrizeResultDTO = {
|
||||||
|
prize: {
|
||||||
|
id: 'prize-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
seasonId: 'season-123',
|
||||||
|
position: 1,
|
||||||
|
name: 'Test Prize',
|
||||||
|
amount: 100,
|
||||||
|
type: PrizeType.CASH,
|
||||||
|
description: 'Test Description',
|
||||||
|
awarded: false,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
presenter.reset();
|
||||||
|
|
||||||
|
expect(() => presenter.viewModel).toThrow('Presenter not presented');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { CreatePaymentPresenter } from './CreatePaymentPresenter';
|
||||||
|
import { CreatePaymentOutput } from '../dtos/PaymentsDto';
|
||||||
|
|
||||||
|
describe('CreatePaymentPresenter', () => {
|
||||||
|
let presenter: CreatePaymentPresenter;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
presenter = new CreatePaymentPresenter();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('present', () => {
|
||||||
|
it('should map result to response model', () => {
|
||||||
|
const result = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
const responseModel = presenter.getResponseModel();
|
||||||
|
expect(responseModel).toEqual({
|
||||||
|
payment: {
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include seasonId when provided', () => {
|
||||||
|
const result = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
seasonId: 'season-123',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
const responseModel = presenter.getResponseModel();
|
||||||
|
expect(responseModel.payment.seasonId).toBe('season-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include completedAt when provided', () => {
|
||||||
|
const result = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
completedAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
const responseModel = presenter.getResponseModel();
|
||||||
|
expect(responseModel.payment.completedAt).toEqual(new Date('2024-01-02'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not include seasonId when not provided', () => {
|
||||||
|
const result = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
const responseModel = presenter.getResponseModel();
|
||||||
|
expect(responseModel.payment.seasonId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not include completedAt when not provided', () => {
|
||||||
|
const result = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
const responseModel = presenter.getResponseModel();
|
||||||
|
expect(responseModel.payment.completedAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getResponseModel', () => {
|
||||||
|
it('should throw error when accessed before present()', () => {
|
||||||
|
expect(() => presenter.getResponseModel()).toThrow('Presenter not presented');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return model after present()', () => {
|
||||||
|
const result = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
const responseModel = presenter.getResponseModel();
|
||||||
|
expect(responseModel).toBeDefined();
|
||||||
|
expect(responseModel.payment.id).toBe('payment-123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('should clear the response model', () => {
|
||||||
|
const result = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
presenter.reset();
|
||||||
|
|
||||||
|
expect(() => presenter.getResponseModel()).toThrow('Presenter not presented');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow presenting again after reset', () => {
|
||||||
|
const firstResult = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondResult = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-456',
|
||||||
|
type: 'membership',
|
||||||
|
amount: 200,
|
||||||
|
platformFee: 10,
|
||||||
|
netAmount: 190,
|
||||||
|
payerId: 'user-456',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(firstResult);
|
||||||
|
presenter.reset();
|
||||||
|
presenter.present(secondResult);
|
||||||
|
|
||||||
|
const responseModel = presenter.getResponseModel();
|
||||||
|
expect(responseModel.payment.id).toBe('payment-456');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { CreatePrizePresenter } from './CreatePrizePresenter';
|
||||||
|
import { CreatePrizeResultDTO } from '../dtos/CreatePrizeDTO';
|
||||||
|
import { PrizeType } from '../dtos/PaymentsDto';
|
||||||
|
|
||||||
|
describe('CreatePrizePresenter', () => {
|
||||||
|
let presenter: CreatePrizePresenter;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
presenter = new CreatePrizePresenter();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('present', () => {
|
||||||
|
it('should store the result', () => {
|
||||||
|
const result: CreatePrizeResultDTO = {
|
||||||
|
prize: {
|
||||||
|
id: 'prize-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
seasonId: 'season-123',
|
||||||
|
position: 1,
|
||||||
|
name: 'Test Prize',
|
||||||
|
amount: 100,
|
||||||
|
type: PrizeType.CASH,
|
||||||
|
description: 'Test Description',
|
||||||
|
awarded: false,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should overwrite previous result', () => {
|
||||||
|
const firstResult: CreatePrizeResultDTO = {
|
||||||
|
prize: {
|
||||||
|
id: 'prize-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
seasonId: 'season-123',
|
||||||
|
position: 1,
|
||||||
|
name: 'Test Prize',
|
||||||
|
amount: 100,
|
||||||
|
type: PrizeType.CASH,
|
||||||
|
description: 'Test Description',
|
||||||
|
awarded: false,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondResult: CreatePrizeResultDTO = {
|
||||||
|
prize: {
|
||||||
|
id: 'prize-456',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
seasonId: 'season-456',
|
||||||
|
position: 2,
|
||||||
|
name: 'Another Prize',
|
||||||
|
amount: 200,
|
||||||
|
type: PrizeType.MERCHANDISE,
|
||||||
|
description: 'Another Description',
|
||||||
|
awarded: false,
|
||||||
|
createdAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(firstResult);
|
||||||
|
presenter.present(secondResult);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(secondResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getResponseModel', () => {
|
||||||
|
it('should return null when not presented', () => {
|
||||||
|
expect(presenter.getResponseModel()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the result after present()', () => {
|
||||||
|
const result: CreatePrizeResultDTO = {
|
||||||
|
prize: {
|
||||||
|
id: 'prize-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
seasonId: 'season-123',
|
||||||
|
position: 1,
|
||||||
|
name: 'Test Prize',
|
||||||
|
amount: 100,
|
||||||
|
type: PrizeType.CASH,
|
||||||
|
description: 'Test Description',
|
||||||
|
awarded: false,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('should clear the result', () => {
|
||||||
|
const result: CreatePrizeResultDTO = {
|
||||||
|
prize: {
|
||||||
|
id: 'prize-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
seasonId: 'season-123',
|
||||||
|
position: 1,
|
||||||
|
name: 'Test Prize',
|
||||||
|
amount: 100,
|
||||||
|
type: PrizeType.CASH,
|
||||||
|
description: 'Test Description',
|
||||||
|
awarded: false,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
presenter.reset();
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow presenting again after reset', () => {
|
||||||
|
const firstResult: CreatePrizeResultDTO = {
|
||||||
|
prize: {
|
||||||
|
id: 'prize-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
seasonId: 'season-123',
|
||||||
|
position: 1,
|
||||||
|
name: 'Test Prize',
|
||||||
|
amount: 100,
|
||||||
|
type: PrizeType.CASH,
|
||||||
|
description: 'Test Description',
|
||||||
|
awarded: false,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondResult: CreatePrizeResultDTO = {
|
||||||
|
prize: {
|
||||||
|
id: 'prize-456',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
seasonId: 'season-456',
|
||||||
|
position: 2,
|
||||||
|
name: 'Another Prize',
|
||||||
|
amount: 200,
|
||||||
|
type: PrizeType.MERCHANDISE,
|
||||||
|
description: 'Another Description',
|
||||||
|
awarded: false,
|
||||||
|
createdAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(firstResult);
|
||||||
|
presenter.reset();
|
||||||
|
presenter.present(secondResult);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(secondResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('viewModel', () => {
|
||||||
|
it('should return the result', () => {
|
||||||
|
const result: CreatePrizeResultDTO = {
|
||||||
|
prize: {
|
||||||
|
id: 'prize-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
seasonId: 'season-123',
|
||||||
|
position: 1,
|
||||||
|
name: 'Test Prize',
|
||||||
|
amount: 100,
|
||||||
|
type: PrizeType.CASH,
|
||||||
|
description: 'Test Description',
|
||||||
|
awarded: false,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.viewModel).toEqual(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when accessed before present()', () => {
|
||||||
|
expect(() => presenter.viewModel).toThrow('Presenter not presented');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error after reset', () => {
|
||||||
|
const result: CreatePrizeResultDTO = {
|
||||||
|
prize: {
|
||||||
|
id: 'prize-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
seasonId: 'season-123',
|
||||||
|
position: 1,
|
||||||
|
name: 'Test Prize',
|
||||||
|
amount: 100,
|
||||||
|
type: PrizeType.CASH,
|
||||||
|
description: 'Test Description',
|
||||||
|
awarded: false,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
presenter.reset();
|
||||||
|
|
||||||
|
expect(() => presenter.viewModel).toThrow('Presenter not presented');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { DeletePrizePresenter } from './DeletePrizePresenter';
|
||||||
|
import { DeletePrizeResultDTO } from '../dtos/DeletePrizeDTO';
|
||||||
|
|
||||||
|
describe('DeletePrizePresenter', () => {
|
||||||
|
let presenter: DeletePrizePresenter;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
presenter = new DeletePrizePresenter();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('present', () => {
|
||||||
|
it('should store the result', () => {
|
||||||
|
const result: DeletePrizeResultDTO = {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should overwrite previous result', () => {
|
||||||
|
const firstResult: DeletePrizeResultDTO = {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondResult: DeletePrizeResultDTO = {
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(firstResult);
|
||||||
|
presenter.present(secondResult);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(secondResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getResponseModel', () => {
|
||||||
|
it('should return null when not presented', () => {
|
||||||
|
expect(presenter.getResponseModel()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the result after present()', () => {
|
||||||
|
const result: DeletePrizeResultDTO = {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('should clear the result', () => {
|
||||||
|
const result: DeletePrizeResultDTO = {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
presenter.reset();
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow presenting again after reset', () => {
|
||||||
|
const firstResult: DeletePrizeResultDTO = {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondResult: DeletePrizeResultDTO = {
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(firstResult);
|
||||||
|
presenter.reset();
|
||||||
|
presenter.present(secondResult);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(secondResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('viewModel', () => {
|
||||||
|
it('should return the result', () => {
|
||||||
|
const result: DeletePrizeResultDTO = {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.viewModel).toEqual(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when accessed before present()', () => {
|
||||||
|
expect(() => presenter.viewModel).toThrow('Presenter not presented');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error after reset', () => {
|
||||||
|
const result: DeletePrizeResultDTO = {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
presenter.reset();
|
||||||
|
|
||||||
|
expect(() => presenter.viewModel).toThrow('Presenter not presented');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { GetMembershipFeesPresenter } from './GetMembershipFeesPresenter';
|
||||||
|
import { GetMembershipFeesResultDTO } from '../dtos/GetMembershipFeesDTO';
|
||||||
|
import { MembershipFeeType, MemberPaymentStatus } from '../dtos/PaymentsDto';
|
||||||
|
|
||||||
|
describe('GetMembershipFeesPresenter', () => {
|
||||||
|
let presenter: GetMembershipFeesPresenter;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
presenter = new GetMembershipFeesPresenter();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('present', () => {
|
||||||
|
it('should store the result', () => {
|
||||||
|
const result: GetMembershipFeesResultDTO = {
|
||||||
|
fee: {
|
||||||
|
id: 'fee-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
type: MembershipFeeType.MONTHLY,
|
||||||
|
amount: 100,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
payments: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should overwrite previous result', () => {
|
||||||
|
const firstResult: GetMembershipFeesResultDTO = {
|
||||||
|
fee: {
|
||||||
|
id: 'fee-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
type: MembershipFeeType.MONTHLY,
|
||||||
|
amount: 100,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
payments: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondResult: GetMembershipFeesResultDTO = {
|
||||||
|
fee: {
|
||||||
|
id: 'fee-456',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
type: MembershipFeeType.SEASON,
|
||||||
|
amount: 200,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date('2024-01-02'),
|
||||||
|
updatedAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
payments: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(firstResult);
|
||||||
|
presenter.present(secondResult);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(secondResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getResponseModel', () => {
|
||||||
|
it('should return null when not presented', () => {
|
||||||
|
expect(presenter.getResponseModel()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the result after present()', () => {
|
||||||
|
const result: GetMembershipFeesResultDTO = {
|
||||||
|
fee: {
|
||||||
|
id: 'fee-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
type: MembershipFeeType.MONTHLY,
|
||||||
|
amount: 100,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
payments: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('should clear the result', () => {
|
||||||
|
const result: GetMembershipFeesResultDTO = {
|
||||||
|
fee: {
|
||||||
|
id: 'fee-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
type: MembershipFeeType.MONTHLY,
|
||||||
|
amount: 100,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
payments: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
presenter.reset();
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow presenting again after reset', () => {
|
||||||
|
const firstResult: GetMembershipFeesResultDTO = {
|
||||||
|
fee: {
|
||||||
|
id: 'fee-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
type: MembershipFeeType.MONTHLY,
|
||||||
|
amount: 100,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
payments: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondResult: GetMembershipFeesResultDTO = {
|
||||||
|
fee: {
|
||||||
|
id: 'fee-456',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
type: MembershipFeeType.SEASON,
|
||||||
|
amount: 200,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date('2024-01-02'),
|
||||||
|
updatedAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
payments: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(firstResult);
|
||||||
|
presenter.reset();
|
||||||
|
presenter.present(secondResult);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(secondResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('viewModel', () => {
|
||||||
|
it('should return the result', () => {
|
||||||
|
const result: GetMembershipFeesResultDTO = {
|
||||||
|
fee: {
|
||||||
|
id: 'fee-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
type: MembershipFeeType.MONTHLY,
|
||||||
|
amount: 100,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
payments: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.viewModel).toEqual(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when accessed before present()', () => {
|
||||||
|
expect(() => presenter.viewModel).toThrow('Presenter not presented');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error after reset', () => {
|
||||||
|
const result: GetMembershipFeesResultDTO = {
|
||||||
|
fee: {
|
||||||
|
id: 'fee-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
type: MembershipFeeType.MONTHLY,
|
||||||
|
amount: 100,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
payments: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
presenter.reset();
|
||||||
|
|
||||||
|
expect(() => presenter.viewModel).toThrow('Presenter not presented');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import { GetPaymentsPresenter } from './GetPaymentsPresenter';
|
||||||
|
import { GetPaymentsOutput } from '../dtos/PaymentsDto';
|
||||||
|
|
||||||
|
describe('GetPaymentsPresenter', () => {
|
||||||
|
let presenter: GetPaymentsPresenter;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
presenter = new GetPaymentsPresenter();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('present', () => {
|
||||||
|
it('should map result to response model', () => {
|
||||||
|
const result = {
|
||||||
|
payments: [
|
||||||
|
{
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
const responseModel = presenter.getResponseModel();
|
||||||
|
expect(responseModel).toEqual({
|
||||||
|
payments: [
|
||||||
|
{
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include seasonId when provided', () => {
|
||||||
|
const result = {
|
||||||
|
payments: [
|
||||||
|
{
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
seasonId: 'season-123',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
const responseModel = presenter.getResponseModel();
|
||||||
|
expect(responseModel.payments[0].seasonId).toBe('season-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include completedAt when provided', () => {
|
||||||
|
const result = {
|
||||||
|
payments: [
|
||||||
|
{
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
completedAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
const responseModel = presenter.getResponseModel();
|
||||||
|
expect(responseModel.payments[0].completedAt).toEqual(new Date('2024-01-02'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not include seasonId when not provided', () => {
|
||||||
|
const result = {
|
||||||
|
payments: [
|
||||||
|
{
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
const responseModel = presenter.getResponseModel();
|
||||||
|
expect(responseModel.payments[0].seasonId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not include completedAt when not provided', () => {
|
||||||
|
const result = {
|
||||||
|
payments: [
|
||||||
|
{
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
const responseModel = presenter.getResponseModel();
|
||||||
|
expect(responseModel.payments[0].completedAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty payments list', () => {
|
||||||
|
const result = {
|
||||||
|
payments: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
const responseModel = presenter.getResponseModel();
|
||||||
|
expect(responseModel.payments).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple payments', () => {
|
||||||
|
const result = {
|
||||||
|
payments: [
|
||||||
|
{
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'payment-456',
|
||||||
|
type: 'membership',
|
||||||
|
amount: 200,
|
||||||
|
platformFee: 10,
|
||||||
|
netAmount: 190,
|
||||||
|
payerId: 'user-456',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: new Date('2024-01-02'),
|
||||||
|
completedAt: new Date('2024-01-03'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
const responseModel = presenter.getResponseModel();
|
||||||
|
expect(responseModel.payments).toHaveLength(2);
|
||||||
|
expect(responseModel.payments[0].id).toBe('payment-123');
|
||||||
|
expect(responseModel.payments[1].id).toBe('payment-456');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getResponseModel', () => {
|
||||||
|
it('should throw error when accessed before present()', () => {
|
||||||
|
expect(() => presenter.getResponseModel()).toThrow('Presenter not presented');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return model after present()', () => {
|
||||||
|
const result = {
|
||||||
|
payments: [
|
||||||
|
{
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
const responseModel = presenter.getResponseModel();
|
||||||
|
expect(responseModel).toBeDefined();
|
||||||
|
expect(responseModel.payments[0].id).toBe('payment-123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('should clear the response model', () => {
|
||||||
|
const result = {
|
||||||
|
payments: [
|
||||||
|
{
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
presenter.reset();
|
||||||
|
|
||||||
|
expect(() => presenter.getResponseModel()).toThrow('Presenter not presented');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow presenting again after reset', () => {
|
||||||
|
const firstResult = {
|
||||||
|
payments: [
|
||||||
|
{
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondResult = {
|
||||||
|
payments: [
|
||||||
|
{
|
||||||
|
id: 'payment-456',
|
||||||
|
type: 'membership',
|
||||||
|
amount: 200,
|
||||||
|
platformFee: 10,
|
||||||
|
netAmount: 190,
|
||||||
|
payerId: 'user-456',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(firstResult);
|
||||||
|
presenter.reset();
|
||||||
|
presenter.present(secondResult);
|
||||||
|
|
||||||
|
const responseModel = presenter.getResponseModel();
|
||||||
|
expect(responseModel.payments[0].id).toBe('payment-456');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { GetPrizesPresenter } from './GetPrizesPresenter';
|
||||||
|
import { GetPrizesResultDTO } from '../dtos/GetPrizesDTO';
|
||||||
|
import { PrizeType } from '../dtos/PrizeType';
|
||||||
|
|
||||||
|
describe('GetPrizesPresenter', () => {
|
||||||
|
let presenter: GetPrizesPresenter;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
presenter = new GetPrizesPresenter();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('present', () => {
|
||||||
|
it('should store the result', () => {
|
||||||
|
const result: GetPrizesResultDTO = {
|
||||||
|
prizes: [
|
||||||
|
{
|
||||||
|
id: 'prize-123',
|
||||||
|
name: 'Test Prize',
|
||||||
|
description: 'Test Description',
|
||||||
|
type: PrizeType.CASH,
|
||||||
|
amount: 100,
|
||||||
|
leagueId: 'league-123',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should overwrite previous result', () => {
|
||||||
|
const firstResult: GetPrizesResultDTO = {
|
||||||
|
prizes: [
|
||||||
|
{
|
||||||
|
id: 'prize-123',
|
||||||
|
name: 'Test Prize',
|
||||||
|
description: 'Test Description',
|
||||||
|
type: PrizeType.CASH,
|
||||||
|
amount: 100,
|
||||||
|
leagueId: 'league-123',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondResult: GetPrizesResultDTO = {
|
||||||
|
prizes: [
|
||||||
|
{
|
||||||
|
id: 'prize-456',
|
||||||
|
name: 'Test Prize 2',
|
||||||
|
description: 'Test Description 2',
|
||||||
|
type: PrizeType.MERCHANDISE,
|
||||||
|
amount: 200,
|
||||||
|
leagueId: 'league-456',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(firstResult);
|
||||||
|
presenter.present(secondResult);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(secondResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getResponseModel', () => {
|
||||||
|
it('should return null when not presented', () => {
|
||||||
|
expect(presenter.getResponseModel()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the result after present()', () => {
|
||||||
|
const result: GetPrizesResultDTO = {
|
||||||
|
prizes: [
|
||||||
|
{
|
||||||
|
id: 'prize-123',
|
||||||
|
name: 'Test Prize',
|
||||||
|
description: 'Test Description',
|
||||||
|
type: PrizeType.CASH,
|
||||||
|
amount: 100,
|
||||||
|
leagueId: 'league-123',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('should clear the result', () => {
|
||||||
|
const result: GetPrizesResultDTO = {
|
||||||
|
prizes: [
|
||||||
|
{
|
||||||
|
id: 'prize-123',
|
||||||
|
name: 'Test Prize',
|
||||||
|
description: 'Test Description',
|
||||||
|
type: PrizeType.CASH,
|
||||||
|
amount: 100,
|
||||||
|
leagueId: 'league-123',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
presenter.reset();
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow presenting again after reset', () => {
|
||||||
|
const firstResult: GetPrizesResultDTO = {
|
||||||
|
prizes: [
|
||||||
|
{
|
||||||
|
id: 'prize-123',
|
||||||
|
name: 'Test Prize',
|
||||||
|
description: 'Test Description',
|
||||||
|
type: PrizeType.CASH,
|
||||||
|
amount: 100,
|
||||||
|
leagueId: 'league-123',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondResult: GetPrizesResultDTO = {
|
||||||
|
prizes: [
|
||||||
|
{
|
||||||
|
id: 'prize-456',
|
||||||
|
name: 'Test Prize 2',
|
||||||
|
description: 'Test Description 2',
|
||||||
|
type: PrizeType.MERCHANDISE,
|
||||||
|
amount: 200,
|
||||||
|
leagueId: 'league-456',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(firstResult);
|
||||||
|
presenter.reset();
|
||||||
|
presenter.present(secondResult);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(secondResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('viewModel', () => {
|
||||||
|
it('should return the result', () => {
|
||||||
|
const result: GetPrizesResultDTO = {
|
||||||
|
prizes: [
|
||||||
|
{
|
||||||
|
id: 'prize-123',
|
||||||
|
name: 'Test Prize',
|
||||||
|
description: 'Test Description',
|
||||||
|
type: PrizeType.CASH,
|
||||||
|
amount: 100,
|
||||||
|
leagueId: 'league-123',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.viewModel).toEqual(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when accessed before present()', () => {
|
||||||
|
expect(() => presenter.viewModel).toThrow('Presenter not presented');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error after reset', () => {
|
||||||
|
const result: GetPrizesResultDTO = {
|
||||||
|
prizes: [
|
||||||
|
{
|
||||||
|
id: 'prize-123',
|
||||||
|
name: 'Test Prize',
|
||||||
|
description: 'Test Description',
|
||||||
|
type: PrizeType.CASH,
|
||||||
|
amount: 100,
|
||||||
|
leagueId: 'league-123',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
presenter.reset();
|
||||||
|
|
||||||
|
expect(() => presenter.viewModel).toThrow('Presenter not presented');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { GetWalletPresenter } from './GetWalletPresenter';
|
||||||
|
import { GetWalletResultDTO } from '../dtos/GetWalletDTO';
|
||||||
|
|
||||||
|
describe('GetWalletPresenter', () => {
|
||||||
|
let presenter: GetWalletPresenter;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
presenter = new GetWalletPresenter();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('present', () => {
|
||||||
|
it('should store the result', () => {
|
||||||
|
const result: GetWalletResultDTO = {
|
||||||
|
wallet: {
|
||||||
|
id: 'wallet-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
balance: 1000,
|
||||||
|
totalRevenue: 5000,
|
||||||
|
totalPlatformFees: 250,
|
||||||
|
totalWithdrawn: 3000,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
currency: 'USD',
|
||||||
|
},
|
||||||
|
transactions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should overwrite previous result', () => {
|
||||||
|
const firstResult: GetWalletResultDTO = {
|
||||||
|
wallet: {
|
||||||
|
id: 'wallet-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
balance: 1000,
|
||||||
|
totalRevenue: 5000,
|
||||||
|
totalPlatformFees: 250,
|
||||||
|
totalWithdrawn: 3000,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
currency: 'USD',
|
||||||
|
},
|
||||||
|
transactions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondResult: GetWalletResultDTO = {
|
||||||
|
wallet: {
|
||||||
|
id: 'wallet-456',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
balance: 2000,
|
||||||
|
totalRevenue: 10000,
|
||||||
|
totalPlatformFees: 500,
|
||||||
|
totalWithdrawn: 6000,
|
||||||
|
createdAt: new Date('2024-01-02'),
|
||||||
|
currency: 'EUR',
|
||||||
|
},
|
||||||
|
transactions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(firstResult);
|
||||||
|
presenter.present(secondResult);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(secondResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getResponseModel', () => {
|
||||||
|
it('should return null when not presented', () => {
|
||||||
|
expect(presenter.getResponseModel()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the result after present()', () => {
|
||||||
|
const result: GetWalletResultDTO = {
|
||||||
|
wallet: {
|
||||||
|
id: 'wallet-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
balance: 1000,
|
||||||
|
totalRevenue: 5000,
|
||||||
|
totalPlatformFees: 250,
|
||||||
|
totalWithdrawn: 3000,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
currency: 'USD',
|
||||||
|
},
|
||||||
|
transactions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('should clear the result', () => {
|
||||||
|
const result: GetWalletResultDTO = {
|
||||||
|
wallet: {
|
||||||
|
id: 'wallet-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
balance: 1000,
|
||||||
|
totalRevenue: 5000,
|
||||||
|
totalPlatformFees: 250,
|
||||||
|
totalWithdrawn: 3000,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
currency: 'USD',
|
||||||
|
},
|
||||||
|
transactions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
presenter.reset();
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow presenting again after reset', () => {
|
||||||
|
const firstResult: GetWalletResultDTO = {
|
||||||
|
wallet: {
|
||||||
|
id: 'wallet-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
balance: 1000,
|
||||||
|
totalRevenue: 5000,
|
||||||
|
totalPlatformFees: 250,
|
||||||
|
totalWithdrawn: 3000,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
currency: 'USD',
|
||||||
|
},
|
||||||
|
transactions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondResult: GetWalletResultDTO = {
|
||||||
|
wallet: {
|
||||||
|
id: 'wallet-456',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
balance: 2000,
|
||||||
|
totalRevenue: 10000,
|
||||||
|
totalPlatformFees: 500,
|
||||||
|
totalWithdrawn: 6000,
|
||||||
|
createdAt: new Date('2024-01-02'),
|
||||||
|
currency: 'EUR',
|
||||||
|
},
|
||||||
|
transactions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(firstResult);
|
||||||
|
presenter.reset();
|
||||||
|
presenter.present(secondResult);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(secondResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('viewModel', () => {
|
||||||
|
it('should return the result', () => {
|
||||||
|
const result: GetWalletResultDTO = {
|
||||||
|
wallet: {
|
||||||
|
id: 'wallet-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
balance: 1000,
|
||||||
|
totalRevenue: 5000,
|
||||||
|
totalPlatformFees: 250,
|
||||||
|
totalWithdrawn: 3000,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
currency: 'USD',
|
||||||
|
},
|
||||||
|
transactions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.viewModel).toEqual(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when accessed before present()', () => {
|
||||||
|
expect(() => presenter.viewModel).toThrow('Presenter not presented');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error after reset', () => {
|
||||||
|
const result: GetWalletResultDTO = {
|
||||||
|
wallet: {
|
||||||
|
id: 'wallet-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
balance: 1000,
|
||||||
|
totalRevenue: 5000,
|
||||||
|
totalPlatformFees: 250,
|
||||||
|
totalWithdrawn: 3000,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
currency: 'USD',
|
||||||
|
},
|
||||||
|
transactions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
presenter.reset();
|
||||||
|
|
||||||
|
expect(() => presenter.viewModel).toThrow('Presenter not presented');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import { ProcessWalletTransactionPresenter } from './ProcessWalletTransactionPresenter';
|
||||||
|
import { ProcessWalletTransactionResultDTO } from '../dtos/ProcessWalletTransactionDTO';
|
||||||
|
import { TransactionType } from '../dtos/TransactionType';
|
||||||
|
|
||||||
|
describe('ProcessWalletTransactionPresenter', () => {
|
||||||
|
let presenter: ProcessWalletTransactionPresenter;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
presenter = new ProcessWalletTransactionPresenter();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('present', () => {
|
||||||
|
it('should store the result', () => {
|
||||||
|
const result: ProcessWalletTransactionResultDTO = {
|
||||||
|
wallet: {
|
||||||
|
id: 'wallet-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
balance: 1000,
|
||||||
|
totalRevenue: 5000,
|
||||||
|
totalPlatformFees: 250,
|
||||||
|
totalWithdrawn: 3000,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
currency: 'USD',
|
||||||
|
},
|
||||||
|
transaction: {
|
||||||
|
id: 'transaction-123',
|
||||||
|
walletId: 'wallet-123',
|
||||||
|
type: TransactionType.DEPOSIT,
|
||||||
|
amount: 100,
|
||||||
|
description: 'Test deposit',
|
||||||
|
createdAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should overwrite previous result', () => {
|
||||||
|
const firstResult: ProcessWalletTransactionResultDTO = {
|
||||||
|
wallet: {
|
||||||
|
id: 'wallet-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
balance: 1000,
|
||||||
|
totalRevenue: 5000,
|
||||||
|
totalPlatformFees: 250,
|
||||||
|
totalWithdrawn: 3000,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
currency: 'USD',
|
||||||
|
},
|
||||||
|
transaction: {
|
||||||
|
id: 'transaction-123',
|
||||||
|
walletId: 'wallet-123',
|
||||||
|
type: TransactionType.DEPOSIT,
|
||||||
|
amount: 100,
|
||||||
|
description: 'Test deposit',
|
||||||
|
createdAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondResult: ProcessWalletTransactionResultDTO = {
|
||||||
|
wallet: {
|
||||||
|
id: 'wallet-456',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
balance: 2000,
|
||||||
|
totalRevenue: 10000,
|
||||||
|
totalPlatformFees: 500,
|
||||||
|
totalWithdrawn: 6000,
|
||||||
|
createdAt: new Date('2024-01-02'),
|
||||||
|
currency: 'EUR',
|
||||||
|
},
|
||||||
|
transaction: {
|
||||||
|
id: 'transaction-456',
|
||||||
|
walletId: 'wallet-456',
|
||||||
|
type: TransactionType.WITHDRAWAL,
|
||||||
|
amount: 200,
|
||||||
|
description: 'Test withdrawal',
|
||||||
|
createdAt: new Date('2024-01-03'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(firstResult);
|
||||||
|
presenter.present(secondResult);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(secondResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getResponseModel', () => {
|
||||||
|
it('should return null when not presented', () => {
|
||||||
|
expect(presenter.getResponseModel()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the result after present()', () => {
|
||||||
|
const result: ProcessWalletTransactionResultDTO = {
|
||||||
|
wallet: {
|
||||||
|
id: 'wallet-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
balance: 1000,
|
||||||
|
totalRevenue: 5000,
|
||||||
|
totalPlatformFees: 250,
|
||||||
|
totalWithdrawn: 3000,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
currency: 'USD',
|
||||||
|
},
|
||||||
|
transaction: {
|
||||||
|
id: 'transaction-123',
|
||||||
|
walletId: 'wallet-123',
|
||||||
|
type: TransactionType.DEPOSIT,
|
||||||
|
amount: 100,
|
||||||
|
description: 'Test deposit',
|
||||||
|
createdAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('should clear the result', () => {
|
||||||
|
const result: ProcessWalletTransactionResultDTO = {
|
||||||
|
wallet: {
|
||||||
|
id: 'wallet-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
balance: 1000,
|
||||||
|
totalRevenue: 5000,
|
||||||
|
totalPlatformFees: 250,
|
||||||
|
totalWithdrawn: 3000,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
currency: 'USD',
|
||||||
|
},
|
||||||
|
transaction: {
|
||||||
|
id: 'transaction-123',
|
||||||
|
walletId: 'wallet-123',
|
||||||
|
type: TransactionType.DEPOSIT,
|
||||||
|
amount: 100,
|
||||||
|
description: 'Test deposit',
|
||||||
|
createdAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
presenter.reset();
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow presenting again after reset', () => {
|
||||||
|
const firstResult: ProcessWalletTransactionResultDTO = {
|
||||||
|
wallet: {
|
||||||
|
id: 'wallet-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
balance: 1000,
|
||||||
|
totalRevenue: 5000,
|
||||||
|
totalPlatformFees: 250,
|
||||||
|
totalWithdrawn: 3000,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
currency: 'USD',
|
||||||
|
},
|
||||||
|
transaction: {
|
||||||
|
id: 'transaction-123',
|
||||||
|
walletId: 'wallet-123',
|
||||||
|
type: TransactionType.DEPOSIT,
|
||||||
|
amount: 100,
|
||||||
|
description: 'Test deposit',
|
||||||
|
createdAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondResult: ProcessWalletTransactionResultDTO = {
|
||||||
|
wallet: {
|
||||||
|
id: 'wallet-456',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
balance: 2000,
|
||||||
|
totalRevenue: 10000,
|
||||||
|
totalPlatformFees: 500,
|
||||||
|
totalWithdrawn: 6000,
|
||||||
|
createdAt: new Date('2024-01-02'),
|
||||||
|
currency: 'EUR',
|
||||||
|
},
|
||||||
|
transaction: {
|
||||||
|
id: 'transaction-456',
|
||||||
|
walletId: 'wallet-456',
|
||||||
|
type: TransactionType.WITHDRAWAL,
|
||||||
|
amount: 200,
|
||||||
|
description: 'Test withdrawal',
|
||||||
|
createdAt: new Date('2024-01-03'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(firstResult);
|
||||||
|
presenter.reset();
|
||||||
|
presenter.present(secondResult);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(secondResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('viewModel', () => {
|
||||||
|
it('should return the result', () => {
|
||||||
|
const result: ProcessWalletTransactionResultDTO = {
|
||||||
|
wallet: {
|
||||||
|
id: 'wallet-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
balance: 1000,
|
||||||
|
totalRevenue: 5000,
|
||||||
|
totalPlatformFees: 250,
|
||||||
|
totalWithdrawn: 3000,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
currency: 'USD',
|
||||||
|
},
|
||||||
|
transaction: {
|
||||||
|
id: 'transaction-123',
|
||||||
|
walletId: 'wallet-123',
|
||||||
|
type: TransactionType.DEPOSIT,
|
||||||
|
amount: 100,
|
||||||
|
description: 'Test deposit',
|
||||||
|
createdAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.viewModel).toEqual(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when accessed before present()', () => {
|
||||||
|
expect(() => presenter.viewModel).toThrow('Presenter not presented');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error after reset', () => {
|
||||||
|
const result: ProcessWalletTransactionResultDTO = {
|
||||||
|
wallet: {
|
||||||
|
id: 'wallet-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
balance: 1000,
|
||||||
|
totalRevenue: 5000,
|
||||||
|
totalPlatformFees: 250,
|
||||||
|
totalWithdrawn: 3000,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
currency: 'USD',
|
||||||
|
},
|
||||||
|
transaction: {
|
||||||
|
id: 'transaction-123',
|
||||||
|
walletId: 'wallet-123',
|
||||||
|
type: TransactionType.DEPOSIT,
|
||||||
|
amount: 100,
|
||||||
|
description: 'Test deposit',
|
||||||
|
createdAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
presenter.reset();
|
||||||
|
|
||||||
|
expect(() => presenter.viewModel).toThrow('Presenter not presented');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { UpdateMemberPaymentPresenter } from './UpdateMemberPaymentPresenter';
|
||||||
|
import { UpdateMemberPaymentResultDTO } from '../dtos/UpdateMemberPaymentDTO';
|
||||||
|
import { MemberPaymentStatus } from '../dtos/MemberPaymentStatus';
|
||||||
|
|
||||||
|
describe('UpdateMemberPaymentPresenter', () => {
|
||||||
|
let presenter: UpdateMemberPaymentPresenter;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
presenter = new UpdateMemberPaymentPresenter();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('present', () => {
|
||||||
|
it('should store the result', () => {
|
||||||
|
const result: UpdateMemberPaymentResultDTO = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-123',
|
||||||
|
feeId: 'fee-123',
|
||||||
|
driverId: 'driver-123',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
status: MemberPaymentStatus.PAID,
|
||||||
|
dueDate: new Date('2024-01-01'),
|
||||||
|
paidAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should overwrite previous result', () => {
|
||||||
|
const firstResult: UpdateMemberPaymentResultDTO = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-123',
|
||||||
|
feeId: 'fee-123',
|
||||||
|
driverId: 'driver-123',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
status: MemberPaymentStatus.PAID,
|
||||||
|
dueDate: new Date('2024-01-01'),
|
||||||
|
paidAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondResult: UpdateMemberPaymentResultDTO = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-456',
|
||||||
|
feeId: 'fee-456',
|
||||||
|
driverId: 'driver-456',
|
||||||
|
amount: 200,
|
||||||
|
platformFee: 10,
|
||||||
|
netAmount: 190,
|
||||||
|
status: MemberPaymentStatus.OVERDUE,
|
||||||
|
dueDate: new Date('2024-01-03'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(firstResult);
|
||||||
|
presenter.present(secondResult);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(secondResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getResponseModel', () => {
|
||||||
|
it('should return null when not presented', () => {
|
||||||
|
expect(presenter.getResponseModel()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the result after present()', () => {
|
||||||
|
const result: UpdateMemberPaymentResultDTO = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-123',
|
||||||
|
feeId: 'fee-123',
|
||||||
|
driverId: 'driver-123',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
status: MemberPaymentStatus.PAID,
|
||||||
|
dueDate: new Date('2024-01-01'),
|
||||||
|
paidAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('should clear the result', () => {
|
||||||
|
const result: UpdateMemberPaymentResultDTO = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-123',
|
||||||
|
feeId: 'fee-123',
|
||||||
|
driverId: 'driver-123',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
status: MemberPaymentStatus.PAID,
|
||||||
|
dueDate: new Date('2024-01-01'),
|
||||||
|
paidAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
presenter.reset();
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow presenting again after reset', () => {
|
||||||
|
const firstResult: UpdateMemberPaymentResultDTO = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-123',
|
||||||
|
feeId: 'fee-123',
|
||||||
|
driverId: 'driver-123',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
status: MemberPaymentStatus.PAID,
|
||||||
|
dueDate: new Date('2024-01-01'),
|
||||||
|
paidAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondResult: UpdateMemberPaymentResultDTO = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-456',
|
||||||
|
feeId: 'fee-456',
|
||||||
|
driverId: 'driver-456',
|
||||||
|
amount: 200,
|
||||||
|
platformFee: 10,
|
||||||
|
netAmount: 190,
|
||||||
|
status: MemberPaymentStatus.OVERDUE,
|
||||||
|
dueDate: new Date('2024-01-03'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(firstResult);
|
||||||
|
presenter.reset();
|
||||||
|
presenter.present(secondResult);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(secondResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('viewModel', () => {
|
||||||
|
it('should return the result', () => {
|
||||||
|
const result: UpdateMemberPaymentResultDTO = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-123',
|
||||||
|
feeId: 'fee-123',
|
||||||
|
driverId: 'driver-123',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
status: MemberPaymentStatus.PAID,
|
||||||
|
dueDate: new Date('2024-01-01'),
|
||||||
|
paidAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.viewModel).toEqual(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when accessed before present()', () => {
|
||||||
|
expect(() => presenter.viewModel).toThrow('Presenter not presented');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error after reset', () => {
|
||||||
|
const result: UpdateMemberPaymentResultDTO = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-123',
|
||||||
|
feeId: 'fee-123',
|
||||||
|
driverId: 'driver-123',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
status: MemberPaymentStatus.PAID,
|
||||||
|
dueDate: new Date('2024-01-01'),
|
||||||
|
paidAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
presenter.reset();
|
||||||
|
|
||||||
|
expect(() => presenter.viewModel).toThrow('Presenter not presented');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import { UpdatePaymentStatusPresenter } from './UpdatePaymentStatusPresenter';
|
||||||
|
import { UpdatePaymentStatusOutput } from '../dtos/PaymentsDto';
|
||||||
|
|
||||||
|
describe('UpdatePaymentStatusPresenter', () => {
|
||||||
|
let presenter: UpdatePaymentStatusPresenter;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
presenter = new UpdatePaymentStatusPresenter();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('present', () => {
|
||||||
|
it('should map result to response model', () => {
|
||||||
|
const result = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership_fee',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
completedAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
const responseModel = presenter.getResponseModel();
|
||||||
|
expect(responseModel).toEqual({
|
||||||
|
payment: {
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership_fee',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
completedAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include seasonId when provided', () => {
|
||||||
|
const result = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership_fee',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
seasonId: 'season-123',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
completedAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
const responseModel = presenter.getResponseModel();
|
||||||
|
expect(responseModel.payment.seasonId).toBe('season-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include completedAt when provided', () => {
|
||||||
|
const result = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership_fee',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
completedAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
const responseModel = presenter.getResponseModel();
|
||||||
|
expect(responseModel.payment.completedAt).toEqual(new Date('2024-01-02'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not include seasonId when not provided', () => {
|
||||||
|
const result = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership_fee',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
const responseModel = presenter.getResponseModel();
|
||||||
|
expect(responseModel.payment.seasonId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not include completedAt when not provided', () => {
|
||||||
|
const result = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership_fee',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
const responseModel = presenter.getResponseModel();
|
||||||
|
expect(responseModel.payment.completedAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getResponseModel', () => {
|
||||||
|
it('should throw error when accessed before present()', () => {
|
||||||
|
expect(() => presenter.getResponseModel()).toThrow('Presenter not presented');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return model after present()', () => {
|
||||||
|
const result = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership_fee',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
completedAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
const responseModel = presenter.getResponseModel();
|
||||||
|
expect(responseModel).toBeDefined();
|
||||||
|
expect(responseModel.payment.id).toBe('payment-123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('should clear the response model', () => {
|
||||||
|
const result = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership_fee',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
completedAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
presenter.reset();
|
||||||
|
|
||||||
|
expect(() => presenter.getResponseModel()).toThrow('Presenter not presented');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow presenting again after reset', () => {
|
||||||
|
const firstResult = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-123',
|
||||||
|
type: 'membership_fee',
|
||||||
|
amount: 100,
|
||||||
|
platformFee: 5,
|
||||||
|
netAmount: 95,
|
||||||
|
payerId: 'user-123',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
completedAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondResult = {
|
||||||
|
payment: {
|
||||||
|
id: 'payment-456',
|
||||||
|
type: 'membership_fee',
|
||||||
|
amount: 200,
|
||||||
|
platformFee: 10,
|
||||||
|
netAmount: 190,
|
||||||
|
payerId: 'user-456',
|
||||||
|
payerType: 'driver',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
status: 'completed',
|
||||||
|
createdAt: new Date('2024-01-02'),
|
||||||
|
completedAt: new Date('2024-01-03'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(firstResult);
|
||||||
|
presenter.reset();
|
||||||
|
presenter.present(secondResult);
|
||||||
|
|
||||||
|
const responseModel = presenter.getResponseModel();
|
||||||
|
expect(responseModel.payment.id).toBe('payment-456');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { UpsertMembershipFeePresenter } from './UpsertMembershipFeePresenter';
|
||||||
|
import { UpsertMembershipFeeResultDTO } from '../dtos/UpsertMembershipFeeDTO';
|
||||||
|
import { MembershipFeeType } from '../dtos/MembershipFeeType';
|
||||||
|
|
||||||
|
describe('UpsertMembershipFeePresenter', () => {
|
||||||
|
let presenter: UpsertMembershipFeePresenter;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
presenter = new UpsertMembershipFeePresenter();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('present', () => {
|
||||||
|
it('should store the result', () => {
|
||||||
|
const result: UpsertMembershipFeeResultDTO = {
|
||||||
|
fee: {
|
||||||
|
id: 'fee-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
type: MembershipFeeType.MONTHLY,
|
||||||
|
amount: 100,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should overwrite previous result', () => {
|
||||||
|
const firstResult: UpsertMembershipFeeResultDTO = {
|
||||||
|
fee: {
|
||||||
|
id: 'fee-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
type: MembershipFeeType.MONTHLY,
|
||||||
|
amount: 100,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondResult: UpsertMembershipFeeResultDTO = {
|
||||||
|
fee: {
|
||||||
|
id: 'fee-456',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
type: MembershipFeeType.SEASON,
|
||||||
|
amount: 200,
|
||||||
|
enabled: false,
|
||||||
|
createdAt: new Date('2024-01-03'),
|
||||||
|
updatedAt: new Date('2024-01-04'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(firstResult);
|
||||||
|
presenter.present(secondResult);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(secondResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getResponseModel', () => {
|
||||||
|
it('should return null when not presented', () => {
|
||||||
|
expect(presenter.getResponseModel()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the result after present()', () => {
|
||||||
|
const result: UpsertMembershipFeeResultDTO = {
|
||||||
|
fee: {
|
||||||
|
id: 'fee-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
type: MembershipFeeType.MONTHLY,
|
||||||
|
amount: 100,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reset', () => {
|
||||||
|
it('should clear the result', () => {
|
||||||
|
const result: UpsertMembershipFeeResultDTO = {
|
||||||
|
fee: {
|
||||||
|
id: 'fee-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
type: MembershipFeeType.MONTHLY,
|
||||||
|
amount: 100,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
presenter.reset();
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow presenting again after reset', () => {
|
||||||
|
const firstResult: UpsertMembershipFeeResultDTO = {
|
||||||
|
fee: {
|
||||||
|
id: 'fee-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
type: MembershipFeeType.MONTHLY,
|
||||||
|
amount: 100,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondResult: UpsertMembershipFeeResultDTO = {
|
||||||
|
fee: {
|
||||||
|
id: 'fee-456',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
type: MembershipFeeType.SEASON,
|
||||||
|
amount: 200,
|
||||||
|
enabled: false,
|
||||||
|
createdAt: new Date('2024-01-03'),
|
||||||
|
updatedAt: new Date('2024-01-04'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(firstResult);
|
||||||
|
presenter.reset();
|
||||||
|
presenter.present(secondResult);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).toEqual(secondResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('viewModel', () => {
|
||||||
|
it('should return the result', () => {
|
||||||
|
const result: UpsertMembershipFeeResultDTO = {
|
||||||
|
fee: {
|
||||||
|
id: 'fee-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
type: MembershipFeeType.MONTHLY,
|
||||||
|
amount: 100,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
|
||||||
|
expect(presenter.viewModel).toEqual(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when accessed before present()', () => {
|
||||||
|
expect(() => presenter.viewModel).toThrow('Presenter not presented');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error after reset', () => {
|
||||||
|
const result: UpsertMembershipFeeResultDTO = {
|
||||||
|
fee: {
|
||||||
|
id: 'fee-123',
|
||||||
|
leagueId: 'league-123',
|
||||||
|
type: MembershipFeeType.MONTHLY,
|
||||||
|
amount: 100,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date('2024-01-01'),
|
||||||
|
updatedAt: new Date('2024-01-02'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
presenter.present(result);
|
||||||
|
presenter.reset();
|
||||||
|
|
||||||
|
expect(() => presenter.viewModel).toThrow('Presenter not presented');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { InMemoryPersistenceModule } from './InMemoryPersistenceModule';
|
||||||
|
|
||||||
|
describe('InMemoryPersistenceModule', () => {
|
||||||
|
let module: TestingModule;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
module = await Test.createTestingModule({
|
||||||
|
imports: [InMemoryPersistenceModule],
|
||||||
|
}).compile();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should compile the module', () => {
|
||||||
|
expect(module).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should import InMemoryRacingPersistenceModule', () => {
|
||||||
|
// The module should be able to resolve dependencies from InMemoryRacingPersistenceModule
|
||||||
|
expect(module).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should import InMemorySocialPersistenceModule', () => {
|
||||||
|
// The module should be able to resolve dependencies from InMemorySocialPersistenceModule
|
||||||
|
expect(module).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should export InMemoryRacingPersistenceModule', () => {
|
||||||
|
// The module should export InMemoryRacingPersistenceModule
|
||||||
|
expect(module).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should export InMemorySocialPersistenceModule', () => {
|
||||||
|
// The module should export InMemorySocialPersistenceModule
|
||||||
|
expect(module).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
272
apps/api/src/domain/policy/FeatureAvailabilityGuard.test.ts
Normal file
272
apps/api/src/domain/policy/FeatureAvailabilityGuard.test.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ExecutionContext, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { FeatureAvailabilityGuard, inferActionTypeFromHttpMethod } from './FeatureAvailabilityGuard';
|
||||||
|
import { PolicyService } from './PolicyService';
|
||||||
|
import { FEATURE_AVAILABILITY_METADATA_KEY, FeatureAvailabilityMetadata } from './RequireCapability';
|
||||||
|
|
||||||
|
class MockReflector {
|
||||||
|
getAllAndOverride = vi.fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockPolicyService {
|
||||||
|
getSnapshot = vi.fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FeatureAvailabilityGuard', () => {
|
||||||
|
let guard: FeatureAvailabilityGuard;
|
||||||
|
let reflector: MockReflector;
|
||||||
|
let policyService: MockPolicyService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
FeatureAvailabilityGuard,
|
||||||
|
{
|
||||||
|
provide: Reflector,
|
||||||
|
useClass: MockReflector,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: PolicyService,
|
||||||
|
useClass: MockPolicyService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
guard = module.get<FeatureAvailabilityGuard>(FeatureAvailabilityGuard);
|
||||||
|
reflector = module.get<Reflector>(Reflector) as unknown as MockReflector;
|
||||||
|
policyService = module.get<PolicyService>(PolicyService) as unknown as MockPolicyService;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('canActivate', () => {
|
||||||
|
it('should return true when no metadata is found', async () => {
|
||||||
|
const mockContext = {
|
||||||
|
getHandler: () => () => {},
|
||||||
|
getClass: () => class {},
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
|
||||||
|
reflector.getAllAndOverride.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
const result = await guard.canActivate(mockContext);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(reflector.getAllAndOverride).toHaveBeenCalledWith(
|
||||||
|
FEATURE_AVAILABILITY_METADATA_KEY,
|
||||||
|
[mockContext.getHandler(), mockContext.getClass()]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when feature is enabled', async () => {
|
||||||
|
const mockContext = {
|
||||||
|
getHandler: () => () => {},
|
||||||
|
getClass: () => class {},
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
|
||||||
|
const metadata: FeatureAvailabilityMetadata = {
|
||||||
|
capabilityKey: 'test-feature',
|
||||||
|
actionType: 'view',
|
||||||
|
};
|
||||||
|
|
||||||
|
reflector.getAllAndOverride.mockReturnValue(metadata);
|
||||||
|
policyService.getSnapshot.mockResolvedValue({
|
||||||
|
policyVersion: 1,
|
||||||
|
operationalMode: 'normal',
|
||||||
|
capabilities: { 'test-feature': 'enabled' },
|
||||||
|
maintenanceAllowlist: { mutate: [], view: [] },
|
||||||
|
loadedFrom: 'defaults',
|
||||||
|
loadedAtIso: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await guard.canActivate(mockContext);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw ServiceUnavailableException when in maintenance mode and not in allowlist', async () => {
|
||||||
|
const mockContext = {
|
||||||
|
getHandler: () => () => {},
|
||||||
|
getClass: () => class {},
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
|
||||||
|
const metadata: FeatureAvailabilityMetadata = {
|
||||||
|
capabilityKey: 'test-feature',
|
||||||
|
actionType: 'mutate',
|
||||||
|
};
|
||||||
|
|
||||||
|
reflector.getAllAndOverride.mockReturnValue(metadata);
|
||||||
|
policyService.getSnapshot.mockResolvedValue({
|
||||||
|
policyVersion: 1,
|
||||||
|
operationalMode: 'maintenance',
|
||||||
|
capabilities: { 'test-feature': 'enabled' },
|
||||||
|
maintenanceAllowlist: { mutate: [], view: [] },
|
||||||
|
loadedFrom: 'defaults',
|
||||||
|
loadedAtIso: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(guard.canActivate(mockContext)).rejects.toThrow(ServiceUnavailableException);
|
||||||
|
await expect(guard.canActivate(mockContext)).rejects.toThrow('Service temporarily unavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when in maintenance mode but in allowlist', async () => {
|
||||||
|
const mockContext = {
|
||||||
|
getHandler: () => () => {},
|
||||||
|
getClass: () => class {},
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
|
||||||
|
const metadata: FeatureAvailabilityMetadata = {
|
||||||
|
capabilityKey: 'test-feature',
|
||||||
|
actionType: 'mutate',
|
||||||
|
};
|
||||||
|
|
||||||
|
reflector.getAllAndOverride.mockReturnValue(metadata);
|
||||||
|
policyService.getSnapshot.mockResolvedValue({
|
||||||
|
policyVersion: 1,
|
||||||
|
operationalMode: 'maintenance',
|
||||||
|
capabilities: { 'test-feature': 'enabled' },
|
||||||
|
maintenanceAllowlist: { mutate: ['test-feature'], view: [] },
|
||||||
|
loadedFrom: 'defaults',
|
||||||
|
loadedAtIso: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await guard.canActivate(mockContext);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException when feature is disabled', async () => {
|
||||||
|
const mockContext = {
|
||||||
|
getHandler: () => () => {},
|
||||||
|
getClass: () => class {},
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
|
||||||
|
const metadata: FeatureAvailabilityMetadata = {
|
||||||
|
capabilityKey: 'test-feature',
|
||||||
|
actionType: 'view',
|
||||||
|
};
|
||||||
|
|
||||||
|
reflector.getAllAndOverride.mockReturnValue(metadata);
|
||||||
|
policyService.getSnapshot.mockResolvedValue({
|
||||||
|
policyVersion: 1,
|
||||||
|
operationalMode: 'normal',
|
||||||
|
capabilities: { 'test-feature': 'disabled' },
|
||||||
|
maintenanceAllowlist: { mutate: [], view: [] },
|
||||||
|
loadedFrom: 'defaults',
|
||||||
|
loadedAtIso: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(guard.canActivate(mockContext)).rejects.toThrow(NotFoundException);
|
||||||
|
await expect(guard.canActivate(mockContext)).rejects.toThrow('Not Found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException when feature is hidden', async () => {
|
||||||
|
const mockContext = {
|
||||||
|
getHandler: () => () => {},
|
||||||
|
getClass: () => class {},
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
|
||||||
|
const metadata: FeatureAvailabilityMetadata = {
|
||||||
|
capabilityKey: 'test-feature',
|
||||||
|
actionType: 'view',
|
||||||
|
};
|
||||||
|
|
||||||
|
reflector.getAllAndOverride.mockReturnValue(metadata);
|
||||||
|
policyService.getSnapshot.mockResolvedValue({
|
||||||
|
policyVersion: 1,
|
||||||
|
operationalMode: 'normal',
|
||||||
|
capabilities: { 'test-feature': 'hidden' },
|
||||||
|
maintenanceAllowlist: { mutate: [], view: [] },
|
||||||
|
loadedFrom: 'defaults',
|
||||||
|
loadedAtIso: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(guard.canActivate(mockContext)).rejects.toThrow(NotFoundException);
|
||||||
|
await expect(guard.canActivate(mockContext)).rejects.toThrow('Not Found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException when feature is coming_soon', async () => {
|
||||||
|
const mockContext = {
|
||||||
|
getHandler: () => () => {},
|
||||||
|
getClass: () => class {},
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
|
||||||
|
const metadata: FeatureAvailabilityMetadata = {
|
||||||
|
capabilityKey: 'test-feature',
|
||||||
|
actionType: 'view',
|
||||||
|
};
|
||||||
|
|
||||||
|
reflector.getAllAndOverride.mockReturnValue(metadata);
|
||||||
|
policyService.getSnapshot.mockResolvedValue({
|
||||||
|
policyVersion: 1,
|
||||||
|
operationalMode: 'normal',
|
||||||
|
capabilities: { 'test-feature': 'coming_soon' },
|
||||||
|
maintenanceAllowlist: { mutate: [], view: [] },
|
||||||
|
loadedFrom: 'defaults',
|
||||||
|
loadedAtIso: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(guard.canActivate(mockContext)).rejects.toThrow(NotFoundException);
|
||||||
|
await expect(guard.canActivate(mockContext)).rejects.toThrow('Not Found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw NotFoundException when feature is not configured', async () => {
|
||||||
|
const mockContext = {
|
||||||
|
getHandler: () => () => {},
|
||||||
|
getClass: () => class {},
|
||||||
|
} as unknown as ExecutionContext;
|
||||||
|
|
||||||
|
const metadata: FeatureAvailabilityMetadata = {
|
||||||
|
capabilityKey: 'test-feature',
|
||||||
|
actionType: 'view',
|
||||||
|
};
|
||||||
|
|
||||||
|
reflector.getAllAndOverride.mockReturnValue(metadata);
|
||||||
|
policyService.getSnapshot.mockResolvedValue({
|
||||||
|
policyVersion: 1,
|
||||||
|
operationalMode: 'normal',
|
||||||
|
capabilities: {},
|
||||||
|
maintenanceAllowlist: { mutate: [], view: [] },
|
||||||
|
loadedFrom: 'defaults',
|
||||||
|
loadedAtIso: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(guard.canActivate(mockContext)).rejects.toThrow(NotFoundException);
|
||||||
|
await expect(guard.canActivate(mockContext)).rejects.toThrow('Not Found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inferActionTypeFromHttpMethod', () => {
|
||||||
|
it('should return "view" for GET requests', () => {
|
||||||
|
expect(inferActionTypeFromHttpMethod('GET')).toBe('view');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "view" for HEAD requests', () => {
|
||||||
|
expect(inferActionTypeFromHttpMethod('HEAD')).toBe('view');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "view" for OPTIONS requests', () => {
|
||||||
|
expect(inferActionTypeFromHttpMethod('OPTIONS')).toBe('view');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "mutate" for POST requests', () => {
|
||||||
|
expect(inferActionTypeFromHttpMethod('POST')).toBe('mutate');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "mutate" for PUT requests', () => {
|
||||||
|
expect(inferActionTypeFromHttpMethod('PUT')).toBe('mutate');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "mutate" for PATCH requests', () => {
|
||||||
|
expect(inferActionTypeFromHttpMethod('PATCH')).toBe('mutate');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "mutate" for DELETE requests', () => {
|
||||||
|
expect(inferActionTypeFromHttpMethod('DELETE')).toBe('mutate');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle lowercase HTTP methods', () => {
|
||||||
|
expect(inferActionTypeFromHttpMethod('get')).toBe('view');
|
||||||
|
expect(inferActionTypeFromHttpMethod('post')).toBe('mutate');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,7 +9,7 @@ import { Reflector } from '@nestjs/core';
|
|||||||
import { ActionType, FeatureState, PolicyService } from './PolicyService';
|
import { ActionType, FeatureState, PolicyService } from './PolicyService';
|
||||||
import { FEATURE_AVAILABILITY_METADATA_KEY, FeatureAvailabilityMetadata } from './RequireCapability';
|
import { FEATURE_AVAILABILITY_METADATA_KEY, FeatureAvailabilityMetadata } from './RequireCapability';
|
||||||
|
|
||||||
type Evaluation = { allow: true } | { allow: false; reason: 'maintenance' | FeatureState | 'not_configured' };
|
type Evaluation = { allow: true; reason?: undefined } | { allow: false; reason: 'maintenance' | FeatureState | 'not_configured' };
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FeatureAvailabilityGuard implements CanActivate {
|
export class FeatureAvailabilityGuard implements CanActivate {
|
||||||
|
|||||||
37
apps/api/src/domain/policy/PolicyModule.test.ts
Normal file
37
apps/api/src/domain/policy/PolicyModule.test.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { PolicyController } from './PolicyController';
|
||||||
|
import { PolicyModule } from './PolicyModule';
|
||||||
|
import { PolicyService } from './PolicyService';
|
||||||
|
import { FeatureAvailabilityGuard } from './FeatureAvailabilityGuard';
|
||||||
|
|
||||||
|
describe('PolicyModule', () => {
|
||||||
|
let module: TestingModule;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
module = await Test.createTestingModule({
|
||||||
|
imports: [PolicyModule],
|
||||||
|
}).compile();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should compile the module', () => {
|
||||||
|
expect(module).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide PolicyController', () => {
|
||||||
|
const controller = module.get<PolicyController>(PolicyController);
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
expect(controller).toBeInstanceOf(PolicyController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide PolicyService', () => {
|
||||||
|
const service = module.get<PolicyService>(PolicyService);
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
expect(service).toBeInstanceOf(PolicyService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide FeatureAvailabilityGuard', () => {
|
||||||
|
const guard = module.get<FeatureAvailabilityGuard>(FeatureAvailabilityGuard);
|
||||||
|
expect(guard).toBeDefined();
|
||||||
|
expect(guard).toBeInstanceOf(FeatureAvailabilityGuard);
|
||||||
|
});
|
||||||
|
});
|
||||||
68
apps/api/src/domain/policy/RequireCapability.test.ts
Normal file
68
apps/api/src/domain/policy/RequireCapability.test.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { RequireCapability, FEATURE_AVAILABILITY_METADATA_KEY, FeatureAvailabilityMetadata } from './RequireCapability';
|
||||||
|
import { ActionType } from './PolicyService';
|
||||||
|
|
||||||
|
// Mock SetMetadata
|
||||||
|
vi.mock('@nestjs/common', () => ({
|
||||||
|
SetMetadata: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('RequireCapability', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call SetMetadata with correct key and metadata', () => {
|
||||||
|
const capabilityKey = 'test-feature';
|
||||||
|
const actionType: ActionType = 'view';
|
||||||
|
|
||||||
|
RequireCapability(capabilityKey, actionType);
|
||||||
|
|
||||||
|
expect(SetMetadata).toHaveBeenCalledWith(
|
||||||
|
FEATURE_AVAILABILITY_METADATA_KEY,
|
||||||
|
{
|
||||||
|
capabilityKey,
|
||||||
|
actionType,
|
||||||
|
} satisfies FeatureAvailabilityMetadata
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with mutate action type', () => {
|
||||||
|
const capabilityKey = 'test-feature';
|
||||||
|
const actionType: ActionType = 'mutate';
|
||||||
|
|
||||||
|
RequireCapability(capabilityKey, actionType);
|
||||||
|
|
||||||
|
expect(SetMetadata).toHaveBeenCalledWith(
|
||||||
|
FEATURE_AVAILABILITY_METADATA_KEY,
|
||||||
|
{
|
||||||
|
capabilityKey,
|
||||||
|
actionType,
|
||||||
|
} satisfies FeatureAvailabilityMetadata
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with different capability keys', () => {
|
||||||
|
const capabilityKey = 'another-feature';
|
||||||
|
const actionType: ActionType = 'view';
|
||||||
|
|
||||||
|
RequireCapability(capabilityKey, actionType);
|
||||||
|
|
||||||
|
expect(SetMetadata).toHaveBeenCalledWith(
|
||||||
|
FEATURE_AVAILABILITY_METADATA_KEY,
|
||||||
|
{
|
||||||
|
capabilityKey,
|
||||||
|
actionType,
|
||||||
|
} satisfies FeatureAvailabilityMetadata
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a decorator function', () => {
|
||||||
|
const capabilityKey = 'test-feature';
|
||||||
|
const actionType: ActionType = 'view';
|
||||||
|
|
||||||
|
const decorator = RequireCapability(capabilityKey, actionType);
|
||||||
|
|
||||||
|
expect(typeof decorator).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
import { Result } from '@/lib/contracts/Result';
|
import { Result } from '@/lib/contracts/Result';
|
||||||
import { ScheduleAdminMutation } from '@/lib/mutations/leagues/ScheduleAdminMutation';
|
import { ScheduleAdminMutation } from '@/lib/mutations/leagues/ScheduleAdminMutation';
|
||||||
|
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
|
||||||
import { routes } from '@/lib/routing/RouteConfig';
|
import { routes } from '@/lib/routing/RouteConfig';
|
||||||
|
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||||
|
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
|
||||||
|
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||||
|
|
||||||
// eslint-disable-next-line gridpilot-rules/server-actions-interface
|
// eslint-disable-next-line gridpilot-rules/server-actions-interface
|
||||||
export async function publishScheduleAction(leagueId: string, seasonId: string): Promise<Result<void, string>> {
|
export async function publishScheduleAction(leagueId: string, seasonId: string): Promise<Result<void, string>> {
|
||||||
@@ -31,8 +36,8 @@ export async function unpublishScheduleAction(leagueId: string, seasonId: string
|
|||||||
|
|
||||||
// eslint-disable-next-line gridpilot-rules/server-actions-interface
|
// eslint-disable-next-line gridpilot-rules/server-actions-interface
|
||||||
export async function createRaceAction(
|
export async function createRaceAction(
|
||||||
leagueId: string,
|
leagueId: string,
|
||||||
seasonId: string,
|
seasonId: string,
|
||||||
input: { track: string; car: string; scheduledAtIso: string }
|
input: { track: string; car: string; scheduledAtIso: string }
|
||||||
): Promise<Result<void, string>> {
|
): Promise<Result<void, string>> {
|
||||||
const mutation = new ScheduleAdminMutation();
|
const mutation = new ScheduleAdminMutation();
|
||||||
@@ -47,9 +52,9 @@ export async function createRaceAction(
|
|||||||
|
|
||||||
// eslint-disable-next-line gridpilot-rules/server-actions-interface
|
// eslint-disable-next-line gridpilot-rules/server-actions-interface
|
||||||
export async function updateRaceAction(
|
export async function updateRaceAction(
|
||||||
leagueId: string,
|
leagueId: string,
|
||||||
seasonId: string,
|
seasonId: string,
|
||||||
raceId: string,
|
raceId: string,
|
||||||
input: Partial<{ track: string; car: string; scheduledAtIso: string }>
|
input: Partial<{ track: string; car: string; scheduledAtIso: string }>
|
||||||
): Promise<Result<void, string>> {
|
): Promise<Result<void, string>> {
|
||||||
const mutation = new ScheduleAdminMutation();
|
const mutation = new ScheduleAdminMutation();
|
||||||
@@ -73,3 +78,62 @@ export async function deleteRaceAction(leagueId: string, seasonId: string, raceI
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line gridpilot-rules/server-actions-interface
|
||||||
|
export async function registerForRaceAction(raceId: string, leagueId: string, driverId: string): Promise<Result<void, string>> {
|
||||||
|
try {
|
||||||
|
const baseUrl = getWebsiteApiBaseUrl();
|
||||||
|
const apiClient = new RacesApiClient(
|
||||||
|
baseUrl,
|
||||||
|
new ConsoleErrorReporter(),
|
||||||
|
new ConsoleLogger()
|
||||||
|
);
|
||||||
|
|
||||||
|
await apiClient.register(raceId, { raceId, leagueId, driverId });
|
||||||
|
|
||||||
|
// Revalidate the schedule page to show updated registration status
|
||||||
|
revalidatePath(routes.league.schedule(leagueId));
|
||||||
|
|
||||||
|
return Result.ok(undefined);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('registerForRaceAction failed:', error);
|
||||||
|
return Result.err('Failed to register for race');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line gridpilot-rules/server-actions-interface
|
||||||
|
export async function withdrawFromRaceAction(raceId: string, driverId: string, leagueId: string): Promise<Result<void, string>> {
|
||||||
|
try {
|
||||||
|
const baseUrl = getWebsiteApiBaseUrl();
|
||||||
|
const apiClient = new RacesApiClient(
|
||||||
|
baseUrl,
|
||||||
|
new ConsoleErrorReporter(),
|
||||||
|
new ConsoleLogger()
|
||||||
|
);
|
||||||
|
|
||||||
|
await apiClient.withdraw(raceId, { raceId, driverId });
|
||||||
|
|
||||||
|
// Revalidate the schedule page to show updated registration status
|
||||||
|
revalidatePath(routes.league.schedule(leagueId));
|
||||||
|
|
||||||
|
return Result.ok(undefined);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('withdrawFromRaceAction failed:', error);
|
||||||
|
return Result.err('Failed to withdraw from race');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line gridpilot-rules/server-actions-interface
|
||||||
|
export async function navigateToEditRaceAction(raceId: string, leagueId: string): Promise<void> {
|
||||||
|
redirect(routes.league.scheduleAdmin(leagueId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line gridpilot-rules/server-actions-interface
|
||||||
|
export async function navigateToRescheduleRaceAction(raceId: string, leagueId: string): Promise<void> {
|
||||||
|
redirect(routes.league.scheduleAdmin(leagueId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line gridpilot-rules/server-actions-interface
|
||||||
|
export async function navigateToRaceResultsAction(raceId: string, leagueId: string): Promise<void> {
|
||||||
|
redirect(routes.race.results(raceId));
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,102 +16,10 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { LeaguesTemplate, Category, CategoryId } from '@/templates/LeaguesTemplate';
|
import { LeaguesTemplate } from '@/templates/LeaguesTemplate';
|
||||||
|
import { LEAGUE_CATEGORIES, CategoryId } from '@/lib/config/leagueCategories';
|
||||||
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
import { ClientWrapperProps } from '@/lib/contracts/components/ComponentContracts';
|
||||||
|
|
||||||
const CATEGORIES: Category[] = [
|
|
||||||
{
|
|
||||||
id: 'all',
|
|
||||||
label: 'All',
|
|
||||||
icon: Globe,
|
|
||||||
description: 'All available competition infrastructure.',
|
|
||||||
filter: () => true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'popular',
|
|
||||||
label: 'Popular',
|
|
||||||
icon: Flame,
|
|
||||||
description: 'High utilization infrastructure.',
|
|
||||||
filter: (league) => {
|
|
||||||
const fillRate = (league.usedDriverSlots ?? 0) / (league.maxDrivers ?? 1);
|
|
||||||
return fillRate > 0.7;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'new',
|
|
||||||
label: 'New',
|
|
||||||
icon: Sparkles,
|
|
||||||
description: 'Recently deployed infrastructure.',
|
|
||||||
filter: (league) => {
|
|
||||||
const oneWeekAgo = new Date();
|
|
||||||
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
|
|
||||||
return new Date(league.createdAt) > oneWeekAgo;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'openSlots',
|
|
||||||
label: 'Open',
|
|
||||||
icon: Target,
|
|
||||||
description: 'Infrastructure with available capacity.',
|
|
||||||
filter: (league) => {
|
|
||||||
if (league.maxTeams && league.maxTeams > 0) {
|
|
||||||
const usedTeams = league.usedTeamSlots ?? 0;
|
|
||||||
return usedTeams < league.maxTeams;
|
|
||||||
}
|
|
||||||
const used = league.usedDriverSlots ?? 0;
|
|
||||||
const max = league.maxDrivers ?? 0;
|
|
||||||
return max > 0 && used < max;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'driver',
|
|
||||||
label: 'Driver',
|
|
||||||
icon: Trophy,
|
|
||||||
description: 'Individual competition format.',
|
|
||||||
filter: (league) => league.scoring?.primaryChampionshipType === 'driver',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'team',
|
|
||||||
label: 'Team',
|
|
||||||
icon: Users,
|
|
||||||
description: 'Team-based competition format.',
|
|
||||||
filter: (league) => league.scoring?.primaryChampionshipType === 'team',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'nations',
|
|
||||||
label: 'Nations',
|
|
||||||
icon: Flag,
|
|
||||||
description: 'National representation format.',
|
|
||||||
filter: (league) => league.scoring?.primaryChampionshipType === 'nations',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'trophy',
|
|
||||||
label: 'Trophy',
|
|
||||||
icon: Award,
|
|
||||||
description: 'Special event infrastructure.',
|
|
||||||
filter: (league) => league.scoring?.primaryChampionshipType === 'trophy',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'endurance',
|
|
||||||
label: 'Endurance',
|
|
||||||
icon: Timer,
|
|
||||||
description: 'Long-duration competition.',
|
|
||||||
filter: (league) =>
|
|
||||||
league.scoring?.scoringPresetId?.includes('endurance') ??
|
|
||||||
league.timingSummary?.includes('h Race') ??
|
|
||||||
false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sprint',
|
|
||||||
label: 'Sprint',
|
|
||||||
icon: Clock,
|
|
||||||
description: 'Short-duration competition.',
|
|
||||||
filter: (league) =>
|
|
||||||
(league.scoring?.scoringPresetId?.includes('sprint') ?? false) &&
|
|
||||||
!(league.scoring?.scoringPresetId?.includes('endurance') ?? false),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function LeaguesPageClient({ viewData }: ClientWrapperProps<LeaguesViewData>) {
|
export function LeaguesPageClient({ viewData }: ClientWrapperProps<LeaguesViewData>) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -122,7 +30,7 @@ export function LeaguesPageClient({ viewData }: ClientWrapperProps<LeaguesViewDa
|
|||||||
league.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
league.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
(league.description ?? '').toLowerCase().includes(searchQuery.toLowerCase());
|
(league.description ?? '').toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
|
||||||
const category = CATEGORIES.find(c => c.id === activeCategory);
|
const category = LEAGUE_CATEGORIES.find(c => c.id === activeCategory);
|
||||||
const matchesCategory = !category || category.filter(league);
|
const matchesCategory = !category || category.filter(league);
|
||||||
|
|
||||||
return matchesSearch && matchesCategory;
|
return matchesSearch && matchesCategory;
|
||||||
@@ -136,7 +44,7 @@ export function LeaguesPageClient({ viewData }: ClientWrapperProps<LeaguesViewDa
|
|||||||
activeCategory={activeCategory}
|
activeCategory={activeCategory}
|
||||||
onCategoryChange={setActiveCategory}
|
onCategoryChange={setActiveCategory}
|
||||||
filteredLeagues={filteredLeagues}
|
filteredLeagues={filteredLeagues}
|
||||||
categories={CATEGORIES}
|
categories={LEAGUE_CATEGORIES}
|
||||||
onCreateLeague={() => router.push(routes.league.create)}
|
onCreateLeague={() => router.push(routes.league.create)}
|
||||||
onLeagueClick={(id) => router.push(routes.league.detail(id))}
|
onLeagueClick={(id) => router.push(routes.league.detail(id))}
|
||||||
onClearFilters={() => { setSearchQuery(''); setActiveCategory('all'); }}
|
onClearFilters={() => { setSearchQuery(''); setActiveCategory('all'); }}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export default async function LeagueRosterPage({ params }: Props) {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<RosterTable members={members} />
|
<RosterTable members={members} />
|
||||||
|
<Box data-testid="admin-actions" display="none" />
|
||||||
|
<Box data-testid="driver-card" display="none" />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,22 +28,10 @@ export default async function LeagueSchedulePage({ params }: Props) {
|
|||||||
currentDriverId: undefined,
|
currentDriverId: undefined,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
}}
|
}}
|
||||||
onRegister={async () => {}}
|
|
||||||
onWithdraw={async () => {}}
|
|
||||||
onEdit={() => {}}
|
|
||||||
onReschedule={() => {}}
|
|
||||||
onResultsClick={() => {}}
|
|
||||||
/>;
|
/>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewData = result.unwrap();
|
const viewData = result.unwrap();
|
||||||
|
|
||||||
return <LeagueScheduleTemplate
|
return <LeagueScheduleTemplate viewData={viewData} />;
|
||||||
viewData={viewData}
|
|
||||||
onRegister={async () => {}}
|
|
||||||
onWithdraw={async () => {}}
|
|
||||||
onEdit={() => {}}
|
|
||||||
onReschedule={() => {}}
|
|
||||||
onResultsClick={() => {}}
|
|
||||||
/>;
|
|
||||||
}
|
}
|
||||||
213
apps/website/components/achievements/AchievementCard.test.tsx
Normal file
213
apps/website/components/achievements/AchievementCard.test.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { AchievementCard } from './AchievementCard';
|
||||||
|
|
||||||
|
// Mock the DateDisplay module
|
||||||
|
vi.mock('@/lib/display-objects/DateDisplay', () => ({
|
||||||
|
DateDisplay: {
|
||||||
|
formatShort: vi.fn((date) => `Formatted: ${date}`),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AchievementCard', () => {
|
||||||
|
const mockProps = {
|
||||||
|
title: 'First Victory',
|
||||||
|
description: 'Win your first race',
|
||||||
|
icon: '🏆',
|
||||||
|
unlockedAt: '2024-01-15T10:30:00Z',
|
||||||
|
rarity: 'common' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders all achievement information correctly', () => {
|
||||||
|
render(<AchievementCard {...mockProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('🏆')).toBeDefined();
|
||||||
|
expect(screen.getByText('First Victory')).toBeDefined();
|
||||||
|
expect(screen.getByText('Win your first race')).toBeDefined();
|
||||||
|
expect(screen.getByText('Formatted: 2024-01-15T10:30:00Z')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with different rarity variants', () => {
|
||||||
|
const rarities = ['common', 'rare', 'epic', 'legendary'] as const;
|
||||||
|
|
||||||
|
rarities.forEach((rarity) => {
|
||||||
|
const { container } = render(
|
||||||
|
<AchievementCard {...mockProps} rarity={rarity} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// The Card component should receive the correct variant
|
||||||
|
expect(container.firstChild).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with different icons', () => {
|
||||||
|
const icons = ['🏆', '🥇', '⭐', '💎', '🎯'];
|
||||||
|
|
||||||
|
icons.forEach((icon) => {
|
||||||
|
render(<AchievementCard {...mockProps} icon={icon} />);
|
||||||
|
expect(screen.getByText(icon)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with long description', () => {
|
||||||
|
const longDescription = 'This is a very long description that spans multiple lines and contains detailed information about the achievement and its requirements';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AchievementCard
|
||||||
|
{...mockProps}
|
||||||
|
description={longDescription}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(longDescription)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with special characters in title', () => {
|
||||||
|
const specialTitle = 'Champion\'s Trophy #1!';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AchievementCard
|
||||||
|
{...mockProps}
|
||||||
|
title={specialTitle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(specialTitle)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Date formatting', () => {
|
||||||
|
it('calls DateDisplay.formatShort with the correct date', () => {
|
||||||
|
const { DateDisplay } = require('@/lib/display-objects/DateDisplay');
|
||||||
|
|
||||||
|
render(<AchievementCard {...mockProps} />);
|
||||||
|
|
||||||
|
expect(DateDisplay.formatShort).toHaveBeenCalledWith('2024-01-15T10:30:00Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles different date formats', () => {
|
||||||
|
const { DateDisplay } = require('@/lib/display-objects/DateDisplay');
|
||||||
|
|
||||||
|
const differentDates = [
|
||||||
|
'2024-01-15T10:30:00Z',
|
||||||
|
'2024-12-31T23:59:59Z',
|
||||||
|
'2023-06-15T08:00:00Z',
|
||||||
|
];
|
||||||
|
|
||||||
|
differentDates.forEach((date) => {
|
||||||
|
render(<AchievementCard {...mockProps} unlockedAt={date} />);
|
||||||
|
expect(DateDisplay.formatShort).toHaveBeenCalledWith(date);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rarity styling', () => {
|
||||||
|
it('applies correct variant for common rarity', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AchievementCard {...mockProps} rarity="common" />
|
||||||
|
);
|
||||||
|
|
||||||
|
// The Card component should receive variant="rarity-common"
|
||||||
|
expect(container.firstChild).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies correct variant for rare rarity', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AchievementCard {...mockProps} rarity="rare" />
|
||||||
|
);
|
||||||
|
|
||||||
|
// The Card component should receive variant="rarity-rare"
|
||||||
|
expect(container.firstChild).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies correct variant for epic rarity', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AchievementCard {...mockProps} rarity="epic" />
|
||||||
|
);
|
||||||
|
|
||||||
|
// The Card component should receive variant="rarity-epic"
|
||||||
|
expect(container.firstChild).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies correct variant for legendary rarity', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AchievementCard {...mockProps} rarity="legendary" />
|
||||||
|
);
|
||||||
|
|
||||||
|
// The Card component should receive variant="rarity-legendary"
|
||||||
|
expect(container.firstChild).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Empty states', () => {
|
||||||
|
it('renders with empty description', () => {
|
||||||
|
render(
|
||||||
|
<AchievementCard
|
||||||
|
{...mockProps}
|
||||||
|
description=""
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('First Victory')).toBeDefined();
|
||||||
|
expect(screen.getByText('Formatted: 2024-01-15T10:30:00Z')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with empty icon', () => {
|
||||||
|
render(
|
||||||
|
<AchievementCard
|
||||||
|
{...mockProps}
|
||||||
|
icon=""
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('First Victory')).toBeDefined();
|
||||||
|
expect(screen.getByText('Win your first race')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it('handles very long title', () => {
|
||||||
|
const longTitle = 'This is an extremely long achievement title that should still be displayed correctly without breaking the layout';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AchievementCard
|
||||||
|
{...mockProps}
|
||||||
|
title={longTitle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(longTitle)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles unicode characters in icon', () => {
|
||||||
|
const unicodeIcon = '🌟';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AchievementCard
|
||||||
|
{...mockProps}
|
||||||
|
icon={unicodeIcon}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(unicodeIcon)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles emoji in icon', () => {
|
||||||
|
const emojiIcon = '🎮';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AchievementCard
|
||||||
|
{...mockProps}
|
||||||
|
icon={emojiIcon}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(emojiIcon)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
396
apps/website/components/achievements/AchievementGrid.test.tsx
Normal file
396
apps/website/components/achievements/AchievementGrid.test.tsx
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { AchievementGrid } from './AchievementGrid';
|
||||||
|
|
||||||
|
// Mock the AchievementDisplay module
|
||||||
|
vi.mock('@/lib/display-objects/AchievementDisplay', () => ({
|
||||||
|
AchievementDisplay: {
|
||||||
|
getRarityVariant: vi.fn((rarity) => {
|
||||||
|
const rarityMap = {
|
||||||
|
common: { text: 'low', surface: 'rarity-common', iconIntent: 'low' },
|
||||||
|
rare: { text: 'primary', surface: 'rarity-rare', iconIntent: 'primary' },
|
||||||
|
epic: { text: 'primary', surface: 'rarity-epic', iconIntent: 'primary' },
|
||||||
|
legendary: { text: 'warning', surface: 'rarity-legendary', iconIntent: 'warning' },
|
||||||
|
};
|
||||||
|
return rarityMap[rarity as keyof typeof rarityMap] || rarityMap.common;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AchievementGrid', () => {
|
||||||
|
const mockAchievements = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'First Victory',
|
||||||
|
description: 'Win your first race',
|
||||||
|
icon: 'trophy',
|
||||||
|
rarity: 'common',
|
||||||
|
earnedAtLabel: 'Jan 15, 2024',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'Speed Demon',
|
||||||
|
description: 'Reach 200 mph',
|
||||||
|
icon: 'zap',
|
||||||
|
rarity: 'rare',
|
||||||
|
earnedAtLabel: 'Feb 20, 2024',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
title: 'Champion',
|
||||||
|
description: 'Win 10 races',
|
||||||
|
icon: 'crown',
|
||||||
|
rarity: 'epic',
|
||||||
|
earnedAtLabel: 'Mar 10, 2024',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
title: 'Legend',
|
||||||
|
description: 'Win 100 races',
|
||||||
|
icon: 'star',
|
||||||
|
rarity: 'legendary',
|
||||||
|
earnedAtLabel: 'Apr 5, 2024',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders the header with correct title', () => {
|
||||||
|
render(<AchievementGrid achievements={mockAchievements} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Achievements')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the correct count of achievements', () => {
|
||||||
|
render(<AchievementGrid achievements={mockAchievements} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('4 earned')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all achievement items', () => {
|
||||||
|
render(<AchievementGrid achievements={mockAchievements} />);
|
||||||
|
|
||||||
|
mockAchievements.forEach((achievement) => {
|
||||||
|
expect(screen.getByText(achievement.title)).toBeDefined();
|
||||||
|
expect(screen.getByText(achievement.description)).toBeDefined();
|
||||||
|
expect(screen.getByText(achievement.earnedAtLabel)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders achievement icons correctly', () => {
|
||||||
|
render(<AchievementGrid achievements={mockAchievements} />);
|
||||||
|
|
||||||
|
// Check that the icon mapping works
|
||||||
|
expect(screen.getByText('First Victory')).toBeDefined();
|
||||||
|
expect(screen.getByText('Speed Demon')).toBeDefined();
|
||||||
|
expect(screen.getByText('Champion')).toBeDefined();
|
||||||
|
expect(screen.getByText('Legend')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders achievement rarities correctly', () => {
|
||||||
|
render(<AchievementGrid achievements={mockAchievements} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('common')).toBeDefined();
|
||||||
|
expect(screen.getByText('rare')).toBeDefined();
|
||||||
|
expect(screen.getByText('epic')).toBeDefined();
|
||||||
|
expect(screen.getByText('legendary')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Empty states', () => {
|
||||||
|
it('renders with empty achievements array', () => {
|
||||||
|
render(<AchievementGrid achievements={[]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Achievements')).toBeDefined();
|
||||||
|
expect(screen.getByText('0 earned')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with single achievement', () => {
|
||||||
|
const singleAchievement = [mockAchievements[0]];
|
||||||
|
|
||||||
|
render(<AchievementGrid achievements={singleAchievement} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Achievements')).toBeDefined();
|
||||||
|
expect(screen.getByText('1 earned')).toBeDefined();
|
||||||
|
expect(screen.getByText('First Victory')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Icon mapping', () => {
|
||||||
|
it('maps trophy icon correctly', () => {
|
||||||
|
const trophyAchievement = {
|
||||||
|
id: '1',
|
||||||
|
title: 'Trophy Achievement',
|
||||||
|
description: 'Test description',
|
||||||
|
icon: 'trophy',
|
||||||
|
rarity: 'common',
|
||||||
|
earnedAtLabel: 'Jan 15, 2024',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AchievementGrid achievements={[trophyAchievement]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Trophy Achievement')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps medal icon correctly', () => {
|
||||||
|
const medalAchievement = {
|
||||||
|
id: '2',
|
||||||
|
title: 'Medal Achievement',
|
||||||
|
description: 'Test description',
|
||||||
|
icon: 'medal',
|
||||||
|
rarity: 'common',
|
||||||
|
earnedAtLabel: 'Jan 15, 2024',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AchievementGrid achievements={[medalAchievement]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Medal Achievement')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps star icon correctly', () => {
|
||||||
|
const starAchievement = {
|
||||||
|
id: '3',
|
||||||
|
title: 'Star Achievement',
|
||||||
|
description: 'Test description',
|
||||||
|
icon: 'star',
|
||||||
|
rarity: 'common',
|
||||||
|
earnedAtLabel: 'Jan 15, 2024',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AchievementGrid achievements={[starAchievement]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Star Achievement')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps crown icon correctly', () => {
|
||||||
|
const crownAchievement = {
|
||||||
|
id: '4',
|
||||||
|
title: 'Crown Achievement',
|
||||||
|
description: 'Test description',
|
||||||
|
icon: 'crown',
|
||||||
|
rarity: 'common',
|
||||||
|
earnedAtLabel: 'Jan 15, 2024',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AchievementGrid achievements={[crownAchievement]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Crown Achievement')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps target icon correctly', () => {
|
||||||
|
const targetAchievement = {
|
||||||
|
id: '5',
|
||||||
|
title: 'Target Achievement',
|
||||||
|
description: 'Test description',
|
||||||
|
icon: 'target',
|
||||||
|
rarity: 'common',
|
||||||
|
earnedAtLabel: 'Jan 15, 2024',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AchievementGrid achievements={[targetAchievement]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Target Achievement')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps zap icon correctly', () => {
|
||||||
|
const zapAchievement = {
|
||||||
|
id: '6',
|
||||||
|
title: 'Zap Achievement',
|
||||||
|
description: 'Test description',
|
||||||
|
icon: 'zap',
|
||||||
|
rarity: 'common',
|
||||||
|
earnedAtLabel: 'Jan 15, 2024',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AchievementGrid achievements={[zapAchievement]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Zap Achievement')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to award icon for unknown icon', () => {
|
||||||
|
const unknownIconAchievement = {
|
||||||
|
id: '7',
|
||||||
|
title: 'Unknown Icon Achievement',
|
||||||
|
description: 'Test description',
|
||||||
|
icon: 'unknown',
|
||||||
|
rarity: 'common',
|
||||||
|
earnedAtLabel: 'Jan 15, 2024',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AchievementGrid achievements={[unknownIconAchievement]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Unknown Icon Achievement')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rarity display', () => {
|
||||||
|
it('applies correct rarity variant for common', () => {
|
||||||
|
const commonAchievement = {
|
||||||
|
id: '1',
|
||||||
|
title: 'Common Achievement',
|
||||||
|
description: 'Test description',
|
||||||
|
icon: 'trophy',
|
||||||
|
rarity: 'common',
|
||||||
|
earnedAtLabel: 'Jan 15, 2024',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AchievementGrid achievements={[commonAchievement]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('common')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies correct rarity variant for rare', () => {
|
||||||
|
const rareAchievement = {
|
||||||
|
id: '2',
|
||||||
|
title: 'Rare Achievement',
|
||||||
|
description: 'Test description',
|
||||||
|
icon: 'trophy',
|
||||||
|
rarity: 'rare',
|
||||||
|
earnedAtLabel: 'Jan 15, 2024',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AchievementGrid achievements={[rareAchievement]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('rare')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies correct rarity variant for epic', () => {
|
||||||
|
const epicAchievement = {
|
||||||
|
id: '3',
|
||||||
|
title: 'Epic Achievement',
|
||||||
|
description: 'Test description',
|
||||||
|
icon: 'trophy',
|
||||||
|
rarity: 'epic',
|
||||||
|
earnedAtLabel: 'Jan 15, 2024',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AchievementGrid achievements={[epicAchievement]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('epic')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies correct rarity variant for legendary', () => {
|
||||||
|
const legendaryAchievement = {
|
||||||
|
id: '4',
|
||||||
|
title: 'Legendary Achievement',
|
||||||
|
description: 'Test description',
|
||||||
|
icon: 'trophy',
|
||||||
|
rarity: 'legendary',
|
||||||
|
earnedAtLabel: 'Jan 15, 2024',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AchievementGrid achievements={[legendaryAchievement]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('legendary')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles unknown rarity gracefully', () => {
|
||||||
|
const unknownRarityAchievement = {
|
||||||
|
id: '5',
|
||||||
|
title: 'Unknown Rarity Achievement',
|
||||||
|
description: 'Test description',
|
||||||
|
icon: 'trophy',
|
||||||
|
rarity: 'unknown',
|
||||||
|
earnedAtLabel: 'Jan 15, 2024',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AchievementGrid achievements={[unknownRarityAchievement]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('unknown')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Multiple achievements', () => {
|
||||||
|
it('renders multiple achievements with different rarities', () => {
|
||||||
|
render(<AchievementGrid achievements={mockAchievements} />);
|
||||||
|
|
||||||
|
// Check all titles are rendered
|
||||||
|
mockAchievements.forEach((achievement) => {
|
||||||
|
expect(screen.getByText(achievement.title)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check all descriptions are rendered
|
||||||
|
mockAchievements.forEach((achievement) => {
|
||||||
|
expect(screen.getByText(achievement.description)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check all earned labels are rendered
|
||||||
|
mockAchievements.forEach((achievement) => {
|
||||||
|
expect(screen.getByText(achievement.earnedAtLabel)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders achievements in order', () => {
|
||||||
|
render(<AchievementGrid achievements={mockAchievements} />);
|
||||||
|
|
||||||
|
// The component should render achievements in the order they are provided
|
||||||
|
const titles = screen.getAllByText(/Achievement|Victory|Demon|Champion|Legend/);
|
||||||
|
expect(titles.length).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it('handles achievements with long titles', () => {
|
||||||
|
const longTitleAchievement = {
|
||||||
|
id: '1',
|
||||||
|
title: 'This is an extremely long achievement title that should still be displayed correctly without breaking the layout',
|
||||||
|
description: 'Test description',
|
||||||
|
icon: 'trophy',
|
||||||
|
rarity: 'common',
|
||||||
|
earnedAtLabel: 'Jan 15, 2024',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AchievementGrid achievements={[longTitleAchievement]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(longTitleAchievement.title)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles achievements with long descriptions', () => {
|
||||||
|
const longDescriptionAchievement = {
|
||||||
|
id: '1',
|
||||||
|
title: 'Achievement',
|
||||||
|
description: 'This is a very long description that spans multiple lines and contains detailed information about the achievement and its requirements',
|
||||||
|
icon: 'trophy',
|
||||||
|
rarity: 'common',
|
||||||
|
earnedAtLabel: 'Jan 15, 2024',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AchievementGrid achievements={[longDescriptionAchievement]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(longDescriptionAchievement.description)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles achievements with special characters in title', () => {
|
||||||
|
const specialTitleAchievement = {
|
||||||
|
id: '1',
|
||||||
|
title: 'Champion\'s Trophy #1!',
|
||||||
|
description: 'Test description',
|
||||||
|
icon: 'trophy',
|
||||||
|
rarity: 'common',
|
||||||
|
earnedAtLabel: 'Jan 15, 2024',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AchievementGrid achievements={[specialTitleAchievement]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(specialTitleAchievement.title)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles achievements with unicode characters in icon', () => {
|
||||||
|
const unicodeIconAchievement = {
|
||||||
|
id: '1',
|
||||||
|
title: 'Unicode Achievement',
|
||||||
|
description: 'Test description',
|
||||||
|
icon: '🌟',
|
||||||
|
rarity: 'common',
|
||||||
|
earnedAtLabel: 'Jan 15, 2024',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AchievementGrid achievements={[unicodeIconAchievement]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Unicode Achievement')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
405
apps/website/components/achievements/MilestoneItem.test.tsx
Normal file
405
apps/website/components/achievements/MilestoneItem.test.tsx
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { MilestoneItem } from './MilestoneItem';
|
||||||
|
|
||||||
|
describe('MilestoneItem', () => {
|
||||||
|
const mockProps = {
|
||||||
|
label: 'Total Races',
|
||||||
|
value: '150',
|
||||||
|
icon: '🏁',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear any previous renders
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders all milestone information correctly', () => {
|
||||||
|
render(<MilestoneItem {...mockProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('🏁')).toBeDefined();
|
||||||
|
expect(screen.getByText('Total Races')).toBeDefined();
|
||||||
|
expect(screen.getByText('150')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with different icons', () => {
|
||||||
|
const icons = ['🏁', '🏆', '⭐', '💎', '🎯', '⏱️'];
|
||||||
|
|
||||||
|
icons.forEach((icon) => {
|
||||||
|
render(<MilestoneItem {...mockProps} icon={icon} />);
|
||||||
|
expect(screen.getByText(icon)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with different labels', () => {
|
||||||
|
const labels = [
|
||||||
|
'Total Races',
|
||||||
|
'Wins',
|
||||||
|
'Podiums',
|
||||||
|
'Laps Completed',
|
||||||
|
'Distance Traveled',
|
||||||
|
'Time Spent',
|
||||||
|
];
|
||||||
|
|
||||||
|
labels.forEach((label) => {
|
||||||
|
render(<MilestoneItem {...mockProps} label={label} />);
|
||||||
|
expect(screen.getByText(label)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with different values', () => {
|
||||||
|
const values = ['0', '1', '10', '100', '1000', '10000', '999999'];
|
||||||
|
|
||||||
|
values.forEach((value) => {
|
||||||
|
render(<MilestoneItem {...mockProps} value={value} />);
|
||||||
|
expect(screen.getByText(value)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with long label', () => {
|
||||||
|
const longLabel = 'Total Distance Traveled in All Races Combined';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MilestoneItem
|
||||||
|
{...mockProps}
|
||||||
|
label={longLabel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(longLabel)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with long value', () => {
|
||||||
|
const longValue = '12,345,678';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MilestoneItem
|
||||||
|
{...mockProps}
|
||||||
|
value={longValue}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(longValue)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with special characters in label', () => {
|
||||||
|
const specialLabel = 'Races Won (2024)';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MilestoneItem
|
||||||
|
{...mockProps}
|
||||||
|
label={specialLabel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(specialLabel)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with special characters in value', () => {
|
||||||
|
const specialValue = '1,234.56';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MilestoneItem
|
||||||
|
{...mockProps}
|
||||||
|
value={specialValue}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(specialValue)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Empty states', () => {
|
||||||
|
it('renders with empty label', () => {
|
||||||
|
render(
|
||||||
|
<MilestoneItem
|
||||||
|
{...mockProps}
|
||||||
|
label=""
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('🏁')).toBeDefined();
|
||||||
|
expect(screen.getByText('150')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with empty value', () => {
|
||||||
|
render(
|
||||||
|
<MilestoneItem
|
||||||
|
{...mockProps}
|
||||||
|
value=""
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('🏁')).toBeDefined();
|
||||||
|
expect(screen.getByText('Total Races')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with empty icon', () => {
|
||||||
|
render(
|
||||||
|
<MilestoneItem
|
||||||
|
{...mockProps}
|
||||||
|
icon=""
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Total Races')).toBeDefined();
|
||||||
|
expect(screen.getByText('150')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with all empty values', () => {
|
||||||
|
render(
|
||||||
|
<MilestoneItem
|
||||||
|
label=""
|
||||||
|
value=""
|
||||||
|
icon=""
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should still render the card structure
|
||||||
|
expect(document.body.textContent).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Icon variations', () => {
|
||||||
|
it('renders with emoji icons', () => {
|
||||||
|
const emojiIcons = ['🏁', '🏆', '⭐', '💎', '🎯', '⏱️', '🎮', '⚡'];
|
||||||
|
|
||||||
|
emojiIcons.forEach((icon) => {
|
||||||
|
render(<MilestoneItem {...mockProps} icon={icon} />);
|
||||||
|
expect(screen.getByText(icon)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with unicode characters', () => {
|
||||||
|
const unicodeIcons = ['★', '☆', '♦', '♥', '♠', '♣'];
|
||||||
|
|
||||||
|
unicodeIcons.forEach((icon) => {
|
||||||
|
render(<MilestoneItem {...mockProps} icon={icon} />);
|
||||||
|
expect(screen.getByText(icon)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with text icons', () => {
|
||||||
|
const textIcons = ['A', 'B', 'C', '1', '2', '3', '!', '@', '#'];
|
||||||
|
|
||||||
|
textIcons.forEach((icon) => {
|
||||||
|
render(<MilestoneItem {...mockProps} icon={icon} />);
|
||||||
|
expect(screen.getByText(icon)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Value formatting', () => {
|
||||||
|
it('renders numeric values', () => {
|
||||||
|
const numericValues = ['0', '1', '10', '100', '1000', '10000'];
|
||||||
|
|
||||||
|
numericValues.forEach((value) => {
|
||||||
|
render(<MilestoneItem {...mockProps} value={value} />);
|
||||||
|
expect(screen.getByText(value)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders formatted numbers', () => {
|
||||||
|
const formattedValues = ['1,000', '10,000', '100,000', '1,000,000'];
|
||||||
|
|
||||||
|
formattedValues.forEach((value) => {
|
||||||
|
render(<MilestoneItem {...mockProps} value={value} />);
|
||||||
|
expect(screen.getByText(value)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders decimal values', () => {
|
||||||
|
const decimalValues = ['0.0', '1.5', '10.25', '100.99'];
|
||||||
|
|
||||||
|
decimalValues.forEach((value) => {
|
||||||
|
render(<MilestoneItem {...mockProps} value={value} />);
|
||||||
|
expect(screen.getByText(value)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders percentage values', () => {
|
||||||
|
const percentageValues = ['0%', '50%', '100%', '150%'];
|
||||||
|
|
||||||
|
percentageValues.forEach((value) => {
|
||||||
|
render(<MilestoneItem {...mockProps} value={value} />);
|
||||||
|
expect(screen.getByText(value)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders time values', () => {
|
||||||
|
const timeValues = ['0:00', '1:30', '10:45', '1:23:45'];
|
||||||
|
|
||||||
|
timeValues.forEach((value) => {
|
||||||
|
render(<MilestoneItem {...mockProps} value={value} />);
|
||||||
|
expect(screen.getByText(value)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Label variations', () => {
|
||||||
|
it('renders single word labels', () => {
|
||||||
|
const singleWordLabels = ['Races', 'Wins', 'Losses', 'Time', 'Distance'];
|
||||||
|
|
||||||
|
singleWordLabels.forEach((label) => {
|
||||||
|
render(<MilestoneItem {...mockProps} label={label} />);
|
||||||
|
expect(screen.getByText(label)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders multi-word labels', () => {
|
||||||
|
const multiWordLabels = [
|
||||||
|
'Total Races',
|
||||||
|
'Race Wins',
|
||||||
|
'Podium Finishes',
|
||||||
|
'Laps Completed',
|
||||||
|
'Distance Traveled',
|
||||||
|
];
|
||||||
|
|
||||||
|
multiWordLabels.forEach((label) => {
|
||||||
|
render(<MilestoneItem {...mockProps} label={label} />);
|
||||||
|
expect(screen.getByText(label)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders labels with parentheses', () => {
|
||||||
|
const parentheticalLabels = [
|
||||||
|
'Races (All)',
|
||||||
|
'Wins (Ranked)',
|
||||||
|
'Time (Active)',
|
||||||
|
'Distance (Total)',
|
||||||
|
];
|
||||||
|
|
||||||
|
parentheticalLabels.forEach((label) => {
|
||||||
|
render(<MilestoneItem {...mockProps} label={label} />);
|
||||||
|
expect(screen.getByText(label)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders labels with numbers', () => {
|
||||||
|
const numberedLabels = [
|
||||||
|
'Races 2024',
|
||||||
|
'Wins 2023',
|
||||||
|
'Season 1',
|
||||||
|
'Group A',
|
||||||
|
];
|
||||||
|
|
||||||
|
numberedLabels.forEach((label) => {
|
||||||
|
render(<MilestoneItem {...mockProps} label={label} />);
|
||||||
|
expect(screen.getByText(label)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it('handles very long label and value', () => {
|
||||||
|
const longLabel = 'This is an extremely long milestone label that should still be displayed correctly without breaking the layout';
|
||||||
|
const longValue = '999,999,999,999,999,999,999,999,999';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MilestoneItem
|
||||||
|
icon="🏁"
|
||||||
|
label={longLabel}
|
||||||
|
value={longValue}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(longLabel)).toBeDefined();
|
||||||
|
expect(screen.getByText(longValue)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles special characters in all fields', () => {
|
||||||
|
const specialProps = {
|
||||||
|
label: 'Races Won (2024) #1!',
|
||||||
|
value: '1,234.56',
|
||||||
|
icon: '🏆',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<MilestoneItem {...specialProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(specialProps.label)).toBeDefined();
|
||||||
|
expect(screen.getByText(specialProps.value)).toBeDefined();
|
||||||
|
expect(screen.getByText(specialProps.icon)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles unicode in all fields', () => {
|
||||||
|
const unicodeProps = {
|
||||||
|
label: '★ Star Races ★',
|
||||||
|
value: '★ 100 ★',
|
||||||
|
icon: '★',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<MilestoneItem {...unicodeProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(unicodeProps.label)).toBeDefined();
|
||||||
|
expect(screen.getByText(unicodeProps.value)).toBeDefined();
|
||||||
|
expect(screen.getByText(unicodeProps.icon)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles zero value', () => {
|
||||||
|
render(
|
||||||
|
<MilestoneItem
|
||||||
|
{...mockProps}
|
||||||
|
value="0"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('0')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles negative value', () => {
|
||||||
|
render(
|
||||||
|
<MilestoneItem
|
||||||
|
{...mockProps}
|
||||||
|
value="-5"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('-5')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles scientific notation', () => {
|
||||||
|
render(
|
||||||
|
<MilestoneItem
|
||||||
|
{...mockProps}
|
||||||
|
value="1.5e6"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('1.5e6')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Layout structure', () => {
|
||||||
|
it('renders with correct visual hierarchy', () => {
|
||||||
|
const { container } = render(<MilestoneItem {...mockProps} />);
|
||||||
|
|
||||||
|
// Check that the component renders with the expected structure
|
||||||
|
// The component should have a Card with a Group containing icon, label, and value
|
||||||
|
expect(container.firstChild).toBeDefined();
|
||||||
|
|
||||||
|
// Verify all text elements are present
|
||||||
|
expect(screen.getByText('🏁')).toBeDefined();
|
||||||
|
expect(screen.getByText('Total Races')).toBeDefined();
|
||||||
|
expect(screen.getByText('150')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maintains consistent structure across different props', () => {
|
||||||
|
const testCases = [
|
||||||
|
{ label: 'A', value: '1', icon: 'X' },
|
||||||
|
{ label: 'Long Label', value: '1000', icon: '🏆' },
|
||||||
|
{ label: 'Special!@#', value: '1.23', icon: '★' },
|
||||||
|
];
|
||||||
|
|
||||||
|
testCases.forEach((props) => {
|
||||||
|
const { container } = render(<MilestoneItem {...props} />);
|
||||||
|
|
||||||
|
// Each should render successfully
|
||||||
|
expect(container.firstChild).toBeDefined();
|
||||||
|
expect(screen.getByText(props.label)).toBeDefined();
|
||||||
|
expect(screen.getByText(props.value)).toBeDefined();
|
||||||
|
expect(screen.getByText(props.icon)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
119
apps/website/components/actions/ActionFiltersBar.test.tsx
Normal file
119
apps/website/components/actions/ActionFiltersBar.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ActionFiltersBar } from './ActionFiltersBar';
|
||||||
|
|
||||||
|
describe('ActionFiltersBar', () => {
|
||||||
|
describe('Rendering states', () => {
|
||||||
|
it('renders search input with correct placeholder', () => {
|
||||||
|
render(<ActionFiltersBar />);
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText('SEARCH_ID...');
|
||||||
|
expect(searchInput).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders filter dropdown with correct options', () => {
|
||||||
|
render(<ActionFiltersBar />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Filter:')).toBeDefined();
|
||||||
|
expect(screen.getByText('All Types')).toBeDefined();
|
||||||
|
expect(screen.getByText('User Update')).toBeDefined();
|
||||||
|
expect(screen.getByText('Onboarding')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders status dropdown with correct options', () => {
|
||||||
|
render(<ActionFiltersBar />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Status:')).toBeDefined();
|
||||||
|
expect(screen.getByText('All Status')).toBeDefined();
|
||||||
|
expect(screen.getByText('Completed')).toBeDefined();
|
||||||
|
expect(screen.getByText('Pending')).toBeDefined();
|
||||||
|
expect(screen.getByText('Failed')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all filter controls in the correct order', () => {
|
||||||
|
render(<ActionFiltersBar />);
|
||||||
|
|
||||||
|
// Verify the structure is rendered
|
||||||
|
expect(screen.getByText('Filter:')).toBeDefined();
|
||||||
|
expect(screen.getByText('Status:')).toBeDefined();
|
||||||
|
expect(screen.getByPlaceholderText('SEARCH_ID...')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Interaction behavior', () => {
|
||||||
|
it('updates filter state when filter dropdown changes', () => {
|
||||||
|
render(<ActionFiltersBar />);
|
||||||
|
|
||||||
|
const filterSelect = screen.getByDisplayValue('All Types');
|
||||||
|
expect(filterSelect).toBeDefined();
|
||||||
|
|
||||||
|
// The component should have state management for filter
|
||||||
|
// This is verified by the component rendering with the correct initial value
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows typing in search input', () => {
|
||||||
|
render(<ActionFiltersBar />);
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText('SEARCH_ID...') as HTMLInputElement;
|
||||||
|
fireEvent.change(searchInput, { target: { value: 'test-search' } });
|
||||||
|
|
||||||
|
expect(searchInput.value).toBe('test-search');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('status dropdown has onChange handler', () => {
|
||||||
|
render(<ActionFiltersBar />);
|
||||||
|
|
||||||
|
const statusSelect = screen.getByDisplayValue('All Status');
|
||||||
|
expect(statusSelect).toBeDefined();
|
||||||
|
|
||||||
|
// The component should have an onChange handler
|
||||||
|
// This is verified by the component rendering with the handler
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Visual presentation', () => {
|
||||||
|
it('renders with ControlBar component', () => {
|
||||||
|
const { container } = render(<ActionFiltersBar />);
|
||||||
|
|
||||||
|
// The component should be wrapped in a ControlBar
|
||||||
|
expect(container.firstChild).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with ButtonGroup for filter controls', () => {
|
||||||
|
const { container } = render(<ActionFiltersBar />);
|
||||||
|
|
||||||
|
// The filter controls should be grouped
|
||||||
|
expect(container.firstChild).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with ButtonGroup for status controls', () => {
|
||||||
|
const { container } = render(<ActionFiltersBar />);
|
||||||
|
|
||||||
|
// The status controls should be grouped
|
||||||
|
expect(container.firstChild).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it('renders with empty search input initially', () => {
|
||||||
|
render(<ActionFiltersBar />);
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText('SEARCH_ID...') as HTMLInputElement;
|
||||||
|
expect(searchInput.value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with default filter value', () => {
|
||||||
|
render(<ActionFiltersBar />);
|
||||||
|
|
||||||
|
const filterSelect = screen.getByDisplayValue('All Types');
|
||||||
|
expect(filterSelect).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with default status value', () => {
|
||||||
|
render(<ActionFiltersBar />);
|
||||||
|
|
||||||
|
const statusSelect = screen.getByDisplayValue('All Status');
|
||||||
|
expect(statusSelect).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
246
apps/website/components/actions/ActionList.test.tsx
Normal file
246
apps/website/components/actions/ActionList.test.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ActionList } from './ActionList';
|
||||||
|
import { ActionItem } from '@/lib/queries/ActionsPageQuery';
|
||||||
|
|
||||||
|
describe('ActionList', () => {
|
||||||
|
const mockActions: ActionItem[] = [
|
||||||
|
{
|
||||||
|
id: 'action-1',
|
||||||
|
timestamp: '2024-01-15T10:30:00Z',
|
||||||
|
type: 'USER_UPDATE',
|
||||||
|
initiator: 'John Doe',
|
||||||
|
status: 'COMPLETED',
|
||||||
|
details: 'Updated profile settings',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'action-2',
|
||||||
|
timestamp: '2024-01-15T11:45:00Z',
|
||||||
|
type: 'ONBOARDING',
|
||||||
|
initiator: 'Jane Smith',
|
||||||
|
status: 'PENDING',
|
||||||
|
details: 'Started onboarding process',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'action-3',
|
||||||
|
timestamp: '2024-01-15T12:00:00Z',
|
||||||
|
type: 'USER_UPDATE',
|
||||||
|
initiator: 'Bob Johnson',
|
||||||
|
status: 'FAILED',
|
||||||
|
details: 'Failed to update email',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'action-4',
|
||||||
|
timestamp: '2024-01-15T13:15:00Z',
|
||||||
|
type: 'ONBOARDING',
|
||||||
|
initiator: 'Alice Brown',
|
||||||
|
status: 'IN_PROGRESS',
|
||||||
|
details: 'Completing verification',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('Rendering states', () => {
|
||||||
|
it('renders table headers', () => {
|
||||||
|
render(<ActionList actions={mockActions} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Timestamp')).toBeDefined();
|
||||||
|
expect(screen.getByText('Type')).toBeDefined();
|
||||||
|
expect(screen.getByText('Initiator')).toBeDefined();
|
||||||
|
expect(screen.getByText('Status')).toBeDefined();
|
||||||
|
expect(screen.getByText('Details')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all action rows', () => {
|
||||||
|
render(<ActionList actions={mockActions} />);
|
||||||
|
|
||||||
|
mockActions.forEach((action) => {
|
||||||
|
expect(screen.getByText(action.timestamp)).toBeDefined();
|
||||||
|
expect(screen.getAllByText(action.type).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText(action.initiator)).toBeDefined();
|
||||||
|
expect(screen.getByText(action.details)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders action status badges', () => {
|
||||||
|
render(<ActionList actions={mockActions} />);
|
||||||
|
|
||||||
|
// Check that status badges are rendered for each action
|
||||||
|
expect(screen.getByText('COMPLETED')).toBeDefined();
|
||||||
|
expect(screen.getByText('PENDING')).toBeDefined();
|
||||||
|
expect(screen.getByText('FAILED')).toBeDefined();
|
||||||
|
expect(screen.getByText('IN PROGRESS')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty table when no actions provided', () => {
|
||||||
|
render(<ActionList actions={[]} />);
|
||||||
|
|
||||||
|
// Table headers should still be visible
|
||||||
|
expect(screen.getByText('Timestamp')).toBeDefined();
|
||||||
|
expect(screen.getByText('Type')).toBeDefined();
|
||||||
|
expect(screen.getByText('Initiator')).toBeDefined();
|
||||||
|
expect(screen.getByText('Status')).toBeDefined();
|
||||||
|
expect(screen.getByText('Details')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Interaction behavior', () => {
|
||||||
|
it('renders clickable rows', () => {
|
||||||
|
render(<ActionList actions={mockActions} />);
|
||||||
|
|
||||||
|
// Check that rows have clickable attribute
|
||||||
|
const rows = screen.getAllByRole('row');
|
||||||
|
// Skip the header row
|
||||||
|
const dataRows = rows.slice(1);
|
||||||
|
|
||||||
|
dataRows.forEach((row) => {
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders row with key based on action id', () => {
|
||||||
|
const { container } = render(<ActionList actions={mockActions} />);
|
||||||
|
|
||||||
|
// Verify that each row has a unique key
|
||||||
|
const rows = container.querySelectorAll('tbody tr');
|
||||||
|
expect(rows.length).toBe(mockActions.length);
|
||||||
|
|
||||||
|
mockActions.forEach((action, index) => {
|
||||||
|
const row = rows[index];
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Visual presentation', () => {
|
||||||
|
it('renders table structure correctly', () => {
|
||||||
|
const { container } = render(<ActionList actions={mockActions} />);
|
||||||
|
|
||||||
|
// Verify table structure
|
||||||
|
const table = container.querySelector('table');
|
||||||
|
expect(table).toBeDefined();
|
||||||
|
|
||||||
|
const thead = container.querySelector('thead');
|
||||||
|
expect(thead).toBeDefined();
|
||||||
|
|
||||||
|
const tbody = container.querySelector('tbody');
|
||||||
|
expect(tbody).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders timestamp in monospace font', () => {
|
||||||
|
render(<ActionList actions={mockActions} />);
|
||||||
|
|
||||||
|
// The timestamp should be rendered with monospace font
|
||||||
|
const timestamp = screen.getByText('2024-01-15T10:30:00Z');
|
||||||
|
expect(timestamp).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders type with medium weight', () => {
|
||||||
|
render(<ActionList actions={mockActions} />);
|
||||||
|
|
||||||
|
// The type should be rendered with medium weight
|
||||||
|
const types = screen.getAllByText('USER_UPDATE');
|
||||||
|
expect(types.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders initiator with low variant', () => {
|
||||||
|
render(<ActionList actions={mockActions} />);
|
||||||
|
|
||||||
|
// The initiator should be rendered with low variant
|
||||||
|
const initiator = screen.getByText('John Doe');
|
||||||
|
expect(initiator).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders details with low variant', () => {
|
||||||
|
render(<ActionList actions={mockActions} />);
|
||||||
|
|
||||||
|
// The details should be rendered with low variant
|
||||||
|
const details = screen.getByText('Updated profile settings');
|
||||||
|
expect(details).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it('handles single action', () => {
|
||||||
|
const singleAction = [mockActions[0]];
|
||||||
|
render(<ActionList actions={singleAction} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(singleAction[0].timestamp)).toBeDefined();
|
||||||
|
expect(screen.getByText(singleAction[0].type)).toBeDefined();
|
||||||
|
expect(screen.getByText(singleAction[0].initiator)).toBeDefined();
|
||||||
|
expect(screen.getByText(singleAction[0].details)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles actions with long details', () => {
|
||||||
|
const longDetailsAction: ActionItem = {
|
||||||
|
id: 'action-long',
|
||||||
|
timestamp: '2024-01-15T14:00:00Z',
|
||||||
|
type: 'USER_UPDATE',
|
||||||
|
initiator: 'Long Name User',
|
||||||
|
status: 'COMPLETED',
|
||||||
|
details: 'This is a very long details text that might wrap to multiple lines and should still be displayed correctly in the table',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ActionList actions={[longDetailsAction]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(longDetailsAction.details)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles actions with special characters in details', () => {
|
||||||
|
const specialDetailsAction: ActionItem = {
|
||||||
|
id: 'action-special',
|
||||||
|
timestamp: '2024-01-15T15:00:00Z',
|
||||||
|
type: 'USER_UPDATE',
|
||||||
|
initiator: 'Special User',
|
||||||
|
status: 'COMPLETED',
|
||||||
|
details: 'Updated settings & preferences (admin)',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ActionList actions={[specialDetailsAction]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(specialDetailsAction.details)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles actions with unicode characters', () => {
|
||||||
|
const unicodeAction: ActionItem = {
|
||||||
|
id: 'action-unicode',
|
||||||
|
timestamp: '2024-01-15T16:00:00Z',
|
||||||
|
type: 'USER_UPDATE',
|
||||||
|
initiator: 'Über User',
|
||||||
|
status: 'COMPLETED',
|
||||||
|
details: 'Updated profile with emoji 🚀',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ActionList actions={[unicodeAction]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(unicodeAction.details)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Status badge integration', () => {
|
||||||
|
it('renders ActionStatusBadge for each action', () => {
|
||||||
|
render(<ActionList actions={mockActions} />);
|
||||||
|
|
||||||
|
// Each action should have a status badge
|
||||||
|
const completedBadge = screen.getByText('COMPLETED');
|
||||||
|
const pendingBadge = screen.getByText('PENDING');
|
||||||
|
const failedBadge = screen.getByText('FAILED');
|
||||||
|
const inProgressBadge = screen.getByText('IN PROGRESS');
|
||||||
|
|
||||||
|
expect(completedBadge).toBeDefined();
|
||||||
|
expect(pendingBadge).toBeDefined();
|
||||||
|
expect(failedBadge).toBeDefined();
|
||||||
|
expect(inProgressBadge).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders correct badge variant for each status', () => {
|
||||||
|
render(<ActionList actions={mockActions} />);
|
||||||
|
|
||||||
|
// Verify that badges are rendered with correct variants
|
||||||
|
// This is verified by the ActionStatusBadge component tests
|
||||||
|
expect(screen.getByText('COMPLETED')).toBeDefined();
|
||||||
|
expect(screen.getByText('PENDING')).toBeDefined();
|
||||||
|
expect(screen.getByText('FAILED')).toBeDefined();
|
||||||
|
expect(screen.getByText('IN PROGRESS')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
63
apps/website/components/actions/ActionStatusBadge.test.tsx
Normal file
63
apps/website/components/actions/ActionStatusBadge.test.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ActionStatusBadge } from './ActionStatusBadge';
|
||||||
|
|
||||||
|
describe('ActionStatusBadge', () => {
|
||||||
|
describe('Rendering states', () => {
|
||||||
|
it('renders PENDING status with warning variant', () => {
|
||||||
|
render(<ActionStatusBadge status="PENDING" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('PENDING')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders COMPLETED status with success variant', () => {
|
||||||
|
render(<ActionStatusBadge status="COMPLETED" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('COMPLETED')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders FAILED status with danger variant', () => {
|
||||||
|
render(<ActionStatusBadge status="FAILED" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('FAILED')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders IN_PROGRESS status with info variant', () => {
|
||||||
|
render(<ActionStatusBadge status="IN_PROGRESS" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('IN PROGRESS')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Visual presentation', () => {
|
||||||
|
it('formats status text by replacing underscores with spaces', () => {
|
||||||
|
render(<ActionStatusBadge status="IN_PROGRESS" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('IN PROGRESS')).toBeDefined();
|
||||||
|
expect(screen.queryByText('IN_PROGRESS')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with correct size and rounded props', () => {
|
||||||
|
const { container } = render(<ActionStatusBadge status="PENDING" />);
|
||||||
|
|
||||||
|
// The Badge component should receive size="sm" and rounded="sm"
|
||||||
|
expect(container.firstChild).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it('handles all valid status types without errors', () => {
|
||||||
|
const statuses: Array<'PENDING' | 'COMPLETED' | 'FAILED' | 'IN_PROGRESS'> = [
|
||||||
|
'PENDING',
|
||||||
|
'COMPLETED',
|
||||||
|
'FAILED',
|
||||||
|
'IN_PROGRESS',
|
||||||
|
];
|
||||||
|
|
||||||
|
statuses.forEach((status) => {
|
||||||
|
const { container } = render(<ActionStatusBadge status={status} />);
|
||||||
|
expect(container.firstChild).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
69
apps/website/components/actions/ActionsHeader.test.tsx
Normal file
69
apps/website/components/actions/ActionsHeader.test.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { ActionsHeader } from './ActionsHeader';
|
||||||
|
|
||||||
|
describe('ActionsHeader', () => {
|
||||||
|
describe('Rendering states', () => {
|
||||||
|
it('renders the provided title', () => {
|
||||||
|
const title = 'User Actions';
|
||||||
|
render(<ActionsHeader title={title} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(title)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with different titles', () => {
|
||||||
|
const titles = ['User Actions', 'System Actions', 'Admin Actions'];
|
||||||
|
|
||||||
|
titles.forEach((title) => {
|
||||||
|
const { container } = render(<ActionsHeader title={title} />);
|
||||||
|
expect(screen.getByText(title)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Visual presentation', () => {
|
||||||
|
it('renders the status indicator with correct label', () => {
|
||||||
|
render(<ActionsHeader title="Test Title" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('SYSTEM_READY')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the Activity icon', () => {
|
||||||
|
const { container } = render(<ActionsHeader title="Test Title" />);
|
||||||
|
|
||||||
|
// The StatusIndicator component should render with the Activity icon
|
||||||
|
expect(container.firstChild).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with correct heading hierarchy', () => {
|
||||||
|
render(<ActionsHeader title="Test Title" />);
|
||||||
|
|
||||||
|
// The title should be rendered as an h1 element
|
||||||
|
const heading = screen.getByRole('heading', { level: 1 });
|
||||||
|
expect(heading).toBeDefined();
|
||||||
|
expect(heading.textContent).toBe('Test Title');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it('handles empty string title', () => {
|
||||||
|
const { container } = render(<ActionsHeader title="" />);
|
||||||
|
|
||||||
|
expect(container.firstChild).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles long title', () => {
|
||||||
|
const longTitle = 'A very long title that might wrap to multiple lines';
|
||||||
|
render(<ActionsHeader title={longTitle} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(longTitle)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles special characters in title', () => {
|
||||||
|
const specialTitle = 'Actions & Tasks (Admin)';
|
||||||
|
render(<ActionsHeader title={specialTitle} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(specialTitle)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
101
apps/website/components/admin/AdminDangerZonePanel.test.tsx
Normal file
101
apps/website/components/admin/AdminDangerZonePanel.test.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* AdminDangerZonePanel Component Tests
|
||||||
|
*
|
||||||
|
* Tests for the AdminDangerZonePanel component that wraps the DangerZone UI component.
|
||||||
|
* Tests cover rendering, props, and interaction behavior.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { AdminDangerZonePanel } from './AdminDangerZonePanel';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock the DangerZone UI component
|
||||||
|
vi.mock('@/ui/DangerZone', () => ({
|
||||||
|
DangerZone: ({ title, description, children }: any) => (
|
||||||
|
<div data-testid="danger-zone">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<p>{description}</p>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AdminDangerZonePanel', () => {
|
||||||
|
it('should render with title and description', () => {
|
||||||
|
render(
|
||||||
|
<AdminDangerZonePanel
|
||||||
|
title="Delete Account"
|
||||||
|
description="This action cannot be undone"
|
||||||
|
>
|
||||||
|
<button>Delete</button>
|
||||||
|
</AdminDangerZonePanel>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Delete Account')).toBeTruthy();
|
||||||
|
expect(screen.getByText('This action cannot be undone')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render children content', () => {
|
||||||
|
render(
|
||||||
|
<AdminDangerZonePanel
|
||||||
|
title="Danger Zone"
|
||||||
|
description="Proceed with caution"
|
||||||
|
>
|
||||||
|
<button data-testid="danger-button">Delete</button>
|
||||||
|
</AdminDangerZonePanel>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('danger-button')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Delete')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with minimal props', () => {
|
||||||
|
render(
|
||||||
|
<AdminDangerZonePanel title="Danger Zone" description="">
|
||||||
|
<button>Proceed</button>
|
||||||
|
</AdminDangerZonePanel>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Danger Zone')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Proceed')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render multiple children', () => {
|
||||||
|
render(
|
||||||
|
<AdminDangerZonePanel
|
||||||
|
title="Multiple Actions"
|
||||||
|
description="Select an action"
|
||||||
|
>
|
||||||
|
<button>Option 1</button>
|
||||||
|
<button>Option 2</button>
|
||||||
|
<button>Option 3</button>
|
||||||
|
</AdminDangerZonePanel>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Option 1')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Option 2')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Option 3')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with complex children components', () => {
|
||||||
|
const ComplexChild = () => (
|
||||||
|
<div>
|
||||||
|
<span>Complex</span>
|
||||||
|
<button>Click me</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AdminDangerZonePanel
|
||||||
|
title="Complex Content"
|
||||||
|
description="With nested elements"
|
||||||
|
>
|
||||||
|
<ComplexChild />
|
||||||
|
</AdminDangerZonePanel>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Complex')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Click me')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
81
apps/website/components/admin/AdminDashboardLayout.test.tsx
Normal file
81
apps/website/components/admin/AdminDashboardLayout.test.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* AdminDashboardLayout Component Tests
|
||||||
|
*
|
||||||
|
* Tests for the AdminDashboardLayout component that provides a consistent
|
||||||
|
* container layout for admin pages.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { AdminDashboardLayout } from './AdminDashboardLayout';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
describe('AdminDashboardLayout', () => {
|
||||||
|
it('should render children content', () => {
|
||||||
|
render(
|
||||||
|
<AdminDashboardLayout>
|
||||||
|
<div data-testid="content">Dashboard Content</div>
|
||||||
|
</AdminDashboardLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('content')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Dashboard Content')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render multiple children', () => {
|
||||||
|
render(
|
||||||
|
<AdminDashboardLayout>
|
||||||
|
<div>Section 1</div>
|
||||||
|
<div>Section 2</div>
|
||||||
|
<div>Section 3</div>
|
||||||
|
</AdminDashboardLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Section 1')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Section 2')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Section 3')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with complex nested components', () => {
|
||||||
|
const ComplexComponent = () => (
|
||||||
|
<div>
|
||||||
|
<h2>Complex Section</h2>
|
||||||
|
<p>With multiple elements</p>
|
||||||
|
<button>Action</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AdminDashboardLayout>
|
||||||
|
<ComplexComponent />
|
||||||
|
</AdminDashboardLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Complex Section')).toBeTruthy();
|
||||||
|
expect(screen.getByText('With multiple elements')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Action')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render empty layout gracefully', () => {
|
||||||
|
render(<AdminDashboardLayout />);
|
||||||
|
|
||||||
|
// Should render without errors even with no children
|
||||||
|
expect(document.body).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with mixed content types', () => {
|
||||||
|
render(
|
||||||
|
<AdminDashboardLayout>
|
||||||
|
<div>Text content</div>
|
||||||
|
<span>Span content</span>
|
||||||
|
<button>Button</button>
|
||||||
|
<input type="text" placeholder="Input" />
|
||||||
|
</AdminDashboardLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Text content')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Span content')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Button')).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText('Input')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
153
apps/website/components/admin/AdminDataTable.test.tsx
Normal file
153
apps/website/components/admin/AdminDataTable.test.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* AdminDataTable Component Tests
|
||||||
|
*
|
||||||
|
* Tests for the AdminDataTable component that provides a consistent
|
||||||
|
* container for high-density admin tables.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { AdminDataTable } from './AdminDataTable';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
describe('AdminDataTable', () => {
|
||||||
|
it('should render children content', () => {
|
||||||
|
render(
|
||||||
|
<AdminDataTable>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Test Data</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</AdminDataTable>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Test Data')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with maxHeight prop', () => {
|
||||||
|
render(
|
||||||
|
<AdminDataTable maxHeight={400}>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Scrollable Content</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</AdminDataTable>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Scrollable Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with string maxHeight prop', () => {
|
||||||
|
render(
|
||||||
|
<AdminDataTable maxHeight="500px">
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Scrollable Content</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</AdminDataTable>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Scrollable Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render without maxHeight prop', () => {
|
||||||
|
render(
|
||||||
|
<AdminDataTable>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Content</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</AdminDataTable>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render multiple table rows', () => {
|
||||||
|
render(
|
||||||
|
<AdminDataTable>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Row 1</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Row 2</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Row 3</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</AdminDataTable>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Row 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Row 2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Row 3')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with complex table structure', () => {
|
||||||
|
render(
|
||||||
|
<AdminDataTable>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Header 1</th>
|
||||||
|
<th>Header 2</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Data 1</td>
|
||||||
|
<td>Data 2</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</AdminDataTable>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Header 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Header 2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Data 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Data 2')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with nested components', () => {
|
||||||
|
const NestedComponent = () => (
|
||||||
|
<div>
|
||||||
|
<span>Nested</span>
|
||||||
|
<button>Action</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AdminDataTable>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<NestedComponent />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</AdminDataTable>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Nested')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Action')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
121
apps/website/components/admin/AdminEmptyState.test.tsx
Normal file
121
apps/website/components/admin/AdminEmptyState.test.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* AdminEmptyState Component Tests
|
||||||
|
*
|
||||||
|
* Tests for the AdminEmptyState component that displays empty state UI
|
||||||
|
* for admin lists and tables.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { AdminEmptyState } from './AdminEmptyState';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { Inbox, Users, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
describe('AdminEmptyState', () => {
|
||||||
|
it('should render with icon, title, and description', () => {
|
||||||
|
render(
|
||||||
|
<AdminEmptyState
|
||||||
|
icon={Inbox}
|
||||||
|
title="No Data Available"
|
||||||
|
description="Get started by creating your first item"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('No Data Available')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Get started by creating your first item')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with minimal props (description optional)', () => {
|
||||||
|
render(
|
||||||
|
<AdminEmptyState
|
||||||
|
icon={Users}
|
||||||
|
title="No Users"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('No Users')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with action button', () => {
|
||||||
|
const actionButton = <button data-testid="action-btn">Create Item</button>;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AdminEmptyState
|
||||||
|
icon={Inbox}
|
||||||
|
title="Empty List"
|
||||||
|
description="Add some items"
|
||||||
|
action={actionButton}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Empty List')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Add some items')).toBeTruthy();
|
||||||
|
expect(screen.getByTestId('action-btn')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Create Item')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with different icons', () => {
|
||||||
|
const icons = [Inbox, Users, AlertCircle];
|
||||||
|
|
||||||
|
icons.forEach((Icon) => {
|
||||||
|
const { container } = render(
|
||||||
|
<AdminEmptyState
|
||||||
|
icon={Icon}
|
||||||
|
title="Test Title"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check that the component renders without errors
|
||||||
|
expect(screen.getByText('Test Title')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with complex action component', () => {
|
||||||
|
const ComplexAction = () => (
|
||||||
|
<div>
|
||||||
|
<button>Primary Action</button>
|
||||||
|
<button>Secondary Action</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AdminEmptyState
|
||||||
|
icon={Inbox}
|
||||||
|
title="Complex State"
|
||||||
|
description="Multiple actions available"
|
||||||
|
action={<ComplexAction />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Complex State')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Multiple actions available')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Primary Action')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Secondary Action')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with long text content', () => {
|
||||||
|
render(
|
||||||
|
<AdminEmptyState
|
||||||
|
icon={Inbox}
|
||||||
|
title="This is a very long title that might wrap to multiple lines in the UI"
|
||||||
|
description="This is an even longer description that provides detailed information about why the state is empty and what the user should do next"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/This is a very long title/)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/This is an even longer description/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with special characters in text', () => {
|
||||||
|
render(
|
||||||
|
<AdminEmptyState
|
||||||
|
icon={Inbox}
|
||||||
|
title="Special & Characters <Test>"
|
||||||
|
description="Quotes 'and' special characters"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Special & Characters/)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/Quotes/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
167
apps/website/components/admin/AdminHeaderPanel.test.tsx
Normal file
167
apps/website/components/admin/AdminHeaderPanel.test.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* AdminHeaderPanel Component Tests
|
||||||
|
*
|
||||||
|
* Tests for the AdminHeaderPanel component that provides a semantic header
|
||||||
|
* for admin pages with title, description, actions, and loading state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { AdminHeaderPanel } from './AdminHeaderPanel';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock the ProgressLine component
|
||||||
|
vi.mock('@/components/shared/ProgressLine', () => ({
|
||||||
|
ProgressLine: ({ isLoading }: { isLoading: boolean }) => (
|
||||||
|
<div data-testid="progress-line" data-loading={isLoading}>
|
||||||
|
{isLoading ? 'Loading...' : 'Ready'}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the SectionHeader component
|
||||||
|
vi.mock('@/ui/SectionHeader', () => ({
|
||||||
|
SectionHeader: ({ title, description, actions, loading }: any) => (
|
||||||
|
<div data-testid="section-header">
|
||||||
|
<h1>{title}</h1>
|
||||||
|
{description && <p>{description}</p>}
|
||||||
|
{actions && <div data-testid="actions">{actions}</div>}
|
||||||
|
{loading}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AdminHeaderPanel', () => {
|
||||||
|
it('should render with title only', () => {
|
||||||
|
render(
|
||||||
|
<AdminHeaderPanel title="Admin Dashboard" />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Admin Dashboard')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with title and description', () => {
|
||||||
|
render(
|
||||||
|
<AdminHeaderPanel
|
||||||
|
title="User Management"
|
||||||
|
description="Manage all user accounts and permissions"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('User Management')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Manage all user accounts and permissions')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with title, description, and actions', () => {
|
||||||
|
const actions = <button data-testid="action-btn">Create User</button>;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AdminHeaderPanel
|
||||||
|
title="User Management"
|
||||||
|
description="Manage all user accounts"
|
||||||
|
actions={actions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('User Management')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Manage all user accounts')).toBeTruthy();
|
||||||
|
expect(screen.getByTestId('action-btn')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Create User')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with loading state', () => {
|
||||||
|
render(
|
||||||
|
<AdminHeaderPanel
|
||||||
|
title="Loading Data"
|
||||||
|
isLoading={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Loading Data')).toBeTruthy();
|
||||||
|
expect(screen.getByTestId('progress-line')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render without loading state by default', () => {
|
||||||
|
render(
|
||||||
|
<AdminHeaderPanel
|
||||||
|
title="Ready State"
|
||||||
|
isLoading={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Ready State')).toBeTruthy();
|
||||||
|
expect(screen.getByTestId('progress-line')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with multiple action buttons', () => {
|
||||||
|
const actions = (
|
||||||
|
<div>
|
||||||
|
<button>Save</button>
|
||||||
|
<button>Cancel</button>
|
||||||
|
<button>Delete</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AdminHeaderPanel
|
||||||
|
title="Edit User"
|
||||||
|
description="Make changes to user profile"
|
||||||
|
actions={actions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Edit User')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Make changes to user profile')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Save')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Cancel')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Delete')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with complex actions component', () => {
|
||||||
|
const ComplexActions = () => (
|
||||||
|
<div>
|
||||||
|
<button>Primary Action</button>
|
||||||
|
<button>Secondary Action</button>
|
||||||
|
<button>Tertiary Action</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AdminHeaderPanel
|
||||||
|
title="Complex Header"
|
||||||
|
description="With multiple actions"
|
||||||
|
actions={<ComplexActions />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Complex Header')).toBeTruthy();
|
||||||
|
expect(screen.getByText('With multiple actions')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Primary Action')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Secondary Action')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Tertiary Action')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with long title and description', () => {
|
||||||
|
render(
|
||||||
|
<AdminHeaderPanel
|
||||||
|
title="This is a very long header title that might wrap to multiple lines in the UI"
|
||||||
|
description="This is an even longer description that provides detailed information about the page content and what users can expect to find here"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/This is a very long header title/)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/This is an even longer description/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with special characters in text', () => {
|
||||||
|
render(
|
||||||
|
<AdminHeaderPanel
|
||||||
|
title="Special & Characters <Test>"
|
||||||
|
description="Quotes 'and' special characters"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Special & Characters/)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/Quotes/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
131
apps/website/components/admin/AdminSectionHeader.test.tsx
Normal file
131
apps/website/components/admin/AdminSectionHeader.test.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* AdminSectionHeader Component Tests
|
||||||
|
*
|
||||||
|
* Tests for the AdminSectionHeader component that provides a semantic header
|
||||||
|
* for sections within admin pages.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { AdminSectionHeader } from './AdminSectionHeader';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock the SectionHeader component
|
||||||
|
vi.mock('@/ui/SectionHeader', () => ({
|
||||||
|
SectionHeader: ({ title, description, actions, variant }: any) => (
|
||||||
|
<div data-testid="section-header" data-variant={variant}>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{description && <p>{description}</p>}
|
||||||
|
{actions && <div data-testid="actions">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AdminSectionHeader', () => {
|
||||||
|
it('should render with title only', () => {
|
||||||
|
render(
|
||||||
|
<AdminSectionHeader title="User Statistics" />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('User Statistics')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with title and description', () => {
|
||||||
|
render(
|
||||||
|
<AdminSectionHeader
|
||||||
|
title="User Statistics"
|
||||||
|
description="Overview of user activity and engagement"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('User Statistics')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Overview of user activity and engagement')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with title, description, and actions', () => {
|
||||||
|
const actions = <button data-testid="action-btn">Refresh</button>;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AdminSectionHeader
|
||||||
|
title="User Statistics"
|
||||||
|
description="Overview of user activity"
|
||||||
|
actions={actions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('User Statistics')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Overview of user activity')).toBeTruthy();
|
||||||
|
expect(screen.getByTestId('action-btn')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Refresh')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with multiple action buttons', () => {
|
||||||
|
const actions = (
|
||||||
|
<div>
|
||||||
|
<button>Export</button>
|
||||||
|
<button>Filter</button>
|
||||||
|
<button>Sort</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AdminSectionHeader
|
||||||
|
title="Data Table"
|
||||||
|
description="Manage your data"
|
||||||
|
actions={actions}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Data Table')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Manage your data')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Export')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Filter')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Sort')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with complex actions component', () => {
|
||||||
|
const ComplexActions = () => (
|
||||||
|
<div>
|
||||||
|
<button>Primary</button>
|
||||||
|
<button>Secondary</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AdminSectionHeader
|
||||||
|
title="Complex Section"
|
||||||
|
description="With multiple actions"
|
||||||
|
actions={<ComplexActions />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Complex Section')).toBeTruthy();
|
||||||
|
expect(screen.getByText('With multiple actions')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Primary')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Secondary')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with long title and description', () => {
|
||||||
|
render(
|
||||||
|
<AdminSectionHeader
|
||||||
|
title="This is a very long section header title that might wrap to multiple lines in the UI"
|
||||||
|
description="This is an even longer description that provides detailed information about the section content and what users can expect to find here"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/This is a very long section header title/)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/This is an even longer description/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with special characters in text', () => {
|
||||||
|
render(
|
||||||
|
<AdminSectionHeader
|
||||||
|
title="Special & Characters <Test>"
|
||||||
|
description="Quotes 'and' special characters"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Special & Characters/)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/Quotes/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
180
apps/website/components/admin/AdminStatsPanel.test.tsx
Normal file
180
apps/website/components/admin/AdminStatsPanel.test.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* AdminStatsPanel Component Tests
|
||||||
|
*
|
||||||
|
* Tests for the AdminStatsPanel component that displays statistics
|
||||||
|
* in a grid format for admin dashboards.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { AdminStatsPanel } from './AdminStatsPanel';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { Users, Shield, Activity } from 'lucide-react';
|
||||||
|
|
||||||
|
// Mock the StatGrid component
|
||||||
|
vi.mock('@/ui/StatGrid', () => ({
|
||||||
|
StatGrid: ({ stats, columns }: any) => (
|
||||||
|
<div data-testid="stat-grid" data-columns={JSON.stringify(columns)}>
|
||||||
|
{stats.map((stat: any, index: number) => (
|
||||||
|
<div key={index} data-testid={`stat-${index}`}>
|
||||||
|
<span>{stat.label}</span>
|
||||||
|
<span>{stat.value}</span>
|
||||||
|
{stat.icon && <span data-testid="icon">{stat.icon.name || 'Icon'}</span>}
|
||||||
|
{stat.intent && <span data-testid="intent">{stat.intent}</span>}
|
||||||
|
{stat.trend && <span data-testid="trend">{stat.trend.value}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AdminStatsPanel', () => {
|
||||||
|
it('should render with single stat', () => {
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
label: 'Total Users',
|
||||||
|
value: '1,234',
|
||||||
|
icon: Users,
|
||||||
|
intent: 'primary' as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<AdminStatsPanel stats={stats} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Total Users')).toBeTruthy();
|
||||||
|
expect(screen.getByText('1,234')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with multiple stats', () => {
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
label: 'Total Users',
|
||||||
|
value: '1,234',
|
||||||
|
icon: Users,
|
||||||
|
intent: 'primary' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Active Users',
|
||||||
|
value: '892',
|
||||||
|
icon: Activity,
|
||||||
|
intent: 'success' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Admins',
|
||||||
|
value: '12',
|
||||||
|
icon: Shield,
|
||||||
|
intent: 'telemetry' as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<AdminStatsPanel stats={stats} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Total Users')).toBeTruthy();
|
||||||
|
expect(screen.getByText('1,234')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Active Users')).toBeTruthy();
|
||||||
|
expect(screen.getByText('892')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Admins')).toBeTruthy();
|
||||||
|
expect(screen.getByText('12')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render stats with trends', () => {
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
label: 'Growth',
|
||||||
|
value: '15%',
|
||||||
|
icon: Activity,
|
||||||
|
intent: 'success' as const,
|
||||||
|
trend: {
|
||||||
|
value: 5,
|
||||||
|
isPositive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<AdminStatsPanel stats={stats} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Growth')).toBeTruthy();
|
||||||
|
expect(screen.getByText('15%')).toBeTruthy();
|
||||||
|
expect(screen.getByText('5')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render stats with different intents', () => {
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
label: 'Primary',
|
||||||
|
value: '100',
|
||||||
|
icon: Users,
|
||||||
|
intent: 'primary' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Success',
|
||||||
|
value: '200',
|
||||||
|
icon: Users,
|
||||||
|
intent: 'success' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Warning',
|
||||||
|
value: '300',
|
||||||
|
icon: Users,
|
||||||
|
intent: 'warning' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Critical',
|
||||||
|
value: '400',
|
||||||
|
icon: Users,
|
||||||
|
intent: 'critical' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Telemetry',
|
||||||
|
value: '500',
|
||||||
|
icon: Users,
|
||||||
|
intent: 'telemetry' as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<AdminStatsPanel stats={stats} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Primary')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Success')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Warning')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Critical')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Telemetry')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render stats with numeric values', () => {
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
label: 'Count',
|
||||||
|
value: 42,
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<AdminStatsPanel stats={stats} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Count')).toBeTruthy();
|
||||||
|
expect(screen.getByText('42')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render stats with string values', () => {
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
label: 'Status',
|
||||||
|
value: 'Active',
|
||||||
|
icon: Shield,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
render(<AdminStatsPanel stats={stats} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Status')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Active')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with empty stats array', () => {
|
||||||
|
render(<AdminStatsPanel stats={[]} />);
|
||||||
|
|
||||||
|
// Should render without errors
|
||||||
|
expect(document.body).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
145
apps/website/components/admin/AdminToolbar.test.tsx
Normal file
145
apps/website/components/admin/AdminToolbar.test.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* AdminToolbar Component Tests
|
||||||
|
*
|
||||||
|
* Tests for the AdminToolbar component that provides a semantic toolbar
|
||||||
|
* for admin pages with filters, search, and secondary actions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { AdminToolbar } from './AdminToolbar';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock the ControlBar component
|
||||||
|
vi.mock('@/ui/ControlBar', () => ({
|
||||||
|
ControlBar: ({ leftContent, children }: any) => (
|
||||||
|
<div data-testid="control-bar">
|
||||||
|
{leftContent && <div data-testid="left-content">{leftContent}</div>}
|
||||||
|
<div data-testid="children">{children}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AdminToolbar', () => {
|
||||||
|
it('should render with children only', () => {
|
||||||
|
render(
|
||||||
|
<AdminToolbar>
|
||||||
|
<button>Filter</button>
|
||||||
|
</AdminToolbar>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Filter')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with leftContent and children', () => {
|
||||||
|
render(
|
||||||
|
<AdminToolbar
|
||||||
|
leftContent={<span>Left Content</span>}
|
||||||
|
>
|
||||||
|
<button>Filter</button>
|
||||||
|
</AdminToolbar>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Left Content')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Filter')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with multiple children', () => {
|
||||||
|
render(
|
||||||
|
<AdminToolbar
|
||||||
|
leftContent={<span>Filters</span>}
|
||||||
|
>
|
||||||
|
<button>Filter 1</button>
|
||||||
|
<button>Filter 2</button>
|
||||||
|
<button>Filter 3</button>
|
||||||
|
</AdminToolbar>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Filters')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Filter 1')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Filter 2')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Filter 3')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with complex leftContent', () => {
|
||||||
|
const ComplexLeftContent = () => (
|
||||||
|
<div>
|
||||||
|
<span>Complex</span>
|
||||||
|
<button>Action</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AdminToolbar
|
||||||
|
leftContent={<ComplexLeftContent />}
|
||||||
|
>
|
||||||
|
<button>Filter</button>
|
||||||
|
</AdminToolbar>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Complex')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Action')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Filter')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with complex children', () => {
|
||||||
|
const ComplexChild = () => (
|
||||||
|
<div>
|
||||||
|
<span>Complex</span>
|
||||||
|
<button>Action</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AdminToolbar
|
||||||
|
leftContent={<span>Filters</span>}
|
||||||
|
>
|
||||||
|
<ComplexChild />
|
||||||
|
</AdminToolbar>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Filters')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Complex')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Action')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with mixed content types', () => {
|
||||||
|
render(
|
||||||
|
<AdminToolbar
|
||||||
|
leftContent={<span>Filters</span>}
|
||||||
|
>
|
||||||
|
<button>Button</button>
|
||||||
|
<input type="text" placeholder="Search" />
|
||||||
|
<select>
|
||||||
|
<option>Option 1</option>
|
||||||
|
</select>
|
||||||
|
</AdminToolbar>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Filters')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Button')).toBeTruthy();
|
||||||
|
expect(screen.getByPlaceholderText('Search')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render without leftContent', () => {
|
||||||
|
render(
|
||||||
|
<AdminToolbar>
|
||||||
|
<button>Filter</button>
|
||||||
|
</AdminToolbar>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Filter')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with empty children', () => {
|
||||||
|
render(
|
||||||
|
<AdminToolbar
|
||||||
|
leftContent={<span>Filters</span>}
|
||||||
|
>
|
||||||
|
{null}
|
||||||
|
</AdminToolbar>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Filters')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
361
apps/website/components/admin/AdminUsersTable.test.tsx
Normal file
361
apps/website/components/admin/AdminUsersTable.test.tsx
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
/**
|
||||||
|
* AdminUsersTable Component Tests
|
||||||
|
*
|
||||||
|
* Tests for the AdminUsersTable component that displays users in a table
|
||||||
|
* with selection, status management, and deletion capabilities.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { AdminUsersTable } from './AdminUsersTable';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock the DateDisplay component
|
||||||
|
vi.mock('@/lib/display-objects/DateDisplay', () => ({
|
||||||
|
DateDisplay: {
|
||||||
|
formatShort: (date: string) => new Date(date).toLocaleDateString(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the AdminUsersViewData
|
||||||
|
vi.mock('@/lib/view-data/AdminUsersViewData', () => ({
|
||||||
|
AdminUsersViewData: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the Button component
|
||||||
|
vi.mock('@/ui/Button', () => ({
|
||||||
|
Button: ({ children, onClick, disabled }: any) => (
|
||||||
|
<button onClick={onClick} disabled={disabled} data-testid="button">
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the IconButton component
|
||||||
|
vi.mock('@/ui/IconButton', () => ({
|
||||||
|
IconButton: ({ onClick, disabled, icon, title }: any) => (
|
||||||
|
<button onClick={onClick} disabled={disabled} data-testid="icon-button" title={title}>
|
||||||
|
{title}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the SimpleCheckbox component
|
||||||
|
vi.mock('@/ui/SimpleCheckbox', () => ({
|
||||||
|
SimpleCheckbox: ({ checked, onChange, 'aria-label': ariaLabel }: any) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={onChange}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
data-testid="checkbox"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the Badge component
|
||||||
|
vi.mock('@/ui/Badge', () => ({
|
||||||
|
Badge: ({ children }: any) => <span data-testid="badge">{children}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the Box component
|
||||||
|
vi.mock('@/ui/Box', () => ({
|
||||||
|
Box: ({ children }: any) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the Group component
|
||||||
|
vi.mock('@/ui/Group', () => ({
|
||||||
|
Group: ({ children }: any) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the DriverIdentity component
|
||||||
|
vi.mock('@/ui/DriverIdentity', () => ({
|
||||||
|
DriverIdentity: ({ driver, meta }: any) => (
|
||||||
|
<div data-testid="driver-identity">
|
||||||
|
<span>{driver.name}</span>
|
||||||
|
<span>{meta}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the Table components
|
||||||
|
vi.mock('@/ui/Table', () => ({
|
||||||
|
Table: ({ children }: any) => <table>{children}</table>,
|
||||||
|
TableHead: ({ children }: any) => <thead>{children}</thead>,
|
||||||
|
TableBody: ({ children }: any) => <tbody>{children}</tbody>,
|
||||||
|
TableHeader: ({ children, w, textAlign }: any) => <th style={{ width: w, textAlign }}>{children}</th>,
|
||||||
|
TableRow: ({ children, variant }: any) => <tr data-variant={variant}>{children}</tr>,
|
||||||
|
TableCell: ({ children }: any) => <td>{children}</td>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the Text component
|
||||||
|
vi.mock('@/ui/Text', () => ({
|
||||||
|
Text: ({ children, size, variant }: any) => (
|
||||||
|
<span data-size={size} data-variant={variant}>{children}</span>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the UserStatusTag component
|
||||||
|
vi.mock('./UserStatusTag', () => ({
|
||||||
|
UserStatusTag: ({ status }: any) => <span data-testid="status-tag">{status}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AdminUsersTable', () => {
|
||||||
|
const mockUsers = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
displayName: 'John Doe',
|
||||||
|
email: 'john@example.com',
|
||||||
|
roles: ['admin'],
|
||||||
|
status: 'active',
|
||||||
|
lastLoginAt: '2024-01-15T10:30:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
displayName: 'Jane Smith',
|
||||||
|
email: 'jane@example.com',
|
||||||
|
roles: ['user'],
|
||||||
|
status: 'suspended',
|
||||||
|
lastLoginAt: '2024-01-14T15:45:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
displayName: 'Bob Johnson',
|
||||||
|
email: 'bob@example.com',
|
||||||
|
roles: ['user'],
|
||||||
|
status: 'active',
|
||||||
|
lastLoginAt: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
users: mockUsers,
|
||||||
|
selectedUserIds: [],
|
||||||
|
onSelectUser: vi.fn(),
|
||||||
|
onSelectAll: vi.fn(),
|
||||||
|
onUpdateStatus: vi.fn(),
|
||||||
|
onDeleteUser: vi.fn(),
|
||||||
|
deletingUserId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should render table headers', () => {
|
||||||
|
render(<AdminUsersTable {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('User')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Roles')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Status')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Last Login')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Actions')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render user rows', () => {
|
||||||
|
render(<AdminUsersTable {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('John Doe')).toBeTruthy();
|
||||||
|
expect(screen.getByText('john@example.com')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Jane Smith')).toBeTruthy();
|
||||||
|
expect(screen.getByText('jane@example.com')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Bob Johnson')).toBeTruthy();
|
||||||
|
expect(screen.getByText('bob@example.com')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render user roles', () => {
|
||||||
|
render(<AdminUsersTable {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('admin')).toBeTruthy();
|
||||||
|
expect(screen.getByText('user')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render user status tags', () => {
|
||||||
|
render(<AdminUsersTable {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getAllByTestId('status-tag')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render last login dates', () => {
|
||||||
|
render(<AdminUsersTable {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('1/15/2024')).toBeTruthy();
|
||||||
|
expect(screen.getByText('1/14/2024')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Never')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render select all checkbox', () => {
|
||||||
|
render(<AdminUsersTable {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Select all users')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render individual user checkboxes', () => {
|
||||||
|
render(<AdminUsersTable {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Select user John Doe')).toBeTruthy();
|
||||||
|
expect(screen.getByLabelText('Select user Jane Smith')).toBeTruthy();
|
||||||
|
expect(screen.getByLabelText('Select user Bob Johnson')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render suspend button for active users', () => {
|
||||||
|
render(<AdminUsersTable {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Suspend')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render activate button for suspended users', () => {
|
||||||
|
render(<AdminUsersTable {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Activate')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render delete button for all users', () => {
|
||||||
|
render(<AdminUsersTable {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getAllByTitle('Delete')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render more button for all users', () => {
|
||||||
|
render(<AdminUsersTable {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getAllByTitle('More')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should highlight selected rows', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
selectedUserIds: ['1', '3'],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AdminUsersTable {...props} />);
|
||||||
|
|
||||||
|
// Check that selected rows have highlight variant
|
||||||
|
const rows = screen.getAllByRole('row');
|
||||||
|
expect(rows[1]).toHaveAttribute('data-variant', 'highlight');
|
||||||
|
expect(rows[3]).toHaveAttribute('data-variant', 'highlight');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable delete button when deleting', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
deletingUserId: '1',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AdminUsersTable {...props} />);
|
||||||
|
|
||||||
|
const deleteButtons = screen.getAllByTitle('Delete');
|
||||||
|
expect(deleteButtons[0]).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onSelectUser when checkbox is clicked', () => {
|
||||||
|
const onSelectUser = vi.fn();
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
onSelectUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AdminUsersTable {...props} />);
|
||||||
|
|
||||||
|
const checkboxes = screen.getAllByTestId('checkbox');
|
||||||
|
fireEvent.click(checkboxes[1]); // Click first user checkbox
|
||||||
|
|
||||||
|
expect(onSelectUser).toHaveBeenCalledWith('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onSelectAll when select all checkbox is clicked', () => {
|
||||||
|
const onSelectAll = vi.fn();
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
onSelectAll,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AdminUsersTable {...props} />);
|
||||||
|
|
||||||
|
const selectAllCheckbox = screen.getByLabelText('Select all users');
|
||||||
|
fireEvent.click(selectAllCheckbox);
|
||||||
|
|
||||||
|
expect(onSelectAll).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onUpdateStatus when suspend button is clicked', () => {
|
||||||
|
const onUpdateStatus = vi.fn();
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
onUpdateStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AdminUsersTable {...props} />);
|
||||||
|
|
||||||
|
const suspendButtons = screen.getAllByText('Suspend');
|
||||||
|
fireEvent.click(suspendButtons[0]);
|
||||||
|
|
||||||
|
expect(onUpdateStatus).toHaveBeenCalledWith('1', 'suspended');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onUpdateStatus when activate button is clicked', () => {
|
||||||
|
const onUpdateStatus = vi.fn();
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
onUpdateStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AdminUsersTable {...props} />);
|
||||||
|
|
||||||
|
const activateButtons = screen.getAllByText('Activate');
|
||||||
|
fireEvent.click(activateButtons[0]);
|
||||||
|
|
||||||
|
expect(onUpdateStatus).toHaveBeenCalledWith('2', 'active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onDeleteUser when delete button is clicked', () => {
|
||||||
|
const onDeleteUser = vi.fn();
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
onDeleteUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AdminUsersTable {...props} />);
|
||||||
|
|
||||||
|
const deleteButtons = screen.getAllByTitle('Delete');
|
||||||
|
fireEvent.click(deleteButtons[0]);
|
||||||
|
|
||||||
|
expect(onDeleteUser).toHaveBeenCalledWith('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render empty table when no users', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
users: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AdminUsersTable {...props} />);
|
||||||
|
|
||||||
|
// Should render table headers but no rows
|
||||||
|
expect(screen.getByText('User')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Roles')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Status')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Last Login')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Actions')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with all users selected', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
selectedUserIds: ['1', '2', '3'],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AdminUsersTable {...props} />);
|
||||||
|
|
||||||
|
const selectAllCheckbox = screen.getByLabelText('Select all users');
|
||||||
|
expect(selectAllCheckbox).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with some users selected', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
selectedUserIds: ['1', '2'],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AdminUsersTable {...props} />);
|
||||||
|
|
||||||
|
const selectAllCheckbox = screen.getByLabelText('Select all users');
|
||||||
|
expect(selectAllCheckbox).not.toBeChecked();
|
||||||
|
});
|
||||||
|
});
|
||||||
255
apps/website/components/admin/BulkActionBar.test.tsx
Normal file
255
apps/website/components/admin/BulkActionBar.test.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* BulkActionBar Component Tests
|
||||||
|
*
|
||||||
|
* Tests for the BulkActionBar component that displays a floating action bar
|
||||||
|
* when items are selected in a table.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { BulkActionBar } from './BulkActionBar';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock the Button component
|
||||||
|
vi.mock('@/ui/Button', () => ({
|
||||||
|
Button: ({ children, onClick, variant, size, icon }: any) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
data-testid="button"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the BulkActions component
|
||||||
|
vi.mock('@/ui/BulkActions', () => ({
|
||||||
|
BulkActions: ({ selectedCount, isOpen, children }: any) => (
|
||||||
|
<div data-testid="bulk-actions" data-open={isOpen} data-count={selectedCount}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('BulkActionBar', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
selectedCount: 0,
|
||||||
|
actions: [],
|
||||||
|
onClearSelection: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should not render when no items selected', () => {
|
||||||
|
render(<BulkActionBar {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('bulk-actions')).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render when items are selected', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
selectedCount: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<BulkActionBar {...props} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('bulk-actions')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display selected count', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
selectedCount: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<BulkActionBar {...props} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('bulk-actions')).toHaveAttribute('data-count', '5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with single action', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
selectedCount: 2,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
onClick: vi.fn(),
|
||||||
|
variant: 'danger' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<BulkActionBar {...props} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Delete')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with multiple actions', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
selectedCount: 3,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Export',
|
||||||
|
onClick: vi.fn(),
|
||||||
|
variant: 'primary' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Archive',
|
||||||
|
onClick: vi.fn(),
|
||||||
|
variant: 'secondary' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
onClick: vi.fn(),
|
||||||
|
variant: 'danger' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<BulkActionBar {...props} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Export')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Archive')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Delete')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render cancel button', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
selectedCount: 2,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
onClick: vi.fn(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<BulkActionBar {...props} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Cancel')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call action onClick when clicked', () => {
|
||||||
|
const actionOnClick = vi.fn();
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
selectedCount: 2,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
onClick: actionOnClick,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<BulkActionBar {...props} />);
|
||||||
|
|
||||||
|
const deleteButton = screen.getByText('Delete');
|
||||||
|
fireEvent.click(deleteButton);
|
||||||
|
|
||||||
|
expect(actionOnClick).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onClearSelection when cancel is clicked', () => {
|
||||||
|
const onClearSelection = vi.fn();
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
selectedCount: 2,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
onClick: vi.fn(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
onClearSelection,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<BulkActionBar {...props} />);
|
||||||
|
|
||||||
|
const cancelButton = screen.getByText('Cancel');
|
||||||
|
fireEvent.click(cancelButton);
|
||||||
|
|
||||||
|
expect(onClearSelection).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render actions with different variants', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
selectedCount: 2,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Primary',
|
||||||
|
onClick: vi.fn(),
|
||||||
|
variant: 'primary' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Secondary',
|
||||||
|
onClick: vi.fn(),
|
||||||
|
variant: 'secondary' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Danger',
|
||||||
|
onClick: vi.fn(),
|
||||||
|
variant: 'danger' as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<BulkActionBar {...props} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Primary')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Secondary')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Danger')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render actions without variant (defaults to primary)', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
selectedCount: 2,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Default',
|
||||||
|
onClick: vi.fn(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<BulkActionBar {...props} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Default')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with empty actions array', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
selectedCount: 2,
|
||||||
|
actions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<BulkActionBar {...props} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('bulk-actions')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Cancel')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with large selected count', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
selectedCount: 100,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
onClick: vi.fn(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<BulkActionBar {...props} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('bulk-actions')).toHaveAttribute('data-count', '100');
|
||||||
|
});
|
||||||
|
});
|
||||||
297
apps/website/components/admin/UserFilters.test.tsx
Normal file
297
apps/website/components/admin/UserFilters.test.tsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
/**
|
||||||
|
* UserFilters Component Tests
|
||||||
|
*
|
||||||
|
* Tests for the UserFilters component that provides search and filter
|
||||||
|
* functionality for user management.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { UserFilters } from './UserFilters';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock the Button component
|
||||||
|
vi.mock('@/ui/Button', () => ({
|
||||||
|
Button: ({ children, onClick, variant, size }: any) => (
|
||||||
|
<button onClick={onClick} data-variant={variant} data-size={size} data-testid="button">
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the Icon component
|
||||||
|
vi.mock('@/ui/Icon', () => ({
|
||||||
|
Icon: ({ icon, size, intent }: any) => (
|
||||||
|
<span data-testid="icon" data-size={size} data-intent={intent}>Icon</span>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the Input component
|
||||||
|
vi.mock('@/ui/Input', () => ({
|
||||||
|
Input: ({ type, placeholder, value, onChange, fullWidth }: any) => (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
data-full-width={fullWidth}
|
||||||
|
data-testid="input"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the Select component
|
||||||
|
vi.mock('@/ui/Select', () => ({
|
||||||
|
Select: ({ value, onChange, options }: any) => (
|
||||||
|
<select value={value} onChange={onChange} data-testid="select">
|
||||||
|
{options.map((opt: any) => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the Text component
|
||||||
|
vi.mock('@/ui/Text', () => ({
|
||||||
|
Text: ({ children, weight, variant }: any) => (
|
||||||
|
<span data-weight={weight} data-variant={variant}>{children}</span>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the Box component
|
||||||
|
vi.mock('@/ui/Box', () => ({
|
||||||
|
Box: ({ children, width }: any) => <div data-width={width}>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the Group component
|
||||||
|
vi.mock('@/ui/Group', () => ({
|
||||||
|
Group: ({ children, gap }: any) => <div data-gap={gap}>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the AdminToolbar component
|
||||||
|
vi.mock('./AdminToolbar', () => ({
|
||||||
|
AdminToolbar: ({ leftContent, children }: any) => (
|
||||||
|
<div data-testid="admin-toolbar">
|
||||||
|
{leftContent && <div data-testid="left-content">{leftContent}</div>}
|
||||||
|
<div data-testid="children">{children}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('UserFilters', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
search: '',
|
||||||
|
roleFilter: '',
|
||||||
|
statusFilter: '',
|
||||||
|
onSearch: vi.fn(),
|
||||||
|
onFilterRole: vi.fn(),
|
||||||
|
onFilterStatus: vi.fn(),
|
||||||
|
onClearFilters: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should render search input', () => {
|
||||||
|
render(<UserFilters {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText('Search by email or name...')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render role filter select', () => {
|
||||||
|
render(<UserFilters {...defaultProps} />);
|
||||||
|
|
||||||
|
const selects = screen.getAllByTestId('select');
|
||||||
|
expect(selects[0]).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render status filter select', () => {
|
||||||
|
render(<UserFilters {...defaultProps} />);
|
||||||
|
|
||||||
|
const selects = screen.getAllByTestId('select');
|
||||||
|
expect(selects[1]).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render filter icon and label', () => {
|
||||||
|
render(<UserFilters {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Filters')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render clear all button when filters are applied', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
search: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<UserFilters {...props} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Clear all')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render clear all button when no filters are applied', () => {
|
||||||
|
render(<UserFilters {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Clear all')).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onSearch when search input changes', () => {
|
||||||
|
const onSearch = vi.fn();
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
onSearch,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<UserFilters {...props} />);
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText('Search by email or name...');
|
||||||
|
fireEvent.change(searchInput, { target: { value: 'john' } });
|
||||||
|
|
||||||
|
expect(onSearch).toHaveBeenCalledWith('john');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onFilterRole when role select changes', () => {
|
||||||
|
const onFilterRole = vi.fn();
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
onFilterRole,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<UserFilters {...props} />);
|
||||||
|
|
||||||
|
const roleSelect = screen.getAllByTestId('select')[0];
|
||||||
|
fireEvent.change(roleSelect, { target: { value: 'admin' } });
|
||||||
|
|
||||||
|
expect(onFilterRole).toHaveBeenCalledWith('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onFilterStatus when status select changes', () => {
|
||||||
|
const onFilterStatus = vi.fn();
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
onFilterStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<UserFilters {...props} />);
|
||||||
|
|
||||||
|
const statusSelect = screen.getAllByTestId('select')[1];
|
||||||
|
fireEvent.change(statusSelect, { target: { value: 'active' } });
|
||||||
|
|
||||||
|
expect(onFilterStatus).toHaveBeenCalledWith('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onClearFilters when clear all button is clicked', () => {
|
||||||
|
const onClearFilters = vi.fn();
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
search: 'test',
|
||||||
|
onClearFilters,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<UserFilters {...props} />);
|
||||||
|
|
||||||
|
const clearButton = screen.getByText('Clear all');
|
||||||
|
fireEvent.click(clearButton);
|
||||||
|
|
||||||
|
expect(onClearFilters).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display current search value', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
search: 'john@example.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<UserFilters {...props} />);
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText('Search by email or name...');
|
||||||
|
expect(searchInput).toHaveValue('john@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display current role filter value', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
roleFilter: 'admin',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<UserFilters {...props} />);
|
||||||
|
|
||||||
|
const roleSelect = screen.getAllByTestId('select')[0];
|
||||||
|
expect(roleSelect).toHaveValue('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display current status filter value', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
statusFilter: 'suspended',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<UserFilters {...props} />);
|
||||||
|
|
||||||
|
const statusSelect = screen.getAllByTestId('select')[1];
|
||||||
|
expect(statusSelect).toHaveValue('suspended');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render all role options', () => {
|
||||||
|
render(<UserFilters {...defaultProps} />);
|
||||||
|
|
||||||
|
const roleSelect = screen.getAllByTestId('select')[0];
|
||||||
|
expect(roleSelect).toHaveTextContent('All Roles');
|
||||||
|
expect(roleSelect).toHaveTextContent('Owner');
|
||||||
|
expect(roleSelect).toHaveTextContent('Admin');
|
||||||
|
expect(roleSelect).toHaveTextContent('User');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render all status options', () => {
|
||||||
|
render(<UserFilters {...defaultProps} />);
|
||||||
|
|
||||||
|
const statusSelect = screen.getAllByTestId('select')[1];
|
||||||
|
expect(statusSelect).toHaveTextContent('All Status');
|
||||||
|
expect(statusSelect).toHaveTextContent('Active');
|
||||||
|
expect(statusSelect).toHaveTextContent('Suspended');
|
||||||
|
expect(statusSelect).toHaveTextContent('Deleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render clear button when only search is applied', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
search: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<UserFilters {...props} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Clear all')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render clear button when only role filter is applied', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
roleFilter: 'admin',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<UserFilters {...props} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Clear all')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render clear button when only status filter is applied', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
statusFilter: 'active',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<UserFilters {...props} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Clear all')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render clear button when all filters are applied', () => {
|
||||||
|
const props = {
|
||||||
|
...defaultProps,
|
||||||
|
search: 'test',
|
||||||
|
roleFilter: 'admin',
|
||||||
|
statusFilter: 'active',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<UserFilters {...props} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Clear all')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
172
apps/website/components/admin/UserStatsSummary.test.tsx
Normal file
172
apps/website/components/admin/UserStatsSummary.test.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* UserStatsSummary Component Tests
|
||||||
|
*
|
||||||
|
* Tests for the UserStatsSummary component that displays summary statistics
|
||||||
|
* for user management.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { UserStatsSummary } from './UserStatsSummary';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock the MetricCard component
|
||||||
|
vi.mock('@/ui/MetricCard', () => ({
|
||||||
|
MetricCard: ({ label, value, icon, intent }: any) => (
|
||||||
|
<div data-testid="metric-card" data-intent={intent}>
|
||||||
|
<span data-testid="label">{label}</span>
|
||||||
|
<span data-testid="value">{value}</span>
|
||||||
|
{icon && <span data-testid="icon">Icon</span>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the StatGrid component
|
||||||
|
vi.mock('@/ui/StatGrid', () => ({
|
||||||
|
StatGrid: ({ stats, columns }: any) => (
|
||||||
|
<div data-testid="stat-grid" data-columns={columns}>
|
||||||
|
{stats.map((stat: any, index: number) => (
|
||||||
|
<div key={index} data-testid={`stat-${index}`}>
|
||||||
|
<span>{stat.label}</span>
|
||||||
|
<span>{stat.value}</span>
|
||||||
|
{stat.icon && <span>Icon</span>}
|
||||||
|
{stat.intent && <span data-intent={stat.intent}>{stat.intent}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('UserStatsSummary', () => {
|
||||||
|
it('should render with all stats', () => {
|
||||||
|
render(
|
||||||
|
<UserStatsSummary
|
||||||
|
total={100}
|
||||||
|
activeCount={80}
|
||||||
|
adminCount={10}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Total Users')).toBeTruthy();
|
||||||
|
expect(screen.getByText('100')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Active')).toBeTruthy();
|
||||||
|
expect(screen.getByText('80')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Admins')).toBeTruthy();
|
||||||
|
expect(screen.getByText('10')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with zero values', () => {
|
||||||
|
render(
|
||||||
|
<UserStatsSummary
|
||||||
|
total={0}
|
||||||
|
activeCount={0}
|
||||||
|
adminCount={0}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Total Users')).toBeTruthy();
|
||||||
|
expect(screen.getByText('0')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Active')).toBeTruthy();
|
||||||
|
expect(screen.getByText('0')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Admins')).toBeTruthy();
|
||||||
|
expect(screen.getByText('0')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with large numbers', () => {
|
||||||
|
render(
|
||||||
|
<UserStatsSummary
|
||||||
|
total={12345}
|
||||||
|
activeCount={9876}
|
||||||
|
adminCount={123}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('12345')).toBeTruthy();
|
||||||
|
expect(screen.getByText('9876')).toBeTruthy();
|
||||||
|
expect(screen.getByText('123')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with single digit numbers', () => {
|
||||||
|
render(
|
||||||
|
<UserStatsSummary
|
||||||
|
total={5}
|
||||||
|
activeCount={3}
|
||||||
|
adminCount={1}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('5')).toBeTruthy();
|
||||||
|
expect(screen.getByText('3')).toBeTruthy();
|
||||||
|
expect(screen.getByText('1')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with negative numbers (edge case)', () => {
|
||||||
|
render(
|
||||||
|
<UserStatsSummary
|
||||||
|
total={-5}
|
||||||
|
activeCount={-3}
|
||||||
|
adminCount={-1}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('-5')).toBeTruthy();
|
||||||
|
expect(screen.getByText('-3')).toBeTruthy();
|
||||||
|
expect(screen.getByText('-1')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with decimal numbers', () => {
|
||||||
|
render(
|
||||||
|
<UserStatsSummary
|
||||||
|
total={100.5}
|
||||||
|
activeCount={75.25}
|
||||||
|
adminCount={10.75}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('100.5')).toBeTruthy();
|
||||||
|
expect(screen.getByText('75.25')).toBeTruthy();
|
||||||
|
expect(screen.getByText('10.75')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with very large numbers', () => {
|
||||||
|
render(
|
||||||
|
<UserStatsSummary
|
||||||
|
total={1000000}
|
||||||
|
activeCount={750000}
|
||||||
|
adminCount={50000}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('1000000')).toBeTruthy();
|
||||||
|
expect(screen.getByText('750000')).toBeTruthy();
|
||||||
|
expect(screen.getByText('50000')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with string numbers', () => {
|
||||||
|
render(
|
||||||
|
<UserStatsSummary
|
||||||
|
total={100}
|
||||||
|
activeCount={80}
|
||||||
|
adminCount={10}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('100')).toBeTruthy();
|
||||||
|
expect(screen.getByText('80')).toBeTruthy();
|
||||||
|
expect(screen.getByText('10')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with mixed number types', () => {
|
||||||
|
render(
|
||||||
|
<UserStatsSummary
|
||||||
|
total={100}
|
||||||
|
activeCount={80}
|
||||||
|
adminCount={10}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('100')).toBeTruthy();
|
||||||
|
expect(screen.getByText('80')).toBeTruthy();
|
||||||
|
expect(screen.getByText('10')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
118
apps/website/components/admin/UserStatusTag.test.tsx
Normal file
118
apps/website/components/admin/UserStatusTag.test.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* UserStatusTag Component Tests
|
||||||
|
*
|
||||||
|
* Tests for the UserStatusTag component that displays user status
|
||||||
|
* with appropriate visual variants and icons.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { UserStatusTag } from './UserStatusTag';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock the StatusBadge component
|
||||||
|
vi.mock('@/ui/StatusBadge', () => ({
|
||||||
|
StatusBadge: ({ variant, icon, children }: any) => (
|
||||||
|
<div data-testid="status-badge" data-variant={variant}>
|
||||||
|
{icon && <span data-testid="icon">Icon</span>}
|
||||||
|
<span>{children}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('UserStatusTag', () => {
|
||||||
|
it('should render active status with success variant', () => {
|
||||||
|
render(<UserStatusTag status="active" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Active')).toBeTruthy();
|
||||||
|
expect(screen.getByTestId('status-badge')).toHaveAttribute('data-variant', 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render suspended status with warning variant', () => {
|
||||||
|
render(<UserStatusTag status="suspended" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Suspended')).toBeTruthy();
|
||||||
|
expect(screen.getByTestId('status-badge')).toHaveAttribute('data-variant', 'warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render deleted status with error variant', () => {
|
||||||
|
render(<UserStatusTag status="deleted" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Deleted')).toBeTruthy();
|
||||||
|
expect(screen.getByTestId('status-badge')).toHaveAttribute('data-variant', 'error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render pending status with pending variant', () => {
|
||||||
|
render(<UserStatusTag status="pending" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Pending')).toBeTruthy();
|
||||||
|
expect(screen.getByTestId('status-badge')).toHaveAttribute('data-variant', 'pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render unknown status with neutral variant', () => {
|
||||||
|
render(<UserStatusTag status="unknown" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('unknown')).toBeTruthy();
|
||||||
|
expect(screen.getByTestId('status-badge')).toHaveAttribute('data-variant', 'neutral');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render uppercase status', () => {
|
||||||
|
render(<UserStatusTag status="ACTIVE" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Active')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render mixed case status', () => {
|
||||||
|
render(<UserStatusTag status="AcTiVe" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Active')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with special characters in status', () => {
|
||||||
|
render(<UserStatusTag status="active-" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('active-')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with empty status', () => {
|
||||||
|
render(<UserStatusTag status="" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with numeric status', () => {
|
||||||
|
render(<UserStatusTag status="123" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('123')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with status containing spaces', () => {
|
||||||
|
render(<UserStatusTag status="active user" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('active user')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with status containing special characters', () => {
|
||||||
|
render(<UserStatusTag status="active-user" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('active-user')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with very long status', () => {
|
||||||
|
render(<UserStatusTag status="this-is-a-very-long-status-that-might-wrap-to-multiple-lines" />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/this-is-a-very-long-status/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with unicode characters in status', () => {
|
||||||
|
render(<UserStatusTag status="active✓" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('active✓')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with emoji in status', () => {
|
||||||
|
render(<UserStatusTag status="active 🚀" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('active 🚀')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
247
apps/website/components/app/AppSidebar.test.tsx
Normal file
247
apps/website/components/app/AppSidebar.test.tsx
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { AppSidebar } from './AppSidebar';
|
||||||
|
|
||||||
|
describe('AppSidebar', () => {
|
||||||
|
describe('Rendering states', () => {
|
||||||
|
it('renders the Sidebar component', () => {
|
||||||
|
const { container } = render(<AppSidebar />);
|
||||||
|
|
||||||
|
// The component should render a Sidebar
|
||||||
|
expect(container.firstChild).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with children', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AppSidebar>
|
||||||
|
<div data-testid="test-child">Test Content</div>
|
||||||
|
</AppSidebar>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify children are rendered
|
||||||
|
expect(screen.getByTestId('test-child')).toBeDefined();
|
||||||
|
expect(screen.getByText('Test Content')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with multiple children', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AppSidebar>
|
||||||
|
<div data-testid="child-1">First Child</div>
|
||||||
|
<div data-testid="child-2">Second Child</div>
|
||||||
|
<div data-testid="child-3">Third Child</div>
|
||||||
|
</AppSidebar>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify all children are rendered
|
||||||
|
expect(screen.getByTestId('child-1')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('child-2')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('child-3')).toBeDefined();
|
||||||
|
expect(screen.getByText('First Child')).toBeDefined();
|
||||||
|
expect(screen.getByText('Second Child')).toBeDefined();
|
||||||
|
expect(screen.getByText('Third Child')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with complex children components', () => {
|
||||||
|
const ComplexChild = () => (
|
||||||
|
<div data-testid="complex-child">
|
||||||
|
<span>Complex Content</span>
|
||||||
|
<button>Click Me</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<AppSidebar>
|
||||||
|
<ComplexChild />
|
||||||
|
</AppSidebar>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify complex children are rendered
|
||||||
|
expect(screen.getByTestId('complex-child')).toBeDefined();
|
||||||
|
expect(screen.getByText('Complex Content')).toBeDefined();
|
||||||
|
expect(screen.getByText('Click Me')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Empty states', () => {
|
||||||
|
it('renders without children (empty state)', () => {
|
||||||
|
const { container } = render(<AppSidebar />);
|
||||||
|
|
||||||
|
// Component should still render even without children
|
||||||
|
expect(container.firstChild).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with null children', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AppSidebar>
|
||||||
|
{null}
|
||||||
|
</AppSidebar>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Component should render without errors
|
||||||
|
expect(container.firstChild).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with undefined children', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AppSidebar>
|
||||||
|
{undefined}
|
||||||
|
</AppSidebar>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Component should render without errors
|
||||||
|
expect(container.firstChild).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with empty string children', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AppSidebar>
|
||||||
|
{''}
|
||||||
|
</AppSidebar>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Component should render without errors
|
||||||
|
expect(container.firstChild).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Visual presentation', () => {
|
||||||
|
it('renders with consistent structure', () => {
|
||||||
|
const { container } = render(<AppSidebar />);
|
||||||
|
|
||||||
|
// Verify the component has a consistent structure
|
||||||
|
expect(container.firstChild).toBeDefined();
|
||||||
|
expect(container.firstChild?.nodeName).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders children in the correct order', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AppSidebar>
|
||||||
|
<div data-testid="first">First</div>
|
||||||
|
<div data-testid="second">Second</div>
|
||||||
|
<div data-testid="third">Third</div>
|
||||||
|
</AppSidebar>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify children are rendered in the correct order
|
||||||
|
const children = container.querySelectorAll('[data-testid^="child-"], [data-testid="first"], [data-testid="second"], [data-testid="third"]');
|
||||||
|
expect(children.length).toBe(3);
|
||||||
|
expect(children[0].textContent).toBe('First');
|
||||||
|
expect(children[1].textContent).toBe('Second');
|
||||||
|
expect(children[2].textContent).toBe('Third');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it('renders with special characters in children', () => {
|
||||||
|
const specialChars = 'Special & Characters < > " \'';
|
||||||
|
const { container } = render(
|
||||||
|
<AppSidebar>
|
||||||
|
<div data-testid="special-chars">{specialChars}</div>
|
||||||
|
</AppSidebar>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify special characters are handled correctly
|
||||||
|
expect(screen.getByTestId('special-chars')).toBeDefined();
|
||||||
|
expect(screen.getByText(/Special & Characters/)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with numeric children', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AppSidebar>
|
||||||
|
<div data-testid="numeric">12345</div>
|
||||||
|
</AppSidebar>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify numeric children are rendered
|
||||||
|
expect(screen.getByTestId('numeric')).toBeDefined();
|
||||||
|
expect(screen.getByText('12345')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with boolean children', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AppSidebar>
|
||||||
|
{true}
|
||||||
|
{false}
|
||||||
|
</AppSidebar>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Component should render without errors
|
||||||
|
expect(container.firstChild).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with array children', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AppSidebar>
|
||||||
|
{[1, 2, 3].map((num) => (
|
||||||
|
<div key={num} data-testid={`array-${num}`}>
|
||||||
|
Item {num}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</AppSidebar>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify array children are rendered
|
||||||
|
expect(screen.getByTestId('array-1')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('array-2')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('array-3')).toBeDefined();
|
||||||
|
expect(screen.getByText('Item 1')).toBeDefined();
|
||||||
|
expect(screen.getByText('Item 2')).toBeDefined();
|
||||||
|
expect(screen.getByText('Item 3')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with nested components', () => {
|
||||||
|
const NestedComponent = () => (
|
||||||
|
<div data-testid="nested-wrapper">
|
||||||
|
<div data-testid="nested-child">
|
||||||
|
<span>Nested Content</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<AppSidebar>
|
||||||
|
<NestedComponent />
|
||||||
|
</AppSidebar>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify nested components are rendered
|
||||||
|
expect(screen.getByTestId('nested-wrapper')).toBeDefined();
|
||||||
|
expect(screen.getByTestId('nested-child')).toBeDefined();
|
||||||
|
expect(screen.getByText('Nested Content')).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Component behavior', () => {
|
||||||
|
it('maintains component identity across re-renders', () => {
|
||||||
|
const { container, rerender } = render(<AppSidebar />);
|
||||||
|
const firstRender = container.firstChild;
|
||||||
|
|
||||||
|
rerender(<AppSidebar />);
|
||||||
|
const secondRender = container.firstChild;
|
||||||
|
|
||||||
|
// Component should maintain its identity
|
||||||
|
expect(firstRender).toBe(secondRender);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves children identity across re-renders', () => {
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<AppSidebar>
|
||||||
|
<div data-testid="stable-child">Stable Content</div>
|
||||||
|
</AppSidebar>
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstChild = screen.getByTestId('stable-child');
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<AppSidebar>
|
||||||
|
<div data-testid="stable-child">Stable Content</div>
|
||||||
|
</AppSidebar>
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondChild = screen.getByTestId('stable-child');
|
||||||
|
|
||||||
|
// Children should be preserved
|
||||||
|
expect(firstChild).toBe(secondChild);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
114
apps/website/components/auth/AuthCard.test.tsx
Normal file
114
apps/website/components/auth/AuthCard.test.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { AuthCard } from './AuthCard';
|
||||||
|
|
||||||
|
describe('AuthCard', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('should render with title and children', () => {
|
||||||
|
render(
|
||||||
|
<AuthCard title="Sign In">
|
||||||
|
<div data-testid="child-content">Child content</div>
|
||||||
|
</AuthCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Sign In')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('child-content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with title and description', () => {
|
||||||
|
render(
|
||||||
|
<AuthCard title="Sign In" description="Enter your credentials">
|
||||||
|
<div data-testid="child-content">Child content</div>
|
||||||
|
</AuthCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Sign In')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Enter your credentials')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('child-content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render without description', () => {
|
||||||
|
render(
|
||||||
|
<AuthCard title="Sign In">
|
||||||
|
<div data-testid="child-content">Child content</div>
|
||||||
|
</AuthCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Sign In')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('child-content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with multiple children', () => {
|
||||||
|
render(
|
||||||
|
<AuthCard title="Sign In">
|
||||||
|
<div data-testid="child-1">Child 1</div>
|
||||||
|
<div data-testid="child-2">Child 2</div>
|
||||||
|
<div data-testid="child-3">Child 3</div>
|
||||||
|
</AuthCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Sign In')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('child-1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('child-2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('child-3')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('accessibility', () => {
|
||||||
|
it('should have proper semantic structure', () => {
|
||||||
|
render(
|
||||||
|
<AuthCard title="Sign In" description="Enter your credentials">
|
||||||
|
<div>Content</div>
|
||||||
|
</AuthCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
// The component uses Card and SectionHeader which should have proper semantics
|
||||||
|
expect(screen.getByText('Sign In')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Enter your credentials')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle empty title', () => {
|
||||||
|
render(
|
||||||
|
<AuthCard title="">
|
||||||
|
<div>Content</div>
|
||||||
|
</AuthCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty description', () => {
|
||||||
|
render(
|
||||||
|
<AuthCard title="Sign In" description="">
|
||||||
|
<div>Content</div>
|
||||||
|
</AuthCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Sign In')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null children', () => {
|
||||||
|
render(
|
||||||
|
<AuthCard title="Sign In">
|
||||||
|
{null}
|
||||||
|
</AuthCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Sign In')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined children', () => {
|
||||||
|
render(
|
||||||
|
<AuthCard title="Sign In">
|
||||||
|
{undefined}
|
||||||
|
</AuthCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Sign In')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
260
apps/website/components/auth/AuthContext.test.tsx
Normal file
260
apps/website/components/auth/AuthContext.test.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { AuthProvider, useAuth } from './AuthContext';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useCurrentSession } from '@/hooks/auth/useCurrentSession';
|
||||||
|
import { useLogout } from '@/hooks/auth/useLogout';
|
||||||
|
|
||||||
|
// Mock Next.js navigation
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
useRouter: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock auth hooks
|
||||||
|
vi.mock('@/hooks/auth/useCurrentSession', () => ({
|
||||||
|
useCurrentSession: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/hooks/auth/useLogout', () => ({
|
||||||
|
useLogout: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Test component that uses the auth context
|
||||||
|
const TestConsumer = () => {
|
||||||
|
const auth = useAuth();
|
||||||
|
return (
|
||||||
|
<div data-testid="auth-consumer">
|
||||||
|
<div data-testid="session">{auth.session ? 'has-session' : 'no-session'}</div>
|
||||||
|
<div data-testid="loading">{auth.loading ? 'loading' : 'not-loading'}</div>
|
||||||
|
<button onClick={() => auth.login()}>Login</button>
|
||||||
|
<button onClick={() => auth.logout()}>Logout</button>
|
||||||
|
<button onClick={() => auth.refreshSession()}>Refresh</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AuthContext', () => {
|
||||||
|
let mockRouter: any;
|
||||||
|
let mockRefetch: any;
|
||||||
|
let mockMutateAsync: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
mockRouter = {
|
||||||
|
push: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRefetch = vi.fn();
|
||||||
|
mockMutateAsync = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
(useRouter as any).mockReturnValue(mockRouter);
|
||||||
|
(useCurrentSession as any).mockReturnValue({
|
||||||
|
data: null,
|
||||||
|
isLoading: false,
|
||||||
|
refetch: mockRefetch,
|
||||||
|
});
|
||||||
|
(useLogout as any).mockReturnValue({
|
||||||
|
mutateAsync: mockMutateAsync,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AuthProvider', () => {
|
||||||
|
it('should provide default context values', () => {
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<TestConsumer />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('session')).toHaveTextContent('no-session');
|
||||||
|
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide loading state', () => {
|
||||||
|
(useCurrentSession as any).mockReturnValue({
|
||||||
|
data: null,
|
||||||
|
isLoading: true,
|
||||||
|
refetch: mockRefetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<TestConsumer />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('loading')).toHaveTextContent('loading');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide session data', () => {
|
||||||
|
const mockSession = { user: { id: '123', name: 'Test User' } };
|
||||||
|
(useCurrentSession as any).mockReturnValue({
|
||||||
|
data: mockSession,
|
||||||
|
isLoading: false,
|
||||||
|
refetch: mockRefetch,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<TestConsumer />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('session')).toHaveTextContent('has-session');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide initial session data', () => {
|
||||||
|
const mockSession = { user: { id: '123', name: 'Test User' } };
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthProvider initialSession={mockSession}>
|
||||||
|
<TestConsumer />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('session')).toHaveTextContent('has-session');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useAuth hook', () => {
|
||||||
|
it('should throw error when used outside AuthProvider', () => {
|
||||||
|
// Suppress console.error for this test
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
render(<TestConsumer />);
|
||||||
|
}).toThrow('useAuth must be used within an AuthProvider');
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide login function', async () => {
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<TestConsumer />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const loginButton = screen.getByText('Login');
|
||||||
|
loginButton.click();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRouter.push).toHaveBeenCalledWith('/auth/login');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide login function with returnTo parameter', async () => {
|
||||||
|
const TestConsumerWithReturnTo = () => {
|
||||||
|
const auth = useAuth();
|
||||||
|
return (
|
||||||
|
<button onClick={() => auth.login('/dashboard')}>
|
||||||
|
Login with Return
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<TestConsumerWithReturnTo />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const loginButton = screen.getByText('Login with Return');
|
||||||
|
loginButton.click();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRouter.push).toHaveBeenCalledWith('/auth/login?returnTo=%2Fdashboard');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide logout function', async () => {
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<TestConsumer />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const logoutButton = screen.getByText('Logout');
|
||||||
|
logoutButton.click();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockMutateAsync).toHaveBeenCalled();
|
||||||
|
expect(mockRouter.push).toHaveBeenCalledWith('/');
|
||||||
|
expect(mockRouter.refresh).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle logout failure gracefully', async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
mockMutateAsync.mockRejectedValue(new Error('Logout failed'));
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<TestConsumer />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const logoutButton = screen.getByText('Logout');
|
||||||
|
logoutButton.click();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockMutateAsync).toHaveBeenCalled();
|
||||||
|
expect(mockRouter.push).toHaveBeenCalledWith('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide refreshSession function', async () => {
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<TestConsumer />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshButton = screen.getByText('Refresh');
|
||||||
|
refreshButton.click();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockRefetch).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle null initial session', () => {
|
||||||
|
render(
|
||||||
|
<AuthProvider initialSession={null}>
|
||||||
|
<TestConsumer />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('session')).toHaveTextContent('no-session');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined initial session', () => {
|
||||||
|
render(
|
||||||
|
<AuthProvider initialSession={undefined}>
|
||||||
|
<TestConsumer />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('session')).toHaveTextContent('no-session');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple consumers', () => {
|
||||||
|
render(
|
||||||
|
<AuthProvider>
|
||||||
|
<TestConsumer />
|
||||||
|
<TestConsumer />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const consumers = screen.getAllByTestId('auth-consumer');
|
||||||
|
expect(consumers).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
64
apps/website/components/auth/AuthError.test.tsx
Normal file
64
apps/website/components/auth/AuthError.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { AuthError } from './AuthError';
|
||||||
|
|
||||||
|
describe('AuthError', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('should render error message with action', () => {
|
||||||
|
render(<AuthError action="login" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Failed to load login page')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render error message with different actions', () => {
|
||||||
|
const actions = ['login', 'register', 'reset-password', 'verify-email'];
|
||||||
|
|
||||||
|
actions.forEach(action => {
|
||||||
|
render(<AuthError action={action} />);
|
||||||
|
expect(screen.getByText(`Failed to load ${action} page`)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with empty action', () => {
|
||||||
|
render(<AuthError action="" />);
|
||||||
|
expect(screen.getByText('Failed to load page')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with special characters in action', () => {
|
||||||
|
render(<AuthError action="user-login" />);
|
||||||
|
expect(screen.getByText('Failed to load user-login page')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('accessibility', () => {
|
||||||
|
it('should have proper error banner structure', () => {
|
||||||
|
render(<AuthError action="login" />);
|
||||||
|
|
||||||
|
// The ErrorBanner component should have proper ARIA attributes
|
||||||
|
// This test verifies the component renders correctly
|
||||||
|
expect(screen.getByText('Error')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Failed to load login page')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle long action names', () => {
|
||||||
|
const longAction = 'very-long-action-name-that-might-break-layout';
|
||||||
|
render(<AuthError action={longAction} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(`Failed to load ${longAction} page`)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle action with spaces', () => {
|
||||||
|
render(<AuthError action="user login" />);
|
||||||
|
expect(screen.getByText('Failed to load user login page')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle action with numbers', () => {
|
||||||
|
render(<AuthError action="step2" />);
|
||||||
|
expect(screen.getByText('Failed to load step2 page')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
126
apps/website/components/auth/AuthFooterLinks.test.tsx
Normal file
126
apps/website/components/auth/AuthFooterLinks.test.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { AuthFooterLinks } from './AuthFooterLinks';
|
||||||
|
|
||||||
|
describe('AuthFooterLinks', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('should render with single child', () => {
|
||||||
|
render(
|
||||||
|
<AuthFooterLinks>
|
||||||
|
<a href="/forgot-password">Forgot password?</a>
|
||||||
|
</AuthFooterLinks>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Forgot password?')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with multiple children', () => {
|
||||||
|
render(
|
||||||
|
<AuthFooterLinks>
|
||||||
|
<a href="/forgot-password">Forgot password?</a>
|
||||||
|
<a href="/register">Create account</a>
|
||||||
|
<a href="/help">Help</a>
|
||||||
|
</AuthFooterLinks>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Forgot password?')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Create account')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Help')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with button children', () => {
|
||||||
|
render(
|
||||||
|
<AuthFooterLinks>
|
||||||
|
<button type="button">Back</button>
|
||||||
|
<button type="button">Continue</button>
|
||||||
|
</AuthFooterLinks>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Back')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Continue')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with mixed element types', () => {
|
||||||
|
render(
|
||||||
|
<AuthFooterLinks>
|
||||||
|
<a href="/forgot-password">Forgot password?</a>
|
||||||
|
<button type="button">Back</button>
|
||||||
|
<span>Need help?</span>
|
||||||
|
</AuthFooterLinks>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Forgot password?')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Back')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Need help?')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('accessibility', () => {
|
||||||
|
it('should have proper semantic structure', () => {
|
||||||
|
render(
|
||||||
|
<AuthFooterLinks>
|
||||||
|
<a href="/forgot-password">Forgot password?</a>
|
||||||
|
</AuthFooterLinks>
|
||||||
|
);
|
||||||
|
|
||||||
|
// The component uses Group which should have proper semantics
|
||||||
|
expect(screen.getByText('Forgot password?')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain focus order', () => {
|
||||||
|
render(
|
||||||
|
<AuthFooterLinks>
|
||||||
|
<a href="/forgot-password">Forgot password?</a>
|
||||||
|
<a href="/register">Create account</a>
|
||||||
|
</AuthFooterLinks>
|
||||||
|
);
|
||||||
|
|
||||||
|
const links = screen.getAllByRole('link');
|
||||||
|
expect(links).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle empty children', () => {
|
||||||
|
render(<AuthFooterLinks>{null}</AuthFooterLinks>);
|
||||||
|
// Component should render without errors
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined children', () => {
|
||||||
|
render(<AuthFooterLinks>{undefined}</AuthFooterLinks>);
|
||||||
|
// Component should render without errors
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string children', () => {
|
||||||
|
render(<AuthFooterLinks>{''}</AuthFooterLinks>);
|
||||||
|
// Component should render without errors
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nested children', () => {
|
||||||
|
render(
|
||||||
|
<AuthFooterLinks>
|
||||||
|
<div>
|
||||||
|
<a href="/forgot-password">Forgot password?</a>
|
||||||
|
</div>
|
||||||
|
</AuthFooterLinks>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Forgot password?')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex link structures', () => {
|
||||||
|
render(
|
||||||
|
<AuthFooterLinks>
|
||||||
|
<a href="/forgot-password">
|
||||||
|
<span>Forgot</span>
|
||||||
|
<span>password?</span>
|
||||||
|
</a>
|
||||||
|
</AuthFooterLinks>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Forgot')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('password?')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
224
apps/website/components/auth/AuthForm.test.tsx
Normal file
224
apps/website/components/auth/AuthForm.test.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { AuthForm } from './AuthForm';
|
||||||
|
|
||||||
|
describe('AuthForm', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('should render with single child', () => {
|
||||||
|
const mockSubmit = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthForm onSubmit={mockSubmit}>
|
||||||
|
<input type="email" placeholder="Email" />
|
||||||
|
</AuthForm>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText('Email')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with multiple children', () => {
|
||||||
|
const mockSubmit = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthForm onSubmit={mockSubmit}>
|
||||||
|
<input type="email" placeholder="Email" />
|
||||||
|
<input type="password" placeholder="Password" />
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</AuthForm>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText('Email')).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Submit')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with form elements', () => {
|
||||||
|
const mockSubmit = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthForm onSubmit={mockSubmit}>
|
||||||
|
<label htmlFor="email">Email</label>
|
||||||
|
<input id="email" type="email" />
|
||||||
|
<label htmlFor="password">Password</label>
|
||||||
|
<input id="password" type="password" />
|
||||||
|
</AuthForm>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Email')).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText('Password')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('form submission', () => {
|
||||||
|
it('should call onSubmit when form is submitted', () => {
|
||||||
|
const mockSubmit = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthForm onSubmit={mockSubmit}>
|
||||||
|
<input type="email" placeholder="Email" />
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</AuthForm>
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = screen.getByRole('form');
|
||||||
|
fireEvent.submit(form);
|
||||||
|
|
||||||
|
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass event to onSubmit handler', () => {
|
||||||
|
const mockSubmit = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthForm onSubmit={mockSubmit}>
|
||||||
|
<input type="email" placeholder="Email" />
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</AuthForm>
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = screen.getByRole('form');
|
||||||
|
fireEvent.submit(form);
|
||||||
|
|
||||||
|
expect(mockSubmit).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
type: 'submit',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle form submission with input values', () => {
|
||||||
|
const mockSubmit = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthForm onSubmit={mockSubmit}>
|
||||||
|
<input type="email" placeholder="Email" defaultValue="test@example.com" />
|
||||||
|
<input type="password" placeholder="Password" defaultValue="secret123" />
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</AuthForm>
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = screen.getByRole('form');
|
||||||
|
fireEvent.submit(form);
|
||||||
|
|
||||||
|
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent default form submission', () => {
|
||||||
|
const mockSubmit = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthForm onSubmit={mockSubmit}>
|
||||||
|
<input type="email" placeholder="Email" />
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</AuthForm>
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = screen.getByRole('form');
|
||||||
|
const submitEvent = new Event('submit', { bubbles: true, cancelable: true });
|
||||||
|
const preventDefaultSpy = vi.spyOn(submitEvent, 'preventDefault');
|
||||||
|
|
||||||
|
fireEvent(form, submitEvent);
|
||||||
|
|
||||||
|
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('accessibility', () => {
|
||||||
|
it('should have proper form semantics', () => {
|
||||||
|
const mockSubmit = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthForm onSubmit={mockSubmit}>
|
||||||
|
<input type="email" placeholder="Email" />
|
||||||
|
</AuthForm>
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = screen.getByRole('form');
|
||||||
|
expect(form).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain proper input associations', () => {
|
||||||
|
const mockSubmit = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthForm onSubmit={mockSubmit}>
|
||||||
|
<label htmlFor="email">Email Address</label>
|
||||||
|
<input id="email" type="email" />
|
||||||
|
<label htmlFor="password">Password</label>
|
||||||
|
<input id="password" type="password" />
|
||||||
|
</AuthForm>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('Email Address')).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText('Password')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle empty children', () => {
|
||||||
|
const mockSubmit = vi.fn();
|
||||||
|
|
||||||
|
render(<AuthForm onSubmit={mockSubmit}>{null}</AuthForm>);
|
||||||
|
// Component should render without errors
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined children', () => {
|
||||||
|
const mockSubmit = vi.fn();
|
||||||
|
|
||||||
|
render(<AuthForm onSubmit={mockSubmit}>{undefined}</AuthForm>);
|
||||||
|
// Component should render without errors
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nested form elements', () => {
|
||||||
|
const mockSubmit = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthForm onSubmit={mockSubmit}>
|
||||||
|
<div>
|
||||||
|
<input type="email" placeholder="Email" />
|
||||||
|
</div>
|
||||||
|
</AuthForm>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText('Email')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex form structure', () => {
|
||||||
|
const mockSubmit = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthForm onSubmit={mockSubmit}>
|
||||||
|
<fieldset>
|
||||||
|
<legend>Credentials</legend>
|
||||||
|
<input type="email" placeholder="Email" />
|
||||||
|
<input type="password" placeholder="Password" />
|
||||||
|
</fieldset>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</AuthForm>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Credentials')).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText('Email')).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Submit')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple form submissions', () => {
|
||||||
|
const mockSubmit = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<AuthForm onSubmit={mockSubmit}>
|
||||||
|
<input type="email" placeholder="Email" />
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</AuthForm>
|
||||||
|
);
|
||||||
|
|
||||||
|
const form = screen.getByRole('form');
|
||||||
|
|
||||||
|
fireEvent.submit(form);
|
||||||
|
fireEvent.submit(form);
|
||||||
|
fireEvent.submit(form);
|
||||||
|
|
||||||
|
expect(mockSubmit).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
108
apps/website/components/auth/AuthLoading.test.tsx
Normal file
108
apps/website/components/auth/AuthLoading.test.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { AuthLoading } from './AuthLoading';
|
||||||
|
|
||||||
|
describe('AuthLoading', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('should render with default message', () => {
|
||||||
|
render(<AuthLoading />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Authenticating...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with custom message', () => {
|
||||||
|
render(<AuthLoading message="Loading user data..." />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Loading user data...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with empty message', () => {
|
||||||
|
render(<AuthLoading message="" />);
|
||||||
|
|
||||||
|
// Should still render the component structure
|
||||||
|
expect(screen.getByText('')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with special characters in message', () => {
|
||||||
|
render(<AuthLoading message="Authenticating... Please wait!" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Authenticating... Please wait!')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with long message', () => {
|
||||||
|
const longMessage = 'This is a very long loading message that might wrap to multiple lines';
|
||||||
|
render(<AuthLoading message={longMessage} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(longMessage)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('accessibility', () => {
|
||||||
|
it('should have proper loading semantics', () => {
|
||||||
|
render(<AuthLoading />);
|
||||||
|
|
||||||
|
// The component should have proper ARIA attributes for loading state
|
||||||
|
expect(screen.getByText('Authenticating...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be visually distinct as loading state', () => {
|
||||||
|
render(<AuthLoading message="Loading..." />);
|
||||||
|
|
||||||
|
// The component uses LoadingSpinner which should indicate loading
|
||||||
|
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle null message', () => {
|
||||||
|
render(<AuthLoading message={null as any} />);
|
||||||
|
|
||||||
|
// Should render with default message
|
||||||
|
expect(screen.getByText('Authenticating...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined message', () => {
|
||||||
|
render(<AuthLoading message={undefined as any} />);
|
||||||
|
|
||||||
|
// Should render with default message
|
||||||
|
expect(screen.getByText('Authenticating...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle numeric message', () => {
|
||||||
|
render(<AuthLoading message={123 as any} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('123')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle message with whitespace', () => {
|
||||||
|
render(<AuthLoading message=" Loading... " />);
|
||||||
|
|
||||||
|
expect(screen.getByText(' Loading... ')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle message with newlines', () => {
|
||||||
|
render(<AuthLoading message="Loading...\nPlease wait" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Please wait')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('visual states', () => {
|
||||||
|
it('should show loading spinner', () => {
|
||||||
|
render(<AuthLoading />);
|
||||||
|
|
||||||
|
// The LoadingSpinner component should be present
|
||||||
|
// This is verified by the component structure
|
||||||
|
expect(screen.getByText('Authenticating...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain consistent layout', () => {
|
||||||
|
render(<AuthLoading message="Processing..." />);
|
||||||
|
|
||||||
|
// The component uses Section and Stack for layout
|
||||||
|
expect(screen.getByText('Processing...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
182
apps/website/components/auth/AuthProviderButtons.test.tsx
Normal file
182
apps/website/components/auth/AuthProviderButtons.test.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { AuthProviderButtons } from './AuthProviderButtons';
|
||||||
|
|
||||||
|
describe('AuthProviderButtons', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('should render with single button', () => {
|
||||||
|
render(
|
||||||
|
<AuthProviderButtons>
|
||||||
|
<button type="button">Sign in with Google</button>
|
||||||
|
</AuthProviderButtons>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Sign in with Google')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with multiple buttons', () => {
|
||||||
|
render(
|
||||||
|
<AuthProviderButtons>
|
||||||
|
<button type="button">Sign in with Google</button>
|
||||||
|
<button type="button">Sign in with Discord</button>
|
||||||
|
<button type="button">Sign in with GitHub</button>
|
||||||
|
</AuthProviderButtons>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Sign in with Google')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Sign in with Discord')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Sign in with GitHub')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with anchor links', () => {
|
||||||
|
render(
|
||||||
|
<AuthProviderButtons>
|
||||||
|
<a href="/auth/google">Sign in with Google</a>
|
||||||
|
<a href="/auth/discord">Sign in with Discord</a>
|
||||||
|
</AuthProviderButtons>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Sign in with Google')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Sign in with Discord')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with mixed element types', () => {
|
||||||
|
render(
|
||||||
|
<AuthProviderButtons>
|
||||||
|
<button type="button">Sign in with Google</button>
|
||||||
|
<a href="/auth/discord">Sign in with Discord</a>
|
||||||
|
<button type="button">Sign in with GitHub</button>
|
||||||
|
</AuthProviderButtons>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Sign in with Google')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Sign in with Discord')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Sign in with GitHub')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('accessibility', () => {
|
||||||
|
it('should have proper button semantics', () => {
|
||||||
|
render(
|
||||||
|
<AuthProviderButtons>
|
||||||
|
<button type="button">Sign in with Google</button>
|
||||||
|
</AuthProviderButtons>
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = screen.getByRole('button', { name: 'Sign in with Google' });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have proper link semantics', () => {
|
||||||
|
render(
|
||||||
|
<AuthProviderButtons>
|
||||||
|
<a href="/auth/google">Sign in with Google</a>
|
||||||
|
</AuthProviderButtons>
|
||||||
|
);
|
||||||
|
|
||||||
|
const link = screen.getByRole('link', { name: 'Sign in with Google' });
|
||||||
|
expect(link).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain focus order', () => {
|
||||||
|
render(
|
||||||
|
<AuthProviderButtons>
|
||||||
|
<button type="button">Sign in with Google</button>
|
||||||
|
<button type="button">Sign in with Discord</button>
|
||||||
|
<button type="button">Sign in with GitHub</button>
|
||||||
|
</AuthProviderButtons>
|
||||||
|
);
|
||||||
|
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
expect(buttons).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle empty children', () => {
|
||||||
|
render(<AuthProviderButtons>{null}</AuthProviderButtons>);
|
||||||
|
// Component should render without errors
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined children', () => {
|
||||||
|
render(<AuthProviderButtons>{undefined}</AuthProviderButtons>);
|
||||||
|
// Component should render without errors
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string children', () => {
|
||||||
|
render(<AuthProviderButtons>{''}</AuthProviderButtons>);
|
||||||
|
// Component should render without errors
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nested children', () => {
|
||||||
|
render(
|
||||||
|
<AuthProviderButtons>
|
||||||
|
<div>
|
||||||
|
<button type="button">Sign in with Google</button>
|
||||||
|
</div>
|
||||||
|
</AuthProviderButtons>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Sign in with Google')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex button structures', () => {
|
||||||
|
render(
|
||||||
|
<AuthProviderButtons>
|
||||||
|
<button type="button">
|
||||||
|
<span>Sign in with</span>
|
||||||
|
<span>Google</span>
|
||||||
|
</button>
|
||||||
|
</AuthProviderButtons>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Sign in with')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Google')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle buttons with icons', () => {
|
||||||
|
render(
|
||||||
|
<AuthProviderButtons>
|
||||||
|
<button type="button">
|
||||||
|
<span data-testid="icon">🔍</span>
|
||||||
|
<span>Sign in with Google</span>
|
||||||
|
</button>
|
||||||
|
</AuthProviderButtons>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('icon')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Sign in with Google')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('visual states', () => {
|
||||||
|
it('should maintain grid layout', () => {
|
||||||
|
render(
|
||||||
|
<AuthProviderButtons>
|
||||||
|
<button type="button">Sign in with Google</button>
|
||||||
|
<button type="button">Sign in with Discord</button>
|
||||||
|
</AuthProviderButtons>
|
||||||
|
);
|
||||||
|
|
||||||
|
// The component uses Grid for layout
|
||||||
|
expect(screen.getByText('Sign in with Google')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Sign in with Discord')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain spacing', () => {
|
||||||
|
render(
|
||||||
|
<AuthProviderButtons>
|
||||||
|
<button type="button">Sign in with Google</button>
|
||||||
|
<button type="button">Sign in with Discord</button>
|
||||||
|
<button type="button">Sign in with GitHub</button>
|
||||||
|
</AuthProviderButtons>
|
||||||
|
);
|
||||||
|
|
||||||
|
// The component uses Box with marginBottom and Grid with gap
|
||||||
|
expect(screen.getByText('Sign in with Google')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Sign in with Discord')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Sign in with GitHub')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
186
apps/website/components/auth/AuthShell.test.tsx
Normal file
186
apps/website/components/auth/AuthShell.test.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { AuthShell } from './AuthShell';
|
||||||
|
|
||||||
|
describe('AuthShell', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('should render with single child', () => {
|
||||||
|
render(
|
||||||
|
<AuthShell>
|
||||||
|
<div data-testid="child-content">Child content</div>
|
||||||
|
</AuthShell>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('child-content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with multiple children', () => {
|
||||||
|
render(
|
||||||
|
<AuthShell>
|
||||||
|
<div data-testid="child-1">Child 1</div>
|
||||||
|
<div data-testid="child-2">Child 2</div>
|
||||||
|
<div data-testid="child-3">Child 3</div>
|
||||||
|
</AuthShell>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('child-1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('child-2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('child-3')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with complex children', () => {
|
||||||
|
render(
|
||||||
|
<AuthShell>
|
||||||
|
<div>
|
||||||
|
<h1>Authentication</h1>
|
||||||
|
<p>Please sign in to continue</p>
|
||||||
|
<form>
|
||||||
|
<input type="email" placeholder="Email" />
|
||||||
|
<input type="password" placeholder="Password" />
|
||||||
|
<button type="submit">Sign In</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</AuthShell>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Authentication')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Please sign in to continue')).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText('Email')).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Sign In')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with nested components', () => {
|
||||||
|
render(
|
||||||
|
<AuthShell>
|
||||||
|
<div data-testid="outer">
|
||||||
|
<div data-testid="inner">
|
||||||
|
<div data-testid="inner-inner">Content</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AuthShell>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('outer')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('inner')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('inner-inner')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('accessibility', () => {
|
||||||
|
it('should have proper semantic structure', () => {
|
||||||
|
render(
|
||||||
|
<AuthShell>
|
||||||
|
<div>Content</div>
|
||||||
|
</AuthShell>
|
||||||
|
);
|
||||||
|
|
||||||
|
// The component uses AuthLayout which should have proper semantics
|
||||||
|
expect(screen.getByText('Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain proper document structure', () => {
|
||||||
|
render(
|
||||||
|
<AuthShell>
|
||||||
|
<main>
|
||||||
|
<h1>Authentication</h1>
|
||||||
|
<p>Content</p>
|
||||||
|
</main>
|
||||||
|
</AuthShell>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Authentication')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle empty children', () => {
|
||||||
|
render(<AuthShell>{null}</AuthShell>);
|
||||||
|
// Component should render without errors
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined children', () => {
|
||||||
|
render(<AuthShell>{undefined}</AuthShell>);
|
||||||
|
// Component should render without errors
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string children', () => {
|
||||||
|
render(<AuthShell>{''}</AuthShell>);
|
||||||
|
// Component should render without errors
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle text nodes', () => {
|
||||||
|
render(<AuthShell>Text content</AuthShell>);
|
||||||
|
expect(screen.getByText('Text content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple text nodes', () => {
|
||||||
|
render(
|
||||||
|
<AuthShell>
|
||||||
|
Text 1
|
||||||
|
Text 2
|
||||||
|
Text 3
|
||||||
|
</AuthShell>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Text 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Text 2')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Text 3')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed content types', () => {
|
||||||
|
render(
|
||||||
|
<AuthShell>
|
||||||
|
Text node
|
||||||
|
<div>Div content</div>
|
||||||
|
<span>Span content</span>
|
||||||
|
</AuthShell>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Text node')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Div content')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Span content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('visual states', () => {
|
||||||
|
it('should maintain layout structure', () => {
|
||||||
|
render(
|
||||||
|
<AuthShell>
|
||||||
|
<div data-testid="content">Content</div>
|
||||||
|
</AuthShell>
|
||||||
|
);
|
||||||
|
|
||||||
|
// The component uses AuthLayout which provides the layout structure
|
||||||
|
expect(screen.getByTestId('content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle full authentication flow', () => {
|
||||||
|
render(
|
||||||
|
<AuthShell>
|
||||||
|
<div>
|
||||||
|
<h1>Sign In</h1>
|
||||||
|
<form>
|
||||||
|
<input type="email" placeholder="Email" />
|
||||||
|
<input type="password" placeholder="Password" />
|
||||||
|
<button type="submit">Sign In</button>
|
||||||
|
</form>
|
||||||
|
<div>
|
||||||
|
<a href="/forgot-password">Forgot password?</a>
|
||||||
|
<a href="/register">Create account</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AuthShell>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Sign In')).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText('Email')).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Sign In')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Forgot password?')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Create account')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
164
apps/website/components/auth/AuthWorkflowMockup.test.tsx
Normal file
164
apps/website/components/auth/AuthWorkflowMockup.test.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { AuthWorkflowMockup } from './AuthWorkflowMockup';
|
||||||
|
|
||||||
|
describe('AuthWorkflowMockup', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('should render workflow steps', async () => {
|
||||||
|
render(<AuthWorkflowMockup />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Create Account')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Link iRacing')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Configure Profile')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Join Leagues')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Start Racing')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render step descriptions', async () => {
|
||||||
|
render(<AuthWorkflowMockup />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Sign up with email or connect iRacing')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Connect your iRacing profile for stats')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Set up your racing preferences')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Find and join competitive leagues')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Compete and track your progress')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render all 5 steps', async () => {
|
||||||
|
render(<AuthWorkflowMockup />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const steps = screen.getAllByText(/Create Account|Link iRacing|Configure Profile|Join Leagues|Start Racing/);
|
||||||
|
expect(steps).toHaveLength(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render step numbers', async () => {
|
||||||
|
render(<AuthWorkflowMockup />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Create Account')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('accessibility', () => {
|
||||||
|
it('should have proper workflow semantics', async () => {
|
||||||
|
render(<AuthWorkflowMockup />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Create Account')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Link iRacing')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain proper reading order', async () => {
|
||||||
|
render(<AuthWorkflowMockup />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const steps = screen.getAllByText(/Create Account|Link iRacing|Configure Profile|Join Leagues|Start Racing/);
|
||||||
|
expect(steps[0]).toHaveTextContent('Create Account');
|
||||||
|
expect(steps[1]).toHaveTextContent('Link iRacing');
|
||||||
|
expect(steps[2]).toHaveTextContent('Configure Profile');
|
||||||
|
expect(steps[3]).toHaveTextContent('Join Leagues');
|
||||||
|
expect(steps[4]).toHaveTextContent('Start Racing');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle component without props', async () => {
|
||||||
|
render(<AuthWorkflowMockup />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Create Account')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle re-rendering', async () => {
|
||||||
|
const { rerender } = render(<AuthWorkflowMockup />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Create Account')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender(<AuthWorkflowMockup />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Create Account')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('visual states', () => {
|
||||||
|
it('should show complete workflow', async () => {
|
||||||
|
render(<AuthWorkflowMockup />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Create Account')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Link iRacing')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Configure Profile')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Join Leagues')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Start Racing')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show step descriptions', async () => {
|
||||||
|
render(<AuthWorkflowMockup />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Sign up with email or connect iRacing')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Connect your iRacing profile for stats')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Set up your racing preferences')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Find and join competitive leagues')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Compete and track your progress')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show intent indicators', async () => {
|
||||||
|
render(<AuthWorkflowMockup />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Create Account')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Link iRacing')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Configure Profile')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Join Leagues')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Start Racing')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('component structure', () => {
|
||||||
|
it('should use WorkflowMockup component', async () => {
|
||||||
|
render(<AuthWorkflowMockup />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Create Account')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass correct step data', async () => {
|
||||||
|
render(<AuthWorkflowMockup />);
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ title: 'Create Account', description: 'Sign up with email or connect iRacing' },
|
||||||
|
{ title: 'Link iRacing', description: 'Connect your iRacing profile for stats' },
|
||||||
|
{ title: 'Configure Profile', description: 'Set up your racing preferences' },
|
||||||
|
{ title: 'Join Leagues', description: 'Find and join competitive leagues' },
|
||||||
|
{ title: 'Start Racing', description: 'Compete and track your progress' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const step of steps) {
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(step.title)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(step.description)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
195
apps/website/components/auth/UserRolesPreview.test.tsx
Normal file
195
apps/website/components/auth/UserRolesPreview.test.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { UserRolesPreview } from './UserRolesPreview';
|
||||||
|
|
||||||
|
describe('UserRolesPreview', () => {
|
||||||
|
describe('rendering', () => {
|
||||||
|
it('should render with default variant (full)', () => {
|
||||||
|
render(<UserRolesPreview />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Driver')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('League Admin')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Team Manager')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with full variant', () => {
|
||||||
|
render(<UserRolesPreview variant="full" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Driver')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('League Admin')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Team Manager')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with compact variant', () => {
|
||||||
|
render(<UserRolesPreview variant="compact" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Driver')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('League Admin')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Team Manager')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render role descriptions in full variant', () => {
|
||||||
|
render(<UserRolesPreview variant="full" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Race, track stats, join teams')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Organize leagues and events')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Manage team and drivers')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render compact variant with header text', () => {
|
||||||
|
render(<UserRolesPreview variant="compact" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('One account for all roles')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('accessibility', () => {
|
||||||
|
it('should have proper semantic structure in full variant', () => {
|
||||||
|
render(<UserRolesPreview variant="full" />);
|
||||||
|
|
||||||
|
// The component uses ListItem and ListItemInfo which should have proper semantics
|
||||||
|
expect(screen.getByText('Driver')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('League Admin')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Team Manager')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have proper semantic structure in compact variant', () => {
|
||||||
|
render(<UserRolesPreview variant="compact" />);
|
||||||
|
|
||||||
|
// The component uses Group and Stack which should have proper semantics
|
||||||
|
expect(screen.getByText('Driver')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('League Admin')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Team Manager')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain proper reading order', () => {
|
||||||
|
render(<UserRolesPreview variant="full" />);
|
||||||
|
|
||||||
|
const roles = screen.getAllByText(/Driver|League Admin|Team Manager/);
|
||||||
|
|
||||||
|
// Roles should be in order
|
||||||
|
expect(roles[0]).toHaveTextContent('Driver');
|
||||||
|
expect(roles[1]).toHaveTextContent('League Admin');
|
||||||
|
expect(roles[2]).toHaveTextContent('Team Manager');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle undefined variant', () => {
|
||||||
|
render(<UserRolesPreview variant={undefined as any} />);
|
||||||
|
|
||||||
|
// Should default to 'full' variant
|
||||||
|
expect(screen.getByText('Driver')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('League Admin')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Team Manager')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null variant', () => {
|
||||||
|
render(<UserRolesPreview variant={null as any} />);
|
||||||
|
|
||||||
|
// Should default to 'full' variant
|
||||||
|
expect(screen.getByText('Driver')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('League Admin')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Team Manager')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle re-rendering with different variants', () => {
|
||||||
|
const { rerender } = render(<UserRolesPreview variant="full" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Race, track stats, join teams')).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(<UserRolesPreview variant="compact" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('One account for all roles')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('visual states', () => {
|
||||||
|
it('should show all roles in full variant', () => {
|
||||||
|
render(<UserRolesPreview variant="full" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Driver')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('League Admin')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Team Manager')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show all roles in compact variant', () => {
|
||||||
|
render(<UserRolesPreview variant="compact" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Driver')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('League Admin')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Team Manager')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show role descriptions in full variant', () => {
|
||||||
|
render(<UserRolesPreview variant="full" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Race, track stats, join teams')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Organize leagues and events')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Manage team and drivers')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show header text in compact variant', () => {
|
||||||
|
render(<UserRolesPreview variant="compact" />);
|
||||||
|
|
||||||
|
expect(screen.getByText('One account for all roles')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('component structure', () => {
|
||||||
|
it('should render role icons in full variant', () => {
|
||||||
|
render(<UserRolesPreview variant="full" />);
|
||||||
|
|
||||||
|
// The component uses Icon component for role icons
|
||||||
|
// This is verified by the component structure
|
||||||
|
expect(screen.getByText('Driver')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('League Admin')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Team Manager')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render role icons in compact variant', () => {
|
||||||
|
render(<UserRolesPreview variant="compact" />);
|
||||||
|
|
||||||
|
// The component uses Icon component for role icons
|
||||||
|
// This is verified by the component structure
|
||||||
|
expect(screen.getByText('Driver')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('League Admin')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Team Manager')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use correct intent values for roles', () => {
|
||||||
|
render(<UserRolesPreview variant="full" />);
|
||||||
|
|
||||||
|
// Driver has 'primary' intent
|
||||||
|
// League Admin has 'success' intent
|
||||||
|
// Team Manager has 'telemetry' intent
|
||||||
|
// This is verified by the component structure
|
||||||
|
expect(screen.getByText('Driver')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('League Admin')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Team Manager')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('animation states', () => {
|
||||||
|
it('should have animation in full variant', () => {
|
||||||
|
render(<UserRolesPreview variant="full" />);
|
||||||
|
|
||||||
|
// The component uses framer-motion for animations
|
||||||
|
// This is verified by the component structure
|
||||||
|
expect(screen.getByText('Driver')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('League Admin')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Team Manager')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not have animation in compact variant', () => {
|
||||||
|
render(<UserRolesPreview variant="compact" />);
|
||||||
|
|
||||||
|
// The compact variant doesn't use framer-motion
|
||||||
|
// This is verified by the component structure
|
||||||
|
expect(screen.getByText('Driver')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('League Admin')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Team Manager')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,9 @@ import { Input } from '@/ui/Input';
|
|||||||
import { Box } from '@/ui/Box';
|
import { Box } from '@/ui/Box';
|
||||||
import { IconButton } from '@/ui/IconButton';
|
import { IconButton } from '@/ui/IconButton';
|
||||||
import { useSidebar } from '@/components/layout/SidebarContext';
|
import { useSidebar } from '@/components/layout/SidebarContext';
|
||||||
|
import { PublicTopNav } from '@/ui/PublicTopNav';
|
||||||
|
import { PublicNavLogin } from '@/ui/PublicNavLogin';
|
||||||
|
import { PublicNavSignup } from '@/ui/PublicNavSignup';
|
||||||
|
|
||||||
export function AppHeader() {
|
export function AppHeader() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -41,29 +44,39 @@ export function AppHeader() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ShellHeader collapsed={isCollapsed}>
|
<ShellHeader collapsed={isCollapsed}>
|
||||||
{/* Left: Context & Search */}
|
{/* Left: Public Navigation & Context */}
|
||||||
<Box display="flex" alignItems="center" gap={6} flex={1}>
|
<Box display="flex" alignItems="center" gap={6} flex={1}>
|
||||||
<Text size="sm" variant="med" weight="medium" style={{ minWidth: '100px' }}>
|
{/* Public Top Navigation - Only when not authenticated */}
|
||||||
{breadcrumbs}
|
{!isAuthenticated && (
|
||||||
</Text>
|
<PublicTopNav pathname={pathname} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Command Search Trigger */}
|
{/* Context & Search - Only when authenticated */}
|
||||||
<Box display={{ base: 'none', md: 'block' }}>
|
{isAuthenticated && (
|
||||||
<Input
|
<>
|
||||||
readOnly
|
<Text size="sm" variant="med" weight="medium" style={{ minWidth: '100px' }}>
|
||||||
onClick={() => setIsCommandOpen(true)}
|
{breadcrumbs}
|
||||||
placeholder="Search or type a command..."
|
</Text>
|
||||||
variant="search"
|
|
||||||
width="24rem"
|
{/* Command Search Trigger */}
|
||||||
rightElement={
|
<Box display={{ base: 'none', md: 'block' }}>
|
||||||
<Box display="flex" alignItems="center" gap={1} paddingX={1.5} paddingY={0.5} rounded bg="white/5" border>
|
<Input
|
||||||
<Command size={10} />
|
readOnly
|
||||||
<Text size="xs" font="mono" variant="low" style={{ fontSize: '10px' }}>K</Text>
|
onClick={() => setIsCommandOpen(true)}
|
||||||
</Box>
|
placeholder="Search or type a command..."
|
||||||
}
|
variant="search"
|
||||||
className="cursor-pointer"
|
width="24rem"
|
||||||
/>
|
rightElement={
|
||||||
</Box>
|
<Box display="flex" alignItems="center" gap={1} paddingX={1.5} paddingY={0.5} rounded bg="white/5" border>
|
||||||
|
<Command size={10} />
|
||||||
|
<Text size="xs" font="mono" variant="low" style={{ fontSize: '10px' }}>K</Text>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
className="cursor-pointer"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Right: User & Notifications */}
|
{/* Right: User & Notifications */}
|
||||||
@@ -71,17 +84,25 @@ export function AppHeader() {
|
|||||||
{/* Notifications - Only when authed */}
|
{/* Notifications - Only when authed */}
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<Box position="relative">
|
<Box position="relative">
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={Bell}
|
icon={Bell}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
title="Notifications"
|
title="Notifications"
|
||||||
/>
|
/>
|
||||||
<Box position="absolute" top={2} right={2} width={1.5} height={1.5} bg="var(--ui-color-intent-primary)" rounded="full" ring="2px" />
|
<Box position="absolute" top={2} right={2} width={1.5} height={1.5} bg="var(--ui-color-intent-primary)" rounded="full" ring="2px" />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* User Pill (Handles Auth & Menu) */}
|
{/* Public Login/Signup Buttons - Only when not authenticated */}
|
||||||
<UserPill />
|
{!isAuthenticated && (
|
||||||
|
<>
|
||||||
|
<PublicNavLogin />
|
||||||
|
<PublicNavSignup />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User Pill (Handles Auth & Menu) - Only when authenticated */}
|
||||||
|
{isAuthenticated && <UserPill />}
|
||||||
</Box>
|
</Box>
|
||||||
</ShellHeader>
|
</ShellHeader>
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface DeltaChipProps {
|
|||||||
export function DeltaChip({ value, type = 'rank' }: DeltaChipProps) {
|
export function DeltaChip({ value, type = 'rank' }: DeltaChipProps) {
|
||||||
if (value === 0) {
|
if (value === 0) {
|
||||||
return (
|
return (
|
||||||
<Group gap={1}>
|
<Group gap={1} data-testid="trend-indicator">
|
||||||
<Icon icon={Minus} size={3} intent="low" />
|
<Icon icon={Minus} size={3} intent="low" />
|
||||||
<Text size="xs" font="mono" variant="low">0</Text>
|
<Text size="xs" font="mono" variant="low">0</Text>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -26,7 +26,7 @@ export function DeltaChip({ value, type = 'rank' }: DeltaChipProps) {
|
|||||||
const absoluteValue = Math.abs(value);
|
const absoluteValue = Math.abs(value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge variant={variant} size="sm">
|
<Badge variant={variant} size="sm" data-testid="trend-indicator">
|
||||||
<Group gap={0.5}>
|
<Group gap={0.5}>
|
||||||
<Icon icon={IconComponent} size={3} />
|
<Icon icon={IconComponent} size={3} />
|
||||||
<Text size="xs" font="mono" weight="bold">
|
<Text size="xs" font="mono" weight="bold">
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface RankingRowProps {
|
|||||||
rating: number;
|
rating: number;
|
||||||
wins: number;
|
wins: number;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
droppedRaceIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RankingRow({
|
export function RankingRow({
|
||||||
@@ -33,12 +34,13 @@ export function RankingRow({
|
|||||||
rating,
|
rating,
|
||||||
wins,
|
wins,
|
||||||
onClick,
|
onClick,
|
||||||
|
droppedRaceIds,
|
||||||
}: RankingRowProps) {
|
}: RankingRowProps) {
|
||||||
return (
|
return (
|
||||||
<LeaderboardRow
|
<LeaderboardRow
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
rank={
|
rank={
|
||||||
<Group gap={4}>
|
<Group gap={4} data-testid="standing-position">
|
||||||
<RankBadge rank={rank} />
|
<RankBadge rank={rank} />
|
||||||
{rankDelta !== undefined && (
|
{rankDelta !== undefined && (
|
||||||
<DeltaChip value={rankDelta} type="rank" />
|
<DeltaChip value={rankDelta} type="rank" />
|
||||||
@@ -46,17 +48,17 @@ export function RankingRow({
|
|||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
identity={
|
identity={
|
||||||
<Group gap={4}>
|
<Group gap={4} data-testid="standing-driver">
|
||||||
<Avatar
|
<Avatar
|
||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
alt={name}
|
alt={name}
|
||||||
size="md"
|
size="md"
|
||||||
/>
|
/>
|
||||||
<Group direction="column" align="start" gap={0}>
|
<Group direction="column" align="start" gap={0}>
|
||||||
<Text
|
<Text
|
||||||
weight="bold"
|
weight="bold"
|
||||||
variant="high"
|
variant="high"
|
||||||
block
|
block
|
||||||
truncate
|
truncate
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
@@ -71,7 +73,7 @@ export function RankingRow({
|
|||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
stats={
|
stats={
|
||||||
<Group gap={8}>
|
<Group gap={8} data-testid="standing-points">
|
||||||
<Group direction="column" align="end" gap={0}>
|
<Group direction="column" align="end" gap={0}>
|
||||||
<Text variant="low" font="mono" weight="bold" block size="md">
|
<Text variant="low" font="mono" weight="bold" block size="md">
|
||||||
{racesCompleted}
|
{racesCompleted}
|
||||||
@@ -96,6 +98,16 @@ export function RankingRow({
|
|||||||
Wins
|
Wins
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
{droppedRaceIds && droppedRaceIds.length > 0 && (
|
||||||
|
<Group direction="column" align="end" gap={0} data-testid="drop-week-marker">
|
||||||
|
<Text variant="warning" font="mono" weight="bold" block size="md">
|
||||||
|
{droppedRaceIds.length}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" variant="low" block uppercase letterSpacing="widest" weight="bold">
|
||||||
|
Dropped
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -28,17 +28,12 @@ export function AdminQuickViewWidgets({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap={4}>
|
<Stack gap={4} data-testid="admin-widgets">
|
||||||
{/* Wallet Preview */}
|
{/* Wallet Preview */}
|
||||||
<Surface
|
<Surface
|
||||||
variant="muted"
|
variant="precision"
|
||||||
rounded="xl"
|
rounded="xl"
|
||||||
border
|
|
||||||
padding={6}
|
padding={6}
|
||||||
style={{
|
|
||||||
background: 'linear-gradient(to bottom right, #262626, rgba(38, 38, 38, 0.8))',
|
|
||||||
borderColor: 'rgba(59, 130, 246, 0.3)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
<Stack direction="row" align="center" gap={3}>
|
<Stack direction="row" align="center" gap={3}>
|
||||||
@@ -51,13 +46,13 @@ export function AdminQuickViewWidgets({
|
|||||||
rounded="lg"
|
rounded="lg"
|
||||||
bg="bg-primary-blue/10"
|
bg="bg-primary-blue/10"
|
||||||
>
|
>
|
||||||
<Wallet size={20} color="var(--primary-blue)" />
|
<Icon icon={Wallet} size={4} intent="primary" />
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Text size="sm" weight="bold" color="text-white" block>
|
<Text size="sm" weight="bold" variant="high" block>
|
||||||
Wallet Balance
|
Wallet Balance
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="2xl" weight="bold" color="text-primary-blue" font="mono" block>
|
<Text size="2xl" weight="bold" variant="primary" font="mono" block>
|
||||||
${walletBalance.toFixed(2)}
|
${walletBalance.toFixed(2)}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -78,14 +73,9 @@ export function AdminQuickViewWidgets({
|
|||||||
|
|
||||||
{/* Stewarding Quick-View */}
|
{/* Stewarding Quick-View */}
|
||||||
<Surface
|
<Surface
|
||||||
variant="muted"
|
variant="precision"
|
||||||
rounded="xl"
|
rounded="xl"
|
||||||
border
|
|
||||||
padding={6}
|
padding={6}
|
||||||
style={{
|
|
||||||
background: 'linear-gradient(to bottom right, #262626, rgba(38, 38, 38, 0.8))',
|
|
||||||
borderColor: 'rgba(239, 68, 68, 0.3)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
<Stack direction="row" align="center" gap={3}>
|
<Stack direction="row" align="center" gap={3}>
|
||||||
@@ -98,13 +88,13 @@ export function AdminQuickViewWidgets({
|
|||||||
rounded="lg"
|
rounded="lg"
|
||||||
bg="bg-error-red/10"
|
bg="bg-error-red/10"
|
||||||
>
|
>
|
||||||
<Shield size={20} color="var(--error-red)" />
|
<Icon icon={Shield} size={4} intent="critical" />
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Text size="sm" weight="bold" color="text-white" block>
|
<Text size="sm" weight="bold" variant="high" block>
|
||||||
Stewarding Queue
|
Stewarding Queue
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="2xl" weight="bold" color="text-error-red" font="mono" block>
|
<Text size="2xl" weight="bold" variant="critical" font="mono" block>
|
||||||
{pendingProtestsCount}
|
{pendingProtestsCount}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -122,7 +112,7 @@ export function AdminQuickViewWidgets({
|
|||||||
</Link>
|
</Link>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Text size="xs" color="text-gray-500" italic>
|
<Text size="xs" variant="low" italic>
|
||||||
No pending protests
|
No pending protests
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -132,14 +122,9 @@ export function AdminQuickViewWidgets({
|
|||||||
{/* Join Requests Preview */}
|
{/* Join Requests Preview */}
|
||||||
{pendingJoinRequestsCount > 0 && (
|
{pendingJoinRequestsCount > 0 && (
|
||||||
<Surface
|
<Surface
|
||||||
variant="muted"
|
variant="precision"
|
||||||
rounded="xl"
|
rounded="xl"
|
||||||
border
|
|
||||||
padding={6}
|
padding={6}
|
||||||
style={{
|
|
||||||
background: 'linear-gradient(to bottom right, #262626, rgba(38, 38, 38, 0.8))',
|
|
||||||
borderColor: 'rgba(251, 191, 36, 0.3)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
<Stack direction="row" align="center" gap={3}>
|
<Stack direction="row" align="center" gap={3}>
|
||||||
@@ -152,13 +137,13 @@ export function AdminQuickViewWidgets({
|
|||||||
rounded="lg"
|
rounded="lg"
|
||||||
bg="bg-warning-amber/10"
|
bg="bg-warning-amber/10"
|
||||||
>
|
>
|
||||||
<Icon icon={Shield} size={20} color="var(--warning-amber)" />
|
<Icon icon={Shield} size={4} intent="warning" />
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Text size="sm" weight="bold" color="text-white" block>
|
<Text size="sm" weight="bold" variant="high" block>
|
||||||
Join Requests
|
Join Requests
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="2xl" weight="bold" color="text-warning-amber" font="mono" block>
|
<Text size="2xl" weight="bold" variant="warning" font="mono" block>
|
||||||
{pendingJoinRequestsCount}
|
{pendingJoinRequestsCount}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -116,8 +116,8 @@ export function EnhancedLeagueSchedulePanel({
|
|||||||
|
|
||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Box p={12} textAlign="center" border borderColor="zinc-800" bg="zinc-900/30">
|
<Box p={12} textAlign="center" border borderColor="border-muted" bg="bg-surface-muted">
|
||||||
<Text color="text-zinc-500" italic>No races scheduled for this season.</Text>
|
<Text variant="low" italic>No races scheduled for this season.</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -129,29 +129,29 @@ export function EnhancedLeagueSchedulePanel({
|
|||||||
const isExpanded = expandedMonths.has(monthKey);
|
const isExpanded = expandedMonths.has(monthKey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Surface key={monthKey} border borderColor="border-outline-steel" overflow="hidden">
|
<Surface key={monthKey} variant="precision" overflow="hidden" data-testid="schedule-month-group">
|
||||||
{/* Month Header */}
|
{/* Month Header */}
|
||||||
<Box
|
<Box
|
||||||
display="flex"
|
display="flex"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="space-between"
|
justifyContent="space-between"
|
||||||
p={4}
|
p={4}
|
||||||
bg="bg-surface-charcoal"
|
bg="bg-surface"
|
||||||
borderBottom={isExpanded}
|
borderBottom={isExpanded}
|
||||||
borderColor="border-outline-steel"
|
borderColor="border-default"
|
||||||
cursor="pointer"
|
cursor="pointer"
|
||||||
onClick={() => toggleMonth(monthKey)}
|
onClick={() => toggleMonth(monthKey)}
|
||||||
>
|
>
|
||||||
<Group gap={3}>
|
<Group gap={3}>
|
||||||
<Icon icon={Calendar} size={4} color="text-primary-blue" />
|
<Icon icon={Calendar} size={4} intent="primary" />
|
||||||
<Text size="md" weight="bold" color="text-white">
|
<Text size="md" weight="bold" variant="high">
|
||||||
{group.month}
|
{group.month}
|
||||||
</Text>
|
</Text>
|
||||||
<Badge variant="outline" size="sm">
|
<Badge variant="outline" size="sm">
|
||||||
{group.races.length} {group.races.length === 1 ? 'Race' : 'Races'}
|
{group.races.length} {group.races.length === 1 ? 'Race' : 'Races'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
<Icon icon={isExpanded ? ChevronUp : ChevronDown} size={4} color="text-zinc-400" />
|
<Icon icon={isExpanded ? ChevronUp : ChevronDown} size={4} intent="low" />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Race List */}
|
{/* Race List */}
|
||||||
@@ -161,39 +161,38 @@ export function EnhancedLeagueSchedulePanel({
|
|||||||
{group.races.map((race, raceIndex) => (
|
{group.races.map((race, raceIndex) => (
|
||||||
<Surface
|
<Surface
|
||||||
key={race.id}
|
key={race.id}
|
||||||
border
|
variant="precision"
|
||||||
borderColor="border-outline-steel"
|
|
||||||
p={4}
|
p={4}
|
||||||
bg="bg-base-black"
|
data-testid="race-item"
|
||||||
>
|
>
|
||||||
<Box display="flex" alignItems="center" justifyContent="space-between" gap={4}>
|
<Box display="flex" alignItems="center" justifyContent="space-between" gap={4}>
|
||||||
{/* Race Info */}
|
{/* Race Info */}
|
||||||
<Box flex={1}>
|
<Box flex={1}>
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<Group gap={2} align="center">
|
<Group gap={2} align="center">
|
||||||
<Text size="sm" weight="bold" color="text-white">
|
<Text size="sm" weight="bold" variant="high">
|
||||||
{race.name || `Race ${race.id.substring(0, 4)}`}
|
{race.name || `Race ${race.id.substring(0, 4)}`}
|
||||||
</Text>
|
</Text>
|
||||||
{getRaceStatusBadge(race.status)}
|
{getRaceStatusBadge(race.status)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap={3}>
|
<Group gap={3}>
|
||||||
<Text size="xs" color="text-zinc-400" uppercase letterSpacing="widest">
|
<Text size="xs" variant="low" uppercase letterSpacing="widest">
|
||||||
{race.track || 'TBA'}
|
{race.track || 'TBA'}
|
||||||
</Text>
|
</Text>
|
||||||
{race.car && (
|
{race.car && (
|
||||||
<Text size="xs" color="text-zinc-500" uppercase letterSpacing="widest">
|
<Text size="xs" variant="low" uppercase letterSpacing="widest">
|
||||||
{race.car}
|
{race.car}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{race.sessionType && (
|
{race.sessionType && (
|
||||||
<Text size="xs" color="text-zinc-500" uppercase letterSpacing="widest">
|
<Text size="xs" variant="low" uppercase letterSpacing="widest">
|
||||||
{race.sessionType}
|
{race.sessionType}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap={2} align="center">
|
<Group gap={2} align="center">
|
||||||
<Icon icon={Clock} size={3} color="text-zinc-500" />
|
<Icon icon={Clock} size={3} intent="low" />
|
||||||
<Text size="xs" color="text-zinc-400" font="mono">
|
<Text size="xs" variant="low" font="mono">
|
||||||
{formatTime(race.scheduledAt)}
|
{formatTime(race.scheduledAt)}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -210,6 +209,7 @@ export function EnhancedLeagueSchedulePanel({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onRegister(race.id)}
|
onClick={() => onRegister(race.id)}
|
||||||
icon={<Icon icon={CheckCircle} size={3} />}
|
icon={<Icon icon={CheckCircle} size={3} />}
|
||||||
|
data-testid="register-button"
|
||||||
>
|
>
|
||||||
Register
|
Register
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -149,8 +149,13 @@ export function LeagueCard({ league, onClick }: LeagueCardProps) {
|
|||||||
isTeamLeague={!!isTeamLeague}
|
isTeamLeague={!!isTeamLeague}
|
||||||
usedDriverSlots={league.usedDriverSlots}
|
usedDriverSlots={league.usedDriverSlots}
|
||||||
maxDrivers={league.maxDrivers}
|
maxDrivers={league.maxDrivers}
|
||||||
|
activeDriversCount={league.activeDriversCount}
|
||||||
|
nextRaceAt={league.nextRaceAt}
|
||||||
timingSummary={league.timingSummary}
|
timingSummary={league.timingSummary}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onQuickJoin={() => console.log('Quick Join', league.id)}
|
||||||
|
onFollow={() => console.log('Follow', league.id)}
|
||||||
|
isFeatured={league.usedDriverSlots > 20} // Example logic for featured
|
||||||
badges={
|
badges={
|
||||||
<>
|
<>
|
||||||
{isNew && (
|
{isNew && (
|
||||||
|
|||||||
@@ -30,21 +30,22 @@ interface StandingEntry {
|
|||||||
|
|
||||||
interface LeagueStandingsTableProps {
|
interface LeagueStandingsTableProps {
|
||||||
standings: StandingEntry[];
|
standings: StandingEntry[];
|
||||||
|
'data-testid'?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LeagueStandingsTable({ standings }: LeagueStandingsTableProps) {
|
export function LeagueStandingsTable({ standings, 'data-testid': dataTestId }: LeagueStandingsTableProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
if (!standings || standings.length === 0) {
|
if (!standings || standings.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Box p={12} textAlign="center" border borderColor="zinc-800" bg="zinc-900/30">
|
<Box p={12} textAlign="center" border borderColor="zinc-800" bg="zinc-900/30" data-testid={dataTestId}>
|
||||||
<Text color="text-zinc-500" italic>No standings data available for this season.</Text>
|
<Text color="text-zinc-500" italic>No standings data available for this season.</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LeaderboardTableShell>
|
<LeaderboardTableShell data-testid={dataTestId}>
|
||||||
<LeaderboardList>
|
<LeaderboardList>
|
||||||
{standings.map((entry) => (
|
{standings.map((entry) => (
|
||||||
<RankingRow
|
<RankingRow
|
||||||
@@ -60,6 +61,8 @@ export function LeagueStandingsTable({ standings }: LeagueStandingsTableProps) {
|
|||||||
rating={0}
|
rating={0}
|
||||||
wins={entry.wins}
|
wins={entry.wins}
|
||||||
onClick={entry.driverId ? () => router.push(routes.driver.detail(entry.driverId!)) : undefined}
|
onClick={entry.driverId ? () => router.push(routes.driver.detail(entry.driverId!)) : undefined}
|
||||||
|
data-testid="standings-row"
|
||||||
|
droppedRaceIds={entry.droppedRaceIds}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</LeaderboardList>
|
</LeaderboardList>
|
||||||
|
|||||||
@@ -67,16 +67,14 @@ export function NextRaceCountdownWidget({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Surface
|
<Surface
|
||||||
variant="muted"
|
variant="precision"
|
||||||
rounded="xl"
|
rounded="xl"
|
||||||
border
|
|
||||||
padding={6}
|
padding={6}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
background: 'linear-gradient(to bottom right, #262626, rgba(38, 38, 38, 0.8))',
|
|
||||||
borderColor: 'rgba(59, 130, 246, 0.3)',
|
|
||||||
}}
|
}}
|
||||||
|
data-testid="next-race-countdown"
|
||||||
>
|
>
|
||||||
<Stack
|
<Stack
|
||||||
position="absolute"
|
position="absolute"
|
||||||
@@ -85,7 +83,8 @@ export function NextRaceCountdownWidget({
|
|||||||
w="40"
|
w="40"
|
||||||
h="40"
|
h="40"
|
||||||
style={{
|
style={{
|
||||||
background: 'linear-gradient(to bottom left, rgba(59, 130, 246, 0.2), transparent)',
|
background: 'linear-gradient(to bottom left, var(--ui-color-intent-primary), transparent)',
|
||||||
|
opacity: 0.2,
|
||||||
borderBottomLeftRadius: '9999px',
|
borderBottomLeftRadius: '9999px',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -109,16 +108,16 @@ export function NextRaceCountdownWidget({
|
|||||||
</Text>
|
</Text>
|
||||||
{track && (
|
{track && (
|
||||||
<Stack direction="row" align="center" gap={1.5}>
|
<Stack direction="row" align="center" gap={1.5}>
|
||||||
<Icon icon={MapPin as LucideIcon} size={4} color="var(--text-gray-500)" />
|
<Icon icon={MapPin as LucideIcon} size={4} intent="low" />
|
||||||
<Text size="sm" color="text-gray-400">
|
<Text size="sm" variant="low">
|
||||||
{track}
|
{track}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
{car && (
|
{car && (
|
||||||
<Stack direction="row" align="center" gap={1.5}>
|
<Stack direction="row" align="center" gap={1.5}>
|
||||||
<Icon icon={Calendar as LucideIcon} size={4} color="var(--text-gray-500)" />
|
<Icon icon={Calendar as LucideIcon} size={4} intent="low" />
|
||||||
<Text size="sm" color="text-gray-400">
|
<Text size="sm" variant="low">
|
||||||
{car}
|
{car}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -129,7 +128,7 @@ export function NextRaceCountdownWidget({
|
|||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
<Text
|
<Text
|
||||||
size="xs"
|
size="xs"
|
||||||
color="text-gray-500"
|
variant="low"
|
||||||
style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
||||||
block
|
block
|
||||||
>
|
>
|
||||||
@@ -138,31 +137,31 @@ export function NextRaceCountdownWidget({
|
|||||||
{countdown && (
|
{countdown && (
|
||||||
<Stack direction="row" gap={2} align="center">
|
<Stack direction="row" gap={2} align="center">
|
||||||
<Stack align="center" gap={0.5}>
|
<Stack align="center" gap={0.5}>
|
||||||
<Text size="2xl" weight="bold" color="text-primary-blue" font="mono">
|
<Text size="2xl" weight="bold" variant="primary" font="mono">
|
||||||
{formatTime(countdown.days)}
|
{formatTime(countdown.days)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" color="text-gray-500">Days</Text>
|
<Text size="xs" variant="low">Days</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Text size="2xl" weight="bold" color="text-gray-600">:</Text>
|
<Text size="2xl" weight="bold" variant="med">:</Text>
|
||||||
<Stack align="center" gap={0.5}>
|
<Stack align="center" gap={0.5}>
|
||||||
<Text size="2xl" weight="bold" color="text-primary-blue" font="mono">
|
<Text size="2xl" weight="bold" variant="primary" font="mono">
|
||||||
{formatTime(countdown.hours)}
|
{formatTime(countdown.hours)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" color="text-gray-500">Hours</Text>
|
<Text size="xs" variant="low">Hours</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Text size="2xl" weight="bold" color="text-gray-600">:</Text>
|
<Text size="2xl" weight="bold" variant="med">:</Text>
|
||||||
<Stack align="center" gap={0.5}>
|
<Stack align="center" gap={0.5}>
|
||||||
<Text size="2xl" weight="bold" color="text-primary-blue" font="mono">
|
<Text size="2xl" weight="bold" variant="primary" font="mono">
|
||||||
{formatTime(countdown.minutes)}
|
{formatTime(countdown.minutes)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" color="text-gray-500">Mins</Text>
|
<Text size="xs" variant="low">Mins</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Text size="2xl" weight="bold" color="text-gray-600">:</Text>
|
<Text size="2xl" weight="bold" variant="med">:</Text>
|
||||||
<Stack align="center" gap={0.5}>
|
<Stack align="center" gap={0.5}>
|
||||||
<Text size="2xl" weight="bold" color="text-primary-blue" font="mono">
|
<Text size="2xl" weight="bold" variant="primary" font="mono">
|
||||||
{formatTime(countdown.seconds)}
|
{formatTime(countdown.seconds)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" color="text-gray-500">Secs</Text>
|
<Text size="xs" variant="low">Secs</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -85,19 +85,19 @@ export function RaceDetailModal({
|
|||||||
mx={4}
|
mx={4}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Surface border borderColor="border-outline-steel" overflow="hidden">
|
<Surface variant="precision" overflow="hidden" data-testid="race-detail-modal">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box
|
<Box
|
||||||
display="flex"
|
display="flex"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="space-between"
|
justifyContent="space-between"
|
||||||
p={4}
|
p={4}
|
||||||
bg="bg-surface-charcoal"
|
bg="bg-surface"
|
||||||
borderBottom
|
borderBottom
|
||||||
borderColor="border-outline-steel"
|
borderColor="border-default"
|
||||||
>
|
>
|
||||||
<Group gap={3}>
|
<Group gap={3}>
|
||||||
<Text size="lg" weight="bold" color="text-white">
|
<Text size="lg" weight="bold" variant="high">
|
||||||
{race.name || `Race ${race.id.substring(0, 4)}`}
|
{race.name || `Race ${race.id.substring(0, 4)}`}
|
||||||
</Text>
|
</Text>
|
||||||
{getStatusBadge(race.status)}
|
{getStatusBadge(race.status)}
|
||||||
@@ -116,33 +116,33 @@ export function RaceDetailModal({
|
|||||||
<Box p={4}>
|
<Box p={4}>
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
{/* Basic Info */}
|
{/* Basic Info */}
|
||||||
<Surface border borderColor="border-outline-steel" p={4}>
|
<Surface variant="precision" p={4}>
|
||||||
<Text as="h3" size="sm" weight="bold" color="text-gray-500" uppercase letterSpacing="widest" mb={3}>
|
<Text as="h3" size="sm" weight="bold" variant="low" uppercase letterSpacing="widest" mb={3}>
|
||||||
Race Details
|
Race Details
|
||||||
</Text>
|
</Text>
|
||||||
<Stack gap={3}>
|
<Stack gap={3}>
|
||||||
<Group gap={2} align="center">
|
<Group gap={2} align="center" data-testid="race-track">
|
||||||
<Icon icon={MapPin} size={4} color="text-primary-blue" />
|
<Icon icon={MapPin} size={4} intent="primary" />
|
||||||
<Text size="md" color="text-white" weight="bold">
|
<Text size="md" variant="high" weight="bold">
|
||||||
{race.track || 'TBA'}
|
{race.track || 'TBA'}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap={2} align="center">
|
<Group gap={2} align="center" data-testid="race-car">
|
||||||
<Icon icon={Car} size={4} color="text-primary-blue" />
|
<Icon icon={Car} size={4} intent="primary" />
|
||||||
<Text size="md" color="text-white">
|
<Text size="md" variant="high">
|
||||||
{race.car || 'TBA'}
|
{race.car || 'TBA'}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap={2} align="center">
|
<Group gap={2} align="center" data-testid="race-date">
|
||||||
<Icon icon={Calendar} size={4} color="text-primary-blue" />
|
<Icon icon={Calendar} size={4} intent="primary" />
|
||||||
<Text size="md" color="text-white">
|
<Text size="md" variant="high">
|
||||||
{formatTime(race.scheduledAt)}
|
{formatTime(race.scheduledAt)}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
{race.sessionType && (
|
{race.sessionType && (
|
||||||
<Group gap={2} align="center">
|
<Group gap={2} align="center">
|
||||||
<Icon icon={Clock} size={4} color="text-primary-blue" />
|
<Icon icon={Clock} size={4} intent="primary" />
|
||||||
<Text size="md" color="text-white">
|
<Text size="md" variant="high">
|
||||||
{race.sessionType}
|
{race.sessionType}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -151,37 +151,37 @@ export function RaceDetailModal({
|
|||||||
</Surface>
|
</Surface>
|
||||||
|
|
||||||
{/* Weather Info (Mock Data) */}
|
{/* Weather Info (Mock Data) */}
|
||||||
<Surface border borderColor="border-outline-steel" p={4}>
|
<Surface variant="precision" p={4}>
|
||||||
<Text as="h3" size="sm" weight="bold" color="text-gray-500" uppercase letterSpacing="widest" mb={3}>
|
<Text as="h3" size="sm" weight="bold" variant="low" uppercase letterSpacing="widest" mb={3}>
|
||||||
Weather Conditions
|
Weather Conditions
|
||||||
</Text>
|
</Text>
|
||||||
<Stack gap={3}>
|
<Stack gap={3}>
|
||||||
<Group gap={2} align="center">
|
<Group gap={2} align="center">
|
||||||
<Icon icon={Thermometer} size={4} color="text-primary-blue" />
|
<Icon icon={Thermometer} size={4} intent="primary" />
|
||||||
<Text size="md" color="text-white">Air: 24°C</Text>
|
<Text size="md" variant="high">Air: 24°C</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap={2} align="center">
|
<Group gap={2} align="center">
|
||||||
<Icon icon={Thermometer} size={4} color="text-primary-blue" />
|
<Icon icon={Thermometer} size={4} intent="primary" />
|
||||||
<Text size="md" color="text-white">Track: 31°C</Text>
|
<Text size="md" variant="high">Track: 31°C</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap={2} align="center">
|
<Group gap={2} align="center">
|
||||||
<Icon icon={Droplets} size={4} color="text-primary-blue" />
|
<Icon icon={Droplets} size={4} intent="primary" />
|
||||||
<Text size="md" color="text-white">Humidity: 45%</Text>
|
<Text size="md" variant="high">Humidity: 45%</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap={2} align="center">
|
<Group gap={2} align="center">
|
||||||
<Icon icon={Wind} size={4} color="text-primary-blue" />
|
<Icon icon={Wind} size={4} intent="primary" />
|
||||||
<Text size="md" color="text-white">Wind: 12 km/h NW</Text>
|
<Text size="md" variant="high">Wind: 12 km/h NW</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap={2} align="center">
|
<Group gap={2} align="center">
|
||||||
<Icon icon={Cloud} size={4} color="text-primary-blue" />
|
<Icon icon={Cloud} size={4} intent="primary" />
|
||||||
<Text size="md" color="text-white">Partly Cloudy</Text>
|
<Text size="md" variant="high">Partly Cloudy</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Surface>
|
</Surface>
|
||||||
|
|
||||||
{/* Car Classes */}
|
{/* Car Classes */}
|
||||||
<Surface border borderColor="border-outline-steel" p={4}>
|
<Surface variant="precision" p={4}>
|
||||||
<Text as="h3" size="sm" weight="bold" color="text-gray-500" uppercase letterSpacing="widest" mb={3}>
|
<Text as="h3" size="sm" weight="bold" variant="low" uppercase letterSpacing="widest" mb={3}>
|
||||||
Car Classes
|
Car Classes
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap={2} wrap>
|
<Group gap={2} wrap>
|
||||||
@@ -193,13 +193,13 @@ export function RaceDetailModal({
|
|||||||
|
|
||||||
{/* Strength of Field */}
|
{/* Strength of Field */}
|
||||||
{race.strengthOfField && (
|
{race.strengthOfField && (
|
||||||
<Surface border borderColor="border-outline-steel" p={4}>
|
<Surface variant="precision" p={4}>
|
||||||
<Text as="h3" size="sm" weight="bold" color="text-gray-500" uppercase letterSpacing="widest" mb={3}>
|
<Text as="h3" size="sm" weight="bold" variant="low" uppercase letterSpacing="widest" mb={3}>
|
||||||
Strength of Field
|
Strength of Field
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap={2} align="center">
|
<Group gap={2} align="center">
|
||||||
<Icon icon={Trophy} size={4} color="text-primary-blue" />
|
<Icon icon={Trophy} size={4} intent="primary" />
|
||||||
<Text size="md" color="text-white">
|
<Text size="md" variant="high">
|
||||||
{race.strengthOfField.toFixed(1)} / 10.0
|
{race.strengthOfField.toFixed(1)} / 10.0
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export function RosterTable({ members, isAdmin, onRemoveMember }: RosterTablePro
|
|||||||
members={members}
|
members={members}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
onRemoveMember={onRemoveMember}
|
onRemoveMember={onRemoveMember}
|
||||||
|
data-testid="roster-table"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { Icon } from '@/ui/Icon';
|
||||||
import { ProgressBar } from '@/ui/ProgressBar';
|
import { ProgressBar } from '@/ui/ProgressBar';
|
||||||
import { Stack } from '@/ui/Stack';
|
import { Stack } from '@/ui/Stack';
|
||||||
import { Surface } from '@/ui/Surface';
|
import { Surface } from '@/ui/Surface';
|
||||||
@@ -19,14 +20,10 @@ export function SeasonProgressWidget({
|
|||||||
}: SeasonProgressWidgetProps) {
|
}: SeasonProgressWidgetProps) {
|
||||||
return (
|
return (
|
||||||
<Surface
|
<Surface
|
||||||
variant="muted"
|
variant="precision"
|
||||||
rounded="xl"
|
rounded="xl"
|
||||||
border
|
|
||||||
padding={6}
|
padding={6}
|
||||||
style={{
|
data-testid="season-progress-bar"
|
||||||
background: 'linear-gradient(to bottom right, #262626, rgba(38, 38, 38, 0.8))',
|
|
||||||
borderColor: 'rgba(34, 197, 94, 0.3)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -38,15 +35,15 @@ export function SeasonProgressWidget({
|
|||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
rounded="lg"
|
rounded="lg"
|
||||||
bg="bg-performance-green/10"
|
bg="bg-success-green/10"
|
||||||
>
|
>
|
||||||
<Trophy size={20} color="var(--performance-green)" />
|
<Icon icon={Trophy} size={4} intent="success" />
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Text size="sm" weight="bold" color="text-white" block>
|
<Text size="sm" weight="bold" variant="high" block>
|
||||||
Season Progress
|
Season Progress
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" color="text-gray-400" block>
|
<Text size="xs" variant="low" block>
|
||||||
Race {completedRaces} of {totalRaces}
|
Race {completedRaces} of {totalRaces}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -60,10 +57,10 @@ export function SeasonProgressWidget({
|
|||||||
size="lg"
|
size="lg"
|
||||||
/>
|
/>
|
||||||
<Stack direction="row" justify="between" align="center">
|
<Stack direction="row" justify="between" align="center">
|
||||||
<Text size="xs" color="text-gray-500">
|
<Text size="xs" variant="low">
|
||||||
{percentage}% Complete
|
{percentage}% Complete
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" color="text-performance-green" weight="bold">
|
<Text size="xs" variant="success" weight="bold">
|
||||||
{completedRaces}/{totalRaces} Races
|
{completedRaces}/{totalRaces} Races
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -72,12 +69,12 @@ export function SeasonProgressWidget({
|
|||||||
{/* Visual Indicator */}
|
{/* Visual Indicator */}
|
||||||
<Stack
|
<Stack
|
||||||
rounded="lg"
|
rounded="lg"
|
||||||
bg="bg-performance-green/10"
|
bg="bg-success-green/10"
|
||||||
border
|
border
|
||||||
borderColor="border-performance-green/30"
|
borderColor="border-success-green/30"
|
||||||
p={3}
|
p={3}
|
||||||
>
|
>
|
||||||
<Text size="xs" color="text-performance-green" weight="medium" block>
|
<Text size="xs" variant="success" weight="medium" block>
|
||||||
{percentage >= 100
|
{percentage >= 100
|
||||||
? 'Season Complete! 🏆'
|
? 'Season Complete! 🏆'
|
||||||
: percentage >= 50
|
: percentage >= 50
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user