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

@@ -7,8 +7,9 @@
"build": "tsc --build --verbose",
"start:dev": "ts-node-dev --respawn --inspect=0.0.0.0:9229 src/main.ts",
"start:prod": "node dist/main",
"test": "vitest run",
"test:watch": "vitest",
"test": "vitest run --config ../../vitest.api.config.ts",
"test:coverage": "vitest run --config ../../vitest.api.config.ts --coverage",
"test:watch": "vitest --config ../../vitest.api.config.ts",
"generate:openapi": "GENERATE_OPENAPI=true ts-node src/main.ts --exit"
},
"keywords": [],
@@ -22,7 +23,7 @@
"@nestjs/common": "^10.4.20",
"@nestjs/core": "^10.4.20",
"@nestjs/platform-express": "^10.4.20",
"@nestjs/swagger": "^11.2.3",
"@nestjs/swagger": "^7.4.2",
"@nestjs/typeorm": "^10.0.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",

View File

@@ -2,7 +2,7 @@ import type { GetAnalyticsMetricsOutput } from '@core/analytics/application/use-
import type { RecordEngagementOutput } from '@core/analytics/application/use-cases/RecordEngagementUseCase';
import type { RecordPageViewOutput } from '@core/analytics/application/use-cases/RecordPageViewUseCase';
import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository';
import type { IPageViewRepository } from '@core/analytics/domain/repositories/IPageViewRepository';
import type { IPageViewRepository } from '@core/analytics/application/repositories/IPageViewRepository';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Provider } from '@nestjs/common';
@@ -22,23 +22,31 @@ import { GetAnalyticsMetricsUseCase } from '@core/analytics/application/use-case
import { GetDashboardDataOutput, GetDashboardDataUseCase } from '@core/analytics/application/use-cases/GetDashboardDataUseCase';
import { RecordEngagementUseCase } from '@core/analytics/application/use-cases/RecordEngagementUseCase';
import { RecordPageViewUseCase } from '@core/analytics/application/use-cases/RecordPageViewUseCase';
import { AnalyticsService } from './AnalyticsService';
import { GetAnalyticsMetricsPresenter } from './presenters/GetAnalyticsMetricsPresenter';
import { GetDashboardDataPresenter } from './presenters/GetDashboardDataPresenter';
import { RecordEngagementPresenter } from './presenters/RecordEngagementPresenter';
import { RecordPageViewPresenter } from './presenters/RecordPageViewPresenter';
export const AnalyticsProviders: Provider[] = [
AnalyticsService,
RecordPageViewPresenter,
RecordEngagementPresenter,
GetDashboardDataPresenter,
GetAnalyticsMetricsPresenter,
{
provide: Logger_TOKEN,
useClass: ConsoleLogger,
},
{
provide: IPAGE_VIEW_REPO_TOKEN,
useClass: InMemoryPageViewRepository,
useFactory: (logger: Logger) => new InMemoryPageViewRepository(logger),
inject: [Logger_TOKEN],
},
{
provide: IENGAGEMENT_REPO_TOKEN,
useClass: InMemoryEngagementRepository,
useFactory: (logger: Logger) => new InMemoryEngagementRepository(logger),
inject: [Logger_TOKEN],
},
{
provide: RECORD_PAGE_VIEW_OUTPUT_PORT_TOKEN,
@@ -76,8 +84,8 @@ export const AnalyticsProviders: Provider[] = [
},
{
provide: GetAnalyticsMetricsUseCase,
useFactory: (repo: IPageViewRepository, logger: Logger, output: UseCaseOutputPort<GetAnalyticsMetricsOutput>) =>
new GetAnalyticsMetricsUseCase(repo, logger, output),
inject: [IPAGE_VIEW_REPO_TOKEN, Logger_TOKEN, GET_ANALYTICS_METRICS_OUTPUT_PORT_TOKEN],
useFactory: (logger: Logger, output: UseCaseOutputPort<GetAnalyticsMetricsOutput>, repo: IPageViewRepository) =>
new GetAnalyticsMetricsUseCase(logger, output, repo),
inject: [Logger_TOKEN, GET_ANALYTICS_METRICS_OUTPUT_PORT_TOKEN, IPAGE_VIEW_REPO_TOKEN],
},
];

View File

@@ -0,0 +1,278 @@
import { describe, expect, it, vi } from 'vitest';
import { Result } from '@core/shared/application/Result';
import { AnalyticsService } from './AnalyticsService';
import { RecordPageViewPresenter } from './presenters/RecordPageViewPresenter';
import { RecordEngagementPresenter } from './presenters/RecordEngagementPresenter';
import { GetDashboardDataPresenter } from './presenters/GetDashboardDataPresenter';
import { GetAnalyticsMetricsPresenter } from './presenters/GetAnalyticsMetricsPresenter';
describe('AnalyticsService', () => {
it('recordPageView returns presenter response on success', async () => {
const recordPageViewPresenter = new RecordPageViewPresenter();
const recordPageViewUseCase = {
execute: vi.fn(async () => {
recordPageViewPresenter.present({ pageViewId: 'pv-1' });
return Result.ok(undefined);
}),
};
const service = new AnalyticsService(
recordPageViewUseCase as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
recordPageViewPresenter,
new RecordEngagementPresenter(),
new GetDashboardDataPresenter(),
new GetAnalyticsMetricsPresenter(),
);
const dto = await service.recordPageView({
entityType: 'league' as any,
entityId: 'l1',
visitorType: 'anonymous' as any,
sessionId: 's1',
});
expect(dto).toEqual({ pageViewId: 'pv-1' });
expect(recordPageViewUseCase.execute).toHaveBeenCalled();
});
it('recordPageView throws on use case error', async () => {
const service = new AnalyticsService(
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'nope' } })) } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
new RecordPageViewPresenter(),
new RecordEngagementPresenter(),
new GetDashboardDataPresenter(),
new GetAnalyticsMetricsPresenter(),
);
await expect(
service.recordPageView({ entityType: 'league' as any, entityId: 'l1', visitorType: 'anonymous' as any, sessionId: 's1' }),
).rejects.toThrow('nope');
});
it('recordPageView throws with fallback message when no details.message', async () => {
const service = new AnalyticsService(
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR' } as any)) } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
new RecordPageViewPresenter(),
new RecordEngagementPresenter(),
new GetDashboardDataPresenter(),
new GetAnalyticsMetricsPresenter(),
);
await expect(
service.recordPageView({ entityType: 'league' as any, entityId: 'l1', visitorType: 'anonymous' as any, sessionId: 's1' }),
).rejects.toThrow('Failed to record page view');
});
it('recordEngagement returns response on success', async () => {
const recordEngagementPresenter = new RecordEngagementPresenter();
const recordEngagementUseCase = {
execute: vi.fn(async () => {
recordEngagementPresenter.present({ eventId: 'e1', engagementWeight: 7 });
return Result.ok(undefined);
}),
};
const service = new AnalyticsService(
{ execute: vi.fn() } as any,
recordEngagementUseCase as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
new RecordPageViewPresenter(),
recordEngagementPresenter,
new GetDashboardDataPresenter(),
new GetAnalyticsMetricsPresenter(),
);
const dto = await service.recordEngagement({
action: 'click' as any,
entityType: 'league' as any,
entityId: 'l1',
actorType: 'anonymous',
sessionId: 's1',
});
expect(dto).toEqual({ eventId: 'e1', engagementWeight: 7 });
});
it('recordEngagement throws with details message on error', async () => {
const service = new AnalyticsService(
{ execute: vi.fn() } as any,
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'nope' } })) } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
new RecordPageViewPresenter(),
new RecordEngagementPresenter(),
new GetDashboardDataPresenter(),
new GetAnalyticsMetricsPresenter(),
);
await expect(
service.recordEngagement({
action: 'click' as any,
entityType: 'league' as any,
entityId: 'l1',
actorType: 'anonymous',
sessionId: 's1',
}),
).rejects.toThrow('nope');
});
it('recordEngagement throws with fallback message when no details.message', async () => {
const service = new AnalyticsService(
{ execute: vi.fn() } as any,
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR' } as any)) } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
new RecordPageViewPresenter(),
new RecordEngagementPresenter(),
new GetDashboardDataPresenter(),
new GetAnalyticsMetricsPresenter(),
);
await expect(
service.recordEngagement({
action: 'click' as any,
entityType: 'league' as any,
entityId: 'l1',
actorType: 'anonymous',
sessionId: 's1',
}),
).rejects.toThrow('Failed to record engagement');
});
it('getDashboardData returns response on success', async () => {
const getDashboardDataPresenter = new GetDashboardDataPresenter();
const getDashboardDataUseCase = {
execute: vi.fn(async () => {
getDashboardDataPresenter.present({
totalUsers: 1,
activeUsers: 2,
totalRaces: 3,
totalLeagues: 4,
});
return Result.ok(undefined);
}),
};
const service = new AnalyticsService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
getDashboardDataUseCase as any,
{ execute: vi.fn() } as any,
new RecordPageViewPresenter(),
new RecordEngagementPresenter(),
getDashboardDataPresenter,
new GetAnalyticsMetricsPresenter(),
);
await expect(service.getDashboardData()).resolves.toEqual({
totalUsers: 1,
activeUsers: 2,
totalRaces: 3,
totalLeagues: 4,
});
});
it('getDashboardData throws with details message on error', async () => {
const service = new AnalyticsService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } })) } as any,
{ execute: vi.fn() } as any,
new RecordPageViewPresenter(),
new RecordEngagementPresenter(),
new GetDashboardDataPresenter(),
new GetAnalyticsMetricsPresenter(),
);
await expect(service.getDashboardData()).rejects.toThrow('boom');
});
it('getDashboardData throws with fallback message when no details.message', async () => {
const service = new AnalyticsService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR' } as any)) } as any,
{ execute: vi.fn() } as any,
new RecordPageViewPresenter(),
new RecordEngagementPresenter(),
new GetDashboardDataPresenter(),
new GetAnalyticsMetricsPresenter(),
);
await expect(service.getDashboardData()).rejects.toThrow('Failed to get dashboard data');
});
it('getAnalyticsMetrics returns response on success', async () => {
const getAnalyticsMetricsPresenter = new GetAnalyticsMetricsPresenter();
const getAnalyticsMetricsUseCase = {
execute: vi.fn(async () => {
getAnalyticsMetricsPresenter.present({
pageViews: 10,
uniqueVisitors: 0,
averageSessionDuration: 0,
bounceRate: 0,
});
return Result.ok(undefined);
}),
};
const service = new AnalyticsService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
getAnalyticsMetricsUseCase as any,
new RecordPageViewPresenter(),
new RecordEngagementPresenter(),
new GetDashboardDataPresenter(),
getAnalyticsMetricsPresenter,
);
await expect(service.getAnalyticsMetrics()).resolves.toEqual({
pageViews: 10,
uniqueVisitors: 0,
averageSessionDuration: 0,
bounceRate: 0,
});
});
it('getAnalyticsMetrics throws with details message on error', async () => {
const service = new AnalyticsService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } })) } as any,
new RecordPageViewPresenter(),
new RecordEngagementPresenter(),
new GetDashboardDataPresenter(),
new GetAnalyticsMetricsPresenter(),
);
await expect(service.getAnalyticsMetrics()).rejects.toThrow('boom');
});
it('getAnalyticsMetrics throws with fallback message when no details.message', async () => {
const service = new AnalyticsService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR' } as any)) } as any,
new RecordPageViewPresenter(),
new RecordEngagementPresenter(),
new GetDashboardDataPresenter(),
new GetAnalyticsMetricsPresenter(),
);
await expect(service.getAnalyticsMetrics()).rejects.toThrow('Failed to get analytics metrics');
});
});

View File

@@ -24,10 +24,14 @@ export class AnalyticsService {
@Inject(RecordEngagementUseCase) private readonly recordEngagementUseCase: RecordEngagementUseCase,
@Inject(GetDashboardDataUseCase) private readonly getDashboardDataUseCase: GetDashboardDataUseCase,
@Inject(GetAnalyticsMetricsUseCase) private readonly getAnalyticsMetricsUseCase: GetAnalyticsMetricsUseCase,
@Inject(RecordPageViewPresenter) private readonly recordPageViewPresenter: RecordPageViewPresenter,
@Inject(RecordEngagementPresenter) private readonly recordEngagementPresenter: RecordEngagementPresenter,
@Inject(GetDashboardDataPresenter) private readonly getDashboardDataPresenter: GetDashboardDataPresenter,
@Inject(GetAnalyticsMetricsPresenter) private readonly getAnalyticsMetricsPresenter: GetAnalyticsMetricsPresenter,
) {}
async recordPageView(input: RecordPageViewInput): Promise<RecordPageViewOutputDTO> {
const presenter = new RecordPageViewPresenter();
this.recordPageViewPresenter.reset();
const result = await this.recordPageViewUseCase.execute(input);
if (result.isErr()) {
@@ -35,11 +39,11 @@ export class AnalyticsService {
throw new Error(error.details?.message ?? 'Failed to record page view');
}
return presenter.responseModel;
return this.recordPageViewPresenter.responseModel;
}
async recordEngagement(input: RecordEngagementInput): Promise<RecordEngagementOutputDTO> {
const presenter = new RecordEngagementPresenter();
this.recordEngagementPresenter.reset();
const result = await this.recordEngagementUseCase.execute(input);
if (result.isErr()) {
@@ -47,23 +51,23 @@ export class AnalyticsService {
throw new Error(error.details?.message ?? 'Failed to record engagement');
}
return presenter.responseModel;
return this.recordEngagementPresenter.responseModel;
}
async getDashboardData(): Promise<GetDashboardDataOutputDTO> {
const presenter = new GetDashboardDataPresenter();
this.getDashboardDataPresenter.reset();
const result = await this.getDashboardDataUseCase.execute({});
const result = await this.getDashboardDataUseCase.execute();
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Failed to get dashboard data');
}
return presenter.responseModel;
return this.getDashboardDataPresenter.responseModel;
}
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsOutputDTO> {
const presenter = new GetAnalyticsMetricsPresenter();
this.getAnalyticsMetricsPresenter.reset();
const result = await this.getAnalyticsMetricsUseCase.execute({});
if (result.isErr()) {
@@ -71,6 +75,6 @@ export class AnalyticsService {
throw new Error(error.details?.message ?? 'Failed to get analytics metrics');
}
return presenter.responseModel;
return this.getAnalyticsMetricsPresenter.responseModel;
}
}

View File

@@ -5,6 +5,10 @@ import type { GetAnalyticsMetricsOutputDTO } from '../dtos/GetAnalyticsMetricsOu
export class GetAnalyticsMetricsPresenter implements UseCaseOutputPort<GetAnalyticsMetricsOutput> {
private model: GetAnalyticsMetricsOutputDTO | null = null;
reset(): void {
this.model = null;
}
present(result: GetAnalyticsMetricsOutput): void {
this.model = {
pageViews: result.pageViews,

View File

@@ -5,6 +5,10 @@ import type { GetDashboardDataOutputDTO } from '../dtos/GetDashboardDataOutputDT
export class GetDashboardDataPresenter implements UseCaseOutputPort<GetDashboardDataOutput> {
private model: GetDashboardDataOutputDTO | null = null;
reset(): void {
this.model = null;
}
present(result: GetDashboardDataOutput): void {
this.model = {
totalUsers: result.totalUsers,

View File

@@ -5,6 +5,10 @@ import type { RecordEngagementOutputDTO } from '../dtos/RecordEngagementOutputDT
export class RecordEngagementPresenter implements UseCaseOutputPort<RecordEngagementOutput> {
private model: RecordEngagementOutputDTO | null = null;
reset(): void {
this.model = null;
}
present(result: RecordEngagementOutput): void {
this.model = {
eventId: result.eventId,

View File

@@ -5,6 +5,10 @@ import type { RecordPageViewOutputDTO } from '../dtos/RecordPageViewOutputDTO';
export class RecordPageViewPresenter implements UseCaseOutputPort<RecordPageViewOutput> {
private model: RecordPageViewOutputDTO | null = null;
reset(): void {
this.model = null;
}
present(result: RecordPageViewOutput): void {
this.model = {
pageViewId: result.pageViewId,

View File

@@ -0,0 +1,230 @@
import { describe, expect, it, vi } from 'vitest';
import { Result } from '@core/shared/application/Result';
import { AuthService } from './AuthService';
class FakeAuthSessionPresenter {
private model: any = null;
reset() {
this.model = null;
}
present(model: any) {
this.model = model;
}
get responseModel() {
if (!this.model) throw new Error('Presenter not presented');
return this.model;
}
}
class FakeCommandResultPresenter {
private model: any = null;
reset() {
this.model = null;
}
present(model: any) {
this.model = model;
}
get responseModel() {
if (!this.model) throw new Error('Presenter not presented');
return this.model;
}
}
describe('AuthService', () => {
it('getCurrentSession returns null when no session', async () => {
const service = new AuthService(
{ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
{ getCurrentSession: vi.fn(async () => null), createSession: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
new FakeAuthSessionPresenter() as any,
new FakeCommandResultPresenter() as any,
);
await expect(service.getCurrentSession()).resolves.toBeNull();
});
it('getCurrentSession maps core session to DTO', async () => {
const service = new AuthService(
{ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
{
getCurrentSession: vi.fn(async () => ({
token: 't1',
user: { id: 'u1', email: null, displayName: 'D' },
})),
createSession: vi.fn(),
} as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
new FakeAuthSessionPresenter() as any,
new FakeCommandResultPresenter() as any,
);
await expect(service.getCurrentSession()).resolves.toEqual({
token: 't1',
user: { userId: 'u1', email: '', displayName: 'D' },
});
});
it('signupWithEmail creates session and returns AuthSessionDTO', async () => {
const authSessionPresenter = new FakeAuthSessionPresenter();
const identitySessionPort = {
getCurrentSession: vi.fn(),
createSession: vi.fn(async () => ({ token: 't2' })),
};
const signupUseCase = {
execute: vi.fn(async () => {
authSessionPresenter.present({ userId: 'u2', email: 'e2', displayName: 'd2' });
return Result.ok(undefined);
}),
};
const service = new AuthService(
{ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
identitySessionPort as any,
{ execute: vi.fn() } as any,
signupUseCase as any,
{ execute: vi.fn() } as any,
authSessionPresenter as any,
new FakeCommandResultPresenter() as any,
);
const session = await service.signupWithEmail({
email: 'e2',
password: 'p2',
displayName: 'd2',
} as any);
expect(signupUseCase.execute).toHaveBeenCalledWith({
email: 'e2',
password: 'p2',
displayName: 'd2',
});
expect(identitySessionPort.createSession).toHaveBeenCalledWith({
id: 'u2',
displayName: 'd2',
email: 'e2',
});
expect(session).toEqual({ token: 't2', user: { userId: 'u2', email: 'e2', displayName: 'd2' } });
});
it('signupWithEmail throws with fallback when no details.message', async () => {
const service = new AuthService(
{ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
{ getCurrentSession: vi.fn(), createSession: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR' } as any)) } as any,
{ execute: vi.fn() } as any,
new FakeAuthSessionPresenter() as any,
new FakeCommandResultPresenter() as any,
);
await expect(
service.signupWithEmail({ email: 'e2', password: 'p2', displayName: 'd2' } as any),
).rejects.toThrow('Signup failed');
});
it('loginWithEmail creates session and returns AuthSessionDTO', async () => {
const authSessionPresenter = new FakeAuthSessionPresenter();
const identitySessionPort = {
getCurrentSession: vi.fn(),
createSession: vi.fn(async () => ({ token: 't3' })),
};
const loginUseCase = {
execute: vi.fn(async () => {
authSessionPresenter.present({ userId: 'u3', email: 'e3', displayName: 'd3' });
return Result.ok(undefined);
}),
};
const service = new AuthService(
{ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
identitySessionPort as any,
loginUseCase as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
authSessionPresenter as any,
new FakeCommandResultPresenter() as any,
);
await expect(service.loginWithEmail({ email: 'e3', password: 'p3' } as any)).resolves.toEqual({
token: 't3',
user: { userId: 'u3', email: 'e3', displayName: 'd3' },
});
expect(loginUseCase.execute).toHaveBeenCalledWith({ email: 'e3', password: 'p3' });
expect(identitySessionPort.createSession).toHaveBeenCalledWith({
id: 'u3',
displayName: 'd3',
email: 'e3',
});
});
it('loginWithEmail throws on use case error and prefers details.message', async () => {
const service = new AuthService(
{ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
{ getCurrentSession: vi.fn(), createSession: vi.fn() } as any,
{ execute: vi.fn(async () => Result.err({ code: 'INVALID_CREDENTIALS', details: { message: 'Bad login' } })) } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
new FakeAuthSessionPresenter() as any,
new FakeCommandResultPresenter() as any,
);
await expect(service.loginWithEmail({ email: 'e', password: 'p' } as any)).rejects.toThrow('Bad login');
});
it('loginWithEmail throws with fallback when no details.message', async () => {
const service = new AuthService(
{ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
{ getCurrentSession: vi.fn(), createSession: vi.fn() } as any,
{ execute: vi.fn(async () => Result.err({ code: 'INVALID_CREDENTIALS' } as any)) } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
new FakeAuthSessionPresenter() as any,
new FakeCommandResultPresenter() as any,
);
await expect(service.loginWithEmail({ email: 'e', password: 'p' } as any)).rejects.toThrow('Login failed');
});
it('logout returns command result on success', async () => {
const commandResultPresenter = new FakeCommandResultPresenter();
const logoutUseCase = {
execute: vi.fn(async () => {
commandResultPresenter.present({ success: true });
return Result.ok(undefined);
}),
};
const service = new AuthService(
{ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
{ getCurrentSession: vi.fn(), createSession: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
logoutUseCase as any,
new FakeAuthSessionPresenter() as any,
commandResultPresenter as any,
);
await expect(service.logout()).resolves.toEqual({ success: true });
});
it('logout throws with fallback when no details.message', async () => {
const service = new AuthService(
{ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
{ getCurrentSession: vi.fn(), createSession: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR' } as any)) } as any,
new FakeAuthSessionPresenter() as any,
new FakeCommandResultPresenter() as any,
);
await expect(service.logout()).rejects.toThrow('Logout failed');
});
});

View File

@@ -133,7 +133,7 @@ export class AuthService {
this.commandResultPresenter.reset();
const result = await this.logoutUseCase.execute({});
const result = await this.logoutUseCase.execute();
if (result.isErr()) {
const error = result.unwrapErr() as LogoutApplicationError;

View File

@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from 'vitest';
import { Result } from '@core/shared/application/Result';
import { DashboardService } from './DashboardService';
describe('DashboardService', () => {
it('getDashboardOverview returns presenter model on success', async () => {
const presenter = { getResponseModel: vi.fn(() => ({ feed: [] })) };
const useCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const service = new DashboardService(
{ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
useCase as any,
presenter as any,
);
await expect(service.getDashboardOverview('d1')).resolves.toEqual({ feed: [] });
expect(useCase.execute).toHaveBeenCalledWith({ driverId: 'd1' });
});
it('getDashboardOverview throws with details message on error', async () => {
const service = new DashboardService(
{ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } })) } as any,
{ getResponseModel: vi.fn() } as any,
);
await expect(service.getDashboardOverview('d1')).rejects.toThrow('Failed to get dashboard overview: boom');
});
it('getDashboardOverview throws with fallback message when no details.message', async () => {
const service = new DashboardService(
{ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR' } as any)) } as any,
{ getResponseModel: vi.fn() } as any,
);
await expect(service.getDashboardOverview('d1')).rejects.toThrow('Failed to get dashboard overview: Unknown error');
});
});

View File

@@ -46,7 +46,7 @@ describe('DriverController', () => {
describe('getDriversLeaderboard', () => {
it('should return drivers leaderboard', async () => {
const leaderboard: DriversLeaderboardDTO = { drivers: [], totalRaces: 0, totalWins: 0, activeCount: 0 };
service.getDriversLeaderboard.mockResolvedValue({ viewModel: leaderboard } as never);
service.getDriversLeaderboard.mockResolvedValue(leaderboard);
const result = await controller.getDriversLeaderboard();
@@ -58,7 +58,7 @@ describe('DriverController', () => {
describe('getTotalDrivers', () => {
it('should return total drivers stats', async () => {
const stats: DriverStatsDTO = { totalDrivers: 100 };
service.getTotalDrivers.mockResolvedValue({ viewModel: stats } as never);
service.getTotalDrivers.mockResolvedValue(stats);
const result = await controller.getTotalDrivers();
@@ -71,7 +71,7 @@ describe('DriverController', () => {
it('should return current driver if userId exists', async () => {
const userId = 'user-123';
const driver: GetDriverOutputDTO = { id: 'driver-123', name: 'Driver' } as GetDriverOutputDTO;
service.getCurrentDriver.mockResolvedValue({ viewModel: driver } as never);
service.getCurrentDriver.mockResolvedValue(driver);
const mockReq: Partial<AuthenticatedRequest> = { user: { userId } };
@@ -96,7 +96,7 @@ describe('DriverController', () => {
const userId = 'user-123';
const input: CompleteOnboardingInputDTO = { someField: 'value' } as unknown as CompleteOnboardingInputDTO;
const output: CompleteOnboardingOutputDTO = { success: true };
service.completeOnboarding.mockResolvedValue({ viewModel: output } as never);
service.completeOnboarding.mockResolvedValue(output);
const mockReq: Partial<AuthenticatedRequest> = { user: { userId } };
@@ -112,7 +112,7 @@ describe('DriverController', () => {
const driverId = 'driver-123';
const raceId = 'race-456';
const status: DriverRegistrationStatusDTO = { registered: true } as unknown as DriverRegistrationStatusDTO;
service.getDriverRegistrationStatus.mockResolvedValue({ viewModel: status } as never);
service.getDriverRegistrationStatus.mockResolvedValue(status);
const result = await controller.getDriverRegistrationStatus(driverId, raceId);
@@ -125,7 +125,7 @@ describe('DriverController', () => {
it('should return driver by id', async () => {
const driverId = 'driver-123';
const driver: GetDriverOutputDTO = { id: driverId, name: 'Driver' } as GetDriverOutputDTO;
service.getDriver.mockResolvedValue({ viewModel: driver } as never);
service.getDriver.mockResolvedValue(driver);
const result = await controller.getDriver(driverId);

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, Put, Req } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Put, Req, Inject } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
import { DriverService } from './DriverService';
@@ -17,7 +17,7 @@ interface AuthenticatedRequest extends Request {
@ApiTags('drivers')
@Controller('drivers')
export class DriverController {
constructor(private readonly driverService: DriverService) {}
constructor(@Inject(DriverService) private readonly driverService: DriverService) {}
@Get('leaderboard')
@ApiOperation({ summary: 'Get drivers leaderboard' })

View File

@@ -5,7 +5,7 @@ import { DriverProviders } from './DriverProviders';
@Module({
controllers: [DriverController],
providers: DriverProviders,
providers: [DriverService, ...DriverProviders],
exports: [DriverService],
})
export class DriverModule {}

View File

@@ -46,38 +46,36 @@ import { DriverStatsPresenter } from './presenters/DriverStatsPresenter';
// Import types for output ports
import type { UseCaseOutputPort } from '@core/shared/application';
// Define injection tokens
export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
export const RANKING_SERVICE_TOKEN = 'IRankingService';
export const DRIVER_STATS_SERVICE_TOKEN = 'IDriverStatsService';
export const DRIVER_RATING_PROVIDER_TOKEN = 'DriverRatingProvider';
export const DRIVER_EXTENDED_PROFILE_PROVIDER_TOKEN = 'DriverExtendedProfileProvider';
export const IMAGE_SERVICE_PORT_TOKEN = 'IImageServicePort';
export const RACE_REGISTRATION_REPOSITORY_TOKEN = 'IRaceRegistrationRepository';
export const NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN = 'INotificationPreferenceRepository';
export const TEAM_REPOSITORY_TOKEN = 'ITeamRepository';
export const TEAM_MEMBERSHIP_REPOSITORY_TOKEN = 'ITeamMembershipRepository';
export const SOCIAL_GRAPH_REPOSITORY_TOKEN = 'ISocialGraphRepository';
export const LOGGER_TOKEN = 'Logger';
import {
DRIVER_REPOSITORY_TOKEN,
RANKING_SERVICE_TOKEN,
DRIVER_STATS_SERVICE_TOKEN,
DRIVER_RATING_PROVIDER_TOKEN,
DRIVER_EXTENDED_PROFILE_PROVIDER_TOKEN,
IMAGE_SERVICE_PORT_TOKEN,
RACE_REGISTRATION_REPOSITORY_TOKEN,
NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN,
TEAM_REPOSITORY_TOKEN,
TEAM_MEMBERSHIP_REPOSITORY_TOKEN,
SOCIAL_GRAPH_REPOSITORY_TOKEN,
LOGGER_TOKEN,
GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN,
GET_TOTAL_DRIVERS_USE_CASE_TOKEN,
COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN,
IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN,
UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN,
GET_PROFILE_OVERVIEW_USE_CASE_TOKEN,
GET_DRIVERS_LEADERBOARD_OUTPUT_PORT_TOKEN,
GET_TOTAL_DRIVERS_OUTPUT_PORT_TOKEN,
COMPLETE_DRIVER_ONBOARDING_OUTPUT_PORT_TOKEN,
IS_DRIVER_REGISTERED_FOR_RACE_OUTPUT_PORT_TOKEN,
UPDATE_DRIVER_PROFILE_OUTPUT_PORT_TOKEN,
GET_PROFILE_OVERVIEW_OUTPUT_PORT_TOKEN,
} from './DriverTokens';
// Use case tokens
export const GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN = 'GetDriversLeaderboardUseCase';
export const GET_TOTAL_DRIVERS_USE_CASE_TOKEN = 'GetTotalDriversUseCase';
export const COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN = 'CompleteDriverOnboardingUseCase';
export const IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN = 'IsDriverRegisteredForRaceUseCase';
export const UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN = 'UpdateDriverProfileUseCase';
export const GET_PROFILE_OVERVIEW_USE_CASE_TOKEN = 'GetProfileOverviewUseCase';
// Output port tokens
export const GET_DRIVERS_LEADERBOARD_OUTPUT_PORT_TOKEN = 'GetDriversLeaderboardOutputPort_TOKEN';
export const GET_TOTAL_DRIVERS_OUTPUT_PORT_TOKEN = 'GetTotalDriversOutputPort_TOKEN';
export const COMPLETE_DRIVER_ONBOARDING_OUTPUT_PORT_TOKEN = 'CompleteDriverOnboardingOutputPort_TOKEN';
export const IS_DRIVER_REGISTERED_FOR_RACE_OUTPUT_PORT_TOKEN = 'IsDriverRegisteredForRaceOutputPort_TOKEN';
export const UPDATE_DRIVER_PROFILE_OUTPUT_PORT_TOKEN = 'UpdateDriverProfileOutputPort_TOKEN';
export const GET_PROFILE_OVERVIEW_OUTPUT_PORT_TOKEN = 'GetProfileOverviewOutputPort_TOKEN';
export * from './DriverTokens';
export const DriverProviders: Provider[] = [
DriverService,
// Presenters
DriversLeaderboardPresenter,
@@ -207,9 +205,9 @@ export const DriverProviders: Provider[] = [
},
{
provide: UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN,
useFactory: (driverRepo: IDriverRepository, logger: Logger) =>
new UpdateDriverProfileUseCase(driverRepo, logger),
inject: [DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN],
useFactory: (driverRepo: IDriverRepository, logger: Logger, output: UseCaseOutputPort<unknown>) =>
new UpdateDriverProfileUseCase(driverRepo, logger, output),
inject: [DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN, UPDATE_DRIVER_PROFILE_OUTPUT_PORT_TOKEN],
},
{
provide: GET_PROFILE_OVERVIEW_USE_CASE_TOKEN,
@@ -218,17 +216,16 @@ export const DriverProviders: Provider[] = [
teamRepository: ITeamRepository,
teamMembershipRepository: ITeamMembershipRepository,
socialRepository: ISocialGraphRepository,
imageService: IImageServicePort,
driverExtendedProfileProvider: DriverExtendedProfileProvider,
driverStatsService: IDriverStatsService,
rankingService: IRankingService,
output: UseCaseOutputPort<unknown>,
) =>
new GetProfileOverviewUseCase(
driverRepo,
teamRepository,
teamMembershipRepository,
socialRepository,
imageService,
driverExtendedProfileProvider,
(driverId: string) => {
const stats = driverStatsService.getDriverStats(driverId);
@@ -240,7 +237,7 @@ export const DriverProviders: Provider[] = [
rating: stats.rating,
wins: stats.wins,
podiums: stats.podiums,
dnfs: 0,
dnfs: (stats as { dnfs?: number }).dnfs ?? 0,
totalRaces: stats.totalRaces,
avgFinish: null,
bestFinish: null,
@@ -256,16 +253,17 @@ export const DriverProviders: Provider[] = [
rating: ranking.rating,
overallRank: ranking.overallRank,
})),
output,
),
inject: [
DRIVER_REPOSITORY_TOKEN,
TEAM_REPOSITORY_TOKEN,
TEAM_MEMBERSHIP_REPOSITORY_TOKEN,
SOCIAL_GRAPH_REPOSITORY_TOKEN,
IMAGE_SERVICE_PORT_TOKEN,
DRIVER_EXTENDED_PROFILE_PROVIDER_TOKEN,
DRIVER_STATS_SERVICE_TOKEN,
RANKING_SERVICE_TOKEN,
GET_PROFILE_OVERVIEW_OUTPUT_PORT_TOKEN,
],
},
];

View File

@@ -1,31 +1,262 @@
import { vi } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { DriverService } from './DriverService';
describe('DriverService', () => {
let service: DriverService;
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
beforeEach(() => {
// Mock all dependencies
service = new DriverService(
{} as any, // getDriversLeaderboardUseCase
{} as any, // getTotalDriversUseCase
{} as any, // completeDriverOnboardingUseCase
{} as any, // isDriverRegisteredForRaceUseCase
{} as any, // updateDriverProfileUseCase
{} as any, // getProfileOverviewUseCase
{} as any, // driverRepository
{} as any, // logger
// Presenters
{} as any, // driversLeaderboardPresenter
{} as any, // driverStatsPresenter
{} as any, // completeOnboardingPresenter
{} as any, // driverRegistrationStatusPresenter
{} as any, // driverPresenter
{} as any, // driverProfilePresenter
it('getDriversLeaderboard executes use case and returns presenter model', async () => {
const getDriversLeaderboardUseCase = { execute: vi.fn(async () => {}) };
const driversLeaderboardPresenter = { getResponseModel: vi.fn(() => ({ items: [] })) };
const service = new DriverService(
getDriversLeaderboardUseCase as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ findById: vi.fn() } as any,
logger as any,
driversLeaderboardPresenter as any,
{ getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any,
{ getResponseModel: vi.fn(() => ({ success: true })) } as any,
{ getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any,
{ getResponseModel: vi.fn(() => null) } as any,
{ getResponseModel: vi.fn(() => ({ profile: {} })) } as any,
);
await expect(service.getDriversLeaderboard()).resolves.toEqual({ items: [] });
expect(getDriversLeaderboardUseCase.execute).toHaveBeenCalledWith({});
expect(driversLeaderboardPresenter.getResponseModel).toHaveBeenCalled();
});
it('should be created', () => {
expect(service).toBeDefined();
it('getTotalDrivers executes use case and returns presenter model', async () => {
const getTotalDriversUseCase = { execute: vi.fn(async () => {}) };
const driverStatsPresenter = { getResponseModel: vi.fn(() => ({ totalDrivers: 123 })) };
const service = new DriverService(
{ execute: vi.fn() } as any,
getTotalDriversUseCase as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ findById: vi.fn() } as any,
logger as any,
{ getResponseModel: vi.fn(() => ({ items: [] })) } as any,
driverStatsPresenter as any,
{ getResponseModel: vi.fn(() => ({ success: true })) } as any,
{ getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any,
{ getResponseModel: vi.fn(() => null) } as any,
{ getResponseModel: vi.fn(() => ({ profile: {} })) } as any,
);
await expect(service.getTotalDrivers()).resolves.toEqual({ totalDrivers: 123 });
expect(getTotalDriversUseCase.execute).toHaveBeenCalledWith({});
expect(driverStatsPresenter.getResponseModel).toHaveBeenCalled();
});
it('completeOnboarding passes optional bio only when provided', async () => {
const completeDriverOnboardingUseCase = { execute: vi.fn(async () => {}) };
const service = new DriverService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
completeDriverOnboardingUseCase as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ findById: vi.fn() } as any,
logger as any,
{ getResponseModel: vi.fn(() => ({ items: [] })) } as any,
{ getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any,
{ getResponseModel: vi.fn(() => ({ success: true })) } as any,
{ getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any,
{ getResponseModel: vi.fn(() => null) } as any,
{ getResponseModel: vi.fn(() => ({ profile: {} })) } as any,
);
await service.completeOnboarding('u1', {
firstName: 'F',
lastName: 'L',
displayName: 'D',
country: 'DE',
} as any);
expect(completeDriverOnboardingUseCase.execute).toHaveBeenCalledWith({
userId: 'u1',
firstName: 'F',
lastName: 'L',
displayName: 'D',
country: 'DE',
});
completeDriverOnboardingUseCase.execute.mockClear();
await service.completeOnboarding('u1', {
firstName: 'F',
lastName: 'L',
displayName: 'D',
country: 'DE',
bio: 'bio',
} as any);
expect(completeDriverOnboardingUseCase.execute).toHaveBeenCalledWith({
userId: 'u1',
firstName: 'F',
lastName: 'L',
displayName: 'D',
country: 'DE',
bio: 'bio',
});
});
it('getDriverRegistrationStatus passes raceId and driverId and returns presenter model', async () => {
const isDriverRegisteredForRaceUseCase = { execute: vi.fn(async () => {}) };
const driverRegistrationStatusPresenter = { getResponseModel: vi.fn(() => ({ isRegistered: true })) };
const service = new DriverService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
isDriverRegisteredForRaceUseCase as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ findById: vi.fn() } as any,
logger as any,
{ getResponseModel: vi.fn(() => ({ items: [] })) } as any,
{ getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any,
{ getResponseModel: vi.fn(() => ({ success: true })) } as any,
driverRegistrationStatusPresenter as any,
{ getResponseModel: vi.fn(() => null) } as any,
{ getResponseModel: vi.fn(() => ({ profile: {} })) } as any,
);
await expect(
service.getDriverRegistrationStatus({ raceId: 'r1', driverId: 'd1' } as any),
).resolves.toEqual({ isRegistered: true });
expect(isDriverRegisteredForRaceUseCase.execute).toHaveBeenCalledWith({ raceId: 'r1', driverId: 'd1' });
expect(driverRegistrationStatusPresenter.getResponseModel).toHaveBeenCalled();
});
it('getCurrentDriver calls repository and returns presenter model', async () => {
const driverRepository = { findById: vi.fn(async () => null) };
const driverPresenter = { getResponseModel: vi.fn(() => null) };
const service = new DriverService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
driverRepository as any,
logger as any,
{ getResponseModel: vi.fn(() => ({ items: [] })) } as any,
{ getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any,
{ getResponseModel: vi.fn(() => ({ success: true })) } as any,
{ getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any,
driverPresenter as any,
{ getResponseModel: vi.fn(() => ({ profile: {} })) } as any,
);
await expect(service.getCurrentDriver('u1')).resolves.toBeNull();
expect(driverRepository.findById).toHaveBeenCalledWith('u1');
expect(driverPresenter.getResponseModel).toHaveBeenCalled();
});
it('updateDriverProfile builds optional input and returns presenter model', async () => {
const updateDriverProfileUseCase = { execute: vi.fn(async () => {}) };
const driverPresenter = { getResponseModel: vi.fn(() => ({ driver: { id: 'd1' } })) };
const service = new DriverService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
updateDriverProfileUseCase as any,
{ execute: vi.fn() } as any,
{ findById: vi.fn() } as any,
logger as any,
{ getResponseModel: vi.fn(() => ({ items: [] })) } as any,
{ getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any,
{ getResponseModel: vi.fn(() => ({ success: true })) } as any,
{ getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any,
driverPresenter as any,
{ getResponseModel: vi.fn(() => ({ profile: {} })) } as any,
);
await service.updateDriverProfile('d1');
expect(updateDriverProfileUseCase.execute).toHaveBeenCalledWith({ driverId: 'd1' });
updateDriverProfileUseCase.execute.mockClear();
await service.updateDriverProfile('d1', 'bio');
expect(updateDriverProfileUseCase.execute).toHaveBeenCalledWith({ driverId: 'd1', bio: 'bio' });
updateDriverProfileUseCase.execute.mockClear();
await service.updateDriverProfile('d1', undefined, 'DE');
expect(updateDriverProfileUseCase.execute).toHaveBeenCalledWith({ driverId: 'd1', country: 'DE' });
updateDriverProfileUseCase.execute.mockClear();
await service.updateDriverProfile('d1', 'bio', 'DE');
expect(updateDriverProfileUseCase.execute).toHaveBeenCalledWith({ driverId: 'd1', bio: 'bio', country: 'DE' });
expect(driverPresenter.getResponseModel).toHaveBeenCalled();
});
it('getDriver calls repository and returns presenter model', async () => {
const driverRepository = { findById: vi.fn(async () => null) };
const driverPresenter = { getResponseModel: vi.fn(() => ({ driver: null })) };
const service = new DriverService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
driverRepository as any,
logger as any,
{ getResponseModel: vi.fn(() => ({ items: [] })) } as any,
{ getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any,
{ getResponseModel: vi.fn(() => ({ success: true })) } as any,
{ getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any,
driverPresenter as any,
{ getResponseModel: vi.fn(() => ({ profile: {} })) } as any,
);
await expect(service.getDriver('d1')).resolves.toEqual({ driver: null });
expect(driverRepository.findById).toHaveBeenCalledWith('d1');
expect(driverPresenter.getResponseModel).toHaveBeenCalled();
});
it('getDriverProfile executes use case and returns presenter model', async () => {
const getProfileOverviewUseCase = { execute: vi.fn(async () => {}) };
const driverProfilePresenter = { getResponseModel: vi.fn(() => ({ profile: { id: 'd1' } })) };
const service = new DriverService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
getProfileOverviewUseCase as any,
{ findById: vi.fn() } as any,
logger as any,
{ getResponseModel: vi.fn(() => ({ items: [] })) } as any,
{ getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any,
{ getResponseModel: vi.fn(() => ({ success: true })) } as any,
{ getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any,
{ getResponseModel: vi.fn(() => null) } as any,
driverProfilePresenter as any,
);
await expect(service.getDriverProfile('d1')).resolves.toEqual({ profile: { id: 'd1' } });
expect(getProfileOverviewUseCase.execute).toHaveBeenCalledWith({ driverId: 'd1' });
expect(driverProfilePresenter.getResponseModel).toHaveBeenCalled();
});
});

View File

@@ -36,7 +36,7 @@ import {
IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN,
LOGGER_TOKEN,
UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN,
} from './DriverProviders';
} from './DriverTokens';
@Injectable()
export class DriverService {

View File

@@ -0,0 +1,26 @@
export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
export const RANKING_SERVICE_TOKEN = 'IRankingService';
export const DRIVER_STATS_SERVICE_TOKEN = 'IDriverStatsService';
export const DRIVER_RATING_PROVIDER_TOKEN = 'DriverRatingProvider';
export const DRIVER_EXTENDED_PROFILE_PROVIDER_TOKEN = 'DriverExtendedProfileProvider';
export const IMAGE_SERVICE_PORT_TOKEN = 'IImageServicePort';
export const RACE_REGISTRATION_REPOSITORY_TOKEN = 'IRaceRegistrationRepository';
export const NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN = 'INotificationPreferenceRepository';
export const TEAM_REPOSITORY_TOKEN = 'ITeamRepository';
export const TEAM_MEMBERSHIP_REPOSITORY_TOKEN = 'ITeamMembershipRepository';
export const SOCIAL_GRAPH_REPOSITORY_TOKEN = 'ISocialGraphRepository';
export const LOGGER_TOKEN = 'Logger';
export const GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN = 'GetDriversLeaderboardUseCase';
export const GET_TOTAL_DRIVERS_USE_CASE_TOKEN = 'GetTotalDriversUseCase';
export const COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN = 'CompleteDriverOnboardingUseCase';
export const IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN = 'IsDriverRegisteredForRaceUseCase';
export const UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN = 'UpdateDriverProfileUseCase';
export const GET_PROFILE_OVERVIEW_USE_CASE_TOKEN = 'GetProfileOverviewUseCase';
export const GET_DRIVERS_LEADERBOARD_OUTPUT_PORT_TOKEN = 'GetDriversLeaderboardOutputPort_TOKEN';
export const GET_TOTAL_DRIVERS_OUTPUT_PORT_TOKEN = 'GetTotalDriversOutputPort_TOKEN';
export const COMPLETE_DRIVER_ONBOARDING_OUTPUT_PORT_TOKEN = 'CompleteDriverOnboardingOutputPort_TOKEN';
export const IS_DRIVER_REGISTERED_FOR_RACE_OUTPUT_PORT_TOKEN = 'IsDriverRegisteredForRaceOutputPort_TOKEN';
export const UPDATE_DRIVER_PROFILE_OUTPUT_PORT_TOKEN = 'UpdateDriverProfileOutputPort_TOKEN';
export const GET_PROFILE_OVERVIEW_OUTPUT_PORT_TOKEN = 'GetProfileOverviewOutputPort_TOKEN';

View File

@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { HelloService } from './HelloService';
describe('HelloService', () => {
it('is defined', () => {
const service = new HelloService();
expect(service).toBeDefined();
});
it('getHello returns "Hello World"', () => {
const service = new HelloService();
expect(service.getHello()).toBe('Hello World');
});
});

View File

@@ -1,41 +1,58 @@
import { Test, TestingModule } from '@nestjs/testing';
import { vi } from 'vitest';
import { LeagueController } from './LeagueController';
import { LeagueService } from './LeagueService';
import { LeagueProviders } from './LeagueProviders';
describe('LeagueController (integration)', () => {
describe('LeagueController', () => {
let controller: LeagueController;
let leagueService: ReturnType<typeof vi.mocked<LeagueService>>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [LeagueController],
providers: [LeagueService, ...LeagueProviders],
providers: [
{
provide: LeagueService,
useValue: {
getTotalLeagues: vi.fn(),
getAllLeaguesWithCapacity: vi.fn(),
getLeagueStandings: vi.fn(),
},
},
],
}).compile();
controller = module.get<LeagueController>(LeagueController);
leagueService = vi.mocked(module.get(LeagueService));
});
it('should get total leagues', async () => {
it('getTotalLeagues should return total leagues', async () => {
const mockResult = { totalLeagues: 1 };
leagueService.getTotalLeagues.mockResolvedValue(mockResult as any);
const result = await controller.getTotalLeagues();
expect(result).toHaveProperty('totalLeagues');
expect(typeof result.totalLeagues).toBe('number');
expect(result).toEqual(mockResult);
expect(leagueService.getTotalLeagues).toHaveBeenCalledTimes(1);
});
it('should get all leagues with capacity', async () => {
it('getAllLeaguesWithCapacity should return leagues and totalCount', async () => {
const mockResult = { leagues: [], totalCount: 0 };
leagueService.getAllLeaguesWithCapacity.mockResolvedValue(mockResult as any);
const result = await controller.getAllLeaguesWithCapacity();
expect(result).toHaveProperty('leagues');
expect(result).toHaveProperty('totalCount');
expect(Array.isArray(result.leagues)).toBe(true);
expect(result).toEqual(mockResult);
expect(leagueService.getAllLeaguesWithCapacity).toHaveBeenCalledTimes(1);
});
it('should get league standings', async () => {
try {
const result = await controller.getLeagueStandings('non-existent-league');
expect(result).toHaveProperty('standings');
expect(Array.isArray(result.standings)).toBe(true);
} catch (error) {
// Expected for non-existent league
expect(error).toBeInstanceOf(Error);
}
it('getLeagueStandings should return standings', async () => {
const mockResult = { standings: [] };
leagueService.getLeagueStandings.mockResolvedValue(mockResult as any);
const result = await controller.getLeagueStandings('league-1');
expect(result).toEqual(mockResult);
expect(leagueService.getLeagueStandings).toHaveBeenCalledWith('league-1');
});
});

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { Body, Controller, Get, Param, Patch, Post, Inject } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { LeagueService } from './LeagueService';
import { AllLeaguesWithCapacityDTO } from './dtos/AllLeaguesWithCapacityDTO';
@@ -37,7 +37,7 @@ import { WithdrawFromLeagueWalletOutputDTO } from './dtos/WithdrawFromLeagueWall
@ApiTags('leagues')
@Controller('leagues')
export class LeagueController {
constructor(private readonly leagueService: LeagueService) {}
constructor(@Inject(LeagueService) private readonly leagueService: LeagueService) {}
@Get('all-with-capacity')
@ApiOperation({ summary: 'Get all leagues with their capacity information' })

View File

@@ -241,10 +241,10 @@ export const LeagueProviders: Provider[] = [
},
// Use cases
{
provide: GetAllLeaguesWithCapacityUseCase,
useFactory: (leagueRepo: ILeagueRepository, membershipRepo: ILeagueMembershipRepository, presenter: AllLeaguesWithCapacityPresenter) =>
new GetAllLeaguesWithCapacityUseCase(leagueRepo, membershipRepo, presenter),
inject: [LEAGUE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, 'AllLeaguesWithCapacityPresenter'],
provide: GET_ALL_LEAGUES_WITH_CAPACITY_USE_CASE,
useFactory: (leagueRepo: ILeagueRepository, membershipRepo: ILeagueMembershipRepository) =>
new GetAllLeaguesWithCapacityUseCase(leagueRepo, membershipRepo),
inject: [LEAGUE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN],
},
{
provide: GET_LEAGUE_STANDINGS_USE_CASE,

View File

@@ -0,0 +1,196 @@
import { describe, expect, it, vi } from 'vitest';
import { Result } from '@core/shared/application/Result';
import { LeagueService } from './LeagueService';
describe('LeagueService', () => {
it('covers LeagueService happy paths and error branches', async () => {
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const ok = async () => Result.ok(undefined);
const err = async () => Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } });
const getAllLeaguesWithCapacityUseCase: any = { execute: vi.fn(async () => Result.ok({ leagues: [] })) };
const getLeagueStandingsUseCase = { execute: vi.fn(ok) };
const getLeagueStatsUseCase = { execute: vi.fn(ok) };
const getLeagueFullConfigUseCase: any = { execute: vi.fn(ok) };
const getLeagueScoringConfigUseCase = { execute: vi.fn(ok) };
const listLeagueScoringPresetsUseCase = { execute: vi.fn(ok) };
const joinLeagueUseCase = { execute: vi.fn(ok) };
const transferLeagueOwnershipUseCase = { execute: vi.fn(ok) };
const createLeagueWithSeasonAndScoringUseCase = { execute: vi.fn(ok) };
const getTotalLeaguesUseCase = { execute: vi.fn(ok) };
const getLeagueJoinRequestsUseCase = { execute: vi.fn(ok) };
const approveLeagueJoinRequestUseCase = { execute: vi.fn(ok) };
const rejectLeagueJoinRequestUseCase = { execute: vi.fn(ok) };
const removeLeagueMemberUseCase = { execute: vi.fn(ok) };
const updateLeagueMemberRoleUseCase = { execute: vi.fn(ok) };
const getLeagueOwnerSummaryUseCase = { execute: vi.fn(ok) };
const getLeagueProtestsUseCase = { execute: vi.fn(ok) };
const getLeagueSeasonsUseCase = { execute: vi.fn(ok) };
const getLeagueMembershipsUseCase = { execute: vi.fn(ok) };
const getLeagueScheduleUseCase = { execute: vi.fn(ok) };
const getLeagueAdminPermissionsUseCase = { execute: vi.fn(ok) };
const getLeagueWalletUseCase = { execute: vi.fn(ok) };
const withdrawFromLeagueWalletUseCase = { execute: vi.fn(ok) };
const getSeasonSponsorshipsUseCase = { execute: vi.fn(ok) };
const allLeaguesWithCapacityPresenter = { present: vi.fn(), getViewModel: vi.fn(() => ({ leagues: [] })) };
const leagueStandingsPresenter = { getResponseModel: vi.fn(() => ({ standings: [] })) };
const leagueProtestsPresenter = { getResponseModel: vi.fn(() => ({ protests: [] })) };
const seasonSponsorshipsPresenter = { getViewModel: vi.fn(() => ({ sponsorships: [] })) };
const leagueScoringPresetsPresenter = { getViewModel: vi.fn(() => ({ presets: [] })) };
const approveLeagueJoinRequestPresenter = { getViewModel: vi.fn(() => ({ success: true })) };
const createLeaguePresenter = { getViewModel: vi.fn(() => ({ id: 'l1' })) };
const getLeagueAdminPermissionsPresenter = { getResponseModel: vi.fn(() => ({ canManage: true })) };
const getLeagueMembershipsPresenter = { getViewModel: vi.fn(() => ({ memberships: { memberships: [] } })) };
const getLeagueOwnerSummaryPresenter = { getViewModel: vi.fn(() => ({ ownerId: 'o1' })) };
const getLeagueSeasonsPresenter = { getResponseModel: vi.fn(() => ([])) };
const joinLeaguePresenter = { getViewModel: vi.fn(() => ({ success: true })) };
const leagueSchedulePresenter = { getViewModel: vi.fn(() => ({ schedule: [] })) };
const leagueStatsPresenter = { getResponseModel: vi.fn(() => ({ stats: {} })) };
const rejectLeagueJoinRequestPresenter = { getViewModel: vi.fn(() => ({ success: true })) };
const removeLeagueMemberPresenter = { getViewModel: vi.fn(() => ({ success: true })) };
const totalLeaguesPresenter = { getResponseModel: vi.fn(() => ({ total: 1 })) };
const transferLeagueOwnershipPresenter = { getViewModel: vi.fn(() => ({ success: true })) };
const updateLeagueMemberRolePresenter = { getViewModel: vi.fn(() => ({ success: true })) };
const leagueConfigPresenter = { getViewModel: vi.fn(() => ({ form: {} })) };
const leagueScoringConfigPresenter = { getViewModel: vi.fn(() => ({ config: {} })) };
const getLeagueWalletPresenter = { getResponseModel: vi.fn(() => ({ balance: 0 })) };
const withdrawFromLeagueWalletPresenter = { getResponseModel: vi.fn(() => ({ success: true })) };
const leagueJoinRequestsPresenter = { getViewModel: vi.fn(() => ({ joinRequests: [] })) };
const leagueRacesPresenter = { getViewModel: vi.fn(() => ([])) };
const service = new LeagueService(
getAllLeaguesWithCapacityUseCase as any,
getLeagueStandingsUseCase as any,
getLeagueStatsUseCase as any,
getLeagueFullConfigUseCase as any,
getLeagueScoringConfigUseCase as any,
listLeagueScoringPresetsUseCase as any,
joinLeagueUseCase as any,
transferLeagueOwnershipUseCase as any,
createLeagueWithSeasonAndScoringUseCase as any,
getTotalLeaguesUseCase as any,
getLeagueJoinRequestsUseCase as any,
approveLeagueJoinRequestUseCase as any,
rejectLeagueJoinRequestUseCase as any,
removeLeagueMemberUseCase as any,
updateLeagueMemberRoleUseCase as any,
getLeagueOwnerSummaryUseCase as any,
getLeagueProtestsUseCase as any,
getLeagueSeasonsUseCase as any,
getLeagueMembershipsUseCase as any,
getLeagueScheduleUseCase as any,
getLeagueAdminPermissionsUseCase as any,
getLeagueWalletUseCase as any,
withdrawFromLeagueWalletUseCase as any,
getSeasonSponsorshipsUseCase as any,
logger as any,
allLeaguesWithCapacityPresenter as any,
leagueStandingsPresenter as any,
leagueProtestsPresenter as any,
seasonSponsorshipsPresenter as any,
leagueScoringPresetsPresenter as any,
approveLeagueJoinRequestPresenter as any,
createLeaguePresenter as any,
getLeagueAdminPermissionsPresenter as any,
getLeagueMembershipsPresenter as any,
getLeagueOwnerSummaryPresenter as any,
getLeagueSeasonsPresenter as any,
joinLeaguePresenter as any,
leagueSchedulePresenter as any,
leagueStatsPresenter as any,
rejectLeagueJoinRequestPresenter as any,
removeLeagueMemberPresenter as any,
totalLeaguesPresenter as any,
transferLeagueOwnershipPresenter as any,
updateLeagueMemberRolePresenter as any,
leagueConfigPresenter as any,
leagueScoringConfigPresenter as any,
getLeagueWalletPresenter as any,
withdrawFromLeagueWalletPresenter as any,
leagueJoinRequestsPresenter as any,
leagueRacesPresenter as any,
);
await expect(service.getTotalLeagues()).resolves.toEqual({ total: 1 });
await expect(service.getLeagueJoinRequests('l1')).resolves.toEqual([]);
await expect(service.approveLeagueJoinRequest({ leagueId: 'l1', requestId: 'r1' } as any)).resolves.toEqual({ success: true });
await expect(service.rejectLeagueJoinRequest({ leagueId: 'l1', requestId: 'r1' } as any)).resolves.toEqual({ success: true });
await expect(service.getLeagueAdminPermissions({ leagueId: 'l1' } as any)).resolves.toEqual({ canManage: true });
await expect(service.removeLeagueMember({ leagueId: 'l1', targetDriverId: 'd1' } as any)).resolves.toEqual({ success: true });
await expect(service.updateLeagueMemberRole({ leagueId: 'l1', targetDriverId: 'd1', newRole: 'member' } as any)).resolves.toEqual({ success: true });
await expect(service.getLeagueOwnerSummary({ leagueId: 'l1' } as any)).resolves.toEqual({ ownerId: 'o1' });
await expect(service.getLeagueProtests({ leagueId: 'l1' } as any)).resolves.toEqual({ protests: [] });
await expect(service.getLeagueSeasons({ leagueId: 'l1' } as any)).resolves.toEqual([]);
await expect(service.getLeagueFullConfig({ leagueId: 'l1' } as any)).resolves.toEqual({ form: {} });
await expect(service.getLeagueScoringConfig('l1')).resolves.toEqual({ config: {} });
await expect(service.getLeagueMemberships('l1')).resolves.toEqual({ memberships: [] });
await expect(service.getLeagueStandings('l1')).resolves.toEqual({ standings: [] });
await expect(service.getLeagueSchedule('l1')).resolves.toEqual({ schedule: [] });
await expect(service.getLeagueStats('l1')).resolves.toEqual({ stats: {} });
await expect(service.createLeague({ name: 'n', description: 'd', ownerId: 'o' } as any)).resolves.toEqual({ id: 'l1' });
await expect(service.listLeagueScoringPresets()).resolves.toEqual({ presets: [] });
await expect(service.joinLeague('l1', 'd1')).resolves.toEqual({ success: true });
await expect(service.transferLeagueOwnership('l1', 'o1', 'o2')).resolves.toEqual({ success: true });
await expect(service.getSeasonSponsorships('s1')).resolves.toEqual({ sponsorships: [] });
await expect(service.getRaces('l1')).resolves.toEqual({ races: [] });
await expect(service.getLeagueWallet('l1')).resolves.toEqual({ balance: 0 });
await expect(service.withdrawFromLeagueWallet('l1', { amount: 1, currency: 'USD', destinationAccount: 'x' } as any)).resolves.toEqual({
success: true,
});
await expect(service.getAllLeaguesWithCapacity()).resolves.toEqual({ leagues: [] });
// Error branch: getAllLeaguesWithCapacity throws on result.isErr()
getAllLeaguesWithCapacityUseCase.execute.mockResolvedValueOnce(Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } }));
await expect(service.getAllLeaguesWithCapacity()).rejects.toThrow('REPOSITORY_ERROR');
// Error branches: try/catch returning null
getLeagueFullConfigUseCase.execute.mockImplementationOnce(async () => {
throw new Error('boom');
});
await expect(service.getLeagueFullConfig({ leagueId: 'l1' } as any)).resolves.toBeNull();
getLeagueScoringConfigUseCase.execute.mockImplementationOnce(async () => {
throw new Error('boom');
});
await expect(service.getLeagueScoringConfig('l1')).resolves.toBeNull();
// Cover non-Error throw branches for logger.error wrapping
getLeagueFullConfigUseCase.execute.mockImplementationOnce(async () => {
throw 'boom';
});
await expect(service.getLeagueFullConfig({ leagueId: 'l1' } as any)).resolves.toBeNull();
getLeagueScoringConfigUseCase.execute.mockImplementationOnce(async () => {
throw 'boom';
});
await expect(service.getLeagueScoringConfig('l1')).resolves.toBeNull();
// getLeagueAdmin error branch: fullConfigResult is Err
getLeagueFullConfigUseCase.execute.mockResolvedValueOnce(Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } }));
await expect(service.getLeagueAdmin('l1')).rejects.toThrow('REPOSITORY_ERROR');
// getLeagueAdmin happy path
getLeagueFullConfigUseCase.execute.mockResolvedValueOnce(Result.ok(undefined));
await expect(service.getLeagueAdmin('l1')).resolves.toEqual({
joinRequests: [],
ownerSummary: { ownerId: 'o1' },
config: { form: { form: {} } },
protests: { protests: [] },
seasons: [],
});
// keep lint happy (ensures err() used)
await err();
});
});

View File

@@ -204,7 +204,17 @@ export class LeagueService {
async getAllLeaguesWithCapacity(): Promise<AllLeaguesWithCapacityViewModel> {
this.logger.debug('[LeagueService] Fetching all leagues with capacity.');
await this.getAllLeaguesWithCapacityUseCase.execute();
const result = await this.getAllLeaguesWithCapacityUseCase.execute({});
if (result.isErr()) {
const err = result.unwrapErr();
this.logger.error('[LeagueService] Failed to fetch leagues with capacity', new Error(err.code), {
details: err.details,
});
throw new Error(err.code);
}
this.allLeaguesWithCapacityPresenter.present(result.unwrap());
return this.allLeaguesWithCapacityPresenter.getViewModel();
}

View File

@@ -13,12 +13,17 @@ describe('GetLeagueMembershipsPresenter', () => {
membership: {
driverId: 'driver-1',
role: 'member',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
joinedAt: {} as any,
joinedAt: { toDate: () => new Date('2024-01-01T00:00:00Z') },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
driver: { id: 'driver-1', name: 'John Doe' } as any,
driver: {
id: 'driver-1',
iracingId: '12345',
name: 'John Doe',
country: 'US',
joinedAt: { toDate: () => new Date('2024-01-01T00:00:00Z') },
} as any,
},
],
};

View File

@@ -10,15 +10,20 @@ export class GetLeagueSeasonsPresenter implements Presenter<GetLeagueSeasonsResu
}
present(input: GetLeagueSeasonsResult) {
this.result = input.seasons.map(seasonSummary => ({
seasonId: seasonSummary.season.id.toString(),
name: seasonSummary.season.name.toString(),
status: seasonSummary.season.status.toString(),
startDate: seasonSummary.season.startDate.toISOString(),
endDate: seasonSummary.season.endDate?.toISOString(),
isPrimary: seasonSummary.isPrimary,
isParallelActive: seasonSummary.isParallelActive,
}));
this.result = input.seasons.map((seasonSummary) => {
const dto = new LeagueSeasonSummaryDTO();
dto.seasonId = seasonSummary.season.id.toString();
dto.name = seasonSummary.season.name.toString();
dto.status = seasonSummary.season.status.toString();
if (seasonSummary.season.startDate) dto.startDate = seasonSummary.season.startDate;
if (seasonSummary.season.endDate) dto.endDate = seasonSummary.season.endDate;
dto.isPrimary = seasonSummary.isPrimary;
dto.isParallelActive = seasonSummary.isParallelActive;
return dto;
});
}
getResponseModel(): LeagueSeasonSummaryDTO[] | null {

View File

@@ -14,8 +14,7 @@ describe('LeagueOwnerSummaryPresenter', () => {
name: 'John Doe',
country: 'US',
bio: 'Racing enthusiast',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
joinedAt: {} as any,
joinedAt: { toDate: () => new Date('2024-01-01T00:00:00Z') } as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
rating: 1500,

View File

@@ -1,4 +1,4 @@
import { Controller, Post, Get, Delete, Put, Body, HttpStatus, Res, Param, UseInterceptors, UploadedFile } from '@nestjs/common';
import { Controller, Post, Get, Delete, Put, Body, HttpStatus, Res, Param, UseInterceptors, UploadedFile, Inject } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation, ApiParam, ApiConsumes } from '@nestjs/swagger';
import type { Response } from 'express';
import { FileInterceptor } from '@nestjs/platform-express';
@@ -21,7 +21,7 @@ type UpdateAvatarInput = UpdateAvatarInputDTO;
@ApiTags('media')
@Controller('media')
export class MediaController {
constructor(private readonly mediaService: MediaService) {}
constructor(@Inject(MediaService) private readonly mediaService: MediaService) {}
@Post('avatar/generate')
@ApiOperation({ summary: 'Request avatar generation' })

View File

@@ -5,7 +5,7 @@ import { MediaProviders } from './MediaProviders';
@Module({
controllers: [MediaController],
providers: MediaProviders,
providers: [MediaService, ...MediaProviders],
exports: [MediaService],
})
export class MediaModule {}

View File

@@ -34,30 +34,29 @@ import { DeleteMediaPresenter } from './presenters/DeleteMediaPresenter';
import { GetAvatarPresenter } from './presenters/GetAvatarPresenter';
import { UpdateAvatarPresenter } from './presenters/UpdateAvatarPresenter';
// Define injection tokens
export const AVATAR_GENERATION_REPOSITORY_TOKEN = 'IAvatarGenerationRepository';
export const MEDIA_REPOSITORY_TOKEN = 'IMediaRepository';
export const AVATAR_REPOSITORY_TOKEN = 'IAvatarRepository';
export const FACE_VALIDATION_PORT_TOKEN = 'FaceValidationPort';
export const AVATAR_GENERATION_PORT_TOKEN = 'AvatarGenerationPort';
export const MEDIA_STORAGE_PORT_TOKEN = 'MediaStoragePort';
export const LOGGER_TOKEN = 'Logger';
import {
AVATAR_GENERATION_REPOSITORY_TOKEN,
MEDIA_REPOSITORY_TOKEN,
AVATAR_REPOSITORY_TOKEN,
FACE_VALIDATION_PORT_TOKEN,
AVATAR_GENERATION_PORT_TOKEN,
MEDIA_STORAGE_PORT_TOKEN,
LOGGER_TOKEN,
REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN,
UPLOAD_MEDIA_USE_CASE_TOKEN,
GET_MEDIA_USE_CASE_TOKEN,
DELETE_MEDIA_USE_CASE_TOKEN,
GET_AVATAR_USE_CASE_TOKEN,
UPDATE_AVATAR_USE_CASE_TOKEN,
REQUEST_AVATAR_GENERATION_OUTPUT_PORT_TOKEN,
UPLOAD_MEDIA_OUTPUT_PORT_TOKEN,
GET_MEDIA_OUTPUT_PORT_TOKEN,
DELETE_MEDIA_OUTPUT_PORT_TOKEN,
GET_AVATAR_OUTPUT_PORT_TOKEN,
UPDATE_AVATAR_OUTPUT_PORT_TOKEN,
} from './MediaTokens';
// Use case tokens
export const REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN = 'RequestAvatarGenerationUseCase';
export const UPLOAD_MEDIA_USE_CASE_TOKEN = 'UploadMediaUseCase';
export const GET_MEDIA_USE_CASE_TOKEN = 'GetMediaUseCase';
export const DELETE_MEDIA_USE_CASE_TOKEN = 'DeleteMediaUseCase';
export const GET_AVATAR_USE_CASE_TOKEN = 'GetAvatarUseCase';
export const UPDATE_AVATAR_USE_CASE_TOKEN = 'UpdateAvatarUseCase';
// Output port tokens
export const REQUEST_AVATAR_GENERATION_OUTPUT_PORT_TOKEN = 'RequestAvatarGenerationOutputPort';
export const UPLOAD_MEDIA_OUTPUT_PORT_TOKEN = 'UploadMediaOutputPort';
export const GET_MEDIA_OUTPUT_PORT_TOKEN = 'GetMediaOutputPort';
export const DELETE_MEDIA_OUTPUT_PORT_TOKEN = 'DeleteMediaOutputPort';
export const GET_AVATAR_OUTPUT_PORT_TOKEN = 'GetAvatarOutputPort';
export const UPDATE_AVATAR_OUTPUT_PORT_TOKEN = 'UpdateAvatarOutputPort';
export * from './MediaTokens';
import type { AvatarGenerationRequest } from '@core/media/domain/entities/AvatarGenerationRequest';
import type { Media } from '@core/media/domain/entities/Media';
@@ -133,7 +132,6 @@ class MockLogger implements Logger {
}
export const MediaProviders: Provider[] = [
MediaService, // Provide the service itself
RequestAvatarGenerationPresenter,
UploadMediaPresenter,
GetMediaPresenter,

View File

@@ -0,0 +1,510 @@
import { describe, expect, it, vi } from 'vitest';
import { Result } from '@core/shared/application/Result';
import { MediaService } from './MediaService';
describe('MediaService', () => {
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
it('requestAvatarGeneration returns presenter response on success', async () => {
const requestAvatarGenerationPresenter = {
responseModel: { success: true, requestId: 'r1', avatarUrls: ['u1'], errorMessage: '' },
};
const requestAvatarGenerationUseCase = {
execute: vi.fn(async () => Result.ok(undefined)),
};
const service = new MediaService(
requestAvatarGenerationUseCase as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
logger as any,
requestAvatarGenerationPresenter as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
);
await expect(
service.requestAvatarGeneration({ userId: 'u1', facePhotoData: {} as any, suitColor: 'red' as any }),
).resolves.toEqual({ success: true, requestId: 'r1', avatarUrls: ['u1'], errorMessage: '' });
expect(requestAvatarGenerationUseCase.execute).toHaveBeenCalledWith({
userId: 'u1',
facePhotoData: {} as any,
suitColor: 'red',
});
});
it('requestAvatarGeneration returns failure DTO on error', async () => {
const service = new MediaService(
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'fail' } })) } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
logger as any,
{ responseModel: { success: true } } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
);
await expect(
service.requestAvatarGeneration({ userId: 'u1', facePhotoData: {} as any, suitColor: 'red' as any }),
).resolves.toEqual({
success: false,
requestId: '',
avatarUrls: [],
errorMessage: 'fail',
});
});
it('uploadMedia returns presenter response on success', async () => {
const uploadMediaPresenter = { responseModel: { success: true, mediaId: 'm1' } };
const uploadMediaUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const service = new MediaService(
{ execute: vi.fn() } as any,
uploadMediaUseCase as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
logger as any,
{ responseModel: {} } as any,
uploadMediaPresenter as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
);
await expect(
service.uploadMedia({ file: {} as any, userId: 'u1', metadata: { a: 1 } } as any),
).resolves.toEqual({
success: true,
mediaId: 'm1',
});
expect(uploadMediaUseCase.execute).toHaveBeenCalledWith({
file: {} as any,
uploadedBy: 'u1',
metadata: { a: 1 },
});
});
it('uploadMedia uses empty uploadedBy when userId missing', async () => {
const uploadMediaUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const service = new MediaService(
{ execute: vi.fn() } as any,
uploadMediaUseCase as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
logger as any,
{ responseModel: {} } as any,
{ responseModel: { success: true, mediaId: 'm1' } } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
);
await expect(service.uploadMedia({ file: {} as any } as any)).resolves.toEqual({ success: true, mediaId: 'm1' });
expect(uploadMediaUseCase.execute).toHaveBeenCalledWith({ file: {} as any, uploadedBy: '', metadata: {} });
});
it('uploadMedia returns failure DTO on error', async () => {
const uploadMediaUseCase = {
execute: vi.fn(async () => Result.err({ code: 'UPLOAD_FAILED', details: { message: 'nope' } })),
};
const service = new MediaService(
{ execute: vi.fn() } as any,
uploadMediaUseCase as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
logger as any,
{ responseModel: {} } as any,
{ responseModel: { success: true } } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
);
await expect(service.uploadMedia({ file: {} as any, userId: 'u1' } as any)).resolves.toEqual({
success: false,
error: 'nope',
});
});
it('getMedia returns presenter response on success', async () => {
const getMediaUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const getMediaPresenter = { responseModel: { mediaId: 'm1' } };
const service = new MediaService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
getMediaUseCase as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
logger as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
getMediaPresenter as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
);
await expect(service.getMedia('m1')).resolves.toEqual({ mediaId: 'm1' });
expect(getMediaUseCase.execute).toHaveBeenCalledWith({ mediaId: 'm1' });
});
it('getMedia returns null on MEDIA_NOT_FOUND', async () => {
const service = new MediaService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn(async () => Result.err({ code: 'MEDIA_NOT_FOUND', details: { message: 'n/a' } })) } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
logger as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
);
await expect(service.getMedia('m1')).resolves.toBeNull();
});
it('getMedia throws on non-not-found error', async () => {
const service = new MediaService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } })) } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
logger as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
);
await expect(service.getMedia('m1')).rejects.toThrow('boom');
});
it('deleteMedia returns presenter response on success', async () => {
const deleteMediaUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const deleteMediaPresenter = { responseModel: { success: true } };
const service = new MediaService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
deleteMediaUseCase as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
logger as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
deleteMediaPresenter as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
);
await expect(service.deleteMedia('m1')).resolves.toEqual({ success: true });
expect(deleteMediaUseCase.execute).toHaveBeenCalledWith({ mediaId: 'm1' });
});
it('deleteMedia returns failure DTO on error', async () => {
const service = new MediaService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'nope' } })) } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
logger as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: { success: true } } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
);
await expect(service.deleteMedia('m1')).resolves.toEqual({ success: false, error: 'nope' });
});
it('getAvatar returns presenter response on success', async () => {
const getAvatarUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const getAvatarPresenter = { responseModel: { avatarUrl: 'u1' } };
const service = new MediaService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
getAvatarUseCase as any,
{ execute: vi.fn() } as any,
logger as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
getAvatarPresenter as any,
{ responseModel: {} } as any,
);
await expect(service.getAvatar('d1')).resolves.toEqual({ avatarUrl: 'u1' });
expect(getAvatarUseCase.execute).toHaveBeenCalledWith({ driverId: 'd1' });
});
it('getAvatar returns null on AVATAR_NOT_FOUND', async () => {
const service = new MediaService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn(async () => Result.err({ code: 'AVATAR_NOT_FOUND', details: { message: 'n/a' } })) } as any,
{ execute: vi.fn() } as any,
logger as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
);
await expect(service.getAvatar('d1')).resolves.toBeNull();
});
it('getAvatar throws on non-not-found error', async () => {
const service = new MediaService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'boom' } })) } as any,
{ execute: vi.fn() } as any,
logger as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
);
await expect(service.getAvatar('d1')).rejects.toThrow('boom');
});
it('updateAvatar returns presenter response on success', async () => {
const updateAvatarUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const updateAvatarPresenter = { responseModel: { success: true } };
const service = new MediaService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
updateAvatarUseCase as any,
logger as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
updateAvatarPresenter as any,
);
await expect(service.updateAvatar('d1', { avatarUrl: 'u1' } as any)).resolves.toEqual({ success: true });
expect(updateAvatarUseCase.execute).toHaveBeenCalledWith({ driverId: 'd1', mediaUrl: 'u1' });
});
it('updateAvatar returns failure DTO on error', async () => {
const service = new MediaService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'nope' } })) } as any,
logger as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: { success: true } } as any,
);
await expect(service.updateAvatar('d1', { avatarUrl: 'u' } as any)).resolves.toEqual({
success: false,
error: 'nope',
});
});
it('requestAvatarGeneration uses fallback errorMessage when no details.message', async () => {
const service = new MediaService(
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR' } as any)) } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
logger as any,
{ responseModel: { success: true } } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
);
await expect(
service.requestAvatarGeneration({ userId: 'u1', facePhotoData: {} as any, suitColor: 'red' as any }),
).resolves.toEqual({
success: false,
requestId: '',
avatarUrls: [],
errorMessage: 'Failed to request avatar generation',
});
});
it('uploadMedia uses fallback error when no details.message', async () => {
const uploadMediaUseCase = {
execute: vi.fn(async () => Result.err({ code: 'UPLOAD_FAILED' } as any)),
};
const service = new MediaService(
{ execute: vi.fn() } as any,
uploadMediaUseCase as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
logger as any,
{ responseModel: {} } as any,
{ responseModel: { success: true } } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
);
await expect(service.uploadMedia({ file: {} as any, userId: 'u1' } as any)).resolves.toEqual({
success: false,
error: 'Upload failed',
});
});
it('getMedia throws fallback message when no details.message and not not-found', async () => {
const service = new MediaService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR' } as any)) } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
logger as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
);
await expect(service.getMedia('m1')).rejects.toThrow('Failed to get media');
});
it('deleteMedia uses fallback message when no details.message', async () => {
const service = new MediaService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR' } as any)) } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
logger as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: { success: true } } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
);
await expect(service.deleteMedia('m1')).resolves.toEqual({ success: false, error: 'Failed to delete media' });
});
it('getAvatar throws fallback message when no details.message and not not-found', async () => {
const service = new MediaService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR' } as any)) } as any,
{ execute: vi.fn() } as any,
logger as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
);
await expect(service.getAvatar('d1')).rejects.toThrow('Failed to get avatar');
});
it('updateAvatar uses fallback message when no details.message', async () => {
const service = new MediaService(
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn() } as any,
{ execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR' } as any)) } as any,
logger as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: {} } as any,
{ responseModel: { success: true } } as any,
);
await expect(service.updateAvatar('d1', { avatarUrl: 'u' } as any)).resolves.toEqual({
success: false,
error: 'Failed to update avatar',
});
});
});

View File

@@ -31,7 +31,6 @@ import { DeleteMediaPresenter } from './presenters/DeleteMediaPresenter';
import { GetAvatarPresenter } from './presenters/GetAvatarPresenter';
import { UpdateAvatarPresenter } from './presenters/UpdateAvatarPresenter';
// Tokens
import {
REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN,
UPLOAD_MEDIA_USE_CASE_TOKEN,
@@ -40,7 +39,7 @@ import {
GET_AVATAR_USE_CASE_TOKEN,
UPDATE_AVATAR_USE_CASE_TOKEN,
LOGGER_TOKEN,
} from './MediaProviders';
} from './MediaTokens';
import type { Logger } from '@core/shared/application';
@Injectable()

View File

@@ -0,0 +1,21 @@
export const AVATAR_GENERATION_REPOSITORY_TOKEN = 'IAvatarGenerationRepository';
export const MEDIA_REPOSITORY_TOKEN = 'IMediaRepository';
export const AVATAR_REPOSITORY_TOKEN = 'IAvatarRepository';
export const FACE_VALIDATION_PORT_TOKEN = 'FaceValidationPort';
export const AVATAR_GENERATION_PORT_TOKEN = 'AvatarGenerationPort';
export const MEDIA_STORAGE_PORT_TOKEN = 'MediaStoragePort';
export const LOGGER_TOKEN = 'Logger';
export const REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN = 'RequestAvatarGenerationUseCase';
export const UPLOAD_MEDIA_USE_CASE_TOKEN = 'UploadMediaUseCase';
export const GET_MEDIA_USE_CASE_TOKEN = 'GetMediaUseCase';
export const DELETE_MEDIA_USE_CASE_TOKEN = 'DeleteMediaUseCase';
export const GET_AVATAR_USE_CASE_TOKEN = 'GetAvatarUseCase';
export const UPDATE_AVATAR_USE_CASE_TOKEN = 'UpdateAvatarUseCase';
export const REQUEST_AVATAR_GENERATION_OUTPUT_PORT_TOKEN = 'RequestAvatarGenerationOutputPort';
export const UPLOAD_MEDIA_OUTPUT_PORT_TOKEN = 'UploadMediaOutputPort';
export const GET_MEDIA_OUTPUT_PORT_TOKEN = 'GetMediaOutputPort';
export const DELETE_MEDIA_OUTPUT_PORT_TOKEN = 'DeleteMediaOutputPort';
export const GET_AVATAR_OUTPUT_PORT_TOKEN = 'GetAvatarOutputPort';
export const UPDATE_AVATAR_OUTPUT_PORT_TOKEN = 'UpdateAvatarOutputPort';

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Post, Patch, Delete, Body, Query, HttpCode, HttpStatus } from '@nestjs/common';
import { Controller, Get, Post, Patch, Delete, Body, Query, HttpCode, HttpStatus, Inject } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger';
import { PaymentsService } from './PaymentsService';
import { CreatePaymentInput, CreatePaymentOutput, UpdatePaymentStatusInput, UpdatePaymentStatusOutput, GetPaymentsQuery, GetPaymentsOutput, GetMembershipFeesQuery, GetMembershipFeesOutput, UpsertMembershipFeeInput, UpsertMembershipFeeOutput, UpdateMemberPaymentInput, UpdateMemberPaymentOutput, GetPrizesQuery, GetPrizesOutput, CreatePrizeInput, CreatePrizeOutput, AwardPrizeInput, AwardPrizeOutput, DeletePrizeInput, DeletePrizeOutput, GetWalletQuery, GetWalletOutput, ProcessWalletTransactionInput, ProcessWalletTransactionOutput } from './dtos/PaymentsDto';
@@ -6,7 +6,7 @@ import { CreatePaymentInput, CreatePaymentOutput, UpdatePaymentStatusInput, Upda
@ApiTags('payments')
@Controller('payments')
export class PaymentsController {
constructor(private readonly paymentsService: PaymentsService) {}
constructor(@Inject(PaymentsService) private readonly paymentsService: PaymentsService) {}
@Get()
@ApiOperation({ summary: 'Get payments based on filters' })

View File

@@ -5,7 +5,7 @@ import { PaymentsProviders } from './PaymentsProviders';
@Module({
controllers: [PaymentsController],
providers: PaymentsProviders,
providers: [PaymentsService, ...PaymentsProviders],
exports: [PaymentsService],
})
export class PaymentsModule {}

View File

@@ -43,45 +43,43 @@ import { DeletePrizePresenter } from './presenters/DeletePrizePresenter';
import { GetWalletPresenter } from './presenters/GetWalletPresenter';
import { ProcessWalletTransactionPresenter } from './presenters/ProcessWalletTransactionPresenter';
// Repository injection tokens
export const PAYMENT_REPOSITORY_TOKEN = 'IPaymentRepository';
export const MEMBERSHIP_FEE_REPOSITORY_TOKEN = 'IMembershipFeeRepository';
export const MEMBER_PAYMENT_REPOSITORY_TOKEN = 'IMemberPaymentRepository';
export const PRIZE_REPOSITORY_TOKEN = 'IPrizeRepository';
export const WALLET_REPOSITORY_TOKEN = 'IWalletRepository';
export const TRANSACTION_REPOSITORY_TOKEN = 'ITransactionRepository';
export const LOGGER_TOKEN = 'Logger';
import {
PAYMENT_REPOSITORY_TOKEN,
MEMBERSHIP_FEE_REPOSITORY_TOKEN,
MEMBER_PAYMENT_REPOSITORY_TOKEN,
PRIZE_REPOSITORY_TOKEN,
WALLET_REPOSITORY_TOKEN,
TRANSACTION_REPOSITORY_TOKEN,
LOGGER_TOKEN,
GET_PAYMENTS_USE_CASE_TOKEN,
CREATE_PAYMENT_USE_CASE_TOKEN,
UPDATE_PAYMENT_STATUS_USE_CASE_TOKEN,
GET_MEMBERSHIP_FEES_USE_CASE_TOKEN,
UPSERT_MEMBERSHIP_FEE_USE_CASE_TOKEN,
UPDATE_MEMBER_PAYMENT_USE_CASE_TOKEN,
GET_PRIZES_USE_CASE_TOKEN,
CREATE_PRIZE_USE_CASE_TOKEN,
AWARD_PRIZE_USE_CASE_TOKEN,
DELETE_PRIZE_USE_CASE_TOKEN,
GET_WALLET_USE_CASE_TOKEN,
PROCESS_WALLET_TRANSACTION_USE_CASE_TOKEN,
GET_PAYMENTS_OUTPUT_PORT_TOKEN,
CREATE_PAYMENT_OUTPUT_PORT_TOKEN,
UPDATE_PAYMENT_STATUS_OUTPUT_PORT_TOKEN,
GET_MEMBERSHIP_FEES_OUTPUT_PORT_TOKEN,
UPSERT_MEMBERSHIP_FEE_OUTPUT_PORT_TOKEN,
UPDATE_MEMBER_PAYMENT_OUTPUT_PORT_TOKEN,
GET_PRIZES_OUTPUT_PORT_TOKEN,
CREATE_PRIZE_OUTPUT_PORT_TOKEN,
AWARD_PRIZE_OUTPUT_PORT_TOKEN,
DELETE_PRIZE_OUTPUT_PORT_TOKEN,
GET_WALLET_OUTPUT_PORT_TOKEN,
PROCESS_WALLET_TRANSACTION_OUTPUT_PORT_TOKEN,
} from './PaymentsTokens';
// Use case injection tokens
export const GET_PAYMENTS_USE_CASE_TOKEN = 'GetPaymentsUseCase';
export const CREATE_PAYMENT_USE_CASE_TOKEN = 'CreatePaymentUseCase';
export const UPDATE_PAYMENT_STATUS_USE_CASE_TOKEN = 'UpdatePaymentStatusUseCase';
export const GET_MEMBERSHIP_FEES_USE_CASE_TOKEN = 'GetMembershipFeesUseCase';
export const UPSERT_MEMBERSHIP_FEE_USE_CASE_TOKEN = 'UpsertMembershipFeeUseCase';
export const UPDATE_MEMBER_PAYMENT_USE_CASE_TOKEN = 'UpdateMemberPaymentUseCase';
export const GET_PRIZES_USE_CASE_TOKEN = 'GetPrizesUseCase';
export const CREATE_PRIZE_USE_CASE_TOKEN = 'CreatePrizeUseCase';
export const AWARD_PRIZE_USE_CASE_TOKEN = 'AwardPrizeUseCase';
export const DELETE_PRIZE_USE_CASE_TOKEN = 'DeletePrizeUseCase';
export const GET_WALLET_USE_CASE_TOKEN = 'GetWalletUseCase';
export const PROCESS_WALLET_TRANSACTION_USE_CASE_TOKEN = 'ProcessWalletTransactionUseCase';
// Output port tokens
export const GET_PAYMENTS_OUTPUT_PORT_TOKEN = 'GetPaymentsOutputPort_TOKEN';
export const CREATE_PAYMENT_OUTPUT_PORT_TOKEN = 'CreatePaymentOutputPort_TOKEN';
export const UPDATE_PAYMENT_STATUS_OUTPUT_PORT_TOKEN = 'UpdatePaymentStatusOutputPort_TOKEN';
export const GET_MEMBERSHIP_FEES_OUTPUT_PORT_TOKEN = 'GetMembershipFeesOutputPort_TOKEN';
export const UPSERT_MEMBERSHIP_FEE_OUTPUT_PORT_TOKEN = 'UpsertMembershipFeeOutputPort_TOKEN';
export const UPDATE_MEMBER_PAYMENT_OUTPUT_PORT_TOKEN = 'UpdateMemberPaymentOutputPort_TOKEN';
export const GET_PRIZES_OUTPUT_PORT_TOKEN = 'GetPrizesOutputPort_TOKEN';
export const CREATE_PRIZE_OUTPUT_PORT_TOKEN = 'CreatePrizeOutputPort_TOKEN';
export const AWARD_PRIZE_OUTPUT_PORT_TOKEN = 'AwardPrizeOutputPort_TOKEN';
export const DELETE_PRIZE_OUTPUT_PORT_TOKEN = 'DeletePrizeOutputPort_TOKEN';
export const GET_WALLET_OUTPUT_PORT_TOKEN = 'GetWalletOutputPort_TOKEN';
export const PROCESS_WALLET_TRANSACTION_OUTPUT_PORT_TOKEN = 'ProcessWalletTransactionOutputPort_TOKEN';
export * from './PaymentsTokens';
export const PaymentsProviders: Provider[] = [
PaymentsService,
// Presenters
GetPaymentsPresenter,

View File

@@ -0,0 +1,313 @@
import { describe, expect, it, vi } from 'vitest';
import { Result } from '@core/shared/application/Result';
import { PaymentsService } from './PaymentsService';
describe('PaymentsService', () => {
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
function makeService(overrides?: Partial<Record<string, any>>) {
const getPaymentsUseCase = overrides?.getPaymentsUseCase ?? { execute: vi.fn(async () => Result.ok(undefined)) };
const createPaymentUseCase = overrides?.createPaymentUseCase ?? { execute: vi.fn(async () => Result.ok(undefined)) };
const updatePaymentStatusUseCase =
overrides?.updatePaymentStatusUseCase ?? { execute: vi.fn(async () => Result.ok(undefined)) };
const getMembershipFeesUseCase =
overrides?.getMembershipFeesUseCase ?? { execute: vi.fn(async () => Result.ok(undefined)) };
const upsertMembershipFeeUseCase =
overrides?.upsertMembershipFeeUseCase ?? { execute: vi.fn(async () => Result.ok(undefined)) };
const updateMemberPaymentUseCase =
overrides?.updateMemberPaymentUseCase ?? { execute: vi.fn(async () => Result.ok(undefined)) };
const getPrizesUseCase = overrides?.getPrizesUseCase ?? { execute: vi.fn(async () => Result.ok(undefined)) };
const createPrizeUseCase = overrides?.createPrizeUseCase ?? { execute: vi.fn(async () => Result.ok(undefined)) };
const awardPrizeUseCase = overrides?.awardPrizeUseCase ?? { execute: vi.fn(async () => Result.ok(undefined)) };
const deletePrizeUseCase = overrides?.deletePrizeUseCase ?? { execute: vi.fn(async () => Result.ok(undefined)) };
const getWalletUseCase = overrides?.getWalletUseCase ?? { execute: vi.fn(async () => Result.ok(undefined)) };
const processWalletTransactionUseCase =
overrides?.processWalletTransactionUseCase ?? { execute: vi.fn(async () => Result.ok(undefined)) };
const getPaymentsPresenter = overrides?.getPaymentsPresenter ?? { getResponseModel: vi.fn(() => ({ payments: [] })) };
const createPaymentPresenter =
overrides?.createPaymentPresenter ?? { getResponseModel: vi.fn(() => ({ paymentId: 'p1' })) };
const updatePaymentStatusPresenter =
overrides?.updatePaymentStatusPresenter ?? { getResponseModel: vi.fn(() => ({ success: true })) };
const getMembershipFeesPresenter = overrides?.getMembershipFeesPresenter ?? { viewModel: { fee: null, payments: [] } };
const upsertMembershipFeePresenter = overrides?.upsertMembershipFeePresenter ?? { viewModel: { success: true } };
const updateMemberPaymentPresenter = overrides?.updateMemberPaymentPresenter ?? { viewModel: { success: true } };
const getPrizesPresenter = overrides?.getPrizesPresenter ?? { viewModel: { prizes: [] } };
const createPrizePresenter = overrides?.createPrizePresenter ?? { viewModel: { success: true } };
const awardPrizePresenter = overrides?.awardPrizePresenter ?? { viewModel: { success: true } };
const deletePrizePresenter = overrides?.deletePrizePresenter ?? { viewModel: { success: true } };
const getWalletPresenter = overrides?.getWalletPresenter ?? { viewModel: { balance: 0 } };
const processWalletTransactionPresenter =
overrides?.processWalletTransactionPresenter ?? { viewModel: { success: true } };
const service = new PaymentsService(
getPaymentsUseCase as any,
createPaymentUseCase as any,
updatePaymentStatusUseCase as any,
getMembershipFeesUseCase as any,
upsertMembershipFeeUseCase as any,
updateMemberPaymentUseCase as any,
getPrizesUseCase as any,
createPrizeUseCase as any,
awardPrizeUseCase as any,
deletePrizeUseCase as any,
getWalletUseCase as any,
processWalletTransactionUseCase as any,
logger as any,
getPaymentsPresenter as any,
createPaymentPresenter as any,
updatePaymentStatusPresenter as any,
getMembershipFeesPresenter as any,
upsertMembershipFeePresenter as any,
updateMemberPaymentPresenter as any,
getPrizesPresenter as any,
createPrizePresenter as any,
awardPrizePresenter as any,
deletePrizePresenter as any,
getWalletPresenter as any,
processWalletTransactionPresenter as any,
);
return {
service,
getPaymentsUseCase,
createPaymentUseCase,
updatePaymentStatusUseCase,
getMembershipFeesUseCase,
upsertMembershipFeeUseCase,
updateMemberPaymentUseCase,
getPrizesUseCase,
createPrizeUseCase,
awardPrizeUseCase,
deletePrizeUseCase,
getWalletUseCase,
processWalletTransactionUseCase,
getPaymentsPresenter,
createPaymentPresenter,
updatePaymentStatusPresenter,
getMembershipFeesPresenter,
upsertMembershipFeePresenter,
updateMemberPaymentPresenter,
getPrizesPresenter,
createPrizePresenter,
awardPrizePresenter,
deletePrizePresenter,
getWalletPresenter,
processWalletTransactionPresenter,
};
}
it('getPayments returns presenter model on success', async () => {
const { service, getPaymentsUseCase, getPaymentsPresenter } = makeService();
await expect(service.getPayments({ leagueId: 'l1' } as any)).resolves.toEqual({ payments: [] });
expect(getPaymentsUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1' });
expect(getPaymentsPresenter.getResponseModel).toHaveBeenCalled();
});
it('getPayments throws when use case returns error (code message)', async () => {
const { service } = makeService({
getPaymentsUseCase: { execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR' })) },
});
await expect(service.getPayments({ leagueId: 'l1' } as any)).rejects.toThrow('REPOSITORY_ERROR');
});
it('createPayment returns presenter model on success', async () => {
const { service, createPaymentUseCase, createPaymentPresenter } = makeService({
createPaymentPresenter: { getResponseModel: vi.fn(() => ({ paymentId: 'p1' })) },
});
await expect(service.createPayment({ leagueId: 'l1' } as any)).resolves.toEqual({ paymentId: 'p1' });
expect(createPaymentUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1' });
expect(createPaymentPresenter.getResponseModel).toHaveBeenCalled();
});
it('createPayment throws when use case returns error', async () => {
const { service } = makeService({
createPaymentUseCase: { execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR' })) },
});
await expect(service.createPayment({ leagueId: 'l1' } as any)).rejects.toThrow('REPOSITORY_ERROR');
});
it('updatePaymentStatus returns presenter model on success', async () => {
const { service, updatePaymentStatusUseCase, updatePaymentStatusPresenter } = makeService({
updatePaymentStatusPresenter: { getResponseModel: vi.fn(() => ({ success: true })) },
});
await expect(service.updatePaymentStatus({ paymentId: 'p1' } as any)).resolves.toEqual({ success: true });
expect(updatePaymentStatusUseCase.execute).toHaveBeenCalledWith({ paymentId: 'p1' });
expect(updatePaymentStatusPresenter.getResponseModel).toHaveBeenCalled();
});
it('updatePaymentStatus throws when use case returns error', async () => {
const { service } = makeService({
updatePaymentStatusUseCase: { execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR' })) },
});
await expect(service.updatePaymentStatus({ paymentId: 'p1' } as any)).rejects.toThrow('REPOSITORY_ERROR');
});
it('getMembershipFees returns viewModel on success', async () => {
const { service, getMembershipFeesUseCase, getMembershipFeesPresenter } = makeService({
getMembershipFeesPresenter: { viewModel: { fee: { amount: 1 }, payments: [] } },
});
await expect(service.getMembershipFees({ leagueId: 'l1', driverId: 'd1' } as any)).resolves.toEqual({
fee: { amount: 1 },
payments: [],
});
expect(getMembershipFeesUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1', driverId: 'd1' });
expect(getMembershipFeesPresenter.viewModel).toBeDefined();
});
it('getMembershipFees throws when use case returns error', async () => {
const { service } = makeService({
getMembershipFeesUseCase: { execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR' })) },
});
await expect(service.getMembershipFees({ leagueId: 'l1' } as any)).rejects.toThrow('REPOSITORY_ERROR');
});
it('upsertMembershipFee returns viewModel on success', async () => {
const { service, upsertMembershipFeeUseCase } = makeService({
upsertMembershipFeePresenter: { viewModel: { success: true } },
});
await expect(service.upsertMembershipFee({ leagueId: 'l1' } as any)).resolves.toEqual({ success: true });
expect(upsertMembershipFeeUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1' });
});
it('upsertMembershipFee throws on error branch (defensive check)', async () => {
const { service } = makeService({
upsertMembershipFeeUseCase: { execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR' } as any)) },
});
await expect(service.upsertMembershipFee({ leagueId: 'l1' } as any)).rejects.toThrow(
'Failed to upsert membership fee',
);
});
it('updateMemberPayment returns viewModel on success', async () => {
const { service, updateMemberPaymentUseCase } = makeService({
updateMemberPaymentPresenter: { viewModel: { success: true } },
});
await expect(service.updateMemberPayment({ leagueId: 'l1' } as any)).resolves.toEqual({ success: true });
expect(updateMemberPaymentUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1' });
});
it('updateMemberPayment throws when use case returns error', async () => {
const { service } = makeService({
updateMemberPaymentUseCase: { execute: vi.fn(async () => Result.err({ code: 'REPOSITORY_ERROR' })) },
});
await expect(service.updateMemberPayment({ leagueId: 'l1' } as any)).rejects.toThrow('REPOSITORY_ERROR');
});
it('getPrizes maps seasonId optional', async () => {
const getPrizesUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const { service } = makeService({
getPrizesUseCase,
getPrizesPresenter: { viewModel: { prizes: [] } },
});
await expect(service.getPrizes({ leagueId: 'l1' } as any)).resolves.toEqual({ prizes: [] });
expect(getPrizesUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1' });
await expect(service.getPrizes({ leagueId: 'l1', seasonId: 's1' } as any)).resolves.toEqual({ prizes: [] });
expect(getPrizesUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1', seasonId: 's1' });
});
it('createPrize calls use case and returns viewModel', async () => {
const createPrizeUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const { service } = makeService({
createPrizeUseCase,
createPrizePresenter: { viewModel: { success: true } },
});
await expect(service.createPrize({ leagueId: 'l1' } as any)).resolves.toEqual({ success: true });
expect(createPrizeUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1' });
});
it('awardPrize calls use case and returns viewModel', async () => {
const awardPrizeUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const { service } = makeService({
awardPrizeUseCase,
awardPrizePresenter: { viewModel: { success: true } },
});
await expect(service.awardPrize({ prizeId: 'p1' } as any)).resolves.toEqual({ success: true });
expect(awardPrizeUseCase.execute).toHaveBeenCalledWith({ prizeId: 'p1' });
});
it('deletePrize calls use case and returns viewModel', async () => {
const deletePrizeUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const { service } = makeService({
deletePrizeUseCase,
deletePrizePresenter: { viewModel: { success: true } },
});
await expect(service.deletePrize({ prizeId: 'p1' } as any)).resolves.toEqual({ success: true });
expect(deletePrizeUseCase.execute).toHaveBeenCalledWith({ prizeId: 'p1' });
});
it('getWallet calls use case and returns viewModel', async () => {
const getWalletUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const { service } = makeService({
getWalletUseCase,
getWalletPresenter: { viewModel: { balance: 10 } },
});
await expect(service.getWallet({ leagueId: 'l1' } as any)).resolves.toEqual({ balance: 10 });
expect(getWalletUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1' });
});
it('processWalletTransaction calls use case and returns viewModel', async () => {
const processWalletTransactionUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const { service } = makeService({
processWalletTransactionUseCase,
processWalletTransactionPresenter: { viewModel: { success: true } },
});
await expect(service.processWalletTransaction({ leagueId: 'l1' } as any)).resolves.toEqual({ success: true });
expect(processWalletTransactionUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1' });
});
it('getPayments throws fallback message when error has no code', async () => {
const { service } = makeService({
getPaymentsUseCase: { execute: vi.fn(async () => Result.err({} as any)) },
});
await expect(service.getPayments({ leagueId: 'l1' } as any)).rejects.toThrow('Failed to get payments');
});
it('createPayment throws fallback message when error has no code', async () => {
const { service } = makeService({
createPaymentUseCase: { execute: vi.fn(async () => Result.err({} as any)) },
});
await expect(service.createPayment({ leagueId: 'l1' } as any)).rejects.toThrow('Failed to create payment');
});
it('updatePaymentStatus throws fallback message when error has no code', async () => {
const { service } = makeService({
updatePaymentStatusUseCase: { execute: vi.fn(async () => Result.err({} as any)) },
});
await expect(service.updatePaymentStatus({ paymentId: 'p1' } as any)).rejects.toThrow('Failed to update payment status');
});
it('getMembershipFees throws fallback message when error has no code', async () => {
const { service } = makeService({
getMembershipFeesUseCase: { execute: vi.fn(async () => Result.err({} as any)) },
});
await expect(service.getMembershipFees({ leagueId: 'l1', driverId: 'd1' } as any)).rejects.toThrow('Failed to get membership fees');
});
it('updateMemberPayment throws fallback message when error has no code', async () => {
const { service } = makeService({
updateMemberPaymentUseCase: { execute: vi.fn(async () => Result.err({} as any)) },
});
await expect(service.updateMemberPayment({ leagueId: 'l1' } as any)).rejects.toThrow('Failed to update member payment');
});
});

View File

@@ -72,7 +72,7 @@ import {
UPDATE_MEMBER_PAYMENT_USE_CASE_TOKEN,
UPDATE_PAYMENT_STATUS_USE_CASE_TOKEN,
UPSERT_MEMBERSHIP_FEE_USE_CASE_TOKEN,
} from './PaymentsProviders';
} from './PaymentsTokens';
@Injectable()
export class PaymentsService {

View File

@@ -0,0 +1,33 @@
export const PAYMENT_REPOSITORY_TOKEN = 'IPaymentRepository';
export const MEMBERSHIP_FEE_REPOSITORY_TOKEN = 'IMembershipFeeRepository';
export const MEMBER_PAYMENT_REPOSITORY_TOKEN = 'IMemberPaymentRepository';
export const PRIZE_REPOSITORY_TOKEN = 'IPrizeRepository';
export const WALLET_REPOSITORY_TOKEN = 'IWalletRepository';
export const TRANSACTION_REPOSITORY_TOKEN = 'ITransactionRepository';
export const LOGGER_TOKEN = 'Logger';
export const GET_PAYMENTS_USE_CASE_TOKEN = 'GetPaymentsUseCase';
export const CREATE_PAYMENT_USE_CASE_TOKEN = 'CreatePaymentUseCase';
export const UPDATE_PAYMENT_STATUS_USE_CASE_TOKEN = 'UpdatePaymentStatusUseCase';
export const GET_MEMBERSHIP_FEES_USE_CASE_TOKEN = 'GetMembershipFeesUseCase';
export const UPSERT_MEMBERSHIP_FEE_USE_CASE_TOKEN = 'UpsertMembershipFeeUseCase';
export const UPDATE_MEMBER_PAYMENT_USE_CASE_TOKEN = 'UpdateMemberPaymentUseCase';
export const GET_PRIZES_USE_CASE_TOKEN = 'GetPrizesUseCase';
export const CREATE_PRIZE_USE_CASE_TOKEN = 'CreatePrizeUseCase';
export const AWARD_PRIZE_USE_CASE_TOKEN = 'AwardPrizeUseCase';
export const DELETE_PRIZE_USE_CASE_TOKEN = 'DeletePrizeUseCase';
export const GET_WALLET_USE_CASE_TOKEN = 'GetWalletUseCase';
export const PROCESS_WALLET_TRANSACTION_USE_CASE_TOKEN = 'ProcessWalletTransactionUseCase';
export const GET_PAYMENTS_OUTPUT_PORT_TOKEN = 'GetPaymentsOutputPort_TOKEN';
export const CREATE_PAYMENT_OUTPUT_PORT_TOKEN = 'CreatePaymentOutputPort_TOKEN';
export const UPDATE_PAYMENT_STATUS_OUTPUT_PORT_TOKEN = 'UpdatePaymentStatusOutputPort_TOKEN';
export const GET_MEMBERSHIP_FEES_OUTPUT_PORT_TOKEN = 'GetMembershipFeesOutputPort_TOKEN';
export const UPSERT_MEMBERSHIP_FEE_OUTPUT_PORT_TOKEN = 'UpsertMembershipFeeOutputPort_TOKEN';
export const UPDATE_MEMBER_PAYMENT_OUTPUT_PORT_TOKEN = 'UpdateMemberPaymentOutputPort_TOKEN';
export const GET_PRIZES_OUTPUT_PORT_TOKEN = 'GetPrizesOutputPort_TOKEN';
export const CREATE_PRIZE_OUTPUT_PORT_TOKEN = 'CreatePrizeOutputPort_TOKEN';
export const AWARD_PRIZE_OUTPUT_PORT_TOKEN = 'AwardPrizeOutputPort_TOKEN';
export const DELETE_PRIZE_OUTPUT_PORT_TOKEN = 'DeletePrizeOutputPort_TOKEN';
export const GET_WALLET_OUTPUT_PORT_TOKEN = 'GetWalletOutputPort_TOKEN';
export const PROCESS_WALLET_TRANSACTION_OUTPUT_PORT_TOKEN = 'ProcessWalletTransactionOutputPort_TOKEN';

View File

@@ -1,4 +1,4 @@
import { Body, Controller, ForbiddenException, HttpCode, HttpStatus, InternalServerErrorException, NotFoundException, Param, Post } from '@nestjs/common';
import { Body, Controller, ForbiddenException, HttpCode, HttpStatus, Inject, InternalServerErrorException, NotFoundException, Param, Post } from '@nestjs/common';
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ProtestsService } from './ProtestsService';
import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO';
@@ -7,7 +7,7 @@ import type { ReviewProtestResponseDTO } from './presenters/ReviewProtestPresent
@ApiTags('protests')
@Controller('protests')
export class ProtestsController {
constructor(private readonly protestsService: ProtestsService) {}
constructor(@Inject(ProtestsService) private readonly protestsService: ProtestsService) {}
@Post(':protestId/review')
@HttpCode(HttpStatus.OK)

View File

@@ -1,5 +1,4 @@
import { Provider } from '@nestjs/common';
import { ProtestsService } from './ProtestsService';
// Import core interfaces
import type { Logger } from '@core/shared/application/Logger';
@@ -24,7 +23,6 @@ export const LOGGER_TOKEN = 'Logger';
export const REVIEW_PROTEST_PRESENTER_TOKEN = 'ReviewProtestPresenter';
export const ProtestsProviders: Provider[] = [
ProtestsService, // Provide the service itself
{
provide: PROTEST_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryProtestRepository(logger),

View File

@@ -4,34 +4,23 @@ import type { Logger } from '@core/shared/application/Logger';
import type {
ReviewProtestUseCase,
ReviewProtestApplicationError,
ReviewProtestResult,
} from '@core/racing/application/use-cases/ReviewProtestUseCase';
import { ProtestsService } from './ProtestsService';
import type { ReviewProtestPresenter } from './presenters/ReviewProtestPresenter';
import type { ReviewProtestResponseDTO } from './presenters/ReviewProtestPresenter';
import { ReviewProtestPresenter, type ReviewProtestResponseDTO } from './presenters/ReviewProtestPresenter';
describe('ProtestsService', () => {
let service: ProtestsService;
let executeMock: MockedFunction<ReviewProtestUseCase['execute']>;
let logger: Logger;
let presenter: ReviewProtestPresenter;
beforeEach(() => {
executeMock = vi.fn();
const reviewProtestUseCase = { execute: executeMock } as unknown as ReviewProtestUseCase;
const reviewProtestPresenter = {
reset: vi.fn(),
setCommand: vi.fn(),
present: vi.fn(),
presentError: vi.fn(),
getResponseModel: vi.fn(),
get responseModel() {
return {
success: true,
protestId: 'test',
stewardId: 'test',
decision: 'uphold' as const,
};
},
} as unknown as ReviewProtestPresenter;
presenter = new ReviewProtestPresenter();
logger = {
debug: vi.fn(),
info: vi.fn(),
@@ -39,7 +28,7 @@ describe('ProtestsService', () => {
error: vi.fn(),
} as unknown as Logger;
service = new ProtestsService(reviewProtestUseCase, reviewProtestPresenter, logger);
service = new ProtestsService(reviewProtestUseCase, presenter, logger);
});
const baseCommand = {
@@ -50,10 +39,14 @@ describe('ProtestsService', () => {
};
it('returns DTO with success model on success', async () => {
executeMock.mockResolvedValue(Result.ok(undefined));
executeMock.mockImplementation(async (command) => {
presenter.present({ protestId: command.protestId } as ReviewProtestResult);
return Result.ok(undefined);
});
const dto = await service.reviewProtest(baseCommand);
expect(presenter.getResponseModel()).not.toBeNull();
expect(executeMock).toHaveBeenCalledWith(baseCommand);
expect(dto).toEqual<ReviewProtestResponseDTO>({
success: true,
@@ -69,7 +62,10 @@ describe('ProtestsService', () => {
details: { message: 'Protest not found' },
};
executeMock.mockResolvedValue(Result.err<void, ReviewProtestApplicationError>(error));
executeMock.mockImplementation(async () => {
presenter.presentError(error);
return Result.err<void, ReviewProtestApplicationError>(error);
});
const dto = await service.reviewProtest(baseCommand);
@@ -86,7 +82,10 @@ describe('ProtestsService', () => {
details: { message: 'Race not found for protest' },
};
executeMock.mockResolvedValue(Result.err<void, ReviewProtestApplicationError>(error));
executeMock.mockImplementation(async () => {
presenter.presentError(error);
return Result.err<void, ReviewProtestApplicationError>(error);
});
const dto = await service.reviewProtest(baseCommand);
@@ -103,7 +102,10 @@ describe('ProtestsService', () => {
details: { message: 'Steward is not authorized to review this protest' },
};
executeMock.mockResolvedValue(Result.err<void, ReviewProtestApplicationError>(error));
executeMock.mockImplementation(async () => {
presenter.presentError(error);
return Result.err<void, ReviewProtestApplicationError>(error);
});
const dto = await service.reviewProtest(baseCommand);
@@ -121,7 +123,10 @@ describe('ProtestsService', () => {
details: { message: 'Failed to review protest' },
};
executeMock.mockResolvedValue(Result.err<void, ReviewProtestApplicationError>(error));
executeMock.mockImplementation(async () => {
presenter.presentError(error);
return Result.err<void, ReviewProtestApplicationError>(error);
});
const dto = await service.reviewProtest(baseCommand);

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, HttpCode, HttpStatus, InternalServerErrorException, Param, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, HttpCode, HttpStatus, InternalServerErrorException, Param, Post, Query, Inject } from '@nestjs/common';
import { ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { RaceService } from './RaceService';
import { AllRacesPageDTO } from './dtos/AllRacesPageDTO';
@@ -21,7 +21,7 @@ import { RequestProtestDefenseCommandDTO } from './dtos/RequestProtestDefenseCom
@ApiTags('races')
@Controller('races')
export class RaceController {
constructor(private readonly raceService: RaceService) {}
constructor(@Inject(RaceService) private readonly raceService: RaceService) {}
@Get('all')
@ApiOperation({ summary: 'Get all races' })

View File

@@ -5,7 +5,7 @@ import { RaceProviders } from './RaceProviders';
@Module({
controllers: [RaceController],
providers: RaceProviders,
providers: [RaceService, ...RaceProviders],
exports: [RaceService],
})
export class RaceModule {}

View File

@@ -90,32 +90,33 @@ import { RaceResultsDetailPresenter } from './presenters/RaceResultsDetailPresen
import { RacesPageDataPresenter } from './presenters/RacesPageDataPresenter';
import { RaceWithSOFPresenter } from './presenters/RaceWithSOFPresenter';
// Define injection tokens
export const RACE_REPOSITORY_TOKEN = 'IRaceRepository';
export const LEAGUE_REPOSITORY_TOKEN = 'ILeagueRepository';
export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
export const RACE_REGISTRATION_REPOSITORY_TOKEN = 'IRaceRegistrationRepository';
export const RESULT_REPOSITORY_TOKEN = 'IResultRepository';
export const LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN = 'ILeagueMembershipRepository';
export const PENALTY_REPOSITORY_TOKEN = 'IPenaltyRepository';
export const PROTEST_REPOSITORY_TOKEN = 'IProtestRepository';
export const STANDING_REPOSITORY_TOKEN = 'IStandingRepository';
export const DRIVER_RATING_PROVIDER_TOKEN = 'DriverRatingProvider';
export const IMAGE_SERVICE_TOKEN = 'IImageServicePort';
export const LOGGER_TOKEN = 'Logger';
import {
RACE_REPOSITORY_TOKEN,
LEAGUE_REPOSITORY_TOKEN,
DRIVER_REPOSITORY_TOKEN,
RACE_REGISTRATION_REPOSITORY_TOKEN,
RESULT_REPOSITORY_TOKEN,
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
PENALTY_REPOSITORY_TOKEN,
PROTEST_REPOSITORY_TOKEN,
STANDING_REPOSITORY_TOKEN,
DRIVER_RATING_PROVIDER_TOKEN,
IMAGE_SERVICE_TOKEN,
LOGGER_TOKEN,
GET_ALL_RACES_PRESENTER_TOKEN,
GET_TOTAL_RACES_PRESENTER_TOKEN,
IMPORT_RACE_RESULTS_API_PRESENTER_TOKEN,
RACE_DETAIL_PRESENTER_TOKEN,
RACES_PAGE_DATA_PRESENTER_TOKEN,
ALL_RACES_PAGE_DATA_PRESENTER_TOKEN,
RACE_RESULTS_DETAIL_PRESENTER_TOKEN,
RACE_WITH_SOF_PRESENTER_TOKEN,
RACE_PROTESTS_PRESENTER_TOKEN,
RACE_PENALTIES_PRESENTER_TOKEN,
COMMAND_RESULT_PRESENTER_TOKEN,
} from './RaceTokens';
// Presenter tokens
export const GET_ALL_RACES_PRESENTER_TOKEN = 'GetAllRacesPresenter';
export const GET_TOTAL_RACES_PRESENTER_TOKEN = 'GetTotalRacesPresenter';
export const IMPORT_RACE_RESULTS_API_PRESENTER_TOKEN = 'ImportRaceResultsApiPresenter';
export const RACE_DETAIL_PRESENTER_TOKEN = 'RaceDetailPresenter';
export const RACES_PAGE_DATA_PRESENTER_TOKEN = 'RacesPageDataPresenter';
export const ALL_RACES_PAGE_DATA_PRESENTER_TOKEN = 'AllRacesPageDataPresenter';
export const RACE_RESULTS_DETAIL_PRESENTER_TOKEN = 'RaceResultsDetailPresenter';
export const RACE_WITH_SOF_PRESENTER_TOKEN = 'RaceWithSOFPresenter';
export const RACE_PROTESTS_PRESENTER_TOKEN = 'RaceProtestsPresenter';
export const RACE_PENALTIES_PRESENTER_TOKEN = 'RacePenaltiesPresenter';
export const COMMAND_RESULT_PRESENTER_TOKEN = 'CommandResultPresenter';
export * from './RaceTokens';
// Adapter classes to bridge presenters with UseCaseOutputPort interface
class GetAllRacesOutputAdapter implements UseCaseOutputPort<GetAllRacesResult> {
@@ -295,7 +296,6 @@ class ReviewProtestOutputAdapter implements UseCaseOutputPort<ReviewProtestResul
}
export const RaceProviders: Provider[] = [
RaceService,
{
provide: RACE_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryRaceRepository(logger),
@@ -471,16 +471,15 @@ export const RaceProviders: Provider[] = [
leagueMembershipRepo: ILeagueMembershipRepository,
presenter: RaceDetailPresenter,
) => {
const useCase = new GetRaceDetailUseCase(
return new GetRaceDetailUseCase(
raceRepo,
leagueRepo,
driverRepo,
raceRegRepo,
resultRepo,
leagueMembershipRepo,
new RaceDetailOutputAdapter(presenter),
);
useCase.setOutput(new RaceDetailOutputAdapter(presenter));
return useCase;
},
inject: [
RACE_REPOSITORY_TOKEN,

View File

@@ -0,0 +1,138 @@
import { describe, expect, it, vi } from 'vitest';
import { RaceService } from './RaceService';
describe('RaceService', () => {
it('invokes each use case and returns the corresponding presenter', async () => {
const mkUseCase = () => ({ execute: vi.fn(async () => {}) });
const getAllRacesUseCase = mkUseCase();
const getTotalRacesUseCase = mkUseCase();
const importRaceResultsApiUseCase = mkUseCase();
const getRaceDetailUseCase = mkUseCase();
const getRacesPageDataUseCase = mkUseCase();
const getAllRacesPageDataUseCase = mkUseCase();
const getRaceResultsDetailUseCase = mkUseCase();
const getRaceWithSOFUseCase = mkUseCase();
const getRaceProtestsUseCase = mkUseCase();
const getRacePenaltiesUseCase = mkUseCase();
const registerForRaceUseCase = mkUseCase();
const withdrawFromRaceUseCase = mkUseCase();
const cancelRaceUseCase = mkUseCase();
const completeRaceUseCase = mkUseCase();
const fileProtestUseCase = mkUseCase();
const quickPenaltyUseCase = mkUseCase();
const applyPenaltyUseCase = mkUseCase();
const requestProtestDefenseUseCase = mkUseCase();
const reviewProtestUseCase = mkUseCase();
const reopenRaceUseCase = mkUseCase();
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const getAllRacesPresenter = {} as any;
const getTotalRacesPresenter = {} as any;
const importRaceResultsApiPresenter = {} as any;
const raceDetailPresenter = {} as any;
const racesPageDataPresenter = {} as any;
const allRacesPageDataPresenter = {} as any;
const raceResultsDetailPresenter = {} as any;
const raceWithSOFPresenter = {} as any;
const raceProtestsPresenter = {} as any;
const racePenaltiesPresenter = {} as any;
const commandResultPresenter = {} as any;
const service = new RaceService(
getAllRacesUseCase as any,
getTotalRacesUseCase as any,
importRaceResultsApiUseCase as any,
getRaceDetailUseCase as any,
getRacesPageDataUseCase as any,
getAllRacesPageDataUseCase as any,
getRaceResultsDetailUseCase as any,
getRaceWithSOFUseCase as any,
getRaceProtestsUseCase as any,
getRacePenaltiesUseCase as any,
registerForRaceUseCase as any,
withdrawFromRaceUseCase as any,
cancelRaceUseCase as any,
completeRaceUseCase as any,
fileProtestUseCase as any,
quickPenaltyUseCase as any,
applyPenaltyUseCase as any,
requestProtestDefenseUseCase as any,
reviewProtestUseCase as any,
reopenRaceUseCase as any,
logger as any,
getAllRacesPresenter,
getTotalRacesPresenter,
importRaceResultsApiPresenter,
raceDetailPresenter,
racesPageDataPresenter,
allRacesPageDataPresenter,
raceResultsDetailPresenter,
raceWithSOFPresenter,
raceProtestsPresenter,
racePenaltiesPresenter,
commandResultPresenter,
);
expect(await service.getAllRaces()).toBe(getAllRacesPresenter);
expect(getAllRacesUseCase.execute).toHaveBeenCalledWith({});
expect(await service.getTotalRaces()).toBe(getTotalRacesPresenter);
expect(getTotalRacesUseCase.execute).toHaveBeenCalledWith({});
expect(await service.importRaceResults({ raceId: 'r1', resultsFileContent: 'x' } as any)).toBe(importRaceResultsApiPresenter);
expect(importRaceResultsApiUseCase.execute).toHaveBeenCalledWith({ raceId: 'r1', resultsFileContent: 'x' });
expect(await service.getRaceDetail({ raceId: 'r1' } as any)).toBe(raceDetailPresenter);
expect(getRaceDetailUseCase.execute).toHaveBeenCalled();
expect(await service.getRacesPageData('l1')).toBe(racesPageDataPresenter);
expect(getRacesPageDataUseCase.execute).toHaveBeenCalledWith({ leagueId: 'l1' });
expect(await service.getAllRacesPageData()).toBe(allRacesPageDataPresenter);
expect(getAllRacesPageDataUseCase.execute).toHaveBeenCalledWith({});
expect(await service.getRaceResultsDetail('r1')).toBe(raceResultsDetailPresenter);
expect(getRaceResultsDetailUseCase.execute).toHaveBeenCalledWith({ raceId: 'r1' });
expect(await service.getRaceWithSOF('r1')).toBe(raceWithSOFPresenter);
expect(getRaceWithSOFUseCase.execute).toHaveBeenCalledWith({ raceId: 'r1' });
expect(await service.getRaceProtests('r1')).toBe(raceProtestsPresenter);
expect(getRaceProtestsUseCase.execute).toHaveBeenCalledWith({ raceId: 'r1' });
expect(await service.getRacePenalties('r1')).toBe(racePenaltiesPresenter);
expect(getRacePenaltiesUseCase.execute).toHaveBeenCalledWith({ raceId: 'r1' });
expect(await service.registerForRace({ raceId: 'r1', driverId: 'd1' } as any)).toBe(commandResultPresenter);
expect(registerForRaceUseCase.execute).toHaveBeenCalled();
expect(await service.withdrawFromRace({ raceId: 'r1', driverId: 'd1' } as any)).toBe(commandResultPresenter);
expect(withdrawFromRaceUseCase.execute).toHaveBeenCalled();
expect(await service.cancelRace({ raceId: 'r1' } as any, 'admin')).toBe(commandResultPresenter);
expect(cancelRaceUseCase.execute).toHaveBeenCalledWith({ raceId: 'r1', cancelledById: 'admin' });
expect(await service.completeRace({ raceId: 'r1' } as any)).toBe(commandResultPresenter);
expect(completeRaceUseCase.execute).toHaveBeenCalledWith({ raceId: 'r1' });
expect(await service.reopenRace({ raceId: 'r1' } as any, 'admin')).toBe(commandResultPresenter);
expect(reopenRaceUseCase.execute).toHaveBeenCalledWith({ raceId: 'r1', reopenedById: 'admin' });
expect(await service.fileProtest({} as any)).toBe(commandResultPresenter);
expect(fileProtestUseCase.execute).toHaveBeenCalled();
expect(await service.applyQuickPenalty({} as any)).toBe(commandResultPresenter);
expect(quickPenaltyUseCase.execute).toHaveBeenCalled();
expect(await service.applyPenalty({} as any)).toBe(commandResultPresenter);
expect(applyPenaltyUseCase.execute).toHaveBeenCalled();
expect(await service.requestProtestDefense({} as any)).toBe(commandResultPresenter);
expect(requestProtestDefenseUseCase.execute).toHaveBeenCalled();
expect(await service.reviewProtest({} as any)).toBe(commandResultPresenter);
expect(reviewProtestUseCase.execute).toHaveBeenCalled();
});
});

View File

@@ -66,7 +66,7 @@ import {
RACE_RESULTS_DETAIL_PRESENTER_TOKEN,
RACE_WITH_SOF_PRESENTER_TOKEN,
RACES_PAGE_DATA_PRESENTER_TOKEN
} from './RaceProviders';
} from './RaceTokens';
@Injectable()
export class RaceService {

View File

@@ -0,0 +1,24 @@
export const RACE_REPOSITORY_TOKEN = 'IRaceRepository';
export const LEAGUE_REPOSITORY_TOKEN = 'ILeagueRepository';
export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
export const RACE_REGISTRATION_REPOSITORY_TOKEN = 'IRaceRegistrationRepository';
export const RESULT_REPOSITORY_TOKEN = 'IResultRepository';
export const LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN = 'ILeagueMembershipRepository';
export const PENALTY_REPOSITORY_TOKEN = 'IPenaltyRepository';
export const PROTEST_REPOSITORY_TOKEN = 'IProtestRepository';
export const STANDING_REPOSITORY_TOKEN = 'IStandingRepository';
export const DRIVER_RATING_PROVIDER_TOKEN = 'DriverRatingProvider';
export const IMAGE_SERVICE_TOKEN = 'IImageServicePort';
export const LOGGER_TOKEN = 'Logger';
export const GET_ALL_RACES_PRESENTER_TOKEN = 'GetAllRacesPresenter';
export const GET_TOTAL_RACES_PRESENTER_TOKEN = 'GetTotalRacesPresenter';
export const IMPORT_RACE_RESULTS_API_PRESENTER_TOKEN = 'ImportRaceResultsApiPresenter';
export const RACE_DETAIL_PRESENTER_TOKEN = 'RaceDetailPresenter';
export const RACES_PAGE_DATA_PRESENTER_TOKEN = 'RacesPageDataPresenter';
export const ALL_RACES_PAGE_DATA_PRESENTER_TOKEN = 'AllRacesPageDataPresenter';
export const RACE_RESULTS_DETAIL_PRESENTER_TOKEN = 'RaceResultsDetailPresenter';
export const RACE_WITH_SOF_PRESENTER_TOKEN = 'RaceWithSOFPresenter';
export const RACE_PROTESTS_PRESENTER_TOKEN = 'RaceProtestsPresenter';
export const RACE_PENALTIES_PRESENTER_TOKEN = 'RacePenaltiesPresenter';
export const COMMAND_RESULT_PRESENTER_TOKEN = 'CommandResultPresenter';

View File

@@ -40,7 +40,7 @@ describe('SponsorController', () => {
describe('getEntitySponsorshipPricing', () => {
it('should return sponsorship pricing', async () => {
const mockResult = { entityType: 'season', entityId: 'season-1', pricing: [] };
sponsorService.getEntitySponsorshipPricing.mockResolvedValue({ viewModel: mockResult } as any);
sponsorService.getEntitySponsorshipPricing.mockResolvedValue(mockResult as any);
const result = await controller.getEntitySponsorshipPricing();
@@ -52,7 +52,7 @@ describe('SponsorController', () => {
describe('getSponsors', () => {
it('should return sponsors list', async () => {
const mockResult = { sponsors: [] };
sponsorService.getSponsors.mockResolvedValue({ viewModel: mockResult } as any);
sponsorService.getSponsors.mockResolvedValue(mockResult as any);
const result = await controller.getSponsors();
@@ -65,7 +65,7 @@ describe('SponsorController', () => {
it('should create sponsor', async () => {
const input = { name: 'Test Sponsor', contactEmail: 'test@example.com' };
const mockResult = { sponsor: { id: 's1', name: 'Test Sponsor' } };
sponsorService.createSponsor.mockResolvedValue({ viewModel: mockResult } as any);
sponsorService.createSponsor.mockResolvedValue(mockResult as any);
const result = await controller.createSponsor(input as any);
@@ -78,7 +78,7 @@ describe('SponsorController', () => {
it('should return sponsor dashboard', async () => {
const sponsorId = 's1';
const mockResult = { sponsorId, metrics: {} as any, sponsoredLeagues: [], investment: {} as any };
sponsorService.getSponsorDashboard.mockResolvedValue({ viewModel: mockResult } as any);
sponsorService.getSponsorDashboard.mockResolvedValue(mockResult as any);
const result = await controller.getSponsorDashboard(sponsorId);
@@ -86,13 +86,11 @@ describe('SponsorController', () => {
expect(sponsorService.getSponsorDashboard).toHaveBeenCalledWith({ sponsorId });
});
it('should return null when sponsor not found', async () => {
it('should throw when sponsor not found', async () => {
const sponsorId = 's1';
sponsorService.getSponsorDashboard.mockResolvedValue({ viewModel: null } as any);
sponsorService.getSponsorDashboard.mockRejectedValue(new Error('Sponsor dashboard not found'));
const result = await controller.getSponsorDashboard(sponsorId);
expect(result).toBeNull();
await expect(controller.getSponsorDashboard(sponsorId)).rejects.toBeInstanceOf(Error);
});
});
@@ -111,7 +109,7 @@ describe('SponsorController', () => {
currency: 'USD',
},
};
sponsorService.getSponsorSponsorships.mockResolvedValue({ viewModel: mockResult } as any);
sponsorService.getSponsorSponsorships.mockResolvedValue(mockResult as any);
const result = await controller.getSponsorSponsorships(sponsorId);
@@ -119,13 +117,11 @@ describe('SponsorController', () => {
expect(sponsorService.getSponsorSponsorships).toHaveBeenCalledWith({ sponsorId });
});
it('should return null when sponsor not found', async () => {
it('should throw when sponsor not found', async () => {
const sponsorId = 's1';
sponsorService.getSponsorSponsorships.mockResolvedValue({ viewModel: null } as any);
sponsorService.getSponsorSponsorships.mockRejectedValue(new Error('Sponsor sponsorships not found'));
const result = await controller.getSponsorSponsorships(sponsorId);
expect(result).toBeNull();
await expect(controller.getSponsorSponsorships(sponsorId)).rejects.toBeInstanceOf(Error);
});
});
@@ -133,7 +129,7 @@ describe('SponsorController', () => {
it('should return sponsor', async () => {
const sponsorId = 's1';
const mockResult = { sponsor: { id: sponsorId, name: 'S1' } };
sponsorService.getSponsor.mockResolvedValue({ viewModel: mockResult } as any);
sponsorService.getSponsor.mockResolvedValue(mockResult as any);
const result = await controller.getSponsor(sponsorId);
@@ -141,13 +137,11 @@ describe('SponsorController', () => {
expect(sponsorService.getSponsor).toHaveBeenCalledWith(sponsorId);
});
it('should return null when sponsor not found', async () => {
it('should throw when sponsor not found', async () => {
const sponsorId = 's1';
sponsorService.getSponsor.mockResolvedValue({ viewModel: null } as any);
sponsorService.getSponsor.mockRejectedValue(new Error('Sponsor not found'));
const result = await controller.getSponsor(sponsorId);
expect(result).toBeNull();
await expect(controller.getSponsor(sponsorId)).rejects.toBeInstanceOf(Error);
});
});
@@ -160,7 +154,7 @@ describe('SponsorController', () => {
requests: [],
totalCount: 0,
};
sponsorService.getPendingSponsorshipRequests.mockResolvedValue({ viewModel: mockResult } as any);
sponsorService.getPendingSponsorshipRequests.mockResolvedValue(mockResult as any);
const result = await controller.getPendingSponsorshipRequests(query);
@@ -181,7 +175,7 @@ describe('SponsorController', () => {
platformFee: 10,
netAmount: 90,
};
sponsorService.acceptSponsorshipRequest.mockResolvedValue({ viewModel: mockResult } as any);
sponsorService.acceptSponsorshipRequest.mockResolvedValue(mockResult as any);
const result = await controller.acceptSponsorshipRequest(requestId, input as any);
@@ -192,14 +186,12 @@ describe('SponsorController', () => {
);
});
it('should return null on error', async () => {
it('should throw on error', async () => {
const requestId = 'r1';
const input = { respondedBy: 'u1' };
sponsorService.acceptSponsorshipRequest.mockResolvedValue({ viewModel: null } as any);
sponsorService.acceptSponsorshipRequest.mockRejectedValue(new Error('Accept sponsorship request failed'));
const result = await controller.acceptSponsorshipRequest(requestId, input as any);
expect(result).toBeNull();
await expect(controller.acceptSponsorshipRequest(requestId, input as any)).rejects.toBeInstanceOf(Error);
});
});
@@ -213,7 +205,7 @@ describe('SponsorController', () => {
rejectedAt: new Date(),
reason: 'Not interested',
};
sponsorService.rejectSponsorshipRequest.mockResolvedValue({ viewModel: mockResult } as any);
sponsorService.rejectSponsorshipRequest.mockResolvedValue(mockResult as any);
const result = await controller.rejectSponsorshipRequest(requestId, input as any);
@@ -225,14 +217,12 @@ describe('SponsorController', () => {
);
});
it('should return null on error', async () => {
it('should throw on error', async () => {
const requestId = 'r1';
const input = { respondedBy: 'u1' };
sponsorService.rejectSponsorshipRequest.mockResolvedValue({ viewModel: null } as any);
sponsorService.rejectSponsorshipRequest.mockRejectedValue(new Error('Reject sponsorship request failed'));
const result = await controller.rejectSponsorshipRequest(requestId, input as any);
expect(result).toBeNull();
await expect(controller.rejectSponsorshipRequest(requestId, input as any)).rejects.toBeInstanceOf(Error);
});
});
@@ -251,7 +241,7 @@ describe('SponsorController', () => {
averageMonthlySpend: 0,
},
};
sponsorService.getSponsorBilling.mockResolvedValue({ viewModel: mockResult } as any);
sponsorService.getSponsorBilling.mockResolvedValue(mockResult as any);
const result = await controller.getSponsorBilling(sponsorId);

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Post, Put, Body, HttpCode, HttpStatus, Param, Query } from '@nestjs/common';
import { Controller, Get, Post, Put, Body, HttpCode, HttpStatus, Param, Query, Inject } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger';
import { SponsorService } from './SponsorService';
import { GetEntitySponsorshipPricingResultDTO } from './dtos/GetEntitySponsorshipPricingResultDTO';
@@ -29,7 +29,7 @@ import type { RejectSponsorshipRequestResult } from '@core/racing/application/us
@ApiTags('sponsors')
@Controller('sponsors')
export class SponsorController {
constructor(private readonly sponsorService: SponsorService) {}
constructor(@Inject(SponsorService) private readonly sponsorService: SponsorService) {}
@Get('pricing')
@ApiOperation({ summary: 'Get sponsorship pricing for an entity' })

View File

@@ -273,15 +273,11 @@ export const SponsorProviders: Provider[] = [
provide: GET_ENTITY_SPONSORSHIP_PRICING_USE_CASE_TOKEN,
useFactory: (
sponsorshipPricingRepo: ISponsorshipPricingRepository,
sponsorshipRequestRepo: ISponsorshipRequestRepository,
seasonSponsorshipRepo: ISeasonSponsorshipRepository,
logger: Logger,
output: UseCaseOutputPort<any>,
) => new GetEntitySponsorshipPricingUseCase(sponsorshipPricingRepo, sponsorshipRequestRepo, seasonSponsorshipRepo, logger, output),
) => new GetEntitySponsorshipPricingUseCase(sponsorshipPricingRepo, logger, output),
inject: [
SPONSORSHIP_PRICING_REPOSITORY_TOKEN,
SPONSORSHIP_REQUEST_REPOSITORY_TOKEN,
SEASON_SPONSORSHIP_REPOSITORY_TOKEN,
LOGGER_TOKEN,
GET_ENTITY_SPONSORSHIP_PRICING_OUTPUT_PORT_TOKEN,
],

View File

@@ -12,6 +12,8 @@ import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import type { CreateSponsorInputDTO } from './dtos/CreateSponsorInputDTO';
import { Sponsor } from '@core/racing/domain/entities/sponsor/Sponsor';
import { Money } from '@core/racing/domain/value-objects/Money';
import { AcceptSponsorshipRequestPresenter } from './presenters/AcceptSponsorshipRequestPresenter';
import { CreateSponsorPresenter } from './presenters/CreateSponsorPresenter';
import { GetEntitySponsorshipPricingPresenter } from './presenters/GetEntitySponsorshipPricingPresenter';
@@ -110,20 +112,20 @@ describe('SponsorService', () => {
const outputPort = {
entityType: 'season',
entityId: 'season-1',
tiers: [
{ name: 'Gold', price: 500, benefits: ['Main slot'] },
],
tiers: [{ name: 'Gold', price: { amount: 500, currency: 'USD' }, benefits: ['Main slot'] }],
};
getSponsorshipPricingUseCase.execute.mockResolvedValue(Result.ok(outputPort));
getSponsorshipPricingUseCase.execute.mockImplementation(async () => {
getEntitySponsorshipPricingPresenter.present(outputPort as any);
return Result.ok(undefined);
});
const result = await service.getEntitySponsorshipPricing();
expect(result).toEqual({
entityType: 'season',
entityId: 'season-1',
pricing: [
{ id: 'Gold', level: 'Gold', price: 500, currency: 'USD' },
],
pricing: [{ id: 'Gold', level: 'Gold', price: 500, currency: 'USD' }],
});
});
@@ -142,13 +144,32 @@ describe('SponsorService', () => {
describe('getSponsors', () => {
it('returns sponsors on success', async () => {
const sponsors = [{ id: 's1', name: 'S1', contactEmail: 's1@test', createdAt: new Date() }];
const outputPort = { sponsors };
getSponsorsUseCase.execute.mockResolvedValue(Result.ok(outputPort));
const sponsors = [
Sponsor.create({
id: 'sponsor-1',
name: 'S1',
contactEmail: 's1@test.com',
createdAt: new Date('2024-01-01T00:00:00Z'),
}),
];
getSponsorsUseCase.execute.mockImplementation(async () => {
getSponsorsPresenter.present(sponsors);
return Result.ok(undefined);
});
const result = await service.getSponsors();
expect(result).toEqual({ sponsors });
expect(result).toEqual({
sponsors: [
{
id: 'sponsor-1',
name: 'S1',
contactEmail: 's1@test.com',
createdAt: new Date('2024-01-01T00:00:00Z'),
},
],
});
});
it('returns empty list on error', async () => {
@@ -163,18 +184,28 @@ describe('SponsorService', () => {
describe('createSponsor', () => {
it('returns created sponsor on success', async () => {
const input: CreateSponsorInputDTO = { name: 'Test', contactEmail: 'test@example.com' };
const sponsor = {
id: 's1',
const sponsor = Sponsor.create({
id: 'sponsor-1',
name: 'Test',
contactEmail: 'test@example.com',
createdAt: new Date(),
};
const outputPort = { sponsor };
createSponsorUseCase.execute.mockResolvedValue(Result.ok(outputPort));
createdAt: new Date('2024-01-01T00:00:00Z'),
});
createSponsorUseCase.execute.mockImplementation(async () => {
createSponsorPresenter.present(sponsor);
return Result.ok(undefined);
});
const result = await service.createSponsor(input);
expect(result).toEqual({ sponsor });
expect(result).toEqual({
sponsor: {
id: 'sponsor-1',
name: 'Test',
contactEmail: 'test@example.com',
createdAt: new Date('2024-01-01T00:00:00Z'),
},
});
});
it('throws on error', async () => {
@@ -185,6 +216,20 @@ describe('SponsorService', () => {
await expect(service.createSponsor(input)).rejects.toThrow('Invalid');
});
it('throws using error.message when details.message is missing', async () => {
const input: CreateSponsorInputDTO = { name: 'Test', contactEmail: 'test@example.com' };
createSponsorUseCase.execute.mockResolvedValue(Result.err({ code: 'VALIDATION_ERROR', message: 'Boom' } as any));
await expect(service.createSponsor(input)).rejects.toThrow('Boom');
});
it('throws default message when details.message and message are missing', async () => {
const input: CreateSponsorInputDTO = { name: 'Test', contactEmail: 'test@example.com' };
createSponsorUseCase.execute.mockResolvedValue(Result.err({ code: 'VALIDATION_ERROR' } as any));
await expect(service.createSponsor(input)).rejects.toThrow('Failed to create sponsor');
});
});
describe('getSponsorDashboard', () => {
@@ -206,15 +251,38 @@ describe('SponsorService', () => {
sponsoredLeagues: [],
investment: {
activeSponsorships: 0,
totalInvestment: 0,
totalInvestment: Money.create(0, 'USD'),
costPerThousandViews: 0,
},
};
getSponsorDashboardUseCase.execute.mockResolvedValue(Result.ok(outputPort));
getSponsorDashboardUseCase.execute.mockImplementation(async () => {
getSponsorDashboardPresenter.present(outputPort as any);
return Result.ok(undefined);
});
const result = await service.getSponsorDashboard(params);
expect(result).toEqual(outputPort);
expect(result).toEqual({
sponsorId: 's1',
sponsorName: 'S1',
metrics: outputPort.metrics,
sponsoredLeagues: [],
investment: {
activeSponsorships: 0,
totalInvestment: 0,
costPerThousandViews: 0,
},
sponsorships: {
leagues: [],
teams: [],
drivers: [],
races: [],
platform: [],
},
recentActivity: [],
upcomingRenewals: [],
});
});
it('throws on error', async () => {
@@ -229,6 +297,28 @@ describe('SponsorService', () => {
it('returns sponsorships on success', async () => {
const params: GetSponsorSponsorshipsInput = { sponsorId: 's1' };
const outputPort = {
sponsor: Sponsor.create({
id: 's1',
name: 'S1',
contactEmail: 's1@example.com',
}),
sponsorships: [],
summary: {
totalSponsorships: 0,
activeSponsorships: 0,
totalInvestment: Money.create(0, 'USD'),
totalPlatformFees: Money.create(0, 'USD'),
},
};
getSponsorSponsorshipsUseCase.execute.mockImplementation(async () => {
getSponsorSponsorshipsPresenter.present(outputPort as any);
return Result.ok(undefined);
});
const result = await service.getSponsorSponsorships(params);
expect(result).toEqual({
sponsorId: 's1',
sponsorName: 'S1',
sponsorships: [],
@@ -239,39 +329,44 @@ describe('SponsorService', () => {
totalPlatformFees: 0,
currency: 'USD',
},
};
getSponsorSponsorshipsUseCase.execute.mockResolvedValue(Result.ok(outputPort));
const result = await service.getSponsorSponsorships(params);
expect(result).toEqual(outputPort);
});
});
it('throws on error', async () => {
const params: GetSponsorSponsorshipsInput = { sponsorId: 's1' };
getSponsorSponsorshipsUseCase.execute.mockResolvedValue(
Result.err({ code: 'REPOSITORY_ERROR' }),
);
getSponsorSponsorshipsUseCase.execute.mockResolvedValue(Result.err({ code: 'REPOSITORY_ERROR' }));
await expect(service.getSponsorSponsorships(params)).rejects.toThrow('Sponsor sponsorships not found');
await expect(service.getSponsorSponsorships(params)).rejects.toThrow(
'Sponsor sponsorships not found',
);
});
});
describe('getSponsor', () => {
it('returns sponsor when found', async () => {
const sponsorId = 's1';
const sponsor = { id: sponsorId, name: 'S1' };
const output = { sponsor };
getSponsorUseCase.execute.mockResolvedValue(Result.ok(output));
const output = { sponsor: { id: sponsorId, name: 'S1' } };
getSponsorUseCase.execute.mockImplementation(async () => {
getSponsorPresenter.present(output);
return Result.ok(undefined);
});
const result = await service.getSponsor(sponsorId);
expect(result).toEqual({ sponsor });
expect(result).toEqual(output);
});
it('throws when not found', async () => {
const sponsorId = 's1';
getSponsorUseCase.execute.mockResolvedValue(Result.ok(null));
getSponsorUseCase.execute.mockResolvedValue(Result.err({ code: 'SPONSOR_NOT_FOUND' }));
await expect(service.getSponsor(sponsorId)).rejects.toThrow('Sponsor not found');
});
it('throws when viewModel is missing even if use case succeeds', async () => {
const sponsorId = 's1';
getSponsorUseCase.execute.mockResolvedValue(Result.ok(undefined));
await expect(service.getSponsor(sponsorId)).rejects.toThrow('Sponsor not found');
});
@@ -286,7 +381,11 @@ describe('SponsorService', () => {
requests: [],
totalCount: 0,
};
getPendingSponsorshipRequestsUseCase.execute.mockResolvedValue(Result.ok(outputPort));
getPendingSponsorshipRequestsUseCase.execute.mockImplementation(async () => {
getPendingSponsorshipRequestsPresenter.present(outputPort as any);
return Result.ok(undefined);
});
const result = await service.getPendingSponsorshipRequests(params);
@@ -295,9 +394,7 @@ describe('SponsorService', () => {
it('returns empty result on error', async () => {
const params = { entityType: 'season' as const, entityId: 'season-1' };
getPendingSponsorshipRequestsUseCase.execute.mockResolvedValue(
Result.err({ code: 'REPOSITORY_ERROR' }),
);
getPendingSponsorshipRequestsUseCase.execute.mockResolvedValue(Result.err({ code: 'REPOSITORY_ERROR' }));
const result = await service.getPendingSponsorshipRequests(params);
@@ -308,6 +405,13 @@ describe('SponsorService', () => {
totalCount: 0,
});
});
it('throws when presenter viewModel is missing on success', async () => {
const params = { entityType: 'season' as const, entityId: 'season-1' };
getPendingSponsorshipRequestsUseCase.execute.mockResolvedValue(Result.ok(undefined));
await expect(service.getPendingSponsorshipRequests(params)).rejects.toThrow('Pending sponsorship requests not found');
});
});
describe('SponsorshipRequest', () => {
@@ -322,7 +426,11 @@ describe('SponsorService', () => {
platformFee: 10,
netAmount: 90,
};
acceptSponsorshipRequestUseCase.execute.mockResolvedValue(Result.ok(outputPort));
acceptSponsorshipRequestUseCase.execute.mockImplementation(async () => {
acceptSponsorshipRequestPresenter.present(outputPort as any);
return Result.ok(undefined);
});
const result = await service.acceptSponsorshipRequest(requestId, respondedBy);
@@ -336,7 +444,19 @@ describe('SponsorService', () => {
Result.err({ code: 'SPONSORSHIP_REQUEST_NOT_FOUND' }),
);
await expect(service.acceptSponsorshipRequest(requestId, respondedBy)).rejects.toThrow('Accept sponsorship request failed');
await expect(service.acceptSponsorshipRequest(requestId, respondedBy)).rejects.toThrow(
'Accept sponsorship request failed',
);
});
it('throws when presenter viewModel is missing on success', async () => {
const requestId = 'r1';
const respondedBy = 'u1';
acceptSponsorshipRequestUseCase.execute.mockResolvedValue(Result.ok(undefined));
await expect(service.acceptSponsorshipRequest(requestId, respondedBy)).rejects.toThrow(
'Accept sponsorship request failed',
);
});
});
@@ -351,13 +471,38 @@ describe('SponsorService', () => {
respondedAt: new Date(),
rejectionReason: reason,
};
rejectSponsorshipRequestUseCase.execute.mockResolvedValue(Result.ok(output));
rejectSponsorshipRequestUseCase.execute.mockImplementation(async () => {
rejectSponsorshipRequestPresenter.present(output as any);
return Result.ok(undefined);
});
const result = await service.rejectSponsorshipRequest(requestId, respondedBy, reason);
expect(result).toEqual(output);
});
it('passes no reason when reason is undefined', async () => {
const requestId = 'r1';
const respondedBy = 'u1';
rejectSponsorshipRequestUseCase.execute.mockImplementation(async (input: any) => {
expect(input).toEqual({ requestId, respondedBy });
rejectSponsorshipRequestPresenter.present({
requestId,
status: 'rejected' as const,
respondedAt: new Date(),
rejectionReason: '',
} as any);
return Result.ok(undefined);
});
await expect(service.rejectSponsorshipRequest(requestId, respondedBy)).resolves.toMatchObject({
requestId,
status: 'rejected',
});
});
it('throws on error', async () => {
const requestId = 'r1';
const respondedBy = 'u1';
@@ -365,7 +510,19 @@ describe('SponsorService', () => {
Result.err({ code: 'SPONSORSHIP_REQUEST_NOT_FOUND' }),
);
await expect(service.rejectSponsorshipRequest(requestId, respondedBy)).rejects.toThrow('Reject sponsorship request failed');
await expect(service.rejectSponsorshipRequest(requestId, respondedBy)).rejects.toThrow(
'Reject sponsorship request failed',
);
});
it('throws when presenter viewModel is missing on success', async () => {
const requestId = 'r1';
const respondedBy = 'u1';
rejectSponsorshipRequestUseCase.execute.mockResolvedValue(Result.ok(undefined));
await expect(service.rejectSponsorshipRequest(requestId, respondedBy)).rejects.toThrow(
'Reject sponsorship request failed',
);
});
});
@@ -395,6 +552,11 @@ describe('SponsorService', () => {
expect(result.invoices).toBeInstanceOf(Array);
expect(result.stats).toBeDefined();
});
it('throws on error', async () => {
getSponsorBillingUseCase.execute.mockResolvedValue(Result.err({ code: 'REPOSITORY_ERROR' } as any));
await expect(service.getSponsorBilling('s1')).rejects.toThrow('Sponsor billing not found');
});
});
describe('getAvailableLeagues', () => {

View File

@@ -130,19 +130,39 @@ export class SponsorService {
async getEntitySponsorshipPricing(): Promise<GetEntitySponsorshipPricingResultDTO> {
this.logger.debug('[SponsorService] Fetching sponsorship pricing.');
await this.getSponsorshipPricingUseCase.execute({});
const result = await this.getSponsorshipPricingUseCase.execute({});
if (result.isErr()) {
return {
entityType: 'season',
entityId: '',
pricing: [],
};
}
return this.getEntitySponsorshipPricingPresenter.viewModel;
}
async getSponsors(): Promise<GetSponsorsOutputDTO> {
this.logger.debug('[SponsorService] Fetching sponsors.');
await this.getSponsorsUseCase.execute();
const result = await this.getSponsorsUseCase.execute();
if (result.isErr()) {
return { sponsors: [] };
}
return this.getSponsorsPresenter.responseModel;
}
async createSponsor(input: CreateSponsorInputDTO): Promise<CreateSponsorOutputDTO> {
this.logger.debug('[SponsorService] Creating sponsor.', { input });
await this.createSponsorUseCase.execute(input);
const result = await this.createSponsorUseCase.execute(input);
if (result.isErr()) {
const error = result.unwrapErr() as { details?: { message?: string }; message?: string };
throw new Error(error.details?.message ?? error.message ?? 'Failed to create sponsor');
}
return this.createSponsorPresenter.viewModel;
}
@@ -150,34 +170,41 @@ export class SponsorService {
params: GetSponsorDashboardQueryParamsDTO,
): Promise<SponsorDashboardDTO> {
this.logger.debug('[SponsorService] Fetching sponsor dashboard.', { params });
await this.getSponsorDashboardUseCase.execute(params);
const result = this.getSponsorDashboardPresenter.viewModel;
if (!result) {
const result = await this.getSponsorDashboardUseCase.execute(params);
if (result.isErr()) {
throw new Error('Sponsor dashboard not found');
}
return result;
return this.getSponsorDashboardPresenter.viewModel;
}
async getSponsorSponsorships(
params: GetSponsorSponsorshipsQueryParamsDTO,
): Promise<SponsorSponsorshipsDTO> {
this.logger.debug('[SponsorService] Fetching sponsor sponsorships.', { params });
await this.getSponsorSponsorshipsUseCase.execute(params);
const result = this.getSponsorSponsorshipsPresenter.viewModel;
if (!result) {
const result = await this.getSponsorSponsorshipsUseCase.execute(params);
if (result.isErr()) {
throw new Error('Sponsor sponsorships not found');
}
return result;
return this.getSponsorSponsorshipsPresenter.viewModel;
}
async getSponsor(sponsorId: string): Promise<GetSponsorOutputDTO> {
this.logger.debug('[SponsorService] Fetching sponsor.', { sponsorId });
await this.getSponsorUseCase.execute({ sponsorId });
const result = this.getSponsorPresenter.viewModel;
if (!result) {
const result = await this.getSponsorUseCase.execute({ sponsorId });
if (result.isErr()) {
throw new Error('Sponsor not found');
}
return result;
const viewModel = this.getSponsorPresenter.viewModel;
if (!viewModel) {
throw new Error('Sponsor not found');
}
return viewModel;
}
async getPendingSponsorshipRequests(params: {
@@ -185,14 +212,26 @@ export class SponsorService {
entityId: string;
}): Promise<GetPendingSponsorshipRequestsOutputDTO> {
this.logger.debug('[SponsorService] Fetching pending sponsorship requests.', { params });
await this.getPendingSponsorshipRequestsUseCase.execute(
const result = await this.getPendingSponsorshipRequestsUseCase.execute(
params as GetPendingSponsorshipRequestsInput,
);
const result = this.getPendingSponsorshipRequestsPresenter.viewModel;
if (!result) {
if (result.isErr()) {
return {
entityType: params.entityType,
entityId: params.entityId,
requests: [],
totalCount: 0,
};
}
const viewModel = this.getPendingSponsorshipRequestsPresenter.viewModel;
if (!viewModel) {
throw new Error('Pending sponsorship requests not found');
}
return result;
return viewModel;
}
async acceptSponsorshipRequest(
@@ -203,15 +242,22 @@ export class SponsorService {
requestId,
respondedBy,
});
await this.acceptSponsorshipRequestUseCase.execute({
const result = await this.acceptSponsorshipRequestUseCase.execute({
requestId,
respondedBy,
});
const result = this.acceptSponsorshipRequestPresenter.viewModel;
if (!result) {
if (result.isErr()) {
throw new Error('Accept sponsorship request failed');
}
return result;
const viewModel = this.acceptSponsorshipRequestPresenter.viewModel;
if (!viewModel) {
throw new Error('Accept sponsorship request failed');
}
return viewModel;
}
async rejectSponsorshipRequest(
@@ -231,12 +277,19 @@ export class SponsorService {
if (reason !== undefined) {
input.reason = reason;
}
await this.rejectSponsorshipRequestUseCase.execute(input);
const result = this.rejectSponsorshipRequestPresenter.viewModel;
if (!result) {
const result = await this.rejectSponsorshipRequestUseCase.execute(input);
if (result.isErr()) {
throw new Error('Reject sponsorship request failed');
}
return result;
const viewModel = this.rejectSponsorshipRequestPresenter.viewModel;
if (!viewModel) {
throw new Error('Reject sponsorship request failed');
}
return viewModel;
}
async getSponsorBilling(sponsorId: string): Promise<{
@@ -245,12 +298,13 @@ export class SponsorService {
stats: BillingStatsDTO;
}> {
this.logger.debug('[SponsorService] Fetching sponsor billing.', { sponsorId });
await this.getSponsorBillingUseCase.execute({ sponsorId });
const result = this.sponsorBillingPresenter.viewModel;
if (!result) {
const result = await this.getSponsorBillingUseCase.execute({ sponsorId });
if (result.isErr()) {
throw new Error('Sponsor billing not found');
}
return result;
return this.sponsorBillingPresenter.viewModel;
}
async getAvailableLeagues(): Promise<AvailableLeaguesPresenter> {

View File

@@ -24,8 +24,8 @@ export class GetEntitySponsorshipPricingPresenter {
pricing: output.tiers.map(item => ({
id: item.name,
level: item.name,
price: item.price,
currency: 'USD',
price: item.price.amount,
currency: item.price.currency,
})),
};
}

View File

@@ -48,7 +48,8 @@ export class GetSponsorDashboardPresenter {
return this.result;
}
get viewModel(): SponsorDashboardDTO | null {
get viewModel(): SponsorDashboardDTO {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -66,7 +66,8 @@ export class GetSponsorSponsorshipsPresenter {
return this.result;
}
get viewModel(): SponsorSponsorshipsDTO | null {
get viewModel(): SponsorSponsorshipsDTO {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -27,7 +27,7 @@ describe('GetSponsorsPresenter', () => {
id: 'sponsor-1',
name: 'Sponsor One',
contactEmail: 's1@example.com',
logoUrl: 'logo1.png',
logoUrl: 'https://one.example.com/logo1.png',
websiteUrl: 'https://one.example.com',
createdAt: new Date('2024-01-01T00:00:00Z'),
}),
@@ -46,7 +46,7 @@ describe('GetSponsorsPresenter', () => {
id: 'sponsor-1',
name: 'Sponsor One',
contactEmail: 's1@example.com',
logoUrl: 'logo1.png',
logoUrl: 'https://one.example.com/logo1.png',
websiteUrl: 'https://one.example.com',
createdAt: new Date('2024-01-01T00:00:00Z'),
},

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';

View File

@@ -1,6 +1,7 @@
import 'reflect-metadata'; // For NestJS DI (before any other imports)
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { writeFileSync } from 'fs';
@@ -10,6 +11,14 @@ import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, process.env.GENERATE_OPENAPI ? { logger: false } : undefined);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
// Swagger/OpenAPI configuration
const config = new DocumentBuilder()
.setTitle('GridPilot API')

View File

@@ -0,0 +1,57 @@
import { ValidationPipe } from '@nestjs/common';
import type { Provider, Type } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import type { TestingModule } from '@nestjs/testing';
import request from 'supertest';
export type ProviderOverride = {
provide: unknown;
useValue: unknown;
};
export type HttpContractHarness = {
// Avoid exporting INestApplication here because this repo can end up with
// multiple @nestjs/common type roots (workspace hoisting), which makes the
// INestApplication types incompatible.
app: unknown;
module: TestingModule;
http: ReturnType<typeof request>;
close: () => Promise<void>;
};
export async function createHttpContractHarness(options: {
controllers: Array<Type<unknown>>;
providers?: Provider[];
overrides?: ProviderOverride[];
}): Promise<HttpContractHarness> {
let moduleBuilder = Test.createTestingModule({
controllers: options.controllers,
providers: options.providers ?? [],
});
for (const override of options.overrides ?? []) {
moduleBuilder = moduleBuilder.overrideProvider(override.provide).useValue(override.useValue);
}
const module = await moduleBuilder.compile();
const app = module.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.init();
return {
app,
module,
http: request(app.getHttpServer()),
close: async () => {
await app.close();
},
};
}

View File

@@ -39,7 +39,8 @@
"types": [
"node",
"express",
"vitest/globals"
"vitest/globals",
"supertest"
]
},
"exclude": [

View File

@@ -1,6 +1,5 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { RecordPageViewUseCase, type RecordPageViewInput, type RecordPageViewOutput } from './RecordPageViewUseCase';
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
import { PageView } from '../../domain/entities/PageView';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { EntityType, VisitorType } from '../../domain/types/PageView';
@@ -14,6 +13,8 @@ describe('RecordPageViewUseCase', () => {
let useCase: RecordPageViewUseCase;
beforeEach(() => {
type PageViewRepository = ConstructorParameters<typeof RecordPageViewUseCase>[0];
pageViewRepository = {
save: vi.fn(),
};
@@ -30,7 +31,7 @@ describe('RecordPageViewUseCase', () => {
};
useCase = new RecordPageViewUseCase(
pageViewRepository as unknown as IPageViewRepository,
pageViewRepository as unknown as PageViewRepository,
logger,
output,
);

View File

@@ -1,5 +1,5 @@
import type { Logger, UseCaseOutputPort, UseCase } from '@core/shared/application';
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
import type { IPageViewRepository } from '../repositories/IPageViewRepository';
import { PageView } from '../../domain/entities/PageView';
import type { EntityType, VisitorType } from '../../domain/types/PageView';
import { Result } from '@core/shared/application/Result';

1000
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"dependencies": {
"@core/social": "file:core/social",
"@nestjs/swagger": "11.2.3",
"@nestjs/swagger": "7.4.2",
"bcrypt": "^6.0.0",
"electron-vite": "3.1.0",
"next": "15.5.9",
@@ -15,6 +15,10 @@
"description": "GridPilot - Clean Architecture monorepo for web platform and Electron companion app",
"devDependencies": {
"@cucumber/cucumber": "^11.0.1",
"@nestjs/common": "10.4.20",
"@nestjs/core": "10.4.20",
"@nestjs/platform-express": "10.4.20",
"@nestjs/testing": "10.4.20",
"@playwright/test": "^1.57.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
@@ -22,9 +26,11 @@
"@types/express": "^4.17.21",
"@types/jsdom": "^27.0.0",
"@types/node": "^24.10.1",
"@types/supertest": "^6.0.3",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitest/ui": "^4.0.15",
"@vitest/coverage-v8": "^4.0.16",
"@vitest/ui": "^4.0.16",
"cheerio": "^1.0.0",
"commander": "^11.0.0",
"electron": "^39.2.7",
@@ -37,10 +43,11 @@
"openapi-typescript": "^7.4.3",
"prettier": "^3.0.0",
"puppeteer": "^24.31.0",
"supertest": "^7.1.4",
"ts-node": "^10.9.2",
"tsx": "^4.7.0",
"typescript": "^5.9.3",
"vitest": "^4.0.15"
"vitest": "^4.0.16"
},
"engines": {
"node": ">=20.0.0"
@@ -52,6 +59,8 @@
"api:generate-spec": "tsx scripts/generate-openapi-spec.ts",
"api:generate-types": "tsx scripts/generate-api-types.ts",
"api:sync-types": "npm run api:generate-spec && npm run api:generate-types",
"api:test": "vitest run --config vitest.api.config.ts",
"api:coverage": "vitest run --config vitest.api.config.ts --coverage",
"build": "echo 'Build all packages placeholder - to be configured'",
"chrome:debug": "open -a 'Google Chrome' --args --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug",
"companion:build": "npm run build --workspace=@gridpilot/companion",
@@ -107,4 +116,4 @@
"apps/*",
"testing/*"
]
}
}

43
vitest.api.config.ts Normal file
View File

@@ -0,0 +1,43 @@
import { defineConfig } from 'vitest/config';
import { resolve } from 'node:path';
export default defineConfig({
test: {
globals: true,
watch: false,
environment: 'node',
setupFiles: ['reflect-metadata'],
include: ['apps/api/src/**/*.{test,spec}.ts'],
exclude: ['node_modules/**', 'apps/api/dist/**', 'dist/**'],
coverage: {
enabled: true,
provider: 'v8',
reportsDirectory: 'coverage/api',
reporter: ['text', 'html', 'lcov'],
// "Logic only" coverage scope: service layer only.
// (Presenters have wide surface area; once all presenter suites exist, we can add them back.)
include: ['apps/api/src/domain/**/*Service.ts'],
exclude: [
'**/*.test.ts',
'**/*.spec.ts',
'**/*.d.ts',
'apps/api/dist/**',
'dist/**',
'node_modules/**',
],
thresholds: {
lines: 100,
branches: 100,
functions: 100,
statements: 100,
},
},
},
resolve: {
alias: {
'@core': resolve(__dirname, './core'),
'@adapters': resolve(__dirname, './adapters'),
'@testing': resolve(__dirname, './testing'),
},
},
});