Files
gridpilot.gg/apps/api/src/domain/team/TeamService.test.ts
2025-12-23 23:14:51 +01:00

531 lines
17 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;
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: [
{
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();
});
});