diff --git a/apps/api/src/domain/admin/Admin.http.test.ts b/apps/api/src/domain/admin/Admin.http.test.ts new file mode 100644 index 000000000..b325f9533 --- /dev/null +++ b/apps/api/src/domain/admin/Admin.http.test.ts @@ -0,0 +1,106 @@ +import 'reflect-metadata'; + +import { ValidationPipe } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { requestContextMiddleware } from '@adapters/http/RequestContext'; +import { AuthenticationGuard } from '../auth/AuthenticationGuard'; +import { AuthorizationGuard } from '../auth/AuthorizationGuard'; +import { IDENTITY_SESSION_PORT_TOKEN } from '../auth/AuthProviders'; +import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard'; + +describe('Admin domain (HTTP, module-wiring)', () => { + const originalEnv = { ...process.env }; + + let app: any; + + beforeAll(async () => { + vi.resetModules(); + + process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory'; + process.env.GRIDPILOT_API_BOOTSTRAP = 'true'; + delete process.env.DATABASE_URL; + + const { AppModule } = await import('../../app.module'); + + const module = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = module.createNestApplication(); + + // Ensure AsyncLocalStorage request context is present for getActorFromRequestContext() + app.use(requestContextMiddleware); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + + const reflector = new Reflector(); + const sessionPort = module.get(IDENTITY_SESSION_PORT_TOKEN); + + const authorizationService = { + getRolesForUser: () => [], + }; + + const policyService = { + getSnapshot: async () => ({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: {}, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + }), + }; + + app.useGlobalGuards( + new AuthenticationGuard(sessionPort as any), + new AuthorizationGuard(reflector, authorizationService as any), + new FeatureAvailabilityGuard(reflector, policyService as any), + ); + + await app.init(); + }, 20_000); + + afterAll(async () => { + await app?.close(); + + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('module compiles and app is initialized', () => { + expect(app).toBeDefined(); + expect(app.getHttpServer()).toBeDefined(); + }); + + it('rejects unauthenticated actor on admin endpoints (401)', async () => { + await request(app.getHttpServer()) + .get('/admin/users') + .expect(401); + + await request(app.getHttpServer()) + .get('/admin/dashboard/stats') + .expect(401); + }); + + it('rejects authenticated non-admin actor (403)', async () => { + const agent = request.agent(app.getHttpServer()); + + await agent + .post('/auth/signup') + .send({ email: 'user-admin-test@gridpilot.local', password: 'Password123!', displayName: 'Regular User' }) + .expect(201); + + await agent.get('/admin/users').expect(403); + await agent.get('/admin/dashboard/stats').expect(403); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/admin/AdminController.test.ts b/apps/api/src/domain/admin/AdminController.test.ts new file mode 100644 index 000000000..f1e958d13 --- /dev/null +++ b/apps/api/src/domain/admin/AdminController.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi } from 'vitest'; +import { AdminController } from './AdminController'; +import { ListUsersRequestDto } from './dtos/ListUsersRequestDto'; +import { UserListResponseDto, UserResponseDto } from './dtos/UserResponseDto'; +import { DashboardStatsResponseDto } from './dto/DashboardStatsResponseDto'; + +describe('AdminController', () => { + let controller: AdminController; + let mockService: { + listUsers: ReturnType; + getDashboardStats: ReturnType; + }; + + beforeEach(() => { + mockService = { + listUsers: vi.fn(), + getDashboardStats: vi.fn(), + }; + + controller = new AdminController(mockService as never); + }); + + describe('listUsers', () => { + it('should list users with basic query params', async () => { + const mockUser: UserResponseDto = { + id: 'user-1', + email: 'test@example.com', + displayName: 'Test User', + roles: ['admin'], + status: 'active', + isSystemAdmin: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockResponse: UserListResponseDto = { + users: [mockUser], + total: 1, + page: 1, + limit: 10, + totalPages: 1, + }; + + mockService.listUsers.mockResolvedValue(mockResponse); + + const query: ListUsersRequestDto = { + page: 1, + limit: 10, + }; + + const req = { user: { userId: 'admin-1' } } as any; + const result = await controller.listUsers(query, req); + + expect(mockService.listUsers).toHaveBeenCalledWith({ + actorId: 'admin-1', + page: 1, + limit: 10, + }); + expect(result).toEqual(mockResponse); + }); + + it('should list users with all query params', async () => { + const mockResponse: UserListResponseDto = { + users: [], + total: 0, + page: 2, + limit: 20, + totalPages: 0, + }; + + mockService.listUsers.mockResolvedValue(mockResponse); + + const query: ListUsersRequestDto = { + page: 2, + limit: 20, + role: 'owner', + status: 'active', + email: 'admin', + search: 'test', + sortBy: 'email', + sortDirection: 'desc', + }; + + const req = { user: { userId: 'owner-1' } } as any; + const result = await controller.listUsers(query, req); + + expect(mockService.listUsers).toHaveBeenCalledWith({ + actorId: 'owner-1', + page: 2, + limit: 20, + role: 'owner', + status: 'active', + email: 'admin', + search: 'test', + sortBy: 'email', + sortDirection: 'desc', + }); + expect(result).toEqual(mockResponse); + }); + + it('should handle missing user ID from request', async () => { + const mockResponse: UserListResponseDto = { + users: [], + total: 0, + page: 1, + limit: 10, + totalPages: 0, + }; + + mockService.listUsers.mockResolvedValue(mockResponse); + + const query: ListUsersRequestDto = { page: 1, limit: 10 }; + const req = {} as any; + + await controller.listUsers(query, req); + + expect(mockService.listUsers).toHaveBeenCalledWith({ + actorId: 'current-user', + page: 1, + limit: 10, + }); + }); + + it('should handle optional query params being undefined', async () => { + const mockResponse: UserListResponseDto = { + users: [], + total: 0, + page: 1, + limit: 10, + totalPages: 0, + }; + + mockService.listUsers.mockResolvedValue(mockResponse); + + const query: ListUsersRequestDto = { + page: 1, + limit: 10, + }; + + const req = { user: { userId: 'admin-1' } } as any; + await controller.listUsers(query, req); + + expect(mockService.listUsers).toHaveBeenCalledWith({ + actorId: 'admin-1', + page: 1, + limit: 10, + }); + }); + }); + + describe('getDashboardStats', () => { + it('should return dashboard stats', async () => { + const mockStats: DashboardStatsResponseDto = { + totalUsers: 150, + activeUsers: 120, + suspendedUsers: 20, + deletedUsers: 10, + systemAdmins: 5, + recentLogins: 25, + newUsersToday: 8, + userGrowth: [], + roleDistribution: [], + statusDistribution: { + active: 120, + suspended: 20, + deleted: 10, + }, + activityTimeline: [], + }; + + mockService.getDashboardStats.mockResolvedValue(mockStats); + + const req = { user: { userId: 'admin-1' } } as any; + const result = await controller.getDashboardStats(req); + + expect(mockService.getDashboardStats).toHaveBeenCalledWith({ + actorId: 'admin-1', + }); + expect(result).toEqual(mockStats); + }); + + it('should handle missing user ID from request', async () => { + const mockStats: DashboardStatsResponseDto = { + totalUsers: 0, + activeUsers: 0, + suspendedUsers: 0, + deletedUsers: 0, + systemAdmins: 0, + recentLogins: 0, + newUsersToday: 0, + userGrowth: [], + roleDistribution: [], + statusDistribution: { + active: 0, + suspended: 0, + deleted: 0, + }, + activityTimeline: [], + }; + + mockService.getDashboardStats.mockResolvedValue(mockStats); + + const req = {} as any; + const result = await controller.getDashboardStats(req); + + expect(mockService.getDashboardStats).toHaveBeenCalledWith({ + actorId: 'current-user', + }); + expect(result).toEqual(mockStats); + }); + + it('should handle service errors gracefully', async () => { + mockService.getDashboardStats.mockRejectedValue(new Error('Database connection failed')); + + const req = { user: { userId: 'admin-1' } } as any; + + await expect(controller.getDashboardStats(req)).rejects.toThrow('Database connection failed'); + }); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/analytics/Analytics.http.test.ts b/apps/api/src/domain/analytics/Analytics.http.test.ts new file mode 100644 index 000000000..9b7f412c3 --- /dev/null +++ b/apps/api/src/domain/analytics/Analytics.http.test.ts @@ -0,0 +1,95 @@ +import 'reflect-metadata'; + +import { ValidationPipe } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { requestContextMiddleware } from '@adapters/http/RequestContext'; +import { AuthenticationGuard } from '../auth/AuthenticationGuard'; +import { AuthorizationGuard } from '../auth/AuthorizationGuard'; +import { IDENTITY_SESSION_PORT_TOKEN } from '../auth/AuthProviders'; +import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard'; + +describe('Analytics domain (HTTP, module-wiring)', () => { + const originalEnv = { ...process.env }; + + let app: any; + + beforeAll(async () => { + vi.resetModules(); + + process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory'; + process.env.GRIDPILOT_API_BOOTSTRAP = 'true'; + delete process.env.DATABASE_URL; + + const { AppModule } = await import('../../app.module'); + + const module = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = module.createNestApplication(); + + // Ensure AsyncLocalStorage request context is present for getActorFromRequestContext() + app.use(requestContextMiddleware); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + + const reflector = new Reflector(); + const sessionPort = module.get(IDENTITY_SESSION_PORT_TOKEN); + + const authorizationService = { + getRolesForUser: () => [], + }; + + const policyService = { + getSnapshot: async () => ({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: {}, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + }), + }; + + app.useGlobalGuards( + new AuthenticationGuard(sessionPort as any), + new AuthorizationGuard(reflector, authorizationService as any), + new FeatureAvailabilityGuard(reflector, policyService as any), + ); + + await app.init(); + }, 20_000); + + afterAll(async () => { + await app?.close(); + + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('module compiles and app is initialized', () => { + expect(app).toBeDefined(); + expect(app.getHttpServer()).toBeDefined(); + }); + + + it('rejects unauthenticated actor on internal dashboard endpoints (401)', async () => { + await request(app.getHttpServer()) + .get('/analytics/dashboard') + .expect(401); + + await request(app.getHttpServer()) + .get('/analytics/metrics') + .expect(401); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/dashboard/Dashboard.http.test.ts b/apps/api/src/domain/dashboard/Dashboard.http.test.ts new file mode 100644 index 000000000..af81c179f --- /dev/null +++ b/apps/api/src/domain/dashboard/Dashboard.http.test.ts @@ -0,0 +1,90 @@ +import 'reflect-metadata'; + +import { ValidationPipe } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { requestContextMiddleware } from '@adapters/http/RequestContext'; +import { AuthenticationGuard } from '../auth/AuthenticationGuard'; +import { AuthorizationGuard } from '../auth/AuthorizationGuard'; +import { IDENTITY_SESSION_PORT_TOKEN } from '../auth/AuthProviders'; +import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard'; + +describe('Dashboard domain (HTTP, module-wiring)', () => { + const originalEnv = { ...process.env }; + + let app: any; + + beforeAll(async () => { + vi.resetModules(); + + process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory'; + process.env.GRIDPILOT_API_BOOTSTRAP = 'true'; + delete process.env.DATABASE_URL; + + const { AppModule } = await import('../../app.module'); + + const module = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = module.createNestApplication(); + + // Ensure AsyncLocalStorage request context is present for getActorFromRequestContext() + app.use(requestContextMiddleware); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + + const reflector = new Reflector(); + const sessionPort = module.get(IDENTITY_SESSION_PORT_TOKEN); + + const authorizationService = { + getRolesForUser: () => [], + }; + + const policyService = { + getSnapshot: async () => ({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: {}, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + }), + }; + + app.useGlobalGuards( + new AuthenticationGuard(sessionPort as any), + new AuthorizationGuard(reflector, authorizationService as any), + new FeatureAvailabilityGuard(reflector, policyService as any), + ); + + await app.init(); + }, 20_000); + + afterAll(async () => { + await app?.close(); + + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('module compiles and app is initialized', () => { + expect(app).toBeDefined(); + expect(app.getHttpServer()).toBeDefined(); + }); + + it('rejects unauthenticated actor (401)', async () => { + await request(app.getHttpServer()) + .get('/dashboard/overview') + .expect(401); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/driver/Driver.http.test.ts b/apps/api/src/domain/driver/Driver.http.test.ts new file mode 100644 index 000000000..460e5e1bf --- /dev/null +++ b/apps/api/src/domain/driver/Driver.http.test.ts @@ -0,0 +1,91 @@ +import 'reflect-metadata'; + +import { ValidationPipe } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { requestContextMiddleware } from '@adapters/http/RequestContext'; +import { AuthenticationGuard } from '../auth/AuthenticationGuard'; +import { AuthorizationGuard } from '../auth/AuthorizationGuard'; +import { IDENTITY_SESSION_PORT_TOKEN } from '../auth/AuthProviders'; +import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard'; + +describe('Driver domain (HTTP, module-wiring)', () => { + const originalEnv = { ...process.env }; + + let app: any; + + beforeAll(async () => { + vi.resetModules(); + + process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory'; + process.env.GRIDPILOT_API_BOOTSTRAP = 'true'; + delete process.env.DATABASE_URL; + + const { AppModule } = await import('../../app.module'); + + const module = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = module.createNestApplication(); + + // Ensure AsyncLocalStorage request context is present for getActorFromRequestContext() + app.use(requestContextMiddleware); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + + const reflector = new Reflector(); + const sessionPort = module.get(IDENTITY_SESSION_PORT_TOKEN); + + const authorizationService = { + getRolesForUser: () => [], + }; + + const policyService = { + getSnapshot: async () => ({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: {}, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + }), + }; + + app.useGlobalGuards( + new AuthenticationGuard(sessionPort as any), + new AuthorizationGuard(reflector, authorizationService as any), + new FeatureAvailabilityGuard(reflector, policyService as any), + ); + + await app.init(); + }, 20_000); + + afterAll(async () => { + await app?.close(); + + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('module compiles and app is initialized', () => { + expect(app).toBeDefined(); + expect(app.getHttpServer()).toBeDefined(); + }); + + + it('rejects unauthenticated actor on current driver endpoint (401)', async () => { + await request(app.getHttpServer()) + .get('/drivers/current') + .expect(401); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/hello/Hello.http.test.ts b/apps/api/src/domain/hello/Hello.http.test.ts new file mode 100644 index 000000000..0153c58a8 --- /dev/null +++ b/apps/api/src/domain/hello/Hello.http.test.ts @@ -0,0 +1,93 @@ +import 'reflect-metadata'; + +import { ValidationPipe } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { requestContextMiddleware } from '@adapters/http/RequestContext'; +import { AuthenticationGuard } from '../auth/AuthenticationGuard'; +import { AuthorizationGuard } from '../auth/AuthorizationGuard'; +import { IDENTITY_SESSION_PORT_TOKEN } from '../auth/AuthProviders'; +import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard'; + +describe('Hello domain (HTTP, module-wiring)', () => { + const originalEnv = { ...process.env }; + + let app: any; + + beforeAll(async () => { + vi.resetModules(); + + process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory'; + process.env.GRIDPILOT_API_BOOTSTRAP = 'true'; + delete process.env.DATABASE_URL; + + const { AppModule } = await import('../../app.module'); + + const module = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = module.createNestApplication(); + + // Ensure AsyncLocalStorage request context is present for getActorFromRequestContext() + app.use(requestContextMiddleware); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + + const reflector = new Reflector(); + const sessionPort = module.get(IDENTITY_SESSION_PORT_TOKEN); + + const authorizationService = { + getRolesForUser: () => [], + }; + + const policyService = { + getSnapshot: async () => ({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: {}, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + }), + }; + + app.useGlobalGuards( + new AuthenticationGuard(sessionPort as any), + new AuthorizationGuard(reflector, authorizationService as any), + new FeatureAvailabilityGuard(reflector, policyService as any), + ); + + await app.init(); + }, 20_000); + + afterAll(async () => { + await app?.close(); + + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('module compiles and app is initialized', () => { + expect(app).toBeDefined(); + expect(app.getHttpServer()).toBeDefined(); + }); + + it('allows public access to health endpoint (happy path)', async () => { + await request(app.getHttpServer()) + .get('/health') + .expect(200) + .expect((res) => { + expect(res.body).toEqual({ status: 'ok' }); + }); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/hello/HelloController.test.ts b/apps/api/src/domain/hello/HelloController.test.ts new file mode 100644 index 000000000..6422a0e4f --- /dev/null +++ b/apps/api/src/domain/hello/HelloController.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi } from 'vitest'; +import { HelloController } from './HelloController'; + +describe('HelloController', () => { + let controller: HelloController; + let mockService: { getHello: ReturnType }; + + beforeEach(() => { + mockService = { + getHello: vi.fn(), + }; + + controller = new HelloController(mockService as never); + }); + + describe('health', () => { + it('should return health status', async () => { + const result = await controller.health(); + + expect(result).toEqual({ status: 'ok' }); + }); + }); + + describe('getHello', () => { + it('should return hello message from service', async () => { + const helloMessage = 'Hello World'; + mockService.getHello.mockReturnValue(helloMessage); + + const result = await controller.getHello(); + + expect(mockService.getHello).toHaveBeenCalledTimes(1); + expect(result).toBe(helloMessage); + }); + + it('should return custom hello message', async () => { + const customMessage = 'Hello from Test'; + mockService.getHello.mockReturnValue(customMessage); + + const result = await controller.getHello(); + + expect(result).toBe(customMessage); + }); + + it('should handle empty string from service', async () => { + mockService.getHello.mockReturnValue(''); + + const result = await controller.getHello(); + + expect(result).toBe(''); + }); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/league/presenters/CreateLeagueSeasonScheduleRacePresenter.test.ts b/apps/api/src/domain/league/presenters/CreateLeagueSeasonScheduleRacePresenter.test.ts new file mode 100644 index 000000000..a17d5b617 --- /dev/null +++ b/apps/api/src/domain/league/presenters/CreateLeagueSeasonScheduleRacePresenter.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { CreateLeagueSeasonScheduleRacePresenter } from './LeagueSeasonScheduleMutationPresenters'; + +describe('CreateLeagueSeasonScheduleRacePresenter', () => { + it('presents create result with raceId', () => { + const presenter = new CreateLeagueSeasonScheduleRacePresenter(); + + presenter.present({ + raceId: 'race-123', + }); + + const vm = presenter.getResponseModel(); + + expect(vm).not.toBeNull(); + expect(vm!.raceId).toBe('race-123'); + }); + + it('returns null before present is called', () => { + const presenter = new CreateLeagueSeasonScheduleRacePresenter(); + + const vm = presenter.getResponseModel(); + + expect(vm).toBeNull(); + }); + + it('can be reset after presenting', () => { + const presenter = new CreateLeagueSeasonScheduleRacePresenter(); + + presenter.present({ + raceId: 'race-123', + }); + + expect(presenter.getResponseModel()).not.toBeNull(); + + presenter.reset(); + + expect(presenter.getResponseModel()).toBeNull(); + }); + + it('can present multiple times with different race IDs', () => { + const presenter = new CreateLeagueSeasonScheduleRacePresenter(); + + // First presentation + presenter.present({ + raceId: 'race-123', + }); + + let vm = presenter.getResponseModel(); + expect(vm!.raceId).toBe('race-123'); + + // Second presentation (simulating reuse) + presenter.present({ + raceId: 'race-456', + }); + + vm = presenter.getResponseModel(); + expect(vm!.raceId).toBe('race-456'); + }); + + it('handles raceId with special characters', () => { + const presenter = new CreateLeagueSeasonScheduleRacePresenter(); + + presenter.present({ + raceId: 'race-abc-123_xyz', + }); + + const vm = presenter.getResponseModel(); + + expect(vm).not.toBeNull(); + expect(vm!.raceId).toBe('race-abc-123_xyz'); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/league/presenters/DeleteLeagueSeasonScheduleRacePresenter.test.ts b/apps/api/src/domain/league/presenters/DeleteLeagueSeasonScheduleRacePresenter.test.ts new file mode 100644 index 000000000..86e26a036 --- /dev/null +++ b/apps/api/src/domain/league/presenters/DeleteLeagueSeasonScheduleRacePresenter.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { DeleteLeagueSeasonScheduleRacePresenter } from './LeagueSeasonScheduleMutationPresenters'; + +describe('DeleteLeagueSeasonScheduleRacePresenter', () => { + it('presents delete result with success=true', () => { + const presenter = new DeleteLeagueSeasonScheduleRacePresenter(); + + presenter.present({ + success: true, + }); + + const vm = presenter.getResponseModel(); + + expect(vm).not.toBeNull(); + expect(vm!.success).toBe(true); + }); + + it('returns null before present is called', () => { + const presenter = new DeleteLeagueSeasonScheduleRacePresenter(); + + const vm = presenter.getResponseModel(); + + expect(vm).toBeNull(); + }); + + it('can be reset after presenting', () => { + const presenter = new DeleteLeagueSeasonScheduleRacePresenter(); + + presenter.present({ + success: true, + }); + + expect(presenter.getResponseModel()).not.toBeNull(); + + presenter.reset(); + + expect(presenter.getResponseModel()).toBeNull(); + }); + + it('can present multiple times', () => { + const presenter = new DeleteLeagueSeasonScheduleRacePresenter(); + + // First presentation + presenter.present({ + success: true, + }); + + let vm = presenter.getResponseModel(); + expect(vm!.success).toBe(true); + + // Second presentation (simulating reuse) + presenter.present({ + success: true, + }); + + vm = presenter.getResponseModel(); + expect(vm!.success).toBe(true); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/league/presenters/PublishLeagueSeasonSchedulePresenter.test.ts b/apps/api/src/domain/league/presenters/PublishLeagueSeasonSchedulePresenter.test.ts new file mode 100644 index 000000000..6310db284 --- /dev/null +++ b/apps/api/src/domain/league/presenters/PublishLeagueSeasonSchedulePresenter.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { PublishLeagueSeasonSchedulePresenter } from './LeagueSeasonScheduleMutationPresenters'; + +describe('PublishLeagueSeasonSchedulePresenter', () => { + it('presents publish result with success and published=true', () => { + const presenter = new PublishLeagueSeasonSchedulePresenter(); + + presenter.present({ + success: true, + seasonId: 'season-1', + published: true, + }); + + const vm = presenter.getResponseModel(); + + expect(vm).not.toBeNull(); + expect(vm!.success).toBe(true); + expect(vm!.published).toBe(true); + }); + + it('returns null before present is called', () => { + const presenter = new PublishLeagueSeasonSchedulePresenter(); + + const vm = presenter.getResponseModel(); + + expect(vm).toBeNull(); + }); + + it('can be reset after presenting', () => { + const presenter = new PublishLeagueSeasonSchedulePresenter(); + + presenter.present({ + success: true, + seasonId: 'season-1', + published: true, + }); + + expect(presenter.getResponseModel()).not.toBeNull(); + + presenter.reset(); + + expect(presenter.getResponseModel()).toBeNull(); + }); + + it('can present multiple times with different results', () => { + const presenter = new PublishLeagueSeasonSchedulePresenter(); + + // First presentation + presenter.present({ + success: true, + seasonId: 'season-1', + published: true, + }); + + let vm = presenter.getResponseModel(); + expect(vm!.published).toBe(true); + + // Second presentation (simulating reuse) + presenter.present({ + success: true, + seasonId: 'season-2', + published: true, + }); + + vm = presenter.getResponseModel(); + expect(vm!.published).toBe(true); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/league/presenters/UnpublishLeagueSeasonSchedulePresenter.test.ts b/apps/api/src/domain/league/presenters/UnpublishLeagueSeasonSchedulePresenter.test.ts new file mode 100644 index 000000000..243534f36 --- /dev/null +++ b/apps/api/src/domain/league/presenters/UnpublishLeagueSeasonSchedulePresenter.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { UnpublishLeagueSeasonSchedulePresenter } from './LeagueSeasonScheduleMutationPresenters'; + +describe('UnpublishLeagueSeasonSchedulePresenter', () => { + it('presents unpublish result with success and published=false', () => { + const presenter = new UnpublishLeagueSeasonSchedulePresenter(); + + presenter.present({ + success: true, + seasonId: 'season-1', + published: false, + }); + + const vm = presenter.getResponseModel(); + + expect(vm).not.toBeNull(); + expect(vm!.success).toBe(true); + expect(vm!.published).toBe(false); + }); + + it('returns null before present is called', () => { + const presenter = new UnpublishLeagueSeasonSchedulePresenter(); + + const vm = presenter.getResponseModel(); + + expect(vm).toBeNull(); + }); + + it('can be reset after presenting', () => { + const presenter = new UnpublishLeagueSeasonSchedulePresenter(); + + presenter.present({ + success: true, + seasonId: 'season-1', + published: false, + }); + + expect(presenter.getResponseModel()).not.toBeNull(); + + presenter.reset(); + + expect(presenter.getResponseModel()).toBeNull(); + }); + + it('can present multiple times with different results', () => { + const presenter = new UnpublishLeagueSeasonSchedulePresenter(); + + // First presentation + presenter.present({ + success: true, + seasonId: 'season-1', + published: false, + }); + + let vm = presenter.getResponseModel(); + expect(vm!.published).toBe(false); + + // Second presentation (simulating reuse) + presenter.present({ + success: true, + seasonId: 'season-2', + published: false, + }); + + vm = presenter.getResponseModel(); + expect(vm!.published).toBe(false); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/league/presenters/UpdateLeagueSeasonScheduleRacePresenter.test.ts b/apps/api/src/domain/league/presenters/UpdateLeagueSeasonScheduleRacePresenter.test.ts new file mode 100644 index 000000000..e383c6a5e --- /dev/null +++ b/apps/api/src/domain/league/presenters/UpdateLeagueSeasonScheduleRacePresenter.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { UpdateLeagueSeasonScheduleRacePresenter } from './LeagueSeasonScheduleMutationPresenters'; + +describe('UpdateLeagueSeasonScheduleRacePresenter', () => { + it('presents update result with success=true', () => { + const presenter = new UpdateLeagueSeasonScheduleRacePresenter(); + + presenter.present({ + success: true, + }); + + const vm = presenter.getResponseModel(); + + expect(vm).not.toBeNull(); + expect(vm!.success).toBe(true); + }); + + it('returns null before present is called', () => { + const presenter = new UpdateLeagueSeasonScheduleRacePresenter(); + + const vm = presenter.getResponseModel(); + + expect(vm).toBeNull(); + }); + + it('can be reset after presenting', () => { + const presenter = new UpdateLeagueSeasonScheduleRacePresenter(); + + presenter.present({ + success: true, + }); + + expect(presenter.getResponseModel()).not.toBeNull(); + + presenter.reset(); + + expect(presenter.getResponseModel()).toBeNull(); + }); + + it('can present multiple times', () => { + const presenter = new UpdateLeagueSeasonScheduleRacePresenter(); + + // First presentation + presenter.present({ + success: true, + }); + + let vm = presenter.getResponseModel(); + expect(vm!.success).toBe(true); + + // Second presentation (simulating reuse) + presenter.present({ + success: true, + }); + + vm = presenter.getResponseModel(); + expect(vm!.success).toBe(true); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/payments/Payments.http.test.ts b/apps/api/src/domain/payments/Payments.http.test.ts new file mode 100644 index 000000000..0e561c382 --- /dev/null +++ b/apps/api/src/domain/payments/Payments.http.test.ts @@ -0,0 +1,101 @@ +import 'reflect-metadata'; + +import { ValidationPipe } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { requestContextMiddleware } from '@adapters/http/RequestContext'; +import { AuthenticationGuard } from '../auth/AuthenticationGuard'; +import { AuthorizationGuard } from '../auth/AuthorizationGuard'; +import { IDENTITY_SESSION_PORT_TOKEN } from '../auth/AuthProviders'; +import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard'; + +describe('Payments domain (HTTP, module-wiring)', () => { + const originalEnv = { ...process.env }; + + let app: any; + + beforeAll(async () => { + vi.resetModules(); + + process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory'; + process.env.GRIDPILOT_API_BOOTSTRAP = 'true'; + delete process.env.DATABASE_URL; + + const { AppModule } = await import('../../app.module'); + + const module = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = module.createNestApplication(); + + // Ensure AsyncLocalStorage request context is present for getActorFromRequestContext() + app.use(requestContextMiddleware); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + + const reflector = new Reflector(); + const sessionPort = module.get(IDENTITY_SESSION_PORT_TOKEN); + + const authorizationService = { + getRolesForUser: () => [], + }; + + const policyService = { + getSnapshot: async () => ({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: {}, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + }), + }; + + app.useGlobalGuards( + new AuthenticationGuard(sessionPort as any), + new AuthorizationGuard(reflector, authorizationService as any), + new FeatureAvailabilityGuard(reflector, policyService as any), + ); + + await app.init(); + }, 20_000); + + afterAll(async () => { + await app?.close(); + + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('module compiles and app is initialized', () => { + expect(app).toBeDefined(); + expect(app.getHttpServer()).toBeDefined(); + }); + + it('rejects unauthenticated actor (401)', async () => { + await request(app.getHttpServer()) + .get('/payments') + .expect(401); + }); + + it('rejects authenticated non-admin actor (403)', async () => { + const agent = request.agent(app.getHttpServer()); + + await agent + .post('/auth/signup') + .send({ email: 'user-payments-test@gridpilot.local', password: 'Password123!', displayName: 'Regular User' }) + .expect(201); + + await agent.get('/payments').expect(403); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/policy/Policy.http.test.ts b/apps/api/src/domain/policy/Policy.http.test.ts new file mode 100644 index 000000000..127a81ce6 --- /dev/null +++ b/apps/api/src/domain/policy/Policy.http.test.ts @@ -0,0 +1,84 @@ +import 'reflect-metadata'; + +import { ValidationPipe } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { requestContextMiddleware } from '@adapters/http/RequestContext'; +import { AuthenticationGuard } from '../auth/AuthenticationGuard'; +import { AuthorizationGuard } from '../auth/AuthorizationGuard'; +import { IDENTITY_SESSION_PORT_TOKEN } from '../auth/AuthProviders'; +import { FeatureAvailabilityGuard } from './FeatureAvailabilityGuard'; + +describe('Policy domain (HTTP, module-wiring)', () => { + const originalEnv = { ...process.env }; + + let app: any; + + beforeAll(async () => { + vi.resetModules(); + + process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory'; + process.env.GRIDPILOT_API_BOOTSTRAP = 'true'; + delete process.env.DATABASE_URL; + + const { AppModule } = await import('../../app.module'); + + const module = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = module.createNestApplication(); + + // Ensure AsyncLocalStorage request context is present for getActorFromRequestContext() + app.use(requestContextMiddleware); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + + const reflector = new Reflector(); + const sessionPort = module.get(IDENTITY_SESSION_PORT_TOKEN); + + const authorizationService = { + getRolesForUser: () => [], + }; + + const policyService = { + getSnapshot: async () => ({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: {}, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + }), + }; + + app.useGlobalGuards( + new AuthenticationGuard(sessionPort as any), + new AuthorizationGuard(reflector, authorizationService as any), + new FeatureAvailabilityGuard(reflector, policyService as any), + ); + + await app.init(); + }, 20_000); + + afterAll(async () => { + await app?.close(); + + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('module compiles and app is initialized', () => { + expect(app).toBeDefined(); + expect(app.getHttpServer()).toBeDefined(); + }); + +}); \ No newline at end of file diff --git a/apps/api/src/domain/policy/PolicyController.test.ts b/apps/api/src/domain/policy/PolicyController.test.ts new file mode 100644 index 000000000..72f123c93 --- /dev/null +++ b/apps/api/src/domain/policy/PolicyController.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi } from 'vitest'; +import { PolicyController } from './PolicyController'; +import { PolicySnapshot } from './PolicyService'; + +describe('PolicyController', () => { + let controller: PolicyController; + let mockService: { getSnapshot: ReturnType }; + + beforeEach(() => { + mockService = { + getSnapshot: vi.fn(), + }; + + controller = new PolicyController(mockService as never); + }); + + describe('getSnapshot', () => { + it('should return policy snapshot from service', async () => { + const mockSnapshot: PolicySnapshot = { + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { + view: ['health'], + mutate: ['admin'], + }, + capabilities: { + 'feature-a': 'enabled', + 'feature-b': 'disabled', + }, + loadedFrom: 'defaults', + loadedAtIso: new Date().toISOString(), + }; + + mockService.getSnapshot.mockResolvedValue(mockSnapshot); + + const result = await controller.getSnapshot(); + + expect(mockService.getSnapshot).toHaveBeenCalledTimes(1); + expect(result).toEqual(mockSnapshot); + }); + + it('should return snapshot with maintenance mode', async () => { + const mockSnapshot: PolicySnapshot = { + policyVersion: 2, + operationalMode: 'maintenance', + maintenanceAllowlist: { + view: ['health', 'status'], + mutate: ['admin'], + }, + capabilities: { + 'dashboard': 'enabled', + 'payments': 'disabled', + }, + loadedFrom: 'file', + loadedAtIso: new Date().toISOString(), + }; + + mockService.getSnapshot.mockResolvedValue(mockSnapshot); + + const result = await controller.getSnapshot(); + + expect(result).toEqual(mockSnapshot); + expect(result.operationalMode).toBe('maintenance'); + }); + + it('should return snapshot with test mode', async () => { + const mockSnapshot: PolicySnapshot = { + policyVersion: 1, + operationalMode: 'test', + maintenanceAllowlist: { + view: [], + mutate: [], + }, + capabilities: { + 'all-features': 'enabled', + }, + loadedFrom: 'env', + loadedAtIso: new Date().toISOString(), + }; + + mockService.getSnapshot.mockResolvedValue(mockSnapshot); + + const result = await controller.getSnapshot(); + + expect(result).toEqual(mockSnapshot); + expect(result.operationalMode).toBe('test'); + }); + + it('should return snapshot with empty capabilities', async () => { + const mockSnapshot: PolicySnapshot = { + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { + view: [], + mutate: [], + }, + capabilities: {}, + loadedFrom: 'defaults', + loadedAtIso: new Date().toISOString(), + }; + + mockService.getSnapshot.mockResolvedValue(mockSnapshot); + + const result = await controller.getSnapshot(); + + expect(result).toEqual(mockSnapshot); + expect(Object.keys(result.capabilities)).toHaveLength(0); + }); + + it('should return snapshot with coming_soon features', async () => { + const mockSnapshot: PolicySnapshot = { + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { + view: [], + mutate: [], + }, + capabilities: { + 'new-feature': 'coming_soon', + 'beta-feature': 'hidden', + }, + loadedFrom: 'file', + loadedAtIso: new Date().toISOString(), + }; + + mockService.getSnapshot.mockResolvedValue(mockSnapshot); + + const result = await controller.getSnapshot(); + + expect(result.capabilities['new-feature']).toBe('coming_soon'); + expect(result.capabilities['beta-feature']).toBe('hidden'); + }); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/policy/PolicyService.test.ts b/apps/api/src/domain/policy/PolicyService.test.ts new file mode 100644 index 000000000..3110d6f82 --- /dev/null +++ b/apps/api/src/domain/policy/PolicyService.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { PolicyService } from './PolicyService'; + +describe('PolicyService', () => { + let service: PolicyService; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = process.env; + process.env = { ...originalEnv }; + service = new PolicyService(); + }); + + afterEach(() => { + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + describe('getSnapshot', () => { + it('should return cached snapshot when not expired', async () => { + // Set a long cache time + process.env.GRIDPILOT_POLICY_CACHE_MS = '60000'; + + const snapshot1 = await service.getSnapshot(); + const snapshot2 = await service.getSnapshot(); + + expect(snapshot1).toEqual(snapshot2); + expect(snapshot1.policyVersion).toBeDefined(); + expect(snapshot1.loadedAtIso).toBeDefined(); + }); + + it('should return new snapshot when cache expires', async () => { + // Set cache to 1ms to force expiration + process.env.GRIDPILOT_POLICY_CACHE_MS = '1'; + + const snapshot1 = await service.getSnapshot(); + + // Wait for cache to expire + await new Promise(resolve => setTimeout(resolve, 10)); + + const snapshot2 = await service.getSnapshot(); + + expect(snapshot1.loadedAtIso).not.toBe(snapshot2.loadedAtIso); + }); + + it('should load from file when GRIDPILOT_POLICY_PATH is set', async () => { + // We can't easily mock readFile in this context, so we'll test the default path + // This test verifies the service structure works + const snapshot = await service.getSnapshot(); + + expect(snapshot).toBeDefined(); + expect(snapshot.policyVersion).toBeGreaterThanOrEqual(1); + expect(snapshot.operationalMode).toBeDefined(); + expect(snapshot.capabilities).toBeDefined(); + }); + + it('should use default values when no env vars are set', async () => { + // Clear all policy-related env vars + delete process.env.GRIDPILOT_POLICY_PATH; + delete process.env.GRIDPILOT_POLICY_CACHE_MS; + delete process.env.GRIDPILOT_OPERATIONAL_MODE; + delete process.env.GRIDPILOT_MAINTENANCE_ALLOW_VIEW; + delete process.env.GRIDPILOT_MAINTENANCE_ALLOW_MUTATE; + + const snapshot = await service.getSnapshot(); + + expect(snapshot.policyVersion).toBe(1); + expect(snapshot.operationalMode).toBe('normal'); + expect(snapshot.maintenanceAllowlist.view).toEqual([]); + expect(snapshot.maintenanceAllowlist.mutate).toEqual([]); + expect(snapshot.capabilities).toBeDefined(); + expect(snapshot.loadedFrom).toBeDefined(); + expect(snapshot.loadedAtIso).toBeDefined(); + }); + + it('should parse operational mode from env', async () => { + process.env.GRIDPILOT_OPERATIONAL_MODE = 'maintenance'; + process.env.GRIDPILOT_POLICY_CACHE_MS = '5000'; + + const snapshot = await service.getSnapshot(); + + expect(snapshot.operationalMode).toBe('maintenance'); + }); + + it('should handle invalid operational mode gracefully', async () => { + process.env.GRIDPILOT_OPERATIONAL_MODE = 'invalid-mode'; + + const snapshot = await service.getSnapshot(); + + expect(snapshot.operationalMode).toBe('normal'); + }); + + it('should parse maintenance allowlist from env', async () => { + process.env.GRIDPILOT_MAINTENANCE_ALLOW_VIEW = 'health, status, api'; + process.env.GRIDPILOT_MAINTENANCE_ALLOW_MUTATE = 'admin, config'; + + const snapshot = await service.getSnapshot(); + + expect(snapshot.maintenanceAllowlist.view).toEqual(['health', 'status', 'api']); + expect(snapshot.maintenanceAllowlist.mutate).toEqual(['admin', 'config']); + }); + + it('should handle empty maintenance allowlist', async () => { + process.env.GRIDPILOT_MAINTENANCE_ALLOW_VIEW = ''; + process.env.GRIDPILOT_MAINTENANCE_ALLOW_MUTATE = ''; + + const snapshot = await service.getSnapshot(); + + expect(snapshot.maintenanceAllowlist.view).toEqual([]); + expect(snapshot.maintenanceAllowlist.mutate).toEqual([]); + }); + + it('should handle missing maintenance allowlist', async () => { + delete process.env.GRIDPILOT_MAINTENANCE_ALLOW_VIEW; + delete process.env.GRIDPILOT_MAINTENANCE_ALLOW_MUTATE; + + const snapshot = await service.getSnapshot(); + + expect(snapshot.maintenanceAllowlist.view).toEqual([]); + expect(snapshot.maintenanceAllowlist.mutate).toEqual([]); + }); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/protests/Protests.http.test.ts b/apps/api/src/domain/protests/Protests.http.test.ts new file mode 100644 index 000000000..f31e909b3 --- /dev/null +++ b/apps/api/src/domain/protests/Protests.http.test.ts @@ -0,0 +1,94 @@ +import 'reflect-metadata'; + +import { ValidationPipe } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, it, vi } from 'vitest'; + +import { requestContextMiddleware } from '@adapters/http/RequestContext'; +import { AuthenticationGuard } from '../auth/AuthenticationGuard'; +import { AuthorizationGuard } from '../auth/AuthorizationGuard'; +import { IDENTITY_SESSION_PORT_TOKEN } from '../auth/AuthProviders'; +import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard'; + +describe('Protests domain (HTTP, module-wiring)', () => { + const originalEnv = { ...process.env }; + + let app: any; + + beforeAll(async () => { + vi.resetModules(); + + process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory'; + process.env.GRIDPILOT_API_BOOTSTRAP = 'true'; + delete process.env.DATABASE_URL; + + const { AppModule } = await import('../../app.module'); + + const module = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = module.createNestApplication(); + + // Ensure AsyncLocalStorage request context is present for getActorFromRequestContext() + app.use(requestContextMiddleware); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + + const reflector = new Reflector(); + const sessionPort = module.get(IDENTITY_SESSION_PORT_TOKEN); + + const authorizationService = { + getRolesForUser: () => [], + }; + + const policyService = { + getSnapshot: async () => ({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: {}, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + }), + }; + + app.useGlobalGuards( + new AuthenticationGuard(sessionPort as any), + new AuthorizationGuard(reflector, authorizationService as any), + new FeatureAvailabilityGuard(reflector, policyService as any), + ); + + await app.init(); + }, 20_000); + + afterAll(async () => { + await app?.close(); + + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('module compiles and app is initialized', () => { + expect(app).toBeDefined(); + expect(app.getHttpServer()).toBeDefined(); + }); + + it('rejects unauthenticated actor (401)', async () => { + await request(app.getHttpServer()) + .post('/protests/protest-123/review') + .send({ + decision: 'approved', + notes: 'Test review', + }) + .expect(401); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/race/Race.http.test.ts b/apps/api/src/domain/race/Race.http.test.ts new file mode 100644 index 000000000..610007169 --- /dev/null +++ b/apps/api/src/domain/race/Race.http.test.ts @@ -0,0 +1,99 @@ +import 'reflect-metadata'; + +import { ValidationPipe } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { requestContextMiddleware } from '@adapters/http/RequestContext'; +import { AuthenticationGuard } from '../auth/AuthenticationGuard'; +import { AuthorizationGuard } from '../auth/AuthorizationGuard'; +import { IDENTITY_SESSION_PORT_TOKEN } from '../auth/AuthProviders'; +import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard'; + +describe('Race domain (HTTP, module-wiring)', () => { + const originalEnv = { ...process.env }; + + let app: any; + + beforeAll(async () => { + vi.resetModules(); + + process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory'; + process.env.GRIDPILOT_API_BOOTSTRAP = 'true'; + delete process.env.DATABASE_URL; + + const { AppModule } = await import('../../app.module'); + + const module = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = module.createNestApplication(); + + // Ensure AsyncLocalStorage request context is present for getActorFromRequestContext() + app.use(requestContextMiddleware); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + + const reflector = new Reflector(); + const sessionPort = module.get(IDENTITY_SESSION_PORT_TOKEN); + + const authorizationService = { + getRolesForUser: () => [], + }; + + const policyService = { + getSnapshot: async () => ({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: {}, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + }), + }; + + app.useGlobalGuards( + new AuthenticationGuard(sessionPort as any), + new AuthorizationGuard(reflector, authorizationService as any), + new FeatureAvailabilityGuard(reflector, policyService as any), + ); + + await app.init(); + }, 20_000); + + afterAll(async () => { + await app?.close(); + + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('module compiles and app is initialized', () => { + expect(app).toBeDefined(); + expect(app.getHttpServer()).toBeDefined(); + }); + + it('allows public access to all races (happy path)', async () => { + await request(app.getHttpServer()) + .get('/races/all') + .expect(200); + }); + + it('rejects unauthenticated actor on register endpoint (401)', async () => { + await request(app.getHttpServer()) + .post('/races/race-123/register') + .send({ + driverId: 'driver-123', + }) + .expect(401); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/sponsor/Sponsor.http.test.ts b/apps/api/src/domain/sponsor/Sponsor.http.test.ts new file mode 100644 index 000000000..6c5e7d80b --- /dev/null +++ b/apps/api/src/domain/sponsor/Sponsor.http.test.ts @@ -0,0 +1,91 @@ +import 'reflect-metadata'; + +import { ValidationPipe } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { requestContextMiddleware } from '@adapters/http/RequestContext'; +import { AuthenticationGuard } from '../auth/AuthenticationGuard'; +import { AuthorizationGuard } from '../auth/AuthorizationGuard'; +import { IDENTITY_SESSION_PORT_TOKEN } from '../auth/AuthProviders'; +import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard'; + +describe('Sponsor domain (HTTP, module-wiring)', () => { + const originalEnv = { ...process.env }; + + let app: any; + + beforeAll(async () => { + vi.resetModules(); + + process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory'; + process.env.GRIDPILOT_API_BOOTSTRAP = 'true'; + delete process.env.DATABASE_URL; + + const { AppModule } = await import('../../app.module'); + + const module = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = module.createNestApplication(); + + // Ensure AsyncLocalStorage request context is present for getActorFromRequestContext() + app.use(requestContextMiddleware); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + + const reflector = new Reflector(); + const sessionPort = module.get(IDENTITY_SESSION_PORT_TOKEN); + + const authorizationService = { + getRolesForUser: () => [], + }; + + const policyService = { + getSnapshot: async () => ({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: {}, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + }), + }; + + app.useGlobalGuards( + new AuthenticationGuard(sessionPort as any), + new AuthorizationGuard(reflector, authorizationService as any), + new FeatureAvailabilityGuard(reflector, policyService as any), + ); + + await app.init(); + }, 20_000); + + afterAll(async () => { + await app?.close(); + + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('module compiles and app is initialized', () => { + expect(app).toBeDefined(); + expect(app.getHttpServer()).toBeDefined(); + }); + + + it('rejects unauthenticated actor on admin endpoints (401)', async () => { + await request(app.getHttpServer()) + .get('/sponsors') + .expect(401); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/team/Team.http.test.ts b/apps/api/src/domain/team/Team.http.test.ts new file mode 100644 index 000000000..a355ad336 --- /dev/null +++ b/apps/api/src/domain/team/Team.http.test.ts @@ -0,0 +1,100 @@ +import 'reflect-metadata'; + +import { ValidationPipe } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { requestContextMiddleware } from '@adapters/http/RequestContext'; +import { AuthenticationGuard } from '../auth/AuthenticationGuard'; +import { AuthorizationGuard } from '../auth/AuthorizationGuard'; +import { IDENTITY_SESSION_PORT_TOKEN } from '../auth/AuthProviders'; +import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard'; + +describe('Team domain (HTTP, module-wiring)', () => { + const originalEnv = { ...process.env }; + + let app: any; + + beforeAll(async () => { + vi.resetModules(); + + process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory'; + process.env.GRIDPILOT_API_BOOTSTRAP = 'true'; + delete process.env.DATABASE_URL; + + const { AppModule } = await import('../../app.module'); + + const module = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = module.createNestApplication(); + + // Ensure AsyncLocalStorage request context is present for getActorFromRequestContext() + app.use(requestContextMiddleware); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + + const reflector = new Reflector(); + const sessionPort = module.get(IDENTITY_SESSION_PORT_TOKEN); + + const authorizationService = { + getRolesForUser: () => [], + }; + + const policyService = { + getSnapshot: async () => ({ + policyVersion: 1, + operationalMode: 'normal', + maintenanceAllowlist: { view: [], mutate: [] }, + capabilities: {}, + loadedFrom: 'defaults', + loadedAtIso: new Date(0).toISOString(), + }), + }; + + app.useGlobalGuards( + new AuthenticationGuard(sessionPort as any), + new AuthorizationGuard(reflector, authorizationService as any), + new FeatureAvailabilityGuard(reflector, policyService as any), + ); + + await app.init(); + }, 20_000); + + afterAll(async () => { + await app?.close(); + + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('module compiles and app is initialized', () => { + expect(app).toBeDefined(); + expect(app.getHttpServer()).toBeDefined(); + }); + + it('allows public access to all teams (happy path)', async () => { + await request(app.getHttpServer()) + .get('/teams/all') + .expect(200); + }); + + it('rejects unauthenticated actor on create team (401)', async () => { + await request(app.getHttpServer()) + .post('/teams') + .send({ + name: 'Test Team', + tag: 'TST', + }) + .expect(401); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/CreateLeagueSeasonScheduleRaceUseCase.test.ts b/core/racing/application/use-cases/CreateLeagueSeasonScheduleRaceUseCase.test.ts new file mode 100644 index 000000000..9b562d714 --- /dev/null +++ b/core/racing/application/use-cases/CreateLeagueSeasonScheduleRaceUseCase.test.ts @@ -0,0 +1,222 @@ +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; + +import { Race } from '../../domain/entities/Race'; +import { Season } from '../../domain/entities/season/Season'; +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; + +import { + CreateLeagueSeasonScheduleRaceUseCase, + type CreateLeagueSeasonScheduleRaceErrorCode, +} from './CreateLeagueSeasonScheduleRaceUseCase'; + +function createLogger(): Logger { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; +} + +function createSeasonWithinWindow(overrides?: Partial<{ leagueId: string }>): Season { + return Season.create({ + id: 'season-1', + leagueId: overrides?.leagueId ?? 'league-1', + gameId: 'iracing', + name: 'Schedule Season', + status: 'planned', + startDate: new Date('2025-01-01T00:00:00Z'), + endDate: new Date('2025-01-31T00:00:00Z'), + }); +} + +describe('CreateLeagueSeasonScheduleRaceUseCase', () => { + let seasonRepository: { findById: Mock }; + let raceRepository: { create: Mock }; + let logger: Logger; + + beforeEach(() => { + seasonRepository = { findById: vi.fn() }; + raceRepository = { create: vi.fn() }; + logger = createLogger(); + }); + + it('creates a race when season belongs to league and scheduledAt is within season window', async () => { + const season = createSeasonWithinWindow(); + seasonRepository.findById.mockResolvedValue(season); + raceRepository.create.mockImplementation(async (race: Race) => race); + + const useCase = new CreateLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger, + { generateRaceId: () => 'race-123' }, + ); + + const scheduledAt = new Date('2025-01-10T20:00:00Z'); + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + track: 'Road Atlanta', + car: 'MX-5', + scheduledAt, + }); + + expect(result.isOk()).toBe(true); + expect(raceRepository.create).toHaveBeenCalledTimes(1); + const createdRace = raceRepository.create.mock.calls[0]?.[0] as Race; + expect(createdRace.id).toBe('race-123'); + expect(createdRace.leagueId).toBe('league-1'); + expect(createdRace.track).toBe('Road Atlanta'); + expect(createdRace.car).toBe('MX-5'); + expect(createdRace.scheduledAt.getTime()).toBe(scheduledAt.getTime()); + }); + + it('returns SEASON_NOT_FOUND when season does not belong to league and does not create', async () => { + const season = createSeasonWithinWindow({ leagueId: 'other-league' }); + seasonRepository.findById.mockResolvedValue(season); + + const useCase = new CreateLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger, + { generateRaceId: () => 'race-123' }, + ); + + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + track: 'Road Atlanta', + car: 'MX-5', + scheduledAt: new Date('2025-01-10T20:00:00Z'), + }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + CreateLeagueSeasonScheduleRaceErrorCode, + { message: string } + >; + expect(error.code).toBe('SEASON_NOT_FOUND'); + expect(raceRepository.create).not.toHaveBeenCalled(); + }); + + + it('returns INVALID_INPUT when Race.create throws due to invalid data', async () => { + const season = createSeasonWithinWindow(); + seasonRepository.findById.mockResolvedValue(season); + + const useCase = new CreateLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger, + { generateRaceId: () => 'race-123' }, + ); + + // Mock Race.create to throw (this would happen with invalid data) + const originalCreate = Race.create; + Race.create = vi.fn().mockImplementation(() => { + throw new Error('Invalid race data'); + }); + + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + track: '', // Invalid empty track + car: 'MX-5', + scheduledAt: new Date('2025-01-10T20:00:00Z'), + }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + CreateLeagueSeasonScheduleRaceErrorCode, + { message: string } + >; + expect(error.code).toBe('INVALID_INPUT'); + expect(error.details.message).toBe('Invalid race data'); + expect(raceRepository.create).not.toHaveBeenCalled(); + + // Restore original + Race.create = originalCreate; + }); + + it('returns REPOSITORY_ERROR when repository throws during create', async () => { + const season = createSeasonWithinWindow(); + const repositoryError = new Error('DB write failed'); + seasonRepository.findById.mockResolvedValue(season); + raceRepository.create.mockRejectedValue(repositoryError); + + const useCase = new CreateLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger, + { generateRaceId: () => 'race-123' }, + ); + + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + track: 'Road Atlanta', + car: 'MX-5', + scheduledAt: new Date('2025-01-10T20:00:00Z'), + }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + CreateLeagueSeasonScheduleRaceErrorCode, + { message: string } + >; + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('DB write failed'); + }); + + it('returns SEASON_NOT_FOUND when season does not exist', async () => { + seasonRepository.findById.mockResolvedValue(null); + + const useCase = new CreateLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger, + { generateRaceId: () => 'race-123' }, + ); + + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + track: 'Road Atlanta', + car: 'MX-5', + scheduledAt: new Date('2025-01-10T20:00:00Z'), + }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + CreateLeagueSeasonScheduleRaceErrorCode, + { message: string } + >; + expect(error.code).toBe('SEASON_NOT_FOUND'); + expect(raceRepository.create).not.toHaveBeenCalled(); + }); + + it('returns REPOSITORY_ERROR when repository throws during find', async () => { + const repositoryError = new Error('DB connection failed'); + seasonRepository.findById.mockRejectedValue(repositoryError); + + const useCase = new CreateLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger, + { generateRaceId: () => 'race-123' }, + ); + + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + track: 'Road Atlanta', + car: 'MX-5', + scheduledAt: new Date('2025-01-10T20:00:00Z'), + }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + CreateLeagueSeasonScheduleRaceErrorCode, + { message: string } + >; + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('DB connection failed'); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/DeleteLeagueSeasonScheduleRaceUseCase.test.ts b/core/racing/application/use-cases/DeleteLeagueSeasonScheduleRaceUseCase.test.ts new file mode 100644 index 000000000..f69bd2b7d --- /dev/null +++ b/core/racing/application/use-cases/DeleteLeagueSeasonScheduleRaceUseCase.test.ts @@ -0,0 +1,259 @@ +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; + +import { Race } from '../../domain/entities/Race'; +import { Season } from '../../domain/entities/season/Season'; +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; + +import { + DeleteLeagueSeasonScheduleRaceUseCase, + type DeleteLeagueSeasonScheduleRaceErrorCode, +} from './DeleteLeagueSeasonScheduleRaceUseCase'; + +function createLogger(): Logger { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; +} + +function createSeasonWithinWindow(overrides?: Partial<{ leagueId: string }>): Season { + return Season.create({ + id: 'season-1', + leagueId: overrides?.leagueId ?? 'league-1', + gameId: 'iracing', + name: 'Schedule Season', + status: 'planned', + startDate: new Date('2025-01-01T00:00:00Z'), + endDate: new Date('2025-01-31T00:00:00Z'), + }); +} + +describe('DeleteLeagueSeasonScheduleRaceUseCase', () => { + let seasonRepository: { findById: Mock }; + let raceRepository: { findById: Mock; delete: Mock }; + let logger: Logger; + + beforeEach(() => { + seasonRepository = { findById: vi.fn() }; + raceRepository = { findById: vi.fn(), delete: vi.fn() }; + logger = createLogger(); + }); + + it('deletes race when season belongs to league and race belongs to league', async () => { + const season = createSeasonWithinWindow(); + seasonRepository.findById.mockResolvedValue(season); + + const existing = Race.create({ + id: 'race-1', + leagueId: 'league-1', + track: 'Track', + car: 'Car', + scheduledAt: new Date('2025-01-05T20:00:00Z'), + }); + raceRepository.findById.mockResolvedValue(existing); + raceRepository.delete.mockResolvedValue(undefined); + + const useCase = new DeleteLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger); + + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + raceId: 'race-1', + }); + + expect(result.isOk()).toBe(true); + expect(raceRepository.delete).toHaveBeenCalledTimes(1); + expect(raceRepository.delete).toHaveBeenCalledWith('race-1'); + }); + + it('returns SEASON_NOT_FOUND when season does not belong to league and does not read/delete race', async () => { + const season = createSeasonWithinWindow({ leagueId: 'other-league' }); + seasonRepository.findById.mockResolvedValue(season); + + const useCase = new DeleteLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger); + + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + raceId: 'race-1', + }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + DeleteLeagueSeasonScheduleRaceErrorCode, + { message: string } + >; + expect(error.code).toBe('SEASON_NOT_FOUND'); + expect(raceRepository.findById).not.toHaveBeenCalled(); + expect(raceRepository.delete).not.toHaveBeenCalled(); + }); + + it('returns RACE_NOT_FOUND when race does not exist for league and does not delete', async () => { + const season = createSeasonWithinWindow(); + seasonRepository.findById.mockResolvedValue(season); + raceRepository.findById.mockResolvedValue(null); + + const useCase = new DeleteLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger); + + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + raceId: 'race-404', + }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + DeleteLeagueSeasonScheduleRaceErrorCode, + { message: string } + >; + expect(error.code).toBe('RACE_NOT_FOUND'); + expect(raceRepository.delete).not.toHaveBeenCalled(); + }); + + it('returns RACE_NOT_FOUND when race belongs to different league', async () => { + const season = createSeasonWithinWindow(); + seasonRepository.findById.mockResolvedValue(season); + + const existing = Race.create({ + id: 'race-1', + leagueId: 'other-league', + track: 'Track', + car: 'Car', + scheduledAt: new Date('2025-01-05T20:00:00Z'), + }); + raceRepository.findById.mockResolvedValue(existing); + + const useCase = new DeleteLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger); + + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + raceId: 'race-1', + }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + DeleteLeagueSeasonScheduleRaceErrorCode, + { message: string } + >; + expect(error.code).toBe('RACE_NOT_FOUND'); + expect(raceRepository.delete).not.toHaveBeenCalled(); + }); + + it('returns SEASON_NOT_FOUND when season does not exist', async () => { + seasonRepository.findById.mockResolvedValue(null); + + const useCase = new DeleteLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger); + + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + raceId: 'race-1', + }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + DeleteLeagueSeasonScheduleRaceErrorCode, + { message: string } + >; + expect(error.code).toBe('SEASON_NOT_FOUND'); + expect(raceRepository.findById).not.toHaveBeenCalled(); + expect(raceRepository.delete).not.toHaveBeenCalled(); + }); + + it('returns REPOSITORY_ERROR when repository throws during find', async () => { + const repositoryError = new Error('DB connection failed'); + seasonRepository.findById.mockRejectedValue(repositoryError); + + const useCase = new DeleteLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger); + + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + raceId: 'race-1', + }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + DeleteLeagueSeasonScheduleRaceErrorCode, + { message: string } + >; + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('DB connection failed'); + }); + + it('returns REPOSITORY_ERROR when repository throws during delete', async () => { + const season = createSeasonWithinWindow(); + const existing = Race.create({ + id: 'race-1', + leagueId: 'league-1', + track: 'Track', + car: 'Car', + scheduledAt: new Date('2025-01-05T20:00:00Z'), + }); + const repositoryError = new Error('DB delete failed'); + seasonRepository.findById.mockResolvedValue(season); + raceRepository.findById.mockResolvedValue(existing); + raceRepository.delete.mockRejectedValue(repositoryError); + + const useCase = new DeleteLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger); + + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + raceId: 'race-1', + }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + DeleteLeagueSeasonScheduleRaceErrorCode, + { message: string } + >; + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('DB delete failed'); + }); + + it('returns REPOSITORY_ERROR when repository throws during race find', async () => { + const season = createSeasonWithinWindow(); + const repositoryError = new Error('DB connection failed'); + seasonRepository.findById.mockResolvedValue(season); + raceRepository.findById.mockRejectedValue(repositoryError); + + const useCase = new DeleteLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger); + + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + raceId: 'race-1', + }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + DeleteLeagueSeasonScheduleRaceErrorCode, + { message: string } + >; + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('DB connection failed'); + expect(raceRepository.delete).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/PublishLeagueSeasonScheduleUseCase.test.ts b/core/racing/application/use-cases/PublishLeagueSeasonScheduleUseCase.test.ts new file mode 100644 index 000000000..cf0f3534b --- /dev/null +++ b/core/racing/application/use-cases/PublishLeagueSeasonScheduleUseCase.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; + +import { Season } from '../../domain/entities/season/Season'; +import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; + +import { + PublishLeagueSeasonScheduleUseCase, + type PublishLeagueSeasonScheduleErrorCode, +} from './PublishLeagueSeasonScheduleUseCase'; + +function createLogger(): Logger { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; +} + +function createSeasonWithinWindow(overrides?: Partial<{ leagueId: string }>): Season { + return Season.create({ + id: 'season-1', + leagueId: overrides?.leagueId ?? 'league-1', + gameId: 'iracing', + name: 'Schedule Season', + status: 'planned', + startDate: new Date('2025-01-01T00:00:00Z'), + endDate: new Date('2025-01-31T00:00:00Z'), + }); +} + +describe('PublishLeagueSeasonScheduleUseCase', () => { + let seasonRepository: { findById: Mock; update: Mock }; + let logger: Logger; + + beforeEach(() => { + seasonRepository = { findById: vi.fn(), update: vi.fn() }; + logger = createLogger(); + }); + + it('publishes schedule deterministically (schedulePublished=true) and persists', async () => { + const season = createSeasonWithinWindow(); + seasonRepository.findById.mockResolvedValue(season); + seasonRepository.update.mockResolvedValue(undefined); + + const useCase = new PublishLeagueSeasonScheduleUseCase(seasonRepository as unknown as ISeasonRepository, + logger); + + const result = await useCase.execute({ leagueId: 'league-1', seasonId: 'season-1' }); + + expect(result.isOk()).toBe(true); + expect(seasonRepository.update).toHaveBeenCalledTimes(1); + const updatedSeason = seasonRepository.update.mock.calls[0]?.[0] as Season; + expect(updatedSeason.id).toBe('season-1'); + expect(updatedSeason.leagueId).toBe('league-1'); + expect(updatedSeason.schedulePublished).toBe(true); + }); + + it('returns SEASON_NOT_FOUND when season does not belong to league and does not update', async () => { + const season = createSeasonWithinWindow({ leagueId: 'other-league' }); + seasonRepository.findById.mockResolvedValue(season); + + const useCase = new PublishLeagueSeasonScheduleUseCase(seasonRepository as unknown as ISeasonRepository, + logger); + + const result = await useCase.execute({ leagueId: 'league-1', seasonId: 'season-1' }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + PublishLeagueSeasonScheduleErrorCode, + { message: string } + >; + expect(error.code).toBe('SEASON_NOT_FOUND'); + expect(seasonRepository.update).not.toHaveBeenCalled(); + }); + + it('returns SEASON_NOT_FOUND when season does not exist', async () => { + seasonRepository.findById.mockResolvedValue(null); + + const useCase = new PublishLeagueSeasonScheduleUseCase(seasonRepository as unknown as ISeasonRepository, + logger); + + const result = await useCase.execute({ leagueId: 'league-1', seasonId: 'season-1' }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + PublishLeagueSeasonScheduleErrorCode, + { message: string } + >; + expect(error.code).toBe('SEASON_NOT_FOUND'); + expect(seasonRepository.update).not.toHaveBeenCalled(); + }); + + it('returns REPOSITORY_ERROR when repository throws during find', async () => { + const repositoryError = new Error('DB connection failed'); + seasonRepository.findById.mockRejectedValue(repositoryError); + + const useCase = new PublishLeagueSeasonScheduleUseCase(seasonRepository as unknown as ISeasonRepository, + logger); + + const result = await useCase.execute({ leagueId: 'league-1', seasonId: 'season-1' }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + PublishLeagueSeasonScheduleErrorCode, + { message: string } + >; + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('DB connection failed'); + }); + + it('returns REPOSITORY_ERROR when repository throws during update', async () => { + const season = createSeasonWithinWindow(); + const repositoryError = new Error('DB write failed'); + seasonRepository.findById.mockResolvedValue(season); + seasonRepository.update.mockRejectedValue(repositoryError); + + const useCase = new PublishLeagueSeasonScheduleUseCase(seasonRepository as unknown as ISeasonRepository, + logger); + + const result = await useCase.execute({ leagueId: 'league-1', seasonId: 'season-1' }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + PublishLeagueSeasonScheduleErrorCode, + { message: string } + >; + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('DB write failed'); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/UnpublishLeagueSeasonScheduleUseCase.test.ts b/core/racing/application/use-cases/UnpublishLeagueSeasonScheduleUseCase.test.ts new file mode 100644 index 000000000..0a8a574d4 --- /dev/null +++ b/core/racing/application/use-cases/UnpublishLeagueSeasonScheduleUseCase.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; + +import { Season } from '../../domain/entities/season/Season'; +import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; + +import { + UnpublishLeagueSeasonScheduleUseCase, + type UnpublishLeagueSeasonScheduleErrorCode, +} from './UnpublishLeagueSeasonScheduleUseCase'; + +function createLogger(): Logger { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; +} + +function createSeasonWithinWindow(overrides?: Partial<{ leagueId: string }>): Season { + return Season.create({ + id: 'season-1', + leagueId: overrides?.leagueId ?? 'league-1', + gameId: 'iracing', + name: 'Schedule Season', + status: 'planned', + startDate: new Date('2025-01-01T00:00:00Z'), + endDate: new Date('2025-01-31T00:00:00Z'), + }); +} + +describe('UnpublishLeagueSeasonScheduleUseCase', () => { + let seasonRepository: { findById: Mock; update: Mock }; + let logger: Logger; + + beforeEach(() => { + seasonRepository = { findById: vi.fn(), update: vi.fn() }; + logger = createLogger(); + }); + + it('unpublishes schedule deterministically (schedulePublished=false) and persists', async () => { + const season = createSeasonWithinWindow().withSchedulePublished(true); + seasonRepository.findById.mockResolvedValue(season); + seasonRepository.update.mockResolvedValue(undefined); + + const useCase = new UnpublishLeagueSeasonScheduleUseCase(seasonRepository as unknown as ISeasonRepository, + logger); + + const result = await useCase.execute({ leagueId: 'league-1', seasonId: 'season-1' }); + + expect(result.isOk()).toBe(true); + expect(seasonRepository.update).toHaveBeenCalledTimes(1); + const updatedSeason = seasonRepository.update.mock.calls[0]?.[0] as Season; + expect(updatedSeason.id).toBe('season-1'); + expect(updatedSeason.leagueId).toBe('league-1'); + expect(updatedSeason.schedulePublished).toBe(false); + }); + + it('returns SEASON_NOT_FOUND when season does not belong to league and does not update', async () => { + const season = createSeasonWithinWindow({ leagueId: 'other-league' }).withSchedulePublished(true); + seasonRepository.findById.mockResolvedValue(season); + + const useCase = new UnpublishLeagueSeasonScheduleUseCase(seasonRepository as unknown as ISeasonRepository, + logger); + + const result = await useCase.execute({ leagueId: 'league-1', seasonId: 'season-1' }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + UnpublishLeagueSeasonScheduleErrorCode, + { message: string } + >; + expect(error.code).toBe('SEASON_NOT_FOUND'); + expect(seasonRepository.update).not.toHaveBeenCalled(); + }); + + it('returns SEASON_NOT_FOUND when season does not exist', async () => { + seasonRepository.findById.mockResolvedValue(null); + + const useCase = new UnpublishLeagueSeasonScheduleUseCase(seasonRepository as unknown as ISeasonRepository, + logger); + + const result = await useCase.execute({ leagueId: 'league-1', seasonId: 'season-1' }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + UnpublishLeagueSeasonScheduleErrorCode, + { message: string } + >; + expect(error.code).toBe('SEASON_NOT_FOUND'); + expect(seasonRepository.update).not.toHaveBeenCalled(); + }); + + it('returns REPOSITORY_ERROR when repository throws during find', async () => { + const repositoryError = new Error('DB connection failed'); + seasonRepository.findById.mockRejectedValue(repositoryError); + + const useCase = new UnpublishLeagueSeasonScheduleUseCase(seasonRepository as unknown as ISeasonRepository, + logger); + + const result = await useCase.execute({ leagueId: 'league-1', seasonId: 'season-1' }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + UnpublishLeagueSeasonScheduleErrorCode, + { message: string } + >; + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('DB connection failed'); + }); + + it('returns REPOSITORY_ERROR when repository throws during update', async () => { + const season = createSeasonWithinWindow().withSchedulePublished(true); + const repositoryError = new Error('DB write failed'); + seasonRepository.findById.mockResolvedValue(season); + seasonRepository.update.mockRejectedValue(repositoryError); + + const useCase = new UnpublishLeagueSeasonScheduleUseCase(seasonRepository as unknown as ISeasonRepository, + logger); + + const result = await useCase.execute({ leagueId: 'league-1', seasonId: 'season-1' }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + UnpublishLeagueSeasonScheduleErrorCode, + { message: string } + >; + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('DB write failed'); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/UpdateLeagueSeasonScheduleRaceUseCase.test.ts b/core/racing/application/use-cases/UpdateLeagueSeasonScheduleRaceUseCase.test.ts new file mode 100644 index 000000000..bc905d996 --- /dev/null +++ b/core/racing/application/use-cases/UpdateLeagueSeasonScheduleRaceUseCase.test.ts @@ -0,0 +1,424 @@ +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; + +import { Race } from '../../domain/entities/Race'; +import { Season } from '../../domain/entities/season/Season'; +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; + +import { + UpdateLeagueSeasonScheduleRaceUseCase, + type UpdateLeagueSeasonScheduleRaceErrorCode, +} from './UpdateLeagueSeasonScheduleRaceUseCase'; + +function createLogger(): Logger { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; +} + +function createSeasonWithinWindow(overrides?: Partial<{ leagueId: string }>): Season { + return Season.create({ + id: 'season-1', + leagueId: overrides?.leagueId ?? 'league-1', + gameId: 'iracing', + name: 'Schedule Season', + status: 'planned', + startDate: new Date('2025-01-01T00:00:00Z'), + endDate: new Date('2025-01-31T00:00:00Z'), + }); +} + +describe('UpdateLeagueSeasonScheduleRaceUseCase', () => { + let seasonRepository: { findById: Mock }; + let raceRepository: { findById: Mock; update: Mock }; + let logger: Logger; + + beforeEach(() => { + seasonRepository = { findById: vi.fn() }; + raceRepository = { findById: vi.fn(), update: vi.fn() }; + logger = createLogger(); + }); + + it('updates race when season belongs to league and updated scheduledAt stays within window', async () => { + const season = createSeasonWithinWindow(); + seasonRepository.findById.mockResolvedValue(season); + + const existing = Race.create({ + id: 'race-1', + leagueId: 'league-1', + track: 'Old Track', + car: 'Old Car', + scheduledAt: new Date('2025-01-05T20:00:00Z'), + }); + raceRepository.findById.mockResolvedValue(existing); + raceRepository.update.mockImplementation(async (race: Race) => race); + + const useCase = new UpdateLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger); + + const newScheduledAt = new Date('2025-01-20T20:00:00Z'); + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + raceId: 'race-1', + track: 'New Track', + car: 'New Car', + scheduledAt: newScheduledAt, + }); + + expect(result.isOk()).toBe(true); + expect(raceRepository.update).toHaveBeenCalledTimes(1); + const updated = raceRepository.update.mock.calls[0]?.[0] as Race; + expect(updated.id).toBe('race-1'); + expect(updated.leagueId).toBe('league-1'); + expect(updated.track).toBe('New Track'); + expect(updated.car).toBe('New Car'); + expect(updated.scheduledAt.getTime()).toBe(newScheduledAt.getTime()); + }); + + it('updates race with partial fields (only track)', async () => { + const season = createSeasonWithinWindow(); + seasonRepository.findById.mockResolvedValue(season); + + const existing = Race.create({ + id: 'race-1', + leagueId: 'league-1', + track: 'Old Track', + car: 'Old Car', + scheduledAt: new Date('2025-01-05T20:00:00Z'), + }); + raceRepository.findById.mockResolvedValue(existing); + raceRepository.update.mockImplementation(async (race: Race) => race); + + const useCase = new UpdateLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger); + + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + raceId: 'race-1', + track: 'New Track', + }); + + expect(result.isOk()).toBe(true); + expect(raceRepository.update).toHaveBeenCalledTimes(1); + const updated = raceRepository.update.mock.calls[0]?.[0] as Race; + expect(updated.track).toBe('New Track'); + expect(updated.car).toBe('Old Car'); // Unchanged + expect(updated.scheduledAt.getTime()).toBe(existing.scheduledAt.getTime()); // Unchanged + }); + + it('updates race with partial fields (only car)', async () => { + const season = createSeasonWithinWindow(); + seasonRepository.findById.mockResolvedValue(season); + + const existing = Race.create({ + id: 'race-1', + leagueId: 'league-1', + track: 'Old Track', + car: 'Old Car', + scheduledAt: new Date('2025-01-05T20:00:00Z'), + }); + raceRepository.findById.mockResolvedValue(existing); + raceRepository.update.mockImplementation(async (race: Race) => race); + + const useCase = new UpdateLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger); + + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + raceId: 'race-1', + car: 'New Car', + }); + + expect(result.isOk()).toBe(true); + expect(raceRepository.update).toHaveBeenCalledTimes(1); + const updated = raceRepository.update.mock.calls[0]?.[0] as Race; + expect(updated.track).toBe('Old Track'); // Unchanged + expect(updated.car).toBe('New Car'); + expect(updated.scheduledAt.getTime()).toBe(existing.scheduledAt.getTime()); // Unchanged + }); + + it('updates race with partial fields (only scheduledAt)', async () => { + const season = createSeasonWithinWindow(); + seasonRepository.findById.mockResolvedValue(season); + + const existing = Race.create({ + id: 'race-1', + leagueId: 'league-1', + track: 'Old Track', + car: 'Old Car', + scheduledAt: new Date('2025-01-05T20:00:00Z'), + }); + raceRepository.findById.mockResolvedValue(existing); + raceRepository.update.mockImplementation(async (race: Race) => race); + + const useCase = new UpdateLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger); + + const newScheduledAt = new Date('2025-01-15T20:00:00Z'); + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + raceId: 'race-1', + scheduledAt: newScheduledAt, + }); + + expect(result.isOk()).toBe(true); + expect(raceRepository.update).toHaveBeenCalledTimes(1); + const updated = raceRepository.update.mock.calls[0]?.[0] as Race; + expect(updated.track).toBe('Old Track'); // Unchanged + expect(updated.car).toBe('Old Car'); // Unchanged + expect(updated.scheduledAt.getTime()).toBe(newScheduledAt.getTime()); + }); + + it('returns SEASON_NOT_FOUND when season does not belong to league and does not read/update race', async () => { + const season = createSeasonWithinWindow({ leagueId: 'other-league' }); + seasonRepository.findById.mockResolvedValue(season); + + const useCase = new UpdateLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger); + + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + raceId: 'race-1', + track: 'New Track', + }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + UpdateLeagueSeasonScheduleRaceErrorCode, + { message: string } + >; + expect(error.code).toBe('SEASON_NOT_FOUND'); + expect(raceRepository.findById).not.toHaveBeenCalled(); + expect(raceRepository.update).not.toHaveBeenCalled(); + }); + + it('returns RACE_OUTSIDE_SEASON_WINDOW when updated scheduledAt is outside window and does not update', async () => { + const season = createSeasonWithinWindow(); + seasonRepository.findById.mockResolvedValue(season); + + const existing = Race.create({ + id: 'race-1', + leagueId: 'league-1', + track: 'Old Track', + car: 'Old Car', + scheduledAt: new Date('2025-01-05T20:00:00Z'), + }); + raceRepository.findById.mockResolvedValue(existing); + + const useCase = new UpdateLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger); + + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + raceId: 'race-1', + scheduledAt: new Date('2025-02-01T00:00:01Z'), + }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + UpdateLeagueSeasonScheduleRaceErrorCode, + { message: string } + >; + expect(error.code).toBe('RACE_OUTSIDE_SEASON_WINDOW'); + expect(raceRepository.update).not.toHaveBeenCalled(); + }); + + it('returns RACE_NOT_FOUND when race does not exist for league and does not update', async () => { + const season = createSeasonWithinWindow(); + seasonRepository.findById.mockResolvedValue(season); + raceRepository.findById.mockResolvedValue(null); + + const useCase = new UpdateLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger); + + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + raceId: 'race-404', + track: 'New Track', + }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + UpdateLeagueSeasonScheduleRaceErrorCode, + { message: string } + >; + expect(error.code).toBe('RACE_NOT_FOUND'); + expect(raceRepository.update).not.toHaveBeenCalled(); + }); + + it('returns RACE_NOT_FOUND when race belongs to different league', async () => { + const season = createSeasonWithinWindow(); + seasonRepository.findById.mockResolvedValue(season); + + const existing = Race.create({ + id: 'race-1', + leagueId: 'other-league', + track: 'Track', + car: 'Car', + scheduledAt: new Date('2025-01-05T20:00:00Z'), + }); + raceRepository.findById.mockResolvedValue(existing); + + const useCase = new UpdateLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger); + + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + raceId: 'race-1', + track: 'New Track', + }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + UpdateLeagueSeasonScheduleRaceErrorCode, + { message: string } + >; + expect(error.code).toBe('RACE_NOT_FOUND'); + expect(raceRepository.update).not.toHaveBeenCalled(); + }); + + it('returns INVALID_INPUT when Race.create throws due to invalid data', async () => { + const season = createSeasonWithinWindow(); + seasonRepository.findById.mockResolvedValue(season); + + const existing = Race.create({ + id: 'race-1', + leagueId: 'league-1', + track: 'Old Track', + car: 'Old Car', + scheduledAt: new Date('2025-01-05T20:00:00Z'), + }); + raceRepository.findById.mockResolvedValue(existing); + + const useCase = new UpdateLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger); + + // Mock Race.create to throw + const originalCreate = Race.create; + Race.create = vi.fn().mockImplementation(() => { + throw new Error('Invalid race data'); + }); + + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + raceId: 'race-1', + track: '', // Invalid + }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + UpdateLeagueSeasonScheduleRaceErrorCode, + { message: string } + >; + expect(error.code).toBe('INVALID_INPUT'); + expect(raceRepository.update).not.toHaveBeenCalled(); + + // Restore original + Race.create = originalCreate; + }); + + it('returns REPOSITORY_ERROR when repository throws during find', async () => { + const season = createSeasonWithinWindow(); + const repositoryError = new Error('DB connection failed'); + seasonRepository.findById.mockResolvedValue(season); + raceRepository.findById.mockRejectedValue(repositoryError); + + const useCase = new UpdateLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger); + + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + raceId: 'race-1', + track: 'New Track', + }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + UpdateLeagueSeasonScheduleRaceErrorCode, + { message: string } + >; + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('DB connection failed'); + }); + + it('returns REPOSITORY_ERROR when repository throws during update', async () => { + const season = createSeasonWithinWindow(); + const existing = Race.create({ + id: 'race-1', + leagueId: 'league-1', + track: 'Old Track', + car: 'Old Car', + scheduledAt: new Date('2025-01-05T20:00:00Z'), + }); + const repositoryError = new Error('DB write failed'); + seasonRepository.findById.mockResolvedValue(season); + raceRepository.findById.mockResolvedValue(existing); + raceRepository.update.mockRejectedValue(repositoryError); + + const useCase = new UpdateLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger); + + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + raceId: 'race-1', + track: 'New Track', + }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + UpdateLeagueSeasonScheduleRaceErrorCode, + { message: string } + >; + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('DB write failed'); + }); + + it('returns SEASON_NOT_FOUND when season does not exist', async () => { + seasonRepository.findById.mockResolvedValue(null); + + const useCase = new UpdateLeagueSeasonScheduleRaceUseCase(seasonRepository as unknown as ISeasonRepository, + raceRepository as unknown as IRaceRepository, + logger); + + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + raceId: 'race-1', + track: 'New Track', + }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + UpdateLeagueSeasonScheduleRaceErrorCode, + { message: string } + >; + expect(error.code).toBe('SEASON_NOT_FOUND'); + expect(raceRepository.findById).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file