test apps api

This commit is contained in:
2025-12-23 23:14:51 +01:00
parent 16cd572c63
commit efcdbd17f2
71 changed files with 3924 additions and 913 deletions

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Post, Patch, Body, Req, Param } from '@nestjs/common';
import { Controller, Get, Post, Patch, Body, Req, Param, Inject } from '@nestjs/common';
import { Request } from 'express';
import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger';
import { TeamService } from './TeamService';
@@ -16,7 +16,7 @@ import { GetTeamMembershipOutputDTO } from './dtos/GetTeamMembershipOutputDTO';
@ApiTags('teams')
@Controller('teams')
export class TeamController {
constructor(private readonly teamService: TeamService) {}
constructor(@Inject(TeamService) private readonly teamService: TeamService) {}
@Get('all')
@ApiOperation({ summary: 'Get all teams' })

View File

@@ -5,7 +5,7 @@ import { TeamProviders } from './TeamProviders';
@Module({
controllers: [TeamController],
providers: TeamProviders,
providers: [TeamService, ...TeamProviders],
exports: [TeamService],
})
export class TeamModule {}

View File

@@ -1,5 +1,20 @@
import { Provider } from '@nestjs/common';
import { TeamService } from './TeamService';
import {
TEAM_REPOSITORY_TOKEN,
TEAM_MEMBERSHIP_REPOSITORY_TOKEN,
DRIVER_REPOSITORY_TOKEN,
IMAGE_SERVICE_TOKEN,
LOGGER_TOKEN,
} from './TeamTokens';
export {
TEAM_REPOSITORY_TOKEN,
TEAM_MEMBERSHIP_REPOSITORY_TOKEN,
DRIVER_REPOSITORY_TOKEN,
IMAGE_SERVICE_TOKEN,
LOGGER_TOKEN,
} from './TeamTokens';
// Import core interfaces
import type { Logger } from '@core/shared/application/Logger';
@@ -13,15 +28,7 @@ import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
// Use cases are imported and used directly in the service
// Define injection tokens
export const TEAM_REPOSITORY_TOKEN = 'ITeamRepository';
export const TEAM_MEMBERSHIP_REPOSITORY_TOKEN = 'ITeamMembershipRepository';
export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
export const IMAGE_SERVICE_TOKEN = 'IImageServicePort';
export const LOGGER_TOKEN = 'Logger';
export const TeamProviders: Provider[] = [
TeamService, // Provide the service itself
{
provide: TEAM_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryTeamRepository(logger),

View File

@@ -1,107 +1,531 @@
import { Test, TestingModule } from '@nestjs/testing';
import { vi } from 'vitest';
import { TeamService } from './TeamService';
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import type { Logger } from '@core/shared/application/Logger';
import { Result } from '@core/shared/application/Result';
import { GetAllTeamsUseCase } from '@core/racing/application/use-cases/GetAllTeamsUseCase';
import { GetTeamDetailsUseCase } from '@core/racing/application/use-cases/GetTeamDetailsUseCase';
import { GetTeamMembersUseCase } from '@core/racing/application/use-cases/GetTeamMembersUseCase';
import { GetTeamJoinRequestsUseCase } from '@core/racing/application/use-cases/GetTeamJoinRequestsUseCase';
import { CreateTeamUseCase } from '@core/racing/application/use-cases/CreateTeamUseCase';
import { UpdateTeamUseCase } from '@core/racing/application/use-cases/UpdateTeamUseCase';
import { GetDriverTeamUseCase } from '@core/racing/application/use-cases/GetDriverTeamUseCase';
import { GetTeamMembershipUseCase } from '@core/racing/application/use-cases/GetTeamMembershipUseCase';
import type { CreateTeamInputDTO } from './dtos/CreateTeamInputDTO';
import type { UpdateTeamInputDTO } from './dtos/UpdateTeamInputDTO';
import { TeamService } from './TeamService';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { AllTeamsPresenter } from './presenters/AllTeamsPresenter';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { DriverTeamPresenter } from './presenters/DriverTeamPresenter';
import { DriverTeamViewModel } from './dtos/TeamDto';
type ValueObjectStub = { props: string; toString(): string };
type TeamEntityStub = {
id: string;
name: ValueObjectStub;
tag: ValueObjectStub;
description: ValueObjectStub;
ownerId: ValueObjectStub;
leagues: ValueObjectStub[];
createdAt: { toDate(): Date };
update: Mock;
};
describe('TeamService', () => {
let service: TeamService;
let getAllTeamsUseCase: ReturnType<typeof vi.mocked<GetAllTeamsUseCase>>;
let getDriverTeamUseCase: ReturnType<typeof vi.mocked<GetDriverTeamUseCase>>;
const makeValueObject = (value: string): ValueObjectStub => ({
props: value,
toString() {
return value;
},
});
beforeEach(async () => {
const mockGetAllTeamsUseCase = {
execute: vi.fn(),
const makeTeam = (overrides?: Partial<Omit<TeamEntityStub, 'update'>>): TeamEntityStub => {
const base: Omit<TeamEntityStub, 'update'> = {
id: 'team-1',
name: makeValueObject('Team One'),
tag: makeValueObject('T1'),
description: makeValueObject('Desc'),
ownerId: makeValueObject('owner-1'),
leagues: [makeValueObject('league-1')],
createdAt: { toDate: () => new Date('2023-01-01T00:00:00.000Z') },
};
const mockGetDriverTeamUseCase = {
execute: vi.fn(),
const team: TeamEntityStub = {
...base,
...overrides,
update: vi.fn((updates: Partial<{ name: string; tag: string; description: string }>) => {
const next: Partial<Omit<TeamEntityStub, 'update'>> = {
...(overrides ?? {}),
...(updates.name !== undefined ? { name: makeValueObject(updates.name) } : {}),
...(updates.tag !== undefined ? { tag: makeValueObject(updates.tag) } : {}),
...(updates.description !== undefined ? { description: makeValueObject(updates.description) } : {}),
};
return makeTeam(next);
}),
};
const mockLogger = {
return team;
};
let teamRepository: {
findAll: Mock;
findById: Mock;
create: Mock;
update: Mock;
};
let membershipRepository: {
countByTeamId: Mock;
getActiveMembershipForDriver: Mock;
getMembership: Mock;
getTeamMembers: Mock;
getJoinRequests: Mock;
saveMembership: Mock;
};
let driverRepository: {
findById: Mock;
};
let logger: Logger;
let service: TeamService;
beforeEach(() => {
teamRepository = {
findAll: vi.fn(),
findById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
};
membershipRepository = {
countByTeamId: vi.fn(),
getActiveMembershipForDriver: vi.fn(),
getMembership: vi.fn(),
getTeamMembers: vi.fn(),
getJoinRequests: vi.fn(),
saveMembership: vi.fn(),
};
driverRepository = {
findById: vi.fn(),
};
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
} as unknown as Logger;
const module: TestingModule = await Test.createTestingModule({
providers: [
TeamService,
service = new TeamService(teamRepository as unknown as never, membershipRepository as unknown as never, driverRepository as unknown as never, logger);
});
it('getAll returns teams and totalCount on success', async () => {
teamRepository.findAll.mockResolvedValue([makeTeam()]);
membershipRepository.countByTeamId.mockResolvedValue(3);
await expect(service.getAll()).resolves.toEqual({
teams: [
{
provide: GetAllTeamsUseCase,
useValue: mockGetAllTeamsUseCase,
},
{
provide: GetDriverTeamUseCase,
useValue: mockGetDriverTeamUseCase,
},
{
provide: 'Logger',
useValue: mockLogger,
id: 'team-1',
name: 'Team One',
tag: 'T1',
description: 'Desc',
memberCount: 3,
leagues: ['league-1'],
},
],
}).compile();
service = module.get<TeamService>(TeamService);
getAllTeamsUseCase = module.get(GetAllTeamsUseCase);
getDriverTeamUseCase = module.get(GetDriverTeamUseCase);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('getAll', () => {
it('should call use case and return result', async () => {
const mockResult = { isOk: () => true, value: { teams: [], totalCount: 0 } };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getAllTeamsUseCase.execute.mockResolvedValue(mockResult as any);
const mockPresenter = {
present: vi.fn(),
getViewModel: vi.fn().mockReturnValue({ teams: [], totalCount: 0 }),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(AllTeamsPresenter as any) = vi.fn().mockImplementation(() => mockPresenter);
const result = await service.getAll();
expect(getAllTeamsUseCase.execute).toHaveBeenCalled();
expect(result).toEqual({ teams: [], totalCount: 0 });
totalCount: 1,
});
});
describe('getDriverTeam', () => {
it('should call use case and return result', async () => {
const mockResult = { isOk: () => true, value: {} };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getDriverTeamUseCase.execute.mockResolvedValue(mockResult as any);
it('getAll returns empty list when use case error message is empty (covers fallback)', async () => {
const executeSpy = vi
.spyOn(GetAllTeamsUseCase.prototype, 'execute')
.mockResolvedValueOnce(Result.err({ code: 'REPOSITORY_ERROR', details: { message: '' } }));
const mockPresenter = {
present: vi.fn(),
getViewModel: vi.fn().mockReturnValue({} as DriverTeamViewModel),
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(DriverTeamPresenter as any) = vi.fn().mockImplementation(() => mockPresenter);
await expect(service.getAll()).resolves.toEqual({ teams: [], totalCount: 0 });
const result = await service.getDriverTeam('driver1');
executeSpy.mockRestore();
});
expect(getDriverTeamUseCase.execute).toHaveBeenCalledWith({ driverId: 'driver1' });
expect(result).toEqual({});
it('getAll returns empty list when repository throws', async () => {
teamRepository.findAll.mockRejectedValue(new Error('boom'));
await expect(service.getAll()).resolves.toEqual({ teams: [], totalCount: 0 });
});
it('getDetails returns DTO on success', async () => {
teamRepository.findById.mockResolvedValue(makeTeam());
membershipRepository.getMembership.mockResolvedValue({
teamId: 'team-1',
driverId: 'driver-1',
role: 'driver',
status: 'active',
joinedAt: new Date('2023-02-01T00:00:00.000Z'),
});
it('should return null on error', async () => {
const mockResult = { isErr: () => true, error: {} };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getDriverTeamUseCase.execute.mockResolvedValue(mockResult as any);
const result = await service.getDriverTeam('driver1');
expect(result).toBeNull();
await expect(service.getDetails('team-1', 'driver-1')).resolves.toEqual({
team: {
id: 'team-1',
name: 'Team One',
tag: 'T1',
description: 'Desc',
ownerId: 'owner-1',
leagues: ['league-1'],
createdAt: '2023-01-01T00:00:00.000Z',
},
membership: {
role: 'member',
joinedAt: '2023-02-01T00:00:00.000Z',
isActive: true,
},
canManage: false,
});
});
it('getDetails passes empty driverId when userId missing', async () => {
const executeSpy = vi
.spyOn(GetTeamDetailsUseCase.prototype, 'execute')
.mockImplementationOnce(async (input) => {
expect(input).toEqual({ teamId: 'team-1', driverId: '' });
return Result.err({ code: 'REPOSITORY_ERROR', details: { message: '' } });
});
await expect(service.getDetails('team-1')).resolves.toBeNull();
executeSpy.mockRestore();
});
it('getDetails returns null on TEAM_NOT_FOUND', async () => {
teamRepository.findById.mockResolvedValue(null);
await expect(service.getDetails('team-1', 'driver-1')).resolves.toBeNull();
});
it('getMembers returns DTO on success (filters missing drivers)', async () => {
teamRepository.findById.mockResolvedValue(makeTeam());
membershipRepository.getTeamMembers.mockResolvedValue([
{
teamId: 'team-1',
driverId: 'd1',
role: 'driver',
status: 'active',
joinedAt: new Date('2023-02-01T00:00:00.000Z'),
},
{
teamId: 'team-1',
driverId: 'd2',
role: 'owner',
status: 'active',
joinedAt: new Date('2023-02-02T00:00:00.000Z'),
},
]);
driverRepository.findById.mockImplementation(async (id: string) => {
if (id === 'd1') return { id: 'd1', name: makeValueObject('Driver One') };
return null;
});
await expect(service.getMembers('team-1')).resolves.toEqual({
members: [
{
driverId: 'd1',
driverName: 'Driver One',
role: 'member',
joinedAt: '2023-02-01T00:00:00.000Z',
isActive: true,
avatarUrl: '',
},
],
totalCount: 1,
ownerCount: 1,
managerCount: 0,
memberCount: 1,
});
});
it('getMembers returns empty DTO on error', async () => {
teamRepository.findById.mockResolvedValue(null);
await expect(service.getMembers('team-1')).resolves.toEqual({
members: [],
totalCount: 0,
ownerCount: 0,
managerCount: 0,
memberCount: 0,
});
});
it('getMembers returns empty DTO when use case error message is empty (covers fallback)', async () => {
const executeSpy = vi
.spyOn(GetTeamMembersUseCase.prototype, 'execute')
.mockResolvedValueOnce(Result.err({ code: 'REPOSITORY_ERROR', details: { message: '' } }));
await expect(service.getMembers('team-1')).resolves.toEqual({
members: [],
totalCount: 0,
ownerCount: 0,
managerCount: 0,
memberCount: 0,
});
executeSpy.mockRestore();
});
it('getJoinRequests returns DTO on success', async () => {
teamRepository.findById.mockResolvedValue(makeTeam());
membershipRepository.getJoinRequests.mockResolvedValue([
{
id: 'jr1',
teamId: 'team-1',
driverId: 'd1',
requestedAt: new Date('2023-02-03T00:00:00.000Z'),
},
]);
driverRepository.findById.mockResolvedValue({ id: 'd1', name: makeValueObject('Driver One') });
await expect(service.getJoinRequests('team-1')).resolves.toEqual({
requests: [
{
requestId: 'jr1',
driverId: 'd1',
driverName: 'Driver One',
teamId: 'team-1',
status: 'pending',
requestedAt: '2023-02-03T00:00:00.000Z',
avatarUrl: '',
},
],
pendingCount: 1,
totalCount: 1,
});
});
it('getJoinRequests returns empty DTO on error', async () => {
teamRepository.findById.mockResolvedValue(null);
await expect(service.getJoinRequests('team-1')).resolves.toEqual({
requests: [],
pendingCount: 0,
totalCount: 0,
});
});
it('getJoinRequests returns empty DTO when use case error message is empty (covers fallback)', async () => {
const executeSpy = vi
.spyOn(GetTeamJoinRequestsUseCase.prototype, 'execute')
.mockResolvedValueOnce(Result.err({ code: 'REPOSITORY_ERROR', details: { message: '' } }));
await expect(service.getJoinRequests('team-1')).resolves.toEqual({
requests: [],
pendingCount: 0,
totalCount: 0,
});
executeSpy.mockRestore();
});
it('create returns success on success', async () => {
membershipRepository.getActiveMembershipForDriver.mockResolvedValue(null);
teamRepository.create.mockImplementation(async (team: unknown) => team);
const input: CreateTeamInputDTO = { name: 'N', tag: 'T', description: 'D' };
await expect(service.create(input, 'owner-1')).resolves.toEqual({
id: expect.any(String),
success: true,
});
expect(membershipRepository.saveMembership).toHaveBeenCalledTimes(1);
});
it('create returns failure DTO on error', async () => {
membershipRepository.getActiveMembershipForDriver.mockResolvedValue({ teamId: 'team-1' });
const input: CreateTeamInputDTO = { name: 'N', tag: 'T' };
await expect(service.create(input, 'owner-1')).resolves.toEqual({
id: '',
success: false,
});
});
it('create uses empty description and ownerId when missing', async () => {
const executeSpy = vi
.spyOn(CreateTeamUseCase.prototype, 'execute')
.mockImplementationOnce(async (command) => {
expect(command).toMatchObject({ description: '', ownerId: '' });
return Result.err({ code: 'REPOSITORY_ERROR', details: { message: '' } });
});
const input: CreateTeamInputDTO = { name: 'N', tag: 'T' };
await expect(service.create(input)).resolves.toEqual({ id: '', success: false });
executeSpy.mockRestore();
});
it('update returns success on success', async () => {
membershipRepository.getMembership.mockResolvedValue({ role: 'owner' });
teamRepository.findById.mockResolvedValue(makeTeam());
teamRepository.update.mockResolvedValue(undefined);
const input: UpdateTeamInputDTO = { name: 'New Name' };
await expect(service.update('team-1', input, 'owner-1')).resolves.toEqual({ success: true });
expect(teamRepository.update).toHaveBeenCalledTimes(1);
});
it('update returns failure DTO on error', async () => {
membershipRepository.getMembership.mockResolvedValue(null);
const input: UpdateTeamInputDTO = { name: 'New Name' };
await expect(service.update('team-1', input, 'owner-1')).resolves.toEqual({ success: false });
});
it('update uses empty updatedBy when userId missing', async () => {
const executeSpy = vi
.spyOn(UpdateTeamUseCase.prototype, 'execute')
.mockImplementationOnce(async (command) => {
expect(command.updatedBy).toBe('');
return Result.err({ code: 'REPOSITORY_ERROR', details: { message: '' } });
});
const input: UpdateTeamInputDTO = { name: 'New Name' };
await expect(service.update('team-1', input)).resolves.toEqual({ success: false });
executeSpy.mockRestore();
});
it('update includes name when provided', async () => {
const executeSpy = vi
.spyOn(UpdateTeamUseCase.prototype, 'execute')
.mockImplementationOnce(async (command) => {
expect(command.updates).toEqual({ name: 'New Name' });
return Result.err({ code: 'REPOSITORY_ERROR', details: { message: '' } });
});
const input: UpdateTeamInputDTO = { name: 'New Name' };
await expect(service.update('team-1', input, 'owner-1')).resolves.toEqual({ success: false });
executeSpy.mockRestore();
});
it('update includes tag when provided', async () => {
const executeSpy = vi
.spyOn(UpdateTeamUseCase.prototype, 'execute')
.mockImplementationOnce(async (command) => {
expect(command.updates).toEqual({ tag: 'NEW' });
return Result.err({ code: 'REPOSITORY_ERROR', details: { message: '' } });
});
const input: UpdateTeamInputDTO = { tag: 'NEW' };
await expect(service.update('team-1', input, 'owner-1')).resolves.toEqual({ success: false });
executeSpy.mockRestore();
});
it('update includes description when provided', async () => {
const executeSpy = vi
.spyOn(UpdateTeamUseCase.prototype, 'execute')
.mockImplementationOnce(async (command) => {
expect(command.updates).toEqual({ description: 'D' });
return Result.err({ code: 'REPOSITORY_ERROR', details: { message: '' } });
});
const input: UpdateTeamInputDTO = { description: 'D' };
await expect(service.update('team-1', input, 'owner-1')).resolves.toEqual({ success: false });
executeSpy.mockRestore();
});
it('update includes no updates when no fields are provided', async () => {
const executeSpy = vi
.spyOn(UpdateTeamUseCase.prototype, 'execute')
.mockImplementationOnce(async (command) => {
expect(command.updates).toEqual({});
return Result.err({ code: 'REPOSITORY_ERROR', details: { message: '' } });
});
const input: UpdateTeamInputDTO = {};
await expect(service.update('team-1', input, 'owner-1')).resolves.toEqual({ success: false });
executeSpy.mockRestore();
});
it('getDriverTeam returns driver team DTO on success', async () => {
membershipRepository.getActiveMembershipForDriver.mockResolvedValue({
teamId: 'team-1',
role: 'driver',
status: 'active',
joinedAt: new Date('2023-02-01T00:00:00.000Z'),
});
teamRepository.findById.mockResolvedValue(makeTeam());
await expect(service.getDriverTeam('driver-1')).resolves.toEqual({
team: {
id: 'team-1',
name: 'Team One',
tag: 'T1',
description: 'Desc',
ownerId: 'owner-1',
leagues: ['league-1'],
createdAt: '2023-01-01T00:00:00.000Z',
},
membership: {
role: 'member',
joinedAt: '2023-02-01T00:00:00.000Z',
isActive: true,
},
isOwner: false,
canManage: false,
});
});
it('getDriverTeam returns null when membership is missing', async () => {
membershipRepository.getActiveMembershipForDriver.mockResolvedValue(null);
await expect(service.getDriverTeam('driver-1')).resolves.toBeNull();
expect(teamRepository.findById).not.toHaveBeenCalled();
});
it('getDriverTeam returns null when use case error message is empty (covers fallback)', async () => {
const executeSpy = vi
.spyOn(GetDriverTeamUseCase.prototype, 'execute')
.mockResolvedValueOnce(Result.err({ code: 'REPOSITORY_ERROR', details: { message: '' } }));
await expect(service.getDriverTeam('driver-1')).resolves.toBeNull();
executeSpy.mockRestore();
});
it('getMembership returns presenter model on success', async () => {
membershipRepository.getMembership.mockResolvedValue({
teamId: 'team-1',
driverId: 'd1',
role: 'driver',
status: 'active',
joinedAt: new Date('2023-02-01T00:00:00.000Z'),
});
await expect(service.getMembership('team-1', 'd1')).resolves.toEqual({
role: 'member',
joinedAt: '2023-02-01T00:00:00.000Z',
isActive: true,
});
});
it('getMembership returns null when missing', async () => {
membershipRepository.getMembership.mockResolvedValue(null);
await expect(service.getMembership('team-1', 'd1')).resolves.toBeNull();
});
it('getMembership returns null when use case error message is empty (covers fallback)', async () => {
const executeSpy = vi
.spyOn(GetTeamMembershipUseCase.prototype, 'execute')
.mockResolvedValueOnce(Result.err({ code: 'REPOSITORY_ERROR', details: { message: '' } }));
await expect(service.getMembership('team-1', 'd1')).resolves.toBeNull();
executeSpy.mockRestore();
});
});

View File

@@ -37,7 +37,7 @@ import { CreateTeamPresenter } from './presenters/CreateTeamPresenter';
import { UpdateTeamPresenter } from './presenters/UpdateTeamPresenter';
// Tokens
import { TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN } from './TeamProviders';
import { TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN } from './TeamTokens';
@Injectable()
export class TeamService {
@@ -187,6 +187,6 @@ export class TeamService {
return null;
}
return presenter.responseModel;
return presenter.getResponseModel();
}
}

View File

@@ -0,0 +1,5 @@
export const TEAM_REPOSITORY_TOKEN = 'ITeamRepository';
export const TEAM_MEMBERSHIP_REPOSITORY_TOKEN = 'ITeamMembershipRepository';
export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
export const IMAGE_SERVICE_TOKEN = 'IImageServicePort';
export const LOGGER_TOKEN = 'Logger';