Files
gridpilot.gg/apps/api/src/domain/team/TeamService.test.ts
2025-12-30 18:33:15 +01:00

566 lines
18 KiB
TypeScript

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';
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', () => {
const makeValueObject = (value: string): ValueObjectStub => ({
props: value,
toString() {
return value;
},
});
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 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);
}),
};
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 teamStatsRepository = {
getTeamStats: vi.fn(),
saveTeamStats: vi.fn(),
getAllStats: vi.fn(),
clear: vi.fn(),
};
const mediaRepository = {
getTeamAvatar: vi.fn(),
saveTeamAvatar: vi.fn(),
getDriverAvatar: vi.fn(),
saveDriverAvatar: vi.fn(),
};
const resultRepository = {
findAll: vi.fn(),
};
const allTeamsPresenter = {
reset: vi.fn(),
present: vi.fn(),
getResponseModel: vi.fn(() => ({ teams: [], totalCount: 0 })),
responseModel: { teams: [], totalCount: 0 },
};
service = new TeamService(
teamRepository as unknown as never,
membershipRepository as unknown as never,
driverRepository as unknown as never,
logger,
teamStatsRepository as unknown as never,
mediaRepository as unknown as never,
resultRepository as unknown as never,
allTeamsPresenter as unknown as never
);
});
it('getAll returns teams and totalCount on success', async () => {
teamRepository.findAll.mockResolvedValue([makeTeam()]);
membershipRepository.countByTeamId.mockResolvedValue(3);
await expect(service.getAll()).resolves.toEqual({
teams: [
{
id: 'team-1',
name: 'Team One',
tag: 'T1',
description: 'Desc',
memberCount: 3,
leagues: ['league-1'],
},
],
totalCount: 1,
});
});
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: '' } }));
await expect(service.getAll()).resolves.toEqual({ teams: [], totalCount: 0 });
executeSpy.mockRestore();
});
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'),
});
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();
});
});