test apps api
This commit is contained in:
@@ -7,8 +7,9 @@
|
|||||||
"build": "tsc --build --verbose",
|
"build": "tsc --build --verbose",
|
||||||
"start:dev": "ts-node-dev --respawn --inspect=0.0.0.0:9229 src/main.ts",
|
"start:dev": "ts-node-dev --respawn --inspect=0.0.0.0:9229 src/main.ts",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
"test": "vitest run",
|
"test": "vitest run --config ../../vitest.api.config.ts",
|
||||||
"test:watch": "vitest",
|
"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"
|
"generate:openapi": "GENERATE_OPENAPI=true ts-node src/main.ts --exit"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
@@ -22,7 +23,7 @@
|
|||||||
"@nestjs/common": "^10.4.20",
|
"@nestjs/common": "^10.4.20",
|
||||||
"@nestjs/core": "^10.4.20",
|
"@nestjs/core": "^10.4.20",
|
||||||
"@nestjs/platform-express": "^10.4.20",
|
"@nestjs/platform-express": "^10.4.20",
|
||||||
"@nestjs/swagger": "^11.2.3",
|
"@nestjs/swagger": "^7.4.2",
|
||||||
"@nestjs/typeorm": "^10.0.2",
|
"@nestjs/typeorm": "^10.0.2",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.3",
|
"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 { RecordEngagementOutput } from '@core/analytics/application/use-cases/RecordEngagementUseCase';
|
||||||
import type { RecordPageViewOutput } from '@core/analytics/application/use-cases/RecordPageViewUseCase';
|
import type { RecordPageViewOutput } from '@core/analytics/application/use-cases/RecordPageViewUseCase';
|
||||||
import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository';
|
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 type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||||
import { Provider } from '@nestjs/common';
|
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 { GetDashboardDataOutput, GetDashboardDataUseCase } from '@core/analytics/application/use-cases/GetDashboardDataUseCase';
|
||||||
import { RecordEngagementUseCase } from '@core/analytics/application/use-cases/RecordEngagementUseCase';
|
import { RecordEngagementUseCase } from '@core/analytics/application/use-cases/RecordEngagementUseCase';
|
||||||
import { RecordPageViewUseCase } from '@core/analytics/application/use-cases/RecordPageViewUseCase';
|
import { RecordPageViewUseCase } from '@core/analytics/application/use-cases/RecordPageViewUseCase';
|
||||||
|
import { AnalyticsService } from './AnalyticsService';
|
||||||
import { GetAnalyticsMetricsPresenter } from './presenters/GetAnalyticsMetricsPresenter';
|
import { GetAnalyticsMetricsPresenter } from './presenters/GetAnalyticsMetricsPresenter';
|
||||||
import { GetDashboardDataPresenter } from './presenters/GetDashboardDataPresenter';
|
import { GetDashboardDataPresenter } from './presenters/GetDashboardDataPresenter';
|
||||||
import { RecordEngagementPresenter } from './presenters/RecordEngagementPresenter';
|
import { RecordEngagementPresenter } from './presenters/RecordEngagementPresenter';
|
||||||
import { RecordPageViewPresenter } from './presenters/RecordPageViewPresenter';
|
import { RecordPageViewPresenter } from './presenters/RecordPageViewPresenter';
|
||||||
|
|
||||||
export const AnalyticsProviders: Provider[] = [
|
export const AnalyticsProviders: Provider[] = [
|
||||||
|
AnalyticsService,
|
||||||
|
RecordPageViewPresenter,
|
||||||
|
RecordEngagementPresenter,
|
||||||
|
GetDashboardDataPresenter,
|
||||||
|
GetAnalyticsMetricsPresenter,
|
||||||
{
|
{
|
||||||
provide: Logger_TOKEN,
|
provide: Logger_TOKEN,
|
||||||
useClass: ConsoleLogger,
|
useClass: ConsoleLogger,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: IPAGE_VIEW_REPO_TOKEN,
|
provide: IPAGE_VIEW_REPO_TOKEN,
|
||||||
useClass: InMemoryPageViewRepository,
|
useFactory: (logger: Logger) => new InMemoryPageViewRepository(logger),
|
||||||
|
inject: [Logger_TOKEN],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: IENGAGEMENT_REPO_TOKEN,
|
provide: IENGAGEMENT_REPO_TOKEN,
|
||||||
useClass: InMemoryEngagementRepository,
|
useFactory: (logger: Logger) => new InMemoryEngagementRepository(logger),
|
||||||
|
inject: [Logger_TOKEN],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: RECORD_PAGE_VIEW_OUTPUT_PORT_TOKEN,
|
provide: RECORD_PAGE_VIEW_OUTPUT_PORT_TOKEN,
|
||||||
@@ -76,8 +84,8 @@ export const AnalyticsProviders: Provider[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: GetAnalyticsMetricsUseCase,
|
provide: GetAnalyticsMetricsUseCase,
|
||||||
useFactory: (repo: IPageViewRepository, logger: Logger, output: UseCaseOutputPort<GetAnalyticsMetricsOutput>) =>
|
useFactory: (logger: Logger, output: UseCaseOutputPort<GetAnalyticsMetricsOutput>, repo: IPageViewRepository) =>
|
||||||
new GetAnalyticsMetricsUseCase(repo, logger, output),
|
new GetAnalyticsMetricsUseCase(logger, output, repo),
|
||||||
inject: [IPAGE_VIEW_REPO_TOKEN, Logger_TOKEN, GET_ANALYTICS_METRICS_OUTPUT_PORT_TOKEN],
|
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(RecordEngagementUseCase) private readonly recordEngagementUseCase: RecordEngagementUseCase,
|
||||||
@Inject(GetDashboardDataUseCase) private readonly getDashboardDataUseCase: GetDashboardDataUseCase,
|
@Inject(GetDashboardDataUseCase) private readonly getDashboardDataUseCase: GetDashboardDataUseCase,
|
||||||
@Inject(GetAnalyticsMetricsUseCase) private readonly getAnalyticsMetricsUseCase: GetAnalyticsMetricsUseCase,
|
@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> {
|
async recordPageView(input: RecordPageViewInput): Promise<RecordPageViewOutputDTO> {
|
||||||
const presenter = new RecordPageViewPresenter();
|
this.recordPageViewPresenter.reset();
|
||||||
|
|
||||||
const result = await this.recordPageViewUseCase.execute(input);
|
const result = await this.recordPageViewUseCase.execute(input);
|
||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
@@ -35,11 +39,11 @@ export class AnalyticsService {
|
|||||||
throw new Error(error.details?.message ?? 'Failed to record page view');
|
throw new Error(error.details?.message ?? 'Failed to record page view');
|
||||||
}
|
}
|
||||||
|
|
||||||
return presenter.responseModel;
|
return this.recordPageViewPresenter.responseModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
async recordEngagement(input: RecordEngagementInput): Promise<RecordEngagementOutputDTO> {
|
async recordEngagement(input: RecordEngagementInput): Promise<RecordEngagementOutputDTO> {
|
||||||
const presenter = new RecordEngagementPresenter();
|
this.recordEngagementPresenter.reset();
|
||||||
|
|
||||||
const result = await this.recordEngagementUseCase.execute(input);
|
const result = await this.recordEngagementUseCase.execute(input);
|
||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
@@ -47,23 +51,23 @@ export class AnalyticsService {
|
|||||||
throw new Error(error.details?.message ?? 'Failed to record engagement');
|
throw new Error(error.details?.message ?? 'Failed to record engagement');
|
||||||
}
|
}
|
||||||
|
|
||||||
return presenter.responseModel;
|
return this.recordEngagementPresenter.responseModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDashboardData(): Promise<GetDashboardDataOutputDTO> {
|
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()) {
|
if (result.isErr()) {
|
||||||
const error = result.unwrapErr();
|
const error = result.unwrapErr();
|
||||||
throw new Error(error.details?.message ?? 'Failed to get dashboard data');
|
throw new Error(error.details?.message ?? 'Failed to get dashboard data');
|
||||||
}
|
}
|
||||||
|
|
||||||
return presenter.responseModel;
|
return this.getDashboardDataPresenter.responseModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsOutputDTO> {
|
async getAnalyticsMetrics(): Promise<GetAnalyticsMetricsOutputDTO> {
|
||||||
const presenter = new GetAnalyticsMetricsPresenter();
|
this.getAnalyticsMetricsPresenter.reset();
|
||||||
|
|
||||||
const result = await this.getAnalyticsMetricsUseCase.execute({});
|
const result = await this.getAnalyticsMetricsUseCase.execute({});
|
||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
@@ -71,6 +75,6 @@ export class AnalyticsService {
|
|||||||
throw new Error(error.details?.message ?? 'Failed to get analytics metrics');
|
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> {
|
export class GetAnalyticsMetricsPresenter implements UseCaseOutputPort<GetAnalyticsMetricsOutput> {
|
||||||
private model: GetAnalyticsMetricsOutputDTO | null = null;
|
private model: GetAnalyticsMetricsOutputDTO | null = null;
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.model = null;
|
||||||
|
}
|
||||||
|
|
||||||
present(result: GetAnalyticsMetricsOutput): void {
|
present(result: GetAnalyticsMetricsOutput): void {
|
||||||
this.model = {
|
this.model = {
|
||||||
pageViews: result.pageViews,
|
pageViews: result.pageViews,
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import type { GetDashboardDataOutputDTO } from '../dtos/GetDashboardDataOutputDT
|
|||||||
export class GetDashboardDataPresenter implements UseCaseOutputPort<GetDashboardDataOutput> {
|
export class GetDashboardDataPresenter implements UseCaseOutputPort<GetDashboardDataOutput> {
|
||||||
private model: GetDashboardDataOutputDTO | null = null;
|
private model: GetDashboardDataOutputDTO | null = null;
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.model = null;
|
||||||
|
}
|
||||||
|
|
||||||
present(result: GetDashboardDataOutput): void {
|
present(result: GetDashboardDataOutput): void {
|
||||||
this.model = {
|
this.model = {
|
||||||
totalUsers: result.totalUsers,
|
totalUsers: result.totalUsers,
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import type { RecordEngagementOutputDTO } from '../dtos/RecordEngagementOutputDT
|
|||||||
export class RecordEngagementPresenter implements UseCaseOutputPort<RecordEngagementOutput> {
|
export class RecordEngagementPresenter implements UseCaseOutputPort<RecordEngagementOutput> {
|
||||||
private model: RecordEngagementOutputDTO | null = null;
|
private model: RecordEngagementOutputDTO | null = null;
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.model = null;
|
||||||
|
}
|
||||||
|
|
||||||
present(result: RecordEngagementOutput): void {
|
present(result: RecordEngagementOutput): void {
|
||||||
this.model = {
|
this.model = {
|
||||||
eventId: result.eventId,
|
eventId: result.eventId,
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import type { RecordPageViewOutputDTO } from '../dtos/RecordPageViewOutputDTO';
|
|||||||
export class RecordPageViewPresenter implements UseCaseOutputPort<RecordPageViewOutput> {
|
export class RecordPageViewPresenter implements UseCaseOutputPort<RecordPageViewOutput> {
|
||||||
private model: RecordPageViewOutputDTO | null = null;
|
private model: RecordPageViewOutputDTO | null = null;
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.model = null;
|
||||||
|
}
|
||||||
|
|
||||||
present(result: RecordPageViewOutput): void {
|
present(result: RecordPageViewOutput): void {
|
||||||
this.model = {
|
this.model = {
|
||||||
pageViewId: result.pageViewId,
|
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();
|
this.commandResultPresenter.reset();
|
||||||
|
|
||||||
const result = await this.logoutUseCase.execute({});
|
const result = await this.logoutUseCase.execute();
|
||||||
|
|
||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
const error = result.unwrapErr() as LogoutApplicationError;
|
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', () => {
|
describe('getDriversLeaderboard', () => {
|
||||||
it('should return drivers leaderboard', async () => {
|
it('should return drivers leaderboard', async () => {
|
||||||
const leaderboard: DriversLeaderboardDTO = { drivers: [], totalRaces: 0, totalWins: 0, activeCount: 0 };
|
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();
|
const result = await controller.getDriversLeaderboard();
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ describe('DriverController', () => {
|
|||||||
describe('getTotalDrivers', () => {
|
describe('getTotalDrivers', () => {
|
||||||
it('should return total drivers stats', async () => {
|
it('should return total drivers stats', async () => {
|
||||||
const stats: DriverStatsDTO = { totalDrivers: 100 };
|
const stats: DriverStatsDTO = { totalDrivers: 100 };
|
||||||
service.getTotalDrivers.mockResolvedValue({ viewModel: stats } as never);
|
service.getTotalDrivers.mockResolvedValue(stats);
|
||||||
|
|
||||||
const result = await controller.getTotalDrivers();
|
const result = await controller.getTotalDrivers();
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ describe('DriverController', () => {
|
|||||||
it('should return current driver if userId exists', async () => {
|
it('should return current driver if userId exists', async () => {
|
||||||
const userId = 'user-123';
|
const userId = 'user-123';
|
||||||
const driver: GetDriverOutputDTO = { id: 'driver-123', name: 'Driver' } as GetDriverOutputDTO;
|
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 } };
|
const mockReq: Partial<AuthenticatedRequest> = { user: { userId } };
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ describe('DriverController', () => {
|
|||||||
const userId = 'user-123';
|
const userId = 'user-123';
|
||||||
const input: CompleteOnboardingInputDTO = { someField: 'value' } as unknown as CompleteOnboardingInputDTO;
|
const input: CompleteOnboardingInputDTO = { someField: 'value' } as unknown as CompleteOnboardingInputDTO;
|
||||||
const output: CompleteOnboardingOutputDTO = { success: true };
|
const output: CompleteOnboardingOutputDTO = { success: true };
|
||||||
service.completeOnboarding.mockResolvedValue({ viewModel: output } as never);
|
service.completeOnboarding.mockResolvedValue(output);
|
||||||
|
|
||||||
const mockReq: Partial<AuthenticatedRequest> = { user: { userId } };
|
const mockReq: Partial<AuthenticatedRequest> = { user: { userId } };
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ describe('DriverController', () => {
|
|||||||
const driverId = 'driver-123';
|
const driverId = 'driver-123';
|
||||||
const raceId = 'race-456';
|
const raceId = 'race-456';
|
||||||
const status: DriverRegistrationStatusDTO = { registered: true } as unknown as DriverRegistrationStatusDTO;
|
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);
|
const result = await controller.getDriverRegistrationStatus(driverId, raceId);
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ describe('DriverController', () => {
|
|||||||
it('should return driver by id', async () => {
|
it('should return driver by id', async () => {
|
||||||
const driverId = 'driver-123';
|
const driverId = 'driver-123';
|
||||||
const driver: GetDriverOutputDTO = { id: driverId, name: 'Driver' } as GetDriverOutputDTO;
|
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);
|
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 { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import { DriverService } from './DriverService';
|
import { DriverService } from './DriverService';
|
||||||
@@ -17,7 +17,7 @@ interface AuthenticatedRequest extends Request {
|
|||||||
@ApiTags('drivers')
|
@ApiTags('drivers')
|
||||||
@Controller('drivers')
|
@Controller('drivers')
|
||||||
export class DriverController {
|
export class DriverController {
|
||||||
constructor(private readonly driverService: DriverService) {}
|
constructor(@Inject(DriverService) private readonly driverService: DriverService) {}
|
||||||
|
|
||||||
@Get('leaderboard')
|
@Get('leaderboard')
|
||||||
@ApiOperation({ summary: 'Get drivers leaderboard' })
|
@ApiOperation({ summary: 'Get drivers leaderboard' })
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { DriverProviders } from './DriverProviders';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [DriverController],
|
controllers: [DriverController],
|
||||||
providers: DriverProviders,
|
providers: [DriverService, ...DriverProviders],
|
||||||
exports: [DriverService],
|
exports: [DriverService],
|
||||||
})
|
})
|
||||||
export class DriverModule {}
|
export class DriverModule {}
|
||||||
|
|||||||
@@ -46,38 +46,36 @@ import { DriverStatsPresenter } from './presenters/DriverStatsPresenter';
|
|||||||
// Import types for output ports
|
// Import types for output ports
|
||||||
import type { UseCaseOutputPort } from '@core/shared/application';
|
import type { UseCaseOutputPort } from '@core/shared/application';
|
||||||
|
|
||||||
// Define injection tokens
|
import {
|
||||||
export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
|
DRIVER_REPOSITORY_TOKEN,
|
||||||
export const RANKING_SERVICE_TOKEN = 'IRankingService';
|
RANKING_SERVICE_TOKEN,
|
||||||
export const DRIVER_STATS_SERVICE_TOKEN = 'IDriverStatsService';
|
DRIVER_STATS_SERVICE_TOKEN,
|
||||||
export const DRIVER_RATING_PROVIDER_TOKEN = 'DriverRatingProvider';
|
DRIVER_RATING_PROVIDER_TOKEN,
|
||||||
export const DRIVER_EXTENDED_PROFILE_PROVIDER_TOKEN = 'DriverExtendedProfileProvider';
|
DRIVER_EXTENDED_PROFILE_PROVIDER_TOKEN,
|
||||||
export const IMAGE_SERVICE_PORT_TOKEN = 'IImageServicePort';
|
IMAGE_SERVICE_PORT_TOKEN,
|
||||||
export const RACE_REGISTRATION_REPOSITORY_TOKEN = 'IRaceRegistrationRepository';
|
RACE_REGISTRATION_REPOSITORY_TOKEN,
|
||||||
export const NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN = 'INotificationPreferenceRepository';
|
NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN,
|
||||||
export const TEAM_REPOSITORY_TOKEN = 'ITeamRepository';
|
TEAM_REPOSITORY_TOKEN,
|
||||||
export const TEAM_MEMBERSHIP_REPOSITORY_TOKEN = 'ITeamMembershipRepository';
|
TEAM_MEMBERSHIP_REPOSITORY_TOKEN,
|
||||||
export const SOCIAL_GRAPH_REPOSITORY_TOKEN = 'ISocialGraphRepository';
|
SOCIAL_GRAPH_REPOSITORY_TOKEN,
|
||||||
export const LOGGER_TOKEN = 'Logger';
|
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 * from './DriverTokens';
|
||||||
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 const DriverProviders: Provider[] = [
|
export const DriverProviders: Provider[] = [
|
||||||
DriverService,
|
|
||||||
|
|
||||||
// Presenters
|
// Presenters
|
||||||
DriversLeaderboardPresenter,
|
DriversLeaderboardPresenter,
|
||||||
@@ -207,9 +205,9 @@ export const DriverProviders: Provider[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN,
|
provide: UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN,
|
||||||
useFactory: (driverRepo: IDriverRepository, logger: Logger) =>
|
useFactory: (driverRepo: IDriverRepository, logger: Logger, output: UseCaseOutputPort<unknown>) =>
|
||||||
new UpdateDriverProfileUseCase(driverRepo, logger),
|
new UpdateDriverProfileUseCase(driverRepo, logger, output),
|
||||||
inject: [DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN],
|
inject: [DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN, UPDATE_DRIVER_PROFILE_OUTPUT_PORT_TOKEN],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: GET_PROFILE_OVERVIEW_USE_CASE_TOKEN,
|
provide: GET_PROFILE_OVERVIEW_USE_CASE_TOKEN,
|
||||||
@@ -218,17 +216,16 @@ export const DriverProviders: Provider[] = [
|
|||||||
teamRepository: ITeamRepository,
|
teamRepository: ITeamRepository,
|
||||||
teamMembershipRepository: ITeamMembershipRepository,
|
teamMembershipRepository: ITeamMembershipRepository,
|
||||||
socialRepository: ISocialGraphRepository,
|
socialRepository: ISocialGraphRepository,
|
||||||
imageService: IImageServicePort,
|
|
||||||
driverExtendedProfileProvider: DriverExtendedProfileProvider,
|
driverExtendedProfileProvider: DriverExtendedProfileProvider,
|
||||||
driverStatsService: IDriverStatsService,
|
driverStatsService: IDriverStatsService,
|
||||||
rankingService: IRankingService,
|
rankingService: IRankingService,
|
||||||
|
output: UseCaseOutputPort<unknown>,
|
||||||
) =>
|
) =>
|
||||||
new GetProfileOverviewUseCase(
|
new GetProfileOverviewUseCase(
|
||||||
driverRepo,
|
driverRepo,
|
||||||
teamRepository,
|
teamRepository,
|
||||||
teamMembershipRepository,
|
teamMembershipRepository,
|
||||||
socialRepository,
|
socialRepository,
|
||||||
imageService,
|
|
||||||
driverExtendedProfileProvider,
|
driverExtendedProfileProvider,
|
||||||
(driverId: string) => {
|
(driverId: string) => {
|
||||||
const stats = driverStatsService.getDriverStats(driverId);
|
const stats = driverStatsService.getDriverStats(driverId);
|
||||||
@@ -240,7 +237,7 @@ export const DriverProviders: Provider[] = [
|
|||||||
rating: stats.rating,
|
rating: stats.rating,
|
||||||
wins: stats.wins,
|
wins: stats.wins,
|
||||||
podiums: stats.podiums,
|
podiums: stats.podiums,
|
||||||
dnfs: 0,
|
dnfs: (stats as { dnfs?: number }).dnfs ?? 0,
|
||||||
totalRaces: stats.totalRaces,
|
totalRaces: stats.totalRaces,
|
||||||
avgFinish: null,
|
avgFinish: null,
|
||||||
bestFinish: null,
|
bestFinish: null,
|
||||||
@@ -256,16 +253,17 @@ export const DriverProviders: Provider[] = [
|
|||||||
rating: ranking.rating,
|
rating: ranking.rating,
|
||||||
overallRank: ranking.overallRank,
|
overallRank: ranking.overallRank,
|
||||||
})),
|
})),
|
||||||
|
output,
|
||||||
),
|
),
|
||||||
inject: [
|
inject: [
|
||||||
DRIVER_REPOSITORY_TOKEN,
|
DRIVER_REPOSITORY_TOKEN,
|
||||||
TEAM_REPOSITORY_TOKEN,
|
TEAM_REPOSITORY_TOKEN,
|
||||||
TEAM_MEMBERSHIP_REPOSITORY_TOKEN,
|
TEAM_MEMBERSHIP_REPOSITORY_TOKEN,
|
||||||
SOCIAL_GRAPH_REPOSITORY_TOKEN,
|
SOCIAL_GRAPH_REPOSITORY_TOKEN,
|
||||||
IMAGE_SERVICE_PORT_TOKEN,
|
|
||||||
DRIVER_EXTENDED_PROFILE_PROVIDER_TOKEN,
|
DRIVER_EXTENDED_PROFILE_PROVIDER_TOKEN,
|
||||||
DRIVER_STATS_SERVICE_TOKEN,
|
DRIVER_STATS_SERVICE_TOKEN,
|
||||||
RANKING_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';
|
import { DriverService } from './DriverService';
|
||||||
|
|
||||||
describe('DriverService', () => {
|
describe('DriverService', () => {
|
||||||
let service: DriverService;
|
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||||
|
|
||||||
beforeEach(() => {
|
it('getDriversLeaderboard executes use case and returns presenter model', async () => {
|
||||||
// Mock all dependencies
|
const getDriversLeaderboardUseCase = { execute: vi.fn(async () => {}) };
|
||||||
service = new DriverService(
|
const driversLeaderboardPresenter = { getResponseModel: vi.fn(() => ({ items: [] })) };
|
||||||
{} as any, // getDriversLeaderboardUseCase
|
|
||||||
{} as any, // getTotalDriversUseCase
|
const service = new DriverService(
|
||||||
{} as any, // completeDriverOnboardingUseCase
|
getDriversLeaderboardUseCase as any,
|
||||||
{} as any, // isDriverRegisteredForRaceUseCase
|
{ execute: vi.fn() } as any,
|
||||||
{} as any, // updateDriverProfileUseCase
|
{ execute: vi.fn() } as any,
|
||||||
{} as any, // getProfileOverviewUseCase
|
{ execute: vi.fn() } as any,
|
||||||
{} as any, // driverRepository
|
{ execute: vi.fn() } as any,
|
||||||
{} as any, // logger
|
{ execute: vi.fn() } as any,
|
||||||
// Presenters
|
{ findById: vi.fn() } as any,
|
||||||
{} as any, // driversLeaderboardPresenter
|
logger as any,
|
||||||
{} as any, // driverStatsPresenter
|
driversLeaderboardPresenter as any,
|
||||||
{} as any, // completeOnboardingPresenter
|
{ getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any,
|
||||||
{} as any, // driverRegistrationStatusPresenter
|
{ getResponseModel: vi.fn(() => ({ success: true })) } as any,
|
||||||
{} as any, // driverPresenter
|
{ getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any,
|
||||||
{} as any, // driverProfilePresenter
|
{ 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', () => {
|
it('getTotalDrivers executes use case and returns presenter model', async () => {
|
||||||
expect(service).toBeDefined();
|
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,
|
IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN,
|
||||||
LOGGER_TOKEN,
|
LOGGER_TOKEN,
|
||||||
UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN,
|
UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN,
|
||||||
} from './DriverProviders';
|
} from './DriverTokens';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DriverService {
|
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 { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { vi } from 'vitest';
|
||||||
import { LeagueController } from './LeagueController';
|
import { LeagueController } from './LeagueController';
|
||||||
import { LeagueService } from './LeagueService';
|
import { LeagueService } from './LeagueService';
|
||||||
import { LeagueProviders } from './LeagueProviders';
|
|
||||||
|
|
||||||
describe('LeagueController (integration)', () => {
|
describe('LeagueController', () => {
|
||||||
let controller: LeagueController;
|
let controller: LeagueController;
|
||||||
|
let leagueService: ReturnType<typeof vi.mocked<LeagueService>>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
controllers: [LeagueController],
|
controllers: [LeagueController],
|
||||||
providers: [LeagueService, ...LeagueProviders],
|
providers: [
|
||||||
|
{
|
||||||
|
provide: LeagueService,
|
||||||
|
useValue: {
|
||||||
|
getTotalLeagues: vi.fn(),
|
||||||
|
getAllLeaguesWithCapacity: vi.fn(),
|
||||||
|
getLeagueStandings: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
controller = module.get<LeagueController>(LeagueController);
|
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();
|
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();
|
const result = await controller.getAllLeaguesWithCapacity();
|
||||||
expect(result).toHaveProperty('leagues');
|
|
||||||
expect(result).toHaveProperty('totalCount');
|
expect(result).toEqual(mockResult);
|
||||||
expect(Array.isArray(result.leagues)).toBe(true);
|
expect(leagueService.getAllLeaguesWithCapacity).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should get league standings', async () => {
|
it('getLeagueStandings should return standings', async () => {
|
||||||
try {
|
const mockResult = { standings: [] };
|
||||||
const result = await controller.getLeagueStandings('non-existent-league');
|
leagueService.getLeagueStandings.mockResolvedValue(mockResult as any);
|
||||||
expect(result).toHaveProperty('standings');
|
|
||||||
expect(Array.isArray(result.standings)).toBe(true);
|
const result = await controller.getLeagueStandings('league-1');
|
||||||
} catch (error) {
|
|
||||||
// Expected for non-existent league
|
expect(result).toEqual(mockResult);
|
||||||
expect(error).toBeInstanceOf(Error);
|
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 { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { LeagueService } from './LeagueService';
|
import { LeagueService } from './LeagueService';
|
||||||
import { AllLeaguesWithCapacityDTO } from './dtos/AllLeaguesWithCapacityDTO';
|
import { AllLeaguesWithCapacityDTO } from './dtos/AllLeaguesWithCapacityDTO';
|
||||||
@@ -37,7 +37,7 @@ import { WithdrawFromLeagueWalletOutputDTO } from './dtos/WithdrawFromLeagueWall
|
|||||||
@ApiTags('leagues')
|
@ApiTags('leagues')
|
||||||
@Controller('leagues')
|
@Controller('leagues')
|
||||||
export class LeagueController {
|
export class LeagueController {
|
||||||
constructor(private readonly leagueService: LeagueService) {}
|
constructor(@Inject(LeagueService) private readonly leagueService: LeagueService) {}
|
||||||
|
|
||||||
@Get('all-with-capacity')
|
@Get('all-with-capacity')
|
||||||
@ApiOperation({ summary: 'Get all leagues with their capacity information' })
|
@ApiOperation({ summary: 'Get all leagues with their capacity information' })
|
||||||
|
|||||||
@@ -241,10 +241,10 @@ export const LeagueProviders: Provider[] = [
|
|||||||
},
|
},
|
||||||
// Use cases
|
// Use cases
|
||||||
{
|
{
|
||||||
provide: GetAllLeaguesWithCapacityUseCase,
|
provide: GET_ALL_LEAGUES_WITH_CAPACITY_USE_CASE,
|
||||||
useFactory: (leagueRepo: ILeagueRepository, membershipRepo: ILeagueMembershipRepository, presenter: AllLeaguesWithCapacityPresenter) =>
|
useFactory: (leagueRepo: ILeagueRepository, membershipRepo: ILeagueMembershipRepository) =>
|
||||||
new GetAllLeaguesWithCapacityUseCase(leagueRepo, membershipRepo, presenter),
|
new GetAllLeaguesWithCapacityUseCase(leagueRepo, membershipRepo),
|
||||||
inject: [LEAGUE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, 'AllLeaguesWithCapacityPresenter'],
|
inject: [LEAGUE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provide: GET_LEAGUE_STANDINGS_USE_CASE,
|
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> {
|
async getAllLeaguesWithCapacity(): Promise<AllLeaguesWithCapacityViewModel> {
|
||||||
this.logger.debug('[LeagueService] Fetching all leagues with capacity.');
|
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();
|
return this.allLeaguesWithCapacityPresenter.getViewModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,12 +13,17 @@ describe('GetLeagueMembershipsPresenter', () => {
|
|||||||
membership: {
|
membership: {
|
||||||
driverId: 'driver-1',
|
driverId: 'driver-1',
|
||||||
role: 'member',
|
role: 'member',
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
joinedAt: { toDate: () => new Date('2024-01-01T00:00:00Z') },
|
||||||
joinedAt: {} as any,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
} as any,
|
} as any,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-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) {
|
present(input: GetLeagueSeasonsResult) {
|
||||||
this.result = input.seasons.map(seasonSummary => ({
|
this.result = input.seasons.map((seasonSummary) => {
|
||||||
seasonId: seasonSummary.season.id.toString(),
|
const dto = new LeagueSeasonSummaryDTO();
|
||||||
name: seasonSummary.season.name.toString(),
|
dto.seasonId = seasonSummary.season.id.toString();
|
||||||
status: seasonSummary.season.status.toString(),
|
dto.name = seasonSummary.season.name.toString();
|
||||||
startDate: seasonSummary.season.startDate.toISOString(),
|
dto.status = seasonSummary.season.status.toString();
|
||||||
endDate: seasonSummary.season.endDate?.toISOString(),
|
|
||||||
isPrimary: seasonSummary.isPrimary,
|
if (seasonSummary.season.startDate) dto.startDate = seasonSummary.season.startDate;
|
||||||
isParallelActive: seasonSummary.isParallelActive,
|
if (seasonSummary.season.endDate) dto.endDate = seasonSummary.season.endDate;
|
||||||
}));
|
|
||||||
|
dto.isPrimary = seasonSummary.isPrimary;
|
||||||
|
dto.isParallelActive = seasonSummary.isParallelActive;
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getResponseModel(): LeagueSeasonSummaryDTO[] | null {
|
getResponseModel(): LeagueSeasonSummaryDTO[] | null {
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ describe('LeagueOwnerSummaryPresenter', () => {
|
|||||||
name: 'John Doe',
|
name: 'John Doe',
|
||||||
country: 'US',
|
country: 'US',
|
||||||
bio: 'Racing enthusiast',
|
bio: 'Racing enthusiast',
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
joinedAt: { toDate: () => new Date('2024-01-01T00:00:00Z') } as any,
|
||||||
joinedAt: {} as any,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
} as any,
|
} as any,
|
||||||
rating: 1500,
|
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 { ApiTags, ApiResponse, ApiOperation, ApiParam, ApiConsumes } from '@nestjs/swagger';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
@@ -21,7 +21,7 @@ type UpdateAvatarInput = UpdateAvatarInputDTO;
|
|||||||
@ApiTags('media')
|
@ApiTags('media')
|
||||||
@Controller('media')
|
@Controller('media')
|
||||||
export class MediaController {
|
export class MediaController {
|
||||||
constructor(private readonly mediaService: MediaService) {}
|
constructor(@Inject(MediaService) private readonly mediaService: MediaService) {}
|
||||||
|
|
||||||
@Post('avatar/generate')
|
@Post('avatar/generate')
|
||||||
@ApiOperation({ summary: 'Request avatar generation' })
|
@ApiOperation({ summary: 'Request avatar generation' })
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { MediaProviders } from './MediaProviders';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [MediaController],
|
controllers: [MediaController],
|
||||||
providers: MediaProviders,
|
providers: [MediaService, ...MediaProviders],
|
||||||
exports: [MediaService],
|
exports: [MediaService],
|
||||||
})
|
})
|
||||||
export class MediaModule {}
|
export class MediaModule {}
|
||||||
|
|||||||
@@ -34,30 +34,29 @@ import { DeleteMediaPresenter } from './presenters/DeleteMediaPresenter';
|
|||||||
import { GetAvatarPresenter } from './presenters/GetAvatarPresenter';
|
import { GetAvatarPresenter } from './presenters/GetAvatarPresenter';
|
||||||
import { UpdateAvatarPresenter } from './presenters/UpdateAvatarPresenter';
|
import { UpdateAvatarPresenter } from './presenters/UpdateAvatarPresenter';
|
||||||
|
|
||||||
// Define injection tokens
|
import {
|
||||||
export const AVATAR_GENERATION_REPOSITORY_TOKEN = 'IAvatarGenerationRepository';
|
AVATAR_GENERATION_REPOSITORY_TOKEN,
|
||||||
export const MEDIA_REPOSITORY_TOKEN = 'IMediaRepository';
|
MEDIA_REPOSITORY_TOKEN,
|
||||||
export const AVATAR_REPOSITORY_TOKEN = 'IAvatarRepository';
|
AVATAR_REPOSITORY_TOKEN,
|
||||||
export const FACE_VALIDATION_PORT_TOKEN = 'FaceValidationPort';
|
FACE_VALIDATION_PORT_TOKEN,
|
||||||
export const AVATAR_GENERATION_PORT_TOKEN = 'AvatarGenerationPort';
|
AVATAR_GENERATION_PORT_TOKEN,
|
||||||
export const MEDIA_STORAGE_PORT_TOKEN = 'MediaStoragePort';
|
MEDIA_STORAGE_PORT_TOKEN,
|
||||||
export const LOGGER_TOKEN = 'Logger';
|
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 * from './MediaTokens';
|
||||||
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';
|
|
||||||
|
|
||||||
import type { AvatarGenerationRequest } from '@core/media/domain/entities/AvatarGenerationRequest';
|
import type { AvatarGenerationRequest } from '@core/media/domain/entities/AvatarGenerationRequest';
|
||||||
import type { Media } from '@core/media/domain/entities/Media';
|
import type { Media } from '@core/media/domain/entities/Media';
|
||||||
@@ -133,7 +132,6 @@ class MockLogger implements Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const MediaProviders: Provider[] = [
|
export const MediaProviders: Provider[] = [
|
||||||
MediaService, // Provide the service itself
|
|
||||||
RequestAvatarGenerationPresenter,
|
RequestAvatarGenerationPresenter,
|
||||||
UploadMediaPresenter,
|
UploadMediaPresenter,
|
||||||
GetMediaPresenter,
|
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 { GetAvatarPresenter } from './presenters/GetAvatarPresenter';
|
||||||
import { UpdateAvatarPresenter } from './presenters/UpdateAvatarPresenter';
|
import { UpdateAvatarPresenter } from './presenters/UpdateAvatarPresenter';
|
||||||
|
|
||||||
// Tokens
|
|
||||||
import {
|
import {
|
||||||
REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN,
|
REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN,
|
||||||
UPLOAD_MEDIA_USE_CASE_TOKEN,
|
UPLOAD_MEDIA_USE_CASE_TOKEN,
|
||||||
@@ -40,7 +39,7 @@ import {
|
|||||||
GET_AVATAR_USE_CASE_TOKEN,
|
GET_AVATAR_USE_CASE_TOKEN,
|
||||||
UPDATE_AVATAR_USE_CASE_TOKEN,
|
UPDATE_AVATAR_USE_CASE_TOKEN,
|
||||||
LOGGER_TOKEN,
|
LOGGER_TOKEN,
|
||||||
} from './MediaProviders';
|
} from './MediaTokens';
|
||||||
import type { Logger } from '@core/shared/application';
|
import type { Logger } from '@core/shared/application';
|
||||||
|
|
||||||
@Injectable()
|
@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 { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger';
|
||||||
import { PaymentsService } from './PaymentsService';
|
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';
|
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')
|
@ApiTags('payments')
|
||||||
@Controller('payments')
|
@Controller('payments')
|
||||||
export class PaymentsController {
|
export class PaymentsController {
|
||||||
constructor(private readonly paymentsService: PaymentsService) {}
|
constructor(@Inject(PaymentsService) private readonly paymentsService: PaymentsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'Get payments based on filters' })
|
@ApiOperation({ summary: 'Get payments based on filters' })
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { PaymentsProviders } from './PaymentsProviders';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [PaymentsController],
|
controllers: [PaymentsController],
|
||||||
providers: PaymentsProviders,
|
providers: [PaymentsService, ...PaymentsProviders],
|
||||||
exports: [PaymentsService],
|
exports: [PaymentsService],
|
||||||
})
|
})
|
||||||
export class PaymentsModule {}
|
export class PaymentsModule {}
|
||||||
|
|||||||
@@ -43,45 +43,43 @@ import { DeletePrizePresenter } from './presenters/DeletePrizePresenter';
|
|||||||
import { GetWalletPresenter } from './presenters/GetWalletPresenter';
|
import { GetWalletPresenter } from './presenters/GetWalletPresenter';
|
||||||
import { ProcessWalletTransactionPresenter } from './presenters/ProcessWalletTransactionPresenter';
|
import { ProcessWalletTransactionPresenter } from './presenters/ProcessWalletTransactionPresenter';
|
||||||
|
|
||||||
// Repository injection tokens
|
import {
|
||||||
export const PAYMENT_REPOSITORY_TOKEN = 'IPaymentRepository';
|
PAYMENT_REPOSITORY_TOKEN,
|
||||||
export const MEMBERSHIP_FEE_REPOSITORY_TOKEN = 'IMembershipFeeRepository';
|
MEMBERSHIP_FEE_REPOSITORY_TOKEN,
|
||||||
export const MEMBER_PAYMENT_REPOSITORY_TOKEN = 'IMemberPaymentRepository';
|
MEMBER_PAYMENT_REPOSITORY_TOKEN,
|
||||||
export const PRIZE_REPOSITORY_TOKEN = 'IPrizeRepository';
|
PRIZE_REPOSITORY_TOKEN,
|
||||||
export const WALLET_REPOSITORY_TOKEN = 'IWalletRepository';
|
WALLET_REPOSITORY_TOKEN,
|
||||||
export const TRANSACTION_REPOSITORY_TOKEN = 'ITransactionRepository';
|
TRANSACTION_REPOSITORY_TOKEN,
|
||||||
export const LOGGER_TOKEN = 'Logger';
|
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 * from './PaymentsTokens';
|
||||||
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 const PaymentsProviders: Provider[] = [
|
export const PaymentsProviders: Provider[] = [
|
||||||
PaymentsService,
|
|
||||||
|
|
||||||
// Presenters
|
// Presenters
|
||||||
GetPaymentsPresenter,
|
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_MEMBER_PAYMENT_USE_CASE_TOKEN,
|
||||||
UPDATE_PAYMENT_STATUS_USE_CASE_TOKEN,
|
UPDATE_PAYMENT_STATUS_USE_CASE_TOKEN,
|
||||||
UPSERT_MEMBERSHIP_FEE_USE_CASE_TOKEN,
|
UPSERT_MEMBERSHIP_FEE_USE_CASE_TOKEN,
|
||||||
} from './PaymentsProviders';
|
} from './PaymentsTokens';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PaymentsService {
|
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 { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { ProtestsService } from './ProtestsService';
|
import { ProtestsService } from './ProtestsService';
|
||||||
import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO';
|
import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO';
|
||||||
@@ -7,7 +7,7 @@ import type { ReviewProtestResponseDTO } from './presenters/ReviewProtestPresent
|
|||||||
@ApiTags('protests')
|
@ApiTags('protests')
|
||||||
@Controller('protests')
|
@Controller('protests')
|
||||||
export class ProtestsController {
|
export class ProtestsController {
|
||||||
constructor(private readonly protestsService: ProtestsService) {}
|
constructor(@Inject(ProtestsService) private readonly protestsService: ProtestsService) {}
|
||||||
|
|
||||||
@Post(':protestId/review')
|
@Post(':protestId/review')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Provider } from '@nestjs/common';
|
import { Provider } from '@nestjs/common';
|
||||||
import { ProtestsService } from './ProtestsService';
|
|
||||||
|
|
||||||
// Import core interfaces
|
// Import core interfaces
|
||||||
import type { Logger } from '@core/shared/application/Logger';
|
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 REVIEW_PROTEST_PRESENTER_TOKEN = 'ReviewProtestPresenter';
|
||||||
|
|
||||||
export const ProtestsProviders: Provider[] = [
|
export const ProtestsProviders: Provider[] = [
|
||||||
ProtestsService, // Provide the service itself
|
|
||||||
{
|
{
|
||||||
provide: PROTEST_REPOSITORY_TOKEN,
|
provide: PROTEST_REPOSITORY_TOKEN,
|
||||||
useFactory: (logger: Logger) => new InMemoryProtestRepository(logger),
|
useFactory: (logger: Logger) => new InMemoryProtestRepository(logger),
|
||||||
|
|||||||
@@ -4,34 +4,23 @@ import type { Logger } from '@core/shared/application/Logger';
|
|||||||
import type {
|
import type {
|
||||||
ReviewProtestUseCase,
|
ReviewProtestUseCase,
|
||||||
ReviewProtestApplicationError,
|
ReviewProtestApplicationError,
|
||||||
|
ReviewProtestResult,
|
||||||
} from '@core/racing/application/use-cases/ReviewProtestUseCase';
|
} from '@core/racing/application/use-cases/ReviewProtestUseCase';
|
||||||
import { ProtestsService } from './ProtestsService';
|
import { ProtestsService } from './ProtestsService';
|
||||||
import type { ReviewProtestPresenter } from './presenters/ReviewProtestPresenter';
|
import { ReviewProtestPresenter, type ReviewProtestResponseDTO } from './presenters/ReviewProtestPresenter';
|
||||||
import type { ReviewProtestResponseDTO } from './presenters/ReviewProtestPresenter';
|
|
||||||
|
|
||||||
describe('ProtestsService', () => {
|
describe('ProtestsService', () => {
|
||||||
let service: ProtestsService;
|
let service: ProtestsService;
|
||||||
let executeMock: MockedFunction<ReviewProtestUseCase['execute']>;
|
let executeMock: MockedFunction<ReviewProtestUseCase['execute']>;
|
||||||
let logger: Logger;
|
let logger: Logger;
|
||||||
|
let presenter: ReviewProtestPresenter;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
executeMock = vi.fn();
|
executeMock = vi.fn();
|
||||||
const reviewProtestUseCase = { execute: executeMock } as unknown as ReviewProtestUseCase;
|
const reviewProtestUseCase = { execute: executeMock } as unknown as ReviewProtestUseCase;
|
||||||
const reviewProtestPresenter = {
|
|
||||||
reset: vi.fn(),
|
presenter = new ReviewProtestPresenter();
|
||||||
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;
|
|
||||||
logger = {
|
logger = {
|
||||||
debug: vi.fn(),
|
debug: vi.fn(),
|
||||||
info: vi.fn(),
|
info: vi.fn(),
|
||||||
@@ -39,7 +28,7 @@ describe('ProtestsService', () => {
|
|||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
} as unknown as Logger;
|
} as unknown as Logger;
|
||||||
|
|
||||||
service = new ProtestsService(reviewProtestUseCase, reviewProtestPresenter, logger);
|
service = new ProtestsService(reviewProtestUseCase, presenter, logger);
|
||||||
});
|
});
|
||||||
|
|
||||||
const baseCommand = {
|
const baseCommand = {
|
||||||
@@ -50,10 +39,14 @@ describe('ProtestsService', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
it('returns DTO with success model on success', async () => {
|
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);
|
const dto = await service.reviewProtest(baseCommand);
|
||||||
|
|
||||||
|
expect(presenter.getResponseModel()).not.toBeNull();
|
||||||
expect(executeMock).toHaveBeenCalledWith(baseCommand);
|
expect(executeMock).toHaveBeenCalledWith(baseCommand);
|
||||||
expect(dto).toEqual<ReviewProtestResponseDTO>({
|
expect(dto).toEqual<ReviewProtestResponseDTO>({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -69,7 +62,10 @@ describe('ProtestsService', () => {
|
|||||||
details: { message: 'Protest not found' },
|
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);
|
const dto = await service.reviewProtest(baseCommand);
|
||||||
|
|
||||||
@@ -86,7 +82,10 @@ describe('ProtestsService', () => {
|
|||||||
details: { message: 'Race not found for protest' },
|
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);
|
const dto = await service.reviewProtest(baseCommand);
|
||||||
|
|
||||||
@@ -103,7 +102,10 @@ describe('ProtestsService', () => {
|
|||||||
details: { message: 'Steward is not authorized to review this protest' },
|
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);
|
const dto = await service.reviewProtest(baseCommand);
|
||||||
|
|
||||||
@@ -121,7 +123,10 @@ describe('ProtestsService', () => {
|
|||||||
details: { message: 'Failed to review protest' },
|
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);
|
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 { ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import { RaceService } from './RaceService';
|
import { RaceService } from './RaceService';
|
||||||
import { AllRacesPageDTO } from './dtos/AllRacesPageDTO';
|
import { AllRacesPageDTO } from './dtos/AllRacesPageDTO';
|
||||||
@@ -21,7 +21,7 @@ import { RequestProtestDefenseCommandDTO } from './dtos/RequestProtestDefenseCom
|
|||||||
@ApiTags('races')
|
@ApiTags('races')
|
||||||
@Controller('races')
|
@Controller('races')
|
||||||
export class RaceController {
|
export class RaceController {
|
||||||
constructor(private readonly raceService: RaceService) {}
|
constructor(@Inject(RaceService) private readonly raceService: RaceService) {}
|
||||||
|
|
||||||
@Get('all')
|
@Get('all')
|
||||||
@ApiOperation({ summary: 'Get all races' })
|
@ApiOperation({ summary: 'Get all races' })
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { RaceProviders } from './RaceProviders';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [RaceController],
|
controllers: [RaceController],
|
||||||
providers: RaceProviders,
|
providers: [RaceService, ...RaceProviders],
|
||||||
exports: [RaceService],
|
exports: [RaceService],
|
||||||
})
|
})
|
||||||
export class RaceModule {}
|
export class RaceModule {}
|
||||||
|
|||||||
@@ -90,32 +90,33 @@ import { RaceResultsDetailPresenter } from './presenters/RaceResultsDetailPresen
|
|||||||
import { RacesPageDataPresenter } from './presenters/RacesPageDataPresenter';
|
import { RacesPageDataPresenter } from './presenters/RacesPageDataPresenter';
|
||||||
import { RaceWithSOFPresenter } from './presenters/RaceWithSOFPresenter';
|
import { RaceWithSOFPresenter } from './presenters/RaceWithSOFPresenter';
|
||||||
|
|
||||||
// Define injection tokens
|
import {
|
||||||
export const RACE_REPOSITORY_TOKEN = 'IRaceRepository';
|
RACE_REPOSITORY_TOKEN,
|
||||||
export const LEAGUE_REPOSITORY_TOKEN = 'ILeagueRepository';
|
LEAGUE_REPOSITORY_TOKEN,
|
||||||
export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
|
DRIVER_REPOSITORY_TOKEN,
|
||||||
export const RACE_REGISTRATION_REPOSITORY_TOKEN = 'IRaceRegistrationRepository';
|
RACE_REGISTRATION_REPOSITORY_TOKEN,
|
||||||
export const RESULT_REPOSITORY_TOKEN = 'IResultRepository';
|
RESULT_REPOSITORY_TOKEN,
|
||||||
export const LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN = 'ILeagueMembershipRepository';
|
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
|
||||||
export const PENALTY_REPOSITORY_TOKEN = 'IPenaltyRepository';
|
PENALTY_REPOSITORY_TOKEN,
|
||||||
export const PROTEST_REPOSITORY_TOKEN = 'IProtestRepository';
|
PROTEST_REPOSITORY_TOKEN,
|
||||||
export const STANDING_REPOSITORY_TOKEN = 'IStandingRepository';
|
STANDING_REPOSITORY_TOKEN,
|
||||||
export const DRIVER_RATING_PROVIDER_TOKEN = 'DriverRatingProvider';
|
DRIVER_RATING_PROVIDER_TOKEN,
|
||||||
export const IMAGE_SERVICE_TOKEN = 'IImageServicePort';
|
IMAGE_SERVICE_TOKEN,
|
||||||
export const LOGGER_TOKEN = 'Logger';
|
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 * from './RaceTokens';
|
||||||
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';
|
|
||||||
|
|
||||||
// Adapter classes to bridge presenters with UseCaseOutputPort interface
|
// Adapter classes to bridge presenters with UseCaseOutputPort interface
|
||||||
class GetAllRacesOutputAdapter implements UseCaseOutputPort<GetAllRacesResult> {
|
class GetAllRacesOutputAdapter implements UseCaseOutputPort<GetAllRacesResult> {
|
||||||
@@ -295,7 +296,6 @@ class ReviewProtestOutputAdapter implements UseCaseOutputPort<ReviewProtestResul
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const RaceProviders: Provider[] = [
|
export const RaceProviders: Provider[] = [
|
||||||
RaceService,
|
|
||||||
{
|
{
|
||||||
provide: RACE_REPOSITORY_TOKEN,
|
provide: RACE_REPOSITORY_TOKEN,
|
||||||
useFactory: (logger: Logger) => new InMemoryRaceRepository(logger),
|
useFactory: (logger: Logger) => new InMemoryRaceRepository(logger),
|
||||||
@@ -471,16 +471,15 @@ export const RaceProviders: Provider[] = [
|
|||||||
leagueMembershipRepo: ILeagueMembershipRepository,
|
leagueMembershipRepo: ILeagueMembershipRepository,
|
||||||
presenter: RaceDetailPresenter,
|
presenter: RaceDetailPresenter,
|
||||||
) => {
|
) => {
|
||||||
const useCase = new GetRaceDetailUseCase(
|
return new GetRaceDetailUseCase(
|
||||||
raceRepo,
|
raceRepo,
|
||||||
leagueRepo,
|
leagueRepo,
|
||||||
driverRepo,
|
driverRepo,
|
||||||
raceRegRepo,
|
raceRegRepo,
|
||||||
resultRepo,
|
resultRepo,
|
||||||
leagueMembershipRepo,
|
leagueMembershipRepo,
|
||||||
|
new RaceDetailOutputAdapter(presenter),
|
||||||
);
|
);
|
||||||
useCase.setOutput(new RaceDetailOutputAdapter(presenter));
|
|
||||||
return useCase;
|
|
||||||
},
|
},
|
||||||
inject: [
|
inject: [
|
||||||
RACE_REPOSITORY_TOKEN,
|
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_RESULTS_DETAIL_PRESENTER_TOKEN,
|
||||||
RACE_WITH_SOF_PRESENTER_TOKEN,
|
RACE_WITH_SOF_PRESENTER_TOKEN,
|
||||||
RACES_PAGE_DATA_PRESENTER_TOKEN
|
RACES_PAGE_DATA_PRESENTER_TOKEN
|
||||||
} from './RaceProviders';
|
} from './RaceTokens';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RaceService {
|
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', () => {
|
describe('getEntitySponsorshipPricing', () => {
|
||||||
it('should return sponsorship pricing', async () => {
|
it('should return sponsorship pricing', async () => {
|
||||||
const mockResult = { entityType: 'season', entityId: 'season-1', pricing: [] };
|
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();
|
const result = await controller.getEntitySponsorshipPricing();
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ describe('SponsorController', () => {
|
|||||||
describe('getSponsors', () => {
|
describe('getSponsors', () => {
|
||||||
it('should return sponsors list', async () => {
|
it('should return sponsors list', async () => {
|
||||||
const mockResult = { sponsors: [] };
|
const mockResult = { sponsors: [] };
|
||||||
sponsorService.getSponsors.mockResolvedValue({ viewModel: mockResult } as any);
|
sponsorService.getSponsors.mockResolvedValue(mockResult as any);
|
||||||
|
|
||||||
const result = await controller.getSponsors();
|
const result = await controller.getSponsors();
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ describe('SponsorController', () => {
|
|||||||
it('should create sponsor', async () => {
|
it('should create sponsor', async () => {
|
||||||
const input = { name: 'Test Sponsor', contactEmail: 'test@example.com' };
|
const input = { name: 'Test Sponsor', contactEmail: 'test@example.com' };
|
||||||
const mockResult = { sponsor: { id: 's1', name: 'Test Sponsor' } };
|
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);
|
const result = await controller.createSponsor(input as any);
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ describe('SponsorController', () => {
|
|||||||
it('should return sponsor dashboard', async () => {
|
it('should return sponsor dashboard', async () => {
|
||||||
const sponsorId = 's1';
|
const sponsorId = 's1';
|
||||||
const mockResult = { sponsorId, metrics: {} as any, sponsoredLeagues: [], investment: {} as any };
|
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);
|
const result = await controller.getSponsorDashboard(sponsorId);
|
||||||
|
|
||||||
@@ -86,13 +86,11 @@ describe('SponsorController', () => {
|
|||||||
expect(sponsorService.getSponsorDashboard).toHaveBeenCalledWith({ sponsorId });
|
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';
|
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);
|
await expect(controller.getSponsorDashboard(sponsorId)).rejects.toBeInstanceOf(Error);
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -111,7 +109,7 @@ describe('SponsorController', () => {
|
|||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
sponsorService.getSponsorSponsorships.mockResolvedValue({ viewModel: mockResult } as any);
|
sponsorService.getSponsorSponsorships.mockResolvedValue(mockResult as any);
|
||||||
|
|
||||||
const result = await controller.getSponsorSponsorships(sponsorId);
|
const result = await controller.getSponsorSponsorships(sponsorId);
|
||||||
|
|
||||||
@@ -119,13 +117,11 @@ describe('SponsorController', () => {
|
|||||||
expect(sponsorService.getSponsorSponsorships).toHaveBeenCalledWith({ sponsorId });
|
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';
|
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);
|
await expect(controller.getSponsorSponsorships(sponsorId)).rejects.toBeInstanceOf(Error);
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,7 +129,7 @@ describe('SponsorController', () => {
|
|||||||
it('should return sponsor', async () => {
|
it('should return sponsor', async () => {
|
||||||
const sponsorId = 's1';
|
const sponsorId = 's1';
|
||||||
const mockResult = { sponsor: { id: sponsorId, name: '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);
|
const result = await controller.getSponsor(sponsorId);
|
||||||
|
|
||||||
@@ -141,13 +137,11 @@ describe('SponsorController', () => {
|
|||||||
expect(sponsorService.getSponsor).toHaveBeenCalledWith(sponsorId);
|
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';
|
const sponsorId = 's1';
|
||||||
sponsorService.getSponsor.mockResolvedValue({ viewModel: null } as any);
|
sponsorService.getSponsor.mockRejectedValue(new Error('Sponsor not found'));
|
||||||
|
|
||||||
const result = await controller.getSponsor(sponsorId);
|
await expect(controller.getSponsor(sponsorId)).rejects.toBeInstanceOf(Error);
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,7 +154,7 @@ describe('SponsorController', () => {
|
|||||||
requests: [],
|
requests: [],
|
||||||
totalCount: 0,
|
totalCount: 0,
|
||||||
};
|
};
|
||||||
sponsorService.getPendingSponsorshipRequests.mockResolvedValue({ viewModel: mockResult } as any);
|
sponsorService.getPendingSponsorshipRequests.mockResolvedValue(mockResult as any);
|
||||||
|
|
||||||
const result = await controller.getPendingSponsorshipRequests(query);
|
const result = await controller.getPendingSponsorshipRequests(query);
|
||||||
|
|
||||||
@@ -181,7 +175,7 @@ describe('SponsorController', () => {
|
|||||||
platformFee: 10,
|
platformFee: 10,
|
||||||
netAmount: 90,
|
netAmount: 90,
|
||||||
};
|
};
|
||||||
sponsorService.acceptSponsorshipRequest.mockResolvedValue({ viewModel: mockResult } as any);
|
sponsorService.acceptSponsorshipRequest.mockResolvedValue(mockResult as any);
|
||||||
|
|
||||||
const result = await controller.acceptSponsorshipRequest(requestId, input 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 requestId = 'r1';
|
||||||
const input = { respondedBy: 'u1' };
|
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);
|
await expect(controller.acceptSponsorshipRequest(requestId, input as any)).rejects.toBeInstanceOf(Error);
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -213,7 +205,7 @@ describe('SponsorController', () => {
|
|||||||
rejectedAt: new Date(),
|
rejectedAt: new Date(),
|
||||||
reason: 'Not interested',
|
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);
|
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 requestId = 'r1';
|
||||||
const input = { respondedBy: 'u1' };
|
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);
|
await expect(controller.rejectSponsorshipRequest(requestId, input as any)).rejects.toBeInstanceOf(Error);
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -251,7 +241,7 @@ describe('SponsorController', () => {
|
|||||||
averageMonthlySpend: 0,
|
averageMonthlySpend: 0,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
sponsorService.getSponsorBilling.mockResolvedValue({ viewModel: mockResult } as any);
|
sponsorService.getSponsorBilling.mockResolvedValue(mockResult as any);
|
||||||
|
|
||||||
const result = await controller.getSponsorBilling(sponsorId);
|
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 { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger';
|
||||||
import { SponsorService } from './SponsorService';
|
import { SponsorService } from './SponsorService';
|
||||||
import { GetEntitySponsorshipPricingResultDTO } from './dtos/GetEntitySponsorshipPricingResultDTO';
|
import { GetEntitySponsorshipPricingResultDTO } from './dtos/GetEntitySponsorshipPricingResultDTO';
|
||||||
@@ -29,7 +29,7 @@ import type { RejectSponsorshipRequestResult } from '@core/racing/application/us
|
|||||||
@ApiTags('sponsors')
|
@ApiTags('sponsors')
|
||||||
@Controller('sponsors')
|
@Controller('sponsors')
|
||||||
export class SponsorController {
|
export class SponsorController {
|
||||||
constructor(private readonly sponsorService: SponsorService) {}
|
constructor(@Inject(SponsorService) private readonly sponsorService: SponsorService) {}
|
||||||
|
|
||||||
@Get('pricing')
|
@Get('pricing')
|
||||||
@ApiOperation({ summary: 'Get sponsorship pricing for an entity' })
|
@ApiOperation({ summary: 'Get sponsorship pricing for an entity' })
|
||||||
|
|||||||
@@ -273,15 +273,11 @@ export const SponsorProviders: Provider[] = [
|
|||||||
provide: GET_ENTITY_SPONSORSHIP_PRICING_USE_CASE_TOKEN,
|
provide: GET_ENTITY_SPONSORSHIP_PRICING_USE_CASE_TOKEN,
|
||||||
useFactory: (
|
useFactory: (
|
||||||
sponsorshipPricingRepo: ISponsorshipPricingRepository,
|
sponsorshipPricingRepo: ISponsorshipPricingRepository,
|
||||||
sponsorshipRequestRepo: ISponsorshipRequestRepository,
|
|
||||||
seasonSponsorshipRepo: ISeasonSponsorshipRepository,
|
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
output: UseCaseOutputPort<any>,
|
output: UseCaseOutputPort<any>,
|
||||||
) => new GetEntitySponsorshipPricingUseCase(sponsorshipPricingRepo, sponsorshipRequestRepo, seasonSponsorshipRepo, logger, output),
|
) => new GetEntitySponsorshipPricingUseCase(sponsorshipPricingRepo, logger, output),
|
||||||
inject: [
|
inject: [
|
||||||
SPONSORSHIP_PRICING_REPOSITORY_TOKEN,
|
SPONSORSHIP_PRICING_REPOSITORY_TOKEN,
|
||||||
SPONSORSHIP_REQUEST_REPOSITORY_TOKEN,
|
|
||||||
SEASON_SPONSORSHIP_REPOSITORY_TOKEN,
|
|
||||||
LOGGER_TOKEN,
|
LOGGER_TOKEN,
|
||||||
GET_ENTITY_SPONSORSHIP_PRICING_OUTPUT_PORT_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 { Result } from '@core/shared/application/Result';
|
||||||
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
|
||||||
import type { CreateSponsorInputDTO } from './dtos/CreateSponsorInputDTO';
|
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 { AcceptSponsorshipRequestPresenter } from './presenters/AcceptSponsorshipRequestPresenter';
|
||||||
import { CreateSponsorPresenter } from './presenters/CreateSponsorPresenter';
|
import { CreateSponsorPresenter } from './presenters/CreateSponsorPresenter';
|
||||||
import { GetEntitySponsorshipPricingPresenter } from './presenters/GetEntitySponsorshipPricingPresenter';
|
import { GetEntitySponsorshipPricingPresenter } from './presenters/GetEntitySponsorshipPricingPresenter';
|
||||||
@@ -110,20 +112,20 @@ describe('SponsorService', () => {
|
|||||||
const outputPort = {
|
const outputPort = {
|
||||||
entityType: 'season',
|
entityType: 'season',
|
||||||
entityId: 'season-1',
|
entityId: 'season-1',
|
||||||
tiers: [
|
tiers: [{ name: 'Gold', price: { amount: 500, currency: 'USD' }, benefits: ['Main slot'] }],
|
||||||
{ name: 'Gold', price: 500, 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();
|
const result = await service.getEntitySponsorshipPricing();
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
entityType: 'season',
|
entityType: 'season',
|
||||||
entityId: 'season-1',
|
entityId: 'season-1',
|
||||||
pricing: [
|
pricing: [{ id: 'Gold', level: 'Gold', price: 500, currency: 'USD' }],
|
||||||
{ id: 'Gold', level: 'Gold', price: 500, currency: 'USD' },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,13 +144,32 @@ describe('SponsorService', () => {
|
|||||||
|
|
||||||
describe('getSponsors', () => {
|
describe('getSponsors', () => {
|
||||||
it('returns sponsors on success', async () => {
|
it('returns sponsors on success', async () => {
|
||||||
const sponsors = [{ id: 's1', name: 'S1', contactEmail: 's1@test', createdAt: new Date() }];
|
const sponsors = [
|
||||||
const outputPort = { sponsors };
|
Sponsor.create({
|
||||||
getSponsorsUseCase.execute.mockResolvedValue(Result.ok(outputPort));
|
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();
|
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 () => {
|
it('returns empty list on error', async () => {
|
||||||
@@ -163,18 +184,28 @@ describe('SponsorService', () => {
|
|||||||
describe('createSponsor', () => {
|
describe('createSponsor', () => {
|
||||||
it('returns created sponsor on success', async () => {
|
it('returns created sponsor on success', async () => {
|
||||||
const input: CreateSponsorInputDTO = { name: 'Test', contactEmail: 'test@example.com' };
|
const input: CreateSponsorInputDTO = { name: 'Test', contactEmail: 'test@example.com' };
|
||||||
const sponsor = {
|
const sponsor = Sponsor.create({
|
||||||
id: 's1',
|
id: 'sponsor-1',
|
||||||
name: 'Test',
|
name: 'Test',
|
||||||
contactEmail: 'test@example.com',
|
contactEmail: 'test@example.com',
|
||||||
createdAt: new Date(),
|
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||||
};
|
});
|
||||||
const outputPort = { sponsor };
|
|
||||||
createSponsorUseCase.execute.mockResolvedValue(Result.ok(outputPort));
|
createSponsorUseCase.execute.mockImplementation(async () => {
|
||||||
|
createSponsorPresenter.present(sponsor);
|
||||||
|
return Result.ok(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
const result = await service.createSponsor(input);
|
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 () => {
|
it('throws on error', async () => {
|
||||||
@@ -185,6 +216,20 @@ describe('SponsorService', () => {
|
|||||||
|
|
||||||
await expect(service.createSponsor(input)).rejects.toThrow('Invalid');
|
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', () => {
|
describe('getSponsorDashboard', () => {
|
||||||
@@ -206,15 +251,38 @@ describe('SponsorService', () => {
|
|||||||
sponsoredLeagues: [],
|
sponsoredLeagues: [],
|
||||||
investment: {
|
investment: {
|
||||||
activeSponsorships: 0,
|
activeSponsorships: 0,
|
||||||
totalInvestment: 0,
|
totalInvestment: Money.create(0, 'USD'),
|
||||||
costPerThousandViews: 0,
|
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);
|
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 () => {
|
it('throws on error', async () => {
|
||||||
@@ -229,6 +297,28 @@ describe('SponsorService', () => {
|
|||||||
it('returns sponsorships on success', async () => {
|
it('returns sponsorships on success', async () => {
|
||||||
const params: GetSponsorSponsorshipsInput = { sponsorId: 's1' };
|
const params: GetSponsorSponsorshipsInput = { sponsorId: 's1' };
|
||||||
const outputPort = {
|
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',
|
sponsorId: 's1',
|
||||||
sponsorName: 'S1',
|
sponsorName: 'S1',
|
||||||
sponsorships: [],
|
sponsorships: [],
|
||||||
@@ -239,39 +329,44 @@ describe('SponsorService', () => {
|
|||||||
totalPlatformFees: 0,
|
totalPlatformFees: 0,
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
getSponsorSponsorshipsUseCase.execute.mockResolvedValue(Result.ok(outputPort));
|
|
||||||
|
|
||||||
const result = await service.getSponsorSponsorships(params);
|
|
||||||
|
|
||||||
expect(result).toEqual(outputPort);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws on error', async () => {
|
it('throws on error', async () => {
|
||||||
const params: GetSponsorSponsorshipsInput = { sponsorId: 's1' };
|
const params: GetSponsorSponsorshipsInput = { sponsorId: 's1' };
|
||||||
getSponsorSponsorshipsUseCase.execute.mockResolvedValue(
|
getSponsorSponsorshipsUseCase.execute.mockResolvedValue(Result.err({ code: 'REPOSITORY_ERROR' }));
|
||||||
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', () => {
|
describe('getSponsor', () => {
|
||||||
it('returns sponsor when found', async () => {
|
it('returns sponsor when found', async () => {
|
||||||
const sponsorId = 's1';
|
const sponsorId = 's1';
|
||||||
const sponsor = { id: sponsorId, name: 'S1' };
|
const output = { sponsor: { id: sponsorId, name: 'S1' } };
|
||||||
const output = { sponsor };
|
|
||||||
getSponsorUseCase.execute.mockResolvedValue(Result.ok(output));
|
getSponsorUseCase.execute.mockImplementation(async () => {
|
||||||
|
getSponsorPresenter.present(output);
|
||||||
|
return Result.ok(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
const result = await service.getSponsor(sponsorId);
|
const result = await service.getSponsor(sponsorId);
|
||||||
|
|
||||||
expect(result).toEqual({ sponsor });
|
expect(result).toEqual(output);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws when not found', async () => {
|
it('throws when not found', async () => {
|
||||||
const sponsorId = 's1';
|
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');
|
await expect(service.getSponsor(sponsorId)).rejects.toThrow('Sponsor not found');
|
||||||
});
|
});
|
||||||
@@ -286,7 +381,11 @@ describe('SponsorService', () => {
|
|||||||
requests: [],
|
requests: [],
|
||||||
totalCount: 0,
|
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);
|
const result = await service.getPendingSponsorshipRequests(params);
|
||||||
|
|
||||||
@@ -295,9 +394,7 @@ describe('SponsorService', () => {
|
|||||||
|
|
||||||
it('returns empty result on error', async () => {
|
it('returns empty result on error', async () => {
|
||||||
const params = { entityType: 'season' as const, entityId: 'season-1' };
|
const params = { entityType: 'season' as const, entityId: 'season-1' };
|
||||||
getPendingSponsorshipRequestsUseCase.execute.mockResolvedValue(
|
getPendingSponsorshipRequestsUseCase.execute.mockResolvedValue(Result.err({ code: 'REPOSITORY_ERROR' }));
|
||||||
Result.err({ code: 'REPOSITORY_ERROR' }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await service.getPendingSponsorshipRequests(params);
|
const result = await service.getPendingSponsorshipRequests(params);
|
||||||
|
|
||||||
@@ -308,6 +405,13 @@ describe('SponsorService', () => {
|
|||||||
totalCount: 0,
|
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', () => {
|
describe('SponsorshipRequest', () => {
|
||||||
@@ -322,7 +426,11 @@ describe('SponsorService', () => {
|
|||||||
platformFee: 10,
|
platformFee: 10,
|
||||||
netAmount: 90,
|
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);
|
const result = await service.acceptSponsorshipRequest(requestId, respondedBy);
|
||||||
|
|
||||||
@@ -336,7 +444,19 @@ describe('SponsorService', () => {
|
|||||||
Result.err({ code: 'SPONSORSHIP_REQUEST_NOT_FOUND' }),
|
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(),
|
respondedAt: new Date(),
|
||||||
rejectionReason: reason,
|
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);
|
const result = await service.rejectSponsorshipRequest(requestId, respondedBy, reason);
|
||||||
|
|
||||||
expect(result).toEqual(output);
|
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 () => {
|
it('throws on error', async () => {
|
||||||
const requestId = 'r1';
|
const requestId = 'r1';
|
||||||
const respondedBy = 'u1';
|
const respondedBy = 'u1';
|
||||||
@@ -365,7 +510,19 @@ describe('SponsorService', () => {
|
|||||||
Result.err({ code: 'SPONSORSHIP_REQUEST_NOT_FOUND' }),
|
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.invoices).toBeInstanceOf(Array);
|
||||||
expect(result.stats).toBeDefined();
|
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', () => {
|
describe('getAvailableLeagues', () => {
|
||||||
|
|||||||
@@ -130,19 +130,39 @@ export class SponsorService {
|
|||||||
|
|
||||||
async getEntitySponsorshipPricing(): Promise<GetEntitySponsorshipPricingResultDTO> {
|
async getEntitySponsorshipPricing(): Promise<GetEntitySponsorshipPricingResultDTO> {
|
||||||
this.logger.debug('[SponsorService] Fetching sponsorship pricing.');
|
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;
|
return this.getEntitySponsorshipPricingPresenter.viewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSponsors(): Promise<GetSponsorsOutputDTO> {
|
async getSponsors(): Promise<GetSponsorsOutputDTO> {
|
||||||
this.logger.debug('[SponsorService] Fetching sponsors.');
|
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;
|
return this.getSponsorsPresenter.responseModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSponsor(input: CreateSponsorInputDTO): Promise<CreateSponsorOutputDTO> {
|
async createSponsor(input: CreateSponsorInputDTO): Promise<CreateSponsorOutputDTO> {
|
||||||
this.logger.debug('[SponsorService] Creating sponsor.', { input });
|
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;
|
return this.createSponsorPresenter.viewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,34 +170,41 @@ export class SponsorService {
|
|||||||
params: GetSponsorDashboardQueryParamsDTO,
|
params: GetSponsorDashboardQueryParamsDTO,
|
||||||
): Promise<SponsorDashboardDTO> {
|
): Promise<SponsorDashboardDTO> {
|
||||||
this.logger.debug('[SponsorService] Fetching sponsor dashboard.', { params });
|
this.logger.debug('[SponsorService] Fetching sponsor dashboard.', { params });
|
||||||
await this.getSponsorDashboardUseCase.execute(params);
|
const result = await this.getSponsorDashboardUseCase.execute(params);
|
||||||
const result = this.getSponsorDashboardPresenter.viewModel;
|
|
||||||
if (!result) {
|
if (result.isErr()) {
|
||||||
throw new Error('Sponsor dashboard not found');
|
throw new Error('Sponsor dashboard not found');
|
||||||
}
|
}
|
||||||
return result;
|
|
||||||
|
return this.getSponsorDashboardPresenter.viewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSponsorSponsorships(
|
async getSponsorSponsorships(
|
||||||
params: GetSponsorSponsorshipsQueryParamsDTO,
|
params: GetSponsorSponsorshipsQueryParamsDTO,
|
||||||
): Promise<SponsorSponsorshipsDTO> {
|
): Promise<SponsorSponsorshipsDTO> {
|
||||||
this.logger.debug('[SponsorService] Fetching sponsor sponsorships.', { params });
|
this.logger.debug('[SponsorService] Fetching sponsor sponsorships.', { params });
|
||||||
await this.getSponsorSponsorshipsUseCase.execute(params);
|
const result = await this.getSponsorSponsorshipsUseCase.execute(params);
|
||||||
const result = this.getSponsorSponsorshipsPresenter.viewModel;
|
|
||||||
if (!result) {
|
if (result.isErr()) {
|
||||||
throw new Error('Sponsor sponsorships not found');
|
throw new Error('Sponsor sponsorships not found');
|
||||||
}
|
}
|
||||||
return result;
|
|
||||||
|
return this.getSponsorSponsorshipsPresenter.viewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSponsor(sponsorId: string): Promise<GetSponsorOutputDTO> {
|
async getSponsor(sponsorId: string): Promise<GetSponsorOutputDTO> {
|
||||||
this.logger.debug('[SponsorService] Fetching sponsor.', { sponsorId });
|
this.logger.debug('[SponsorService] Fetching sponsor.', { sponsorId });
|
||||||
await this.getSponsorUseCase.execute({ sponsorId });
|
const result = await this.getSponsorUseCase.execute({ sponsorId });
|
||||||
const result = this.getSponsorPresenter.viewModel;
|
|
||||||
if (!result) {
|
if (result.isErr()) {
|
||||||
throw new Error('Sponsor not found');
|
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: {
|
async getPendingSponsorshipRequests(params: {
|
||||||
@@ -185,14 +212,26 @@ export class SponsorService {
|
|||||||
entityId: string;
|
entityId: string;
|
||||||
}): Promise<GetPendingSponsorshipRequestsOutputDTO> {
|
}): Promise<GetPendingSponsorshipRequestsOutputDTO> {
|
||||||
this.logger.debug('[SponsorService] Fetching pending sponsorship requests.', { params });
|
this.logger.debug('[SponsorService] Fetching pending sponsorship requests.', { params });
|
||||||
await this.getPendingSponsorshipRequestsUseCase.execute(
|
|
||||||
|
const result = await this.getPendingSponsorshipRequestsUseCase.execute(
|
||||||
params as GetPendingSponsorshipRequestsInput,
|
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');
|
throw new Error('Pending sponsorship requests not found');
|
||||||
}
|
}
|
||||||
return result;
|
|
||||||
|
return viewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
async acceptSponsorshipRequest(
|
async acceptSponsorshipRequest(
|
||||||
@@ -203,15 +242,22 @@ export class SponsorService {
|
|||||||
requestId,
|
requestId,
|
||||||
respondedBy,
|
respondedBy,
|
||||||
});
|
});
|
||||||
await this.acceptSponsorshipRequestUseCase.execute({
|
|
||||||
|
const result = await this.acceptSponsorshipRequestUseCase.execute({
|
||||||
requestId,
|
requestId,
|
||||||
respondedBy,
|
respondedBy,
|
||||||
});
|
});
|
||||||
const result = this.acceptSponsorshipRequestPresenter.viewModel;
|
|
||||||
if (!result) {
|
if (result.isErr()) {
|
||||||
throw new Error('Accept sponsorship request failed');
|
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(
|
async rejectSponsorshipRequest(
|
||||||
@@ -231,12 +277,19 @@ export class SponsorService {
|
|||||||
if (reason !== undefined) {
|
if (reason !== undefined) {
|
||||||
input.reason = reason;
|
input.reason = reason;
|
||||||
}
|
}
|
||||||
await this.rejectSponsorshipRequestUseCase.execute(input);
|
|
||||||
const result = this.rejectSponsorshipRequestPresenter.viewModel;
|
const result = await this.rejectSponsorshipRequestUseCase.execute(input);
|
||||||
if (!result) {
|
|
||||||
|
if (result.isErr()) {
|
||||||
throw new Error('Reject sponsorship request failed');
|
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<{
|
async getSponsorBilling(sponsorId: string): Promise<{
|
||||||
@@ -245,12 +298,13 @@ export class SponsorService {
|
|||||||
stats: BillingStatsDTO;
|
stats: BillingStatsDTO;
|
||||||
}> {
|
}> {
|
||||||
this.logger.debug('[SponsorService] Fetching sponsor billing.', { sponsorId });
|
this.logger.debug('[SponsorService] Fetching sponsor billing.', { sponsorId });
|
||||||
await this.getSponsorBillingUseCase.execute({ sponsorId });
|
const result = await this.getSponsorBillingUseCase.execute({ sponsorId });
|
||||||
const result = this.sponsorBillingPresenter.viewModel;
|
|
||||||
if (!result) {
|
if (result.isErr()) {
|
||||||
throw new Error('Sponsor billing not found');
|
throw new Error('Sponsor billing not found');
|
||||||
}
|
}
|
||||||
return result;
|
|
||||||
|
return this.sponsorBillingPresenter.viewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAvailableLeagues(): Promise<AvailableLeaguesPresenter> {
|
async getAvailableLeagues(): Promise<AvailableLeaguesPresenter> {
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export class GetEntitySponsorshipPricingPresenter {
|
|||||||
pricing: output.tiers.map(item => ({
|
pricing: output.tiers.map(item => ({
|
||||||
id: item.name,
|
id: item.name,
|
||||||
level: item.name,
|
level: item.name,
|
||||||
price: item.price,
|
price: item.price.amount,
|
||||||
currency: 'USD',
|
currency: item.price.currency,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ export class GetSponsorDashboardPresenter {
|
|||||||
return this.result;
|
return this.result;
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): SponsorDashboardDTO | null {
|
get viewModel(): SponsorDashboardDTO {
|
||||||
|
if (!this.result) throw new Error('Presenter not presented');
|
||||||
return this.result;
|
return this.result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ export class GetSponsorSponsorshipsPresenter {
|
|||||||
return this.result;
|
return this.result;
|
||||||
}
|
}
|
||||||
|
|
||||||
get viewModel(): SponsorSponsorshipsDTO | null {
|
get viewModel(): SponsorSponsorshipsDTO {
|
||||||
|
if (!this.result) throw new Error('Presenter not presented');
|
||||||
return this.result;
|
return this.result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ describe('GetSponsorsPresenter', () => {
|
|||||||
id: 'sponsor-1',
|
id: 'sponsor-1',
|
||||||
name: 'Sponsor One',
|
name: 'Sponsor One',
|
||||||
contactEmail: 's1@example.com',
|
contactEmail: 's1@example.com',
|
||||||
logoUrl: 'logo1.png',
|
logoUrl: 'https://one.example.com/logo1.png',
|
||||||
websiteUrl: 'https://one.example.com',
|
websiteUrl: 'https://one.example.com',
|
||||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||||
}),
|
}),
|
||||||
@@ -46,7 +46,7 @@ describe('GetSponsorsPresenter', () => {
|
|||||||
id: 'sponsor-1',
|
id: 'sponsor-1',
|
||||||
name: 'Sponsor One',
|
name: 'Sponsor One',
|
||||||
contactEmail: 's1@example.com',
|
contactEmail: 's1@example.com',
|
||||||
logoUrl: 'logo1.png',
|
logoUrl: 'https://one.example.com/logo1.png',
|
||||||
websiteUrl: 'https://one.example.com',
|
websiteUrl: 'https://one.example.com',
|
||||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
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 { Request } from 'express';
|
||||||
import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger';
|
||||||
import { TeamService } from './TeamService';
|
import { TeamService } from './TeamService';
|
||||||
@@ -16,7 +16,7 @@ import { GetTeamMembershipOutputDTO } from './dtos/GetTeamMembershipOutputDTO';
|
|||||||
@ApiTags('teams')
|
@ApiTags('teams')
|
||||||
@Controller('teams')
|
@Controller('teams')
|
||||||
export class TeamController {
|
export class TeamController {
|
||||||
constructor(private readonly teamService: TeamService) {}
|
constructor(@Inject(TeamService) private readonly teamService: TeamService) {}
|
||||||
|
|
||||||
@Get('all')
|
@Get('all')
|
||||||
@ApiOperation({ summary: 'Get all teams' })
|
@ApiOperation({ summary: 'Get all teams' })
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { TeamProviders } from './TeamProviders';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [TeamController],
|
controllers: [TeamController],
|
||||||
providers: TeamProviders,
|
providers: [TeamService, ...TeamProviders],
|
||||||
exports: [TeamService],
|
exports: [TeamService],
|
||||||
})
|
})
|
||||||
export class TeamModule {}
|
export class TeamModule {}
|
||||||
@@ -1,5 +1,20 @@
|
|||||||
import { Provider } from '@nestjs/common';
|
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 core interfaces
|
||||||
import type { Logger } from '@core/shared/application/Logger';
|
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
|
// 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[] = [
|
export const TeamProviders: Provider[] = [
|
||||||
TeamService, // Provide the service itself
|
|
||||||
{
|
{
|
||||||
provide: TEAM_REPOSITORY_TOKEN,
|
provide: TEAM_REPOSITORY_TOKEN,
|
||||||
useFactory: (logger: Logger) => new InMemoryTeamRepository(logger),
|
useFactory: (logger: Logger) => new InMemoryTeamRepository(logger),
|
||||||
|
|||||||
@@ -1,107 +1,531 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||||
import { vi } from 'vitest';
|
import type { Logger } from '@core/shared/application/Logger';
|
||||||
import { TeamService } from './TeamService';
|
import { Result } from '@core/shared/application/Result';
|
||||||
import { GetAllTeamsUseCase } from '@core/racing/application/use-cases/GetAllTeamsUseCase';
|
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 { 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
|
type ValueObjectStub = { props: string; toString(): string };
|
||||||
import { AllTeamsPresenter } from './presenters/AllTeamsPresenter';
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
type TeamEntityStub = {
|
||||||
import { DriverTeamPresenter } from './presenters/DriverTeamPresenter';
|
id: string;
|
||||||
import { DriverTeamViewModel } from './dtos/TeamDto';
|
name: ValueObjectStub;
|
||||||
|
tag: ValueObjectStub;
|
||||||
|
description: ValueObjectStub;
|
||||||
|
ownerId: ValueObjectStub;
|
||||||
|
leagues: ValueObjectStub[];
|
||||||
|
createdAt: { toDate(): Date };
|
||||||
|
update: Mock;
|
||||||
|
};
|
||||||
|
|
||||||
describe('TeamService', () => {
|
describe('TeamService', () => {
|
||||||
let service: TeamService;
|
const makeValueObject = (value: string): ValueObjectStub => ({
|
||||||
let getAllTeamsUseCase: ReturnType<typeof vi.mocked<GetAllTeamsUseCase>>;
|
props: value,
|
||||||
let getDriverTeamUseCase: ReturnType<typeof vi.mocked<GetDriverTeamUseCase>>;
|
toString() {
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
const makeTeam = (overrides?: Partial<Omit<TeamEntityStub, 'update'>>): TeamEntityStub => {
|
||||||
const mockGetAllTeamsUseCase = {
|
const base: Omit<TeamEntityStub, 'update'> = {
|
||||||
execute: vi.fn(),
|
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(),
|
debug: vi.fn(),
|
||||||
info: vi.fn(),
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
};
|
} as unknown as Logger;
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
service = new TeamService(teamRepository as unknown as never, membershipRepository as unknown as never, driverRepository as unknown as never, logger);
|
||||||
providers: [
|
});
|
||||||
TeamService,
|
|
||||||
|
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,
|
id: 'team-1',
|
||||||
useValue: mockGetAllTeamsUseCase,
|
name: 'Team One',
|
||||||
},
|
tag: 'T1',
|
||||||
{
|
description: 'Desc',
|
||||||
provide: GetDriverTeamUseCase,
|
memberCount: 3,
|
||||||
useValue: mockGetDriverTeamUseCase,
|
leagues: ['league-1'],
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: 'Logger',
|
|
||||||
useValue: mockLogger,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
totalCount: 1,
|
||||||
|
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getDriverTeam', () => {
|
it('getAll returns empty list when use case error message is empty (covers fallback)', async () => {
|
||||||
it('should call use case and return result', async () => {
|
const executeSpy = vi
|
||||||
const mockResult = { isOk: () => true, value: {} };
|
.spyOn(GetAllTeamsUseCase.prototype, 'execute')
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
.mockResolvedValueOnce(Result.err({ code: 'REPOSITORY_ERROR', details: { message: '' } }));
|
||||||
getDriverTeamUseCase.execute.mockResolvedValue(mockResult as any);
|
|
||||||
|
|
||||||
const mockPresenter = {
|
await expect(service.getAll()).resolves.toEqual({ teams: [], totalCount: 0 });
|
||||||
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);
|
|
||||||
|
|
||||||
const result = await service.getDriverTeam('driver1');
|
executeSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
expect(getDriverTeamUseCase.execute).toHaveBeenCalledWith({ driverId: 'driver1' });
|
it('getAll returns empty list when repository throws', async () => {
|
||||||
expect(result).toEqual({});
|
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 () => {
|
await expect(service.getDetails('team-1', 'driver-1')).resolves.toEqual({
|
||||||
const mockResult = { isErr: () => true, error: {} };
|
team: {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
id: 'team-1',
|
||||||
getDriverTeamUseCase.execute.mockResolvedValue(mockResult as any);
|
name: 'Team One',
|
||||||
|
tag: 'T1',
|
||||||
const result = await service.getDriverTeam('driver1');
|
description: 'Desc',
|
||||||
|
ownerId: 'owner-1',
|
||||||
expect(result).toBeNull();
|
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';
|
import { UpdateTeamPresenter } from './presenters/UpdateTeamPresenter';
|
||||||
|
|
||||||
// Tokens
|
// 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()
|
@Injectable()
|
||||||
export class TeamService {
|
export class TeamService {
|
||||||
@@ -187,6 +187,6 @@ export class TeamService {
|
|||||||
return null;
|
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 'reflect-metadata'; // For NestJS DI (before any other imports)
|
||||||
|
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import { writeFileSync } from 'fs';
|
import { writeFileSync } from 'fs';
|
||||||
@@ -10,6 +11,14 @@ import { AppModule } from './app.module';
|
|||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule, process.env.GENERATE_OPENAPI ? { logger: false } : undefined);
|
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
|
// Swagger/OpenAPI configuration
|
||||||
const config = new DocumentBuilder()
|
const config = new DocumentBuilder()
|
||||||
.setTitle('GridPilot API')
|
.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": [
|
"types": [
|
||||||
"node",
|
"node",
|
||||||
"express",
|
"express",
|
||||||
"vitest/globals"
|
"vitest/globals",
|
||||||
|
"supertest"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": [
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||||
import { RecordPageViewUseCase, type RecordPageViewInput, type RecordPageViewOutput } from './RecordPageViewUseCase';
|
import { RecordPageViewUseCase, type RecordPageViewInput, type RecordPageViewOutput } from './RecordPageViewUseCase';
|
||||||
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
|
|
||||||
import { PageView } from '../../domain/entities/PageView';
|
import { PageView } from '../../domain/entities/PageView';
|
||||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||||
import type { EntityType, VisitorType } from '../../domain/types/PageView';
|
import type { EntityType, VisitorType } from '../../domain/types/PageView';
|
||||||
@@ -14,6 +13,8 @@ describe('RecordPageViewUseCase', () => {
|
|||||||
let useCase: RecordPageViewUseCase;
|
let useCase: RecordPageViewUseCase;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
type PageViewRepository = ConstructorParameters<typeof RecordPageViewUseCase>[0];
|
||||||
|
|
||||||
pageViewRepository = {
|
pageViewRepository = {
|
||||||
save: vi.fn(),
|
save: vi.fn(),
|
||||||
};
|
};
|
||||||
@@ -30,7 +31,7 @@ describe('RecordPageViewUseCase', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useCase = new RecordPageViewUseCase(
|
useCase = new RecordPageViewUseCase(
|
||||||
pageViewRepository as unknown as IPageViewRepository,
|
pageViewRepository as unknown as PageViewRepository,
|
||||||
logger,
|
logger,
|
||||||
output,
|
output,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Logger, UseCaseOutputPort, UseCase } from '@core/shared/application';
|
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 { PageView } from '../../domain/entities/PageView';
|
||||||
import type { EntityType, VisitorType } from '../../domain/types/PageView';
|
import type { EntityType, VisitorType } from '../../domain/types/PageView';
|
||||||
import { Result } from '@core/shared/application/Result';
|
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": {
|
"dependencies": {
|
||||||
"@core/social": "file:core/social",
|
"@core/social": "file:core/social",
|
||||||
"@nestjs/swagger": "11.2.3",
|
"@nestjs/swagger": "7.4.2",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"electron-vite": "3.1.0",
|
"electron-vite": "3.1.0",
|
||||||
"next": "15.5.9",
|
"next": "15.5.9",
|
||||||
@@ -15,6 +15,10 @@
|
|||||||
"description": "GridPilot - Clean Architecture monorepo for web platform and Electron companion app",
|
"description": "GridPilot - Clean Architecture monorepo for web platform and Electron companion app",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cucumber/cucumber": "^11.0.1",
|
"@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",
|
"@playwright/test": "^1.57.0",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
@@ -22,9 +26,11 @@
|
|||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/jsdom": "^27.0.0",
|
"@types/jsdom": "^27.0.0",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/supertest": "^6.0.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||||
"@typescript-eslint/parser": "^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",
|
"cheerio": "^1.0.0",
|
||||||
"commander": "^11.0.0",
|
"commander": "^11.0.0",
|
||||||
"electron": "^39.2.7",
|
"electron": "^39.2.7",
|
||||||
@@ -37,10 +43,11 @@
|
|||||||
"openapi-typescript": "^7.4.3",
|
"openapi-typescript": "^7.4.3",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.0.0",
|
||||||
"puppeteer": "^24.31.0",
|
"puppeteer": "^24.31.0",
|
||||||
|
"supertest": "^7.1.4",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsx": "^4.7.0",
|
"tsx": "^4.7.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.0.15"
|
"vitest": "^4.0.16"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
@@ -52,6 +59,8 @@
|
|||||||
"api:generate-spec": "tsx scripts/generate-openapi-spec.ts",
|
"api:generate-spec": "tsx scripts/generate-openapi-spec.ts",
|
||||||
"api:generate-types": "tsx scripts/generate-api-types.ts",
|
"api:generate-types": "tsx scripts/generate-api-types.ts",
|
||||||
"api:sync-types": "npm run api:generate-spec && npm run api:generate-types",
|
"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'",
|
"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",
|
"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",
|
"companion:build": "npm run build --workspace=@gridpilot/companion",
|
||||||
@@ -107,4 +116,4 @@
|
|||||||
"apps/*",
|
"apps/*",
|
||||||
"testing/*"
|
"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