Files
gridpilot.gg/apps/api/src/domain/team/TeamService.test.ts
2026-01-16 21:44:26 +01:00

579 lines
19 KiB
TypeScript

import { CreateTeamUseCase } from '@core/racing/application/use-cases/CreateTeamUseCase';
import { GetAllTeamsUseCase } from '@core/racing/application/use-cases/GetAllTeamsUseCase';
import { GetDriverTeamUseCase } from '@core/racing/application/use-cases/GetDriverTeamUseCase';
import { GetTeamDetailsUseCase } from '@core/racing/application/use-cases/GetTeamDetailsUseCase';
import { GetTeamJoinRequestsUseCase } from '@core/racing/application/use-cases/GetTeamJoinRequestsUseCase';
import { GetTeamMembershipUseCase } from '@core/racing/application/use-cases/GetTeamMembershipUseCase';
import { GetTeamMembersUseCase } from '@core/racing/application/use-cases/GetTeamMembersUseCase';
import { UpdateTeamUseCase } from '@core/racing/application/use-cases/UpdateTeamUseCase';
import type { Logger } from '@core/shared/application/Logger';
import { Result } from '@core/shared/domain/Result';
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
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 };
logoRef: unknown;
category: string | undefined;
isRecruiting: boolean;
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') },
logoRef: { type: 'system-default', variant: 'logo' },
category: undefined,
isRecruiting: false,
};
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().mockResolvedValue([]),
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().mockResolvedValue(undefined),
saveTeamStats: vi.fn(),
getAllStats: vi.fn(),
clear: vi.fn(),
};
service = new TeamService(
new GetAllTeamsUseCase(teamRepository as never, membershipRepository as never, teamStatsRepository as never, logger),
new GetTeamDetailsUseCase(teamRepository as never, membershipRepository as never),
new GetTeamMembersUseCase(membershipRepository as never, driverRepository as never, teamRepository as never, logger),
new GetTeamJoinRequestsUseCase(membershipRepository as never, driverRepository as never, teamRepository as never),
new CreateTeamUseCase(teamRepository as never, membershipRepository as never, logger),
new UpdateTeamUseCase(teamRepository as never, membershipRepository as never),
new GetDriverTeamUseCase(teamRepository as never, membershipRepository as never, logger),
new GetTeamMembershipUseCase(membershipRepository as never, logger),
{ execute: vi.fn() } as never, // joinTeamUseCase
logger
);
});
it('getAll returns teams and totalCount on success', async () => {
teamRepository.findAll.mockResolvedValue([makeTeam()]);
membershipRepository.countByTeamId.mockResolvedValue(3);
const result = await service.getAll();
await expect(result).toEqual({
teams: [
{
id: 'team-1',
name: 'Team One',
tag: 'T1',
description: 'Desc',
memberCount: 3,
leagues: ['league-1'],
totalWins: 0,
totalRaces: 0,
performanceLevel: 'intermediate',
specialization: 'mixed',
region: '',
languages: [],
rating: 0,
logoUrl: '/media/teams/team-1/logo',
isRecruiting: false,
},
],
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'],
category: undefined,
isRecruiting: false,
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: '',
},
{
driverId: '',
driverName: '',
role: 'owner',
joinedAt: '2023-02-02T00:00:00.000Z',
isActive: true,
avatarUrl: '',
},
],
totalCount: 2,
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'],
category: undefined,
isRecruiting: false,
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();
});
});