566 lines
18 KiB
TypeScript
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();
|
|
});
|
|
});
|