test apps api
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
];
|
||||
278
apps/api/src/domain/analytics/AnalyticsService.test.ts
Normal file
278
apps/api/src/domain/analytics/AnalyticsService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
230
apps/api/src/domain/auth/AuthService.test.ts
Normal file
230
apps/api/src/domain/auth/AuthService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
39
apps/api/src/domain/dashboard/DashboardService.test.ts
Normal file
39
apps/api/src/domain/dashboard/DashboardService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -5,7 +5,7 @@ import { DriverProviders } from './DriverProviders';
|
||||
|
||||
@Module({
|
||||
controllers: [DriverController],
|
||||
providers: DriverProviders,
|
||||
providers: [DriverService, ...DriverProviders],
|
||||
exports: [DriverService],
|
||||
})
|
||||
export class DriverModule {}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
26
apps/api/src/domain/driver/DriverTokens.ts
Normal file
26
apps/api/src/domain/driver/DriverTokens.ts
Normal 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';
|
||||
14
apps/api/src/domain/hello/HelloService.test.ts
Normal file
14
apps/api/src/domain/hello/HelloService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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' })
|
||||
|
||||
@@ -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,
|
||||
|
||||
196
apps/api/src/domain/league/LeagueService.test.ts
Normal file
196
apps/api/src/domain/league/LeagueService.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -5,7 +5,7 @@ import { MediaProviders } from './MediaProviders';
|
||||
|
||||
@Module({
|
||||
controllers: [MediaController],
|
||||
providers: MediaProviders,
|
||||
providers: [MediaService, ...MediaProviders],
|
||||
exports: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
510
apps/api/src/domain/media/MediaService.test.ts
Normal file
510
apps/api/src/domain/media/MediaService.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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()
|
||||
|
||||
21
apps/api/src/domain/media/MediaTokens.ts
Normal file
21
apps/api/src/domain/media/MediaTokens.ts
Normal 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';
|
||||
@@ -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' })
|
||||
|
||||
@@ -5,7 +5,7 @@ import { PaymentsProviders } from './PaymentsProviders';
|
||||
|
||||
@Module({
|
||||
controllers: [PaymentsController],
|
||||
providers: PaymentsProviders,
|
||||
providers: [PaymentsService, ...PaymentsProviders],
|
||||
exports: [PaymentsService],
|
||||
})
|
||||
export class PaymentsModule {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
313
apps/api/src/domain/payments/PaymentsService.test.ts
Normal file
313
apps/api/src/domain/payments/PaymentsService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
33
apps/api/src/domain/payments/PaymentsTokens.ts
Normal file
33
apps/api/src/domain/payments/PaymentsTokens.ts
Normal 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';
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -5,7 +5,7 @@ import { RaceProviders } from './RaceProviders';
|
||||
|
||||
@Module({
|
||||
controllers: [RaceController],
|
||||
providers: RaceProviders,
|
||||
providers: [RaceService, ...RaceProviders],
|
||||
exports: [RaceService],
|
||||
})
|
||||
export class RaceModule {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
138
apps/api/src/domain/race/RaceService.test.ts
Normal file
138
apps/api/src/domain/race/RaceService.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
24
apps/api/src/domain/race/RaceTokens.ts
Normal file
24
apps/api/src/domain/race/RaceTokens.ts
Normal 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';
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TeamProviders } from './TeamProviders';
|
||||
|
||||
@Module({
|
||||
controllers: [TeamController],
|
||||
providers: TeamProviders,
|
||||
providers: [TeamService, ...TeamProviders],
|
||||
exports: [TeamService],
|
||||
})
|
||||
export class TeamModule {}
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
5
apps/api/src/domain/team/TeamTokens.ts
Normal file
5
apps/api/src/domain/team/TeamTokens.ts
Normal 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';
|
||||
@@ -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')
|
||||
|
||||
57
apps/api/src/shared/testing/httpContractHarness.ts
Normal file
57
apps/api/src/shared/testing/httpContractHarness.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -39,7 +39,8 @@
|
||||
"types": [
|
||||
"node",
|
||||
"express",
|
||||
"vitest/globals"
|
||||
"vitest/globals",
|
||||
"supertest"
|
||||
]
|
||||
},
|
||||
"exclude": [
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
1000
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -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
43
vitest.api.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user