tests
This commit is contained in:
106
apps/api/src/domain/admin/Admin.http.test.ts
Normal file
106
apps/api/src/domain/admin/Admin.http.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
220
apps/api/src/domain/admin/AdminController.test.ts
Normal file
220
apps/api/src/domain/admin/AdminController.test.ts
Normal file
@@ -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<typeof vi.fn>;
|
||||
getDashboardStats: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
95
apps/api/src/domain/analytics/Analytics.http.test.ts
Normal file
95
apps/api/src/domain/analytics/Analytics.http.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
90
apps/api/src/domain/dashboard/Dashboard.http.test.ts
Normal file
90
apps/api/src/domain/dashboard/Dashboard.http.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
91
apps/api/src/domain/driver/Driver.http.test.ts
Normal file
91
apps/api/src/domain/driver/Driver.http.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
93
apps/api/src/domain/hello/Hello.http.test.ts
Normal file
93
apps/api/src/domain/hello/Hello.http.test.ts
Normal file
@@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
52
apps/api/src/domain/hello/HelloController.test.ts
Normal file
52
apps/api/src/domain/hello/HelloController.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HelloController } from './HelloController';
|
||||
|
||||
describe('HelloController', () => {
|
||||
let controller: HelloController;
|
||||
let mockService: { getHello: ReturnType<typeof vi.fn> };
|
||||
|
||||
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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
101
apps/api/src/domain/payments/Payments.http.test.ts
Normal file
101
apps/api/src/domain/payments/Payments.http.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
84
apps/api/src/domain/policy/Policy.http.test.ts
Normal file
84
apps/api/src/domain/policy/Policy.http.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
134
apps/api/src/domain/policy/PolicyController.test.ts
Normal file
134
apps/api/src/domain/policy/PolicyController.test.ts
Normal file
@@ -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<typeof vi.fn> };
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
123
apps/api/src/domain/policy/PolicyService.test.ts
Normal file
123
apps/api/src/domain/policy/PolicyService.test.ts
Normal file
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
94
apps/api/src/domain/protests/Protests.http.test.ts
Normal file
94
apps/api/src/domain/protests/Protests.http.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
99
apps/api/src/domain/race/Race.http.test.ts
Normal file
99
apps/api/src/domain/race/Race.http.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
91
apps/api/src/domain/sponsor/Sponsor.http.test.ts
Normal file
91
apps/api/src/domain/sponsor/Sponsor.http.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
100
apps/api/src/domain/team/Team.http.test.ts
Normal file
100
apps/api/src/domain/team/Team.http.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user