From efcdbd17f2386dd346b13d64a389b0a81fc36609 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Tue, 23 Dec 2025 23:14:51 +0100 Subject: [PATCH] test apps api --- apps/api/package.json | 7 +- .../domain/analytics/AnalyticsProviders.ts | 20 +- .../domain/analytics/AnalyticsService.test.ts | 278 +++++ .../src/domain/analytics/AnalyticsService.ts | 22 +- .../GetAnalyticsMetricsPresenter.ts | 4 + .../presenters/GetDashboardDataPresenter.ts | 4 + .../presenters/RecordEngagementPresenter.ts | 4 + .../presenters/RecordPageViewPresenter.ts | 4 + apps/api/src/domain/auth/AuthService.test.ts | 230 ++++ apps/api/src/domain/auth/AuthService.ts | 2 +- .../domain/dashboard/DashboardService.test.ts | 39 + .../domain/driver/DriverController.test.ts | 12 +- .../api/src/domain/driver/DriverController.ts | 4 +- apps/api/src/domain/driver/DriverModule.ts | 2 +- apps/api/src/domain/driver/DriverProviders.ts | 70 +- .../src/domain/driver/DriverService.test.ts | 275 ++++- apps/api/src/domain/driver/DriverService.ts | 2 +- apps/api/src/domain/driver/DriverTokens.ts | 26 + .../api/src/domain/hello/HelloService.test.ts | 14 + .../domain/league/LeagueController.test.ts | 55 +- .../api/src/domain/league/LeagueController.ts | 4 +- apps/api/src/domain/league/LeagueProviders.ts | 8 +- .../src/domain/league/LeagueService.test.ts | 196 ++++ apps/api/src/domain/league/LeagueService.ts | 12 +- .../GetLeagueMembershipsPresenter.test.ts | 11 +- .../presenters/GetLeagueSeasonsPresenter.ts | 23 +- .../LeagueOwnerSummaryPresenter.test.ts | 3 +- apps/api/src/domain/media/MediaController.ts | 4 +- apps/api/src/domain/media/MediaModule.ts | 2 +- apps/api/src/domain/media/MediaProviders.ts | 46 +- .../api/src/domain/media/MediaService.test.ts | 510 +++++++++ apps/api/src/domain/media/MediaService.ts | 3 +- apps/api/src/domain/media/MediaTokens.ts | 21 + .../src/domain/payments/PaymentsController.ts | 4 +- .../api/src/domain/payments/PaymentsModule.ts | 2 +- .../src/domain/payments/PaymentsProviders.ts | 70 +- .../domain/payments/PaymentsService.test.ts | 313 ++++++ .../src/domain/payments/PaymentsService.ts | 2 +- .../api/src/domain/payments/PaymentsTokens.ts | 33 + .../src/domain/protests/ProtestsController.ts | 4 +- .../src/domain/protests/ProtestsProviders.ts | 2 - .../domain/protests/ProtestsService.test.ts | 51 +- apps/api/src/domain/race/RaceController.ts | 4 +- apps/api/src/domain/race/RaceModule.ts | 2 +- apps/api/src/domain/race/RaceProviders.ts | 57 +- apps/api/src/domain/race/RaceService.test.ts | 138 +++ apps/api/src/domain/race/RaceService.ts | 2 +- apps/api/src/domain/race/RaceTokens.ts | 24 + .../domain/sponsor/SponsorController.test.ts | 60 +- .../src/domain/sponsor/SponsorController.ts | 4 +- .../src/domain/sponsor/SponsorProviders.ts | 6 +- .../src/domain/sponsor/SponsorService.test.ts | 250 ++++- apps/api/src/domain/sponsor/SponsorService.ts | 116 +- .../GetEntitySponsorshipPricingPresenter.ts | 4 +- .../GetSponsorDashboardPresenter.ts | 3 +- .../GetSponsorSponsorshipsPresenter.ts | 3 +- .../presenters/GetSponsorsPresenter.test.ts | 4 +- apps/api/src/domain/team/TeamController.ts | 4 +- apps/api/src/domain/team/TeamModule.ts | 2 +- apps/api/src/domain/team/TeamProviders.ts | 25 +- apps/api/src/domain/team/TeamService.test.ts | 586 ++++++++-- apps/api/src/domain/team/TeamService.ts | 4 +- apps/api/src/domain/team/TeamTokens.ts | 5 + apps/api/src/main.ts | 9 + .../src/shared/testing/httpContractHarness.ts | 57 + apps/api/tsconfig.json | 3 +- .../use-cases/RecordPageViewUseCase.test.ts | 5 +- .../use-cases/RecordPageViewUseCase.ts | 2 +- package-lock.json | 1000 ++++++++++------- package.json | 17 +- vitest.api.config.ts | 43 + 71 files changed, 3924 insertions(+), 913 deletions(-) create mode 100644 apps/api/src/domain/analytics/AnalyticsService.test.ts create mode 100644 apps/api/src/domain/auth/AuthService.test.ts create mode 100644 apps/api/src/domain/dashboard/DashboardService.test.ts create mode 100644 apps/api/src/domain/driver/DriverTokens.ts create mode 100644 apps/api/src/domain/hello/HelloService.test.ts create mode 100644 apps/api/src/domain/league/LeagueService.test.ts create mode 100644 apps/api/src/domain/media/MediaService.test.ts create mode 100644 apps/api/src/domain/media/MediaTokens.ts create mode 100644 apps/api/src/domain/payments/PaymentsService.test.ts create mode 100644 apps/api/src/domain/payments/PaymentsTokens.ts create mode 100644 apps/api/src/domain/race/RaceService.test.ts create mode 100644 apps/api/src/domain/race/RaceTokens.ts create mode 100644 apps/api/src/domain/team/TeamTokens.ts create mode 100644 apps/api/src/shared/testing/httpContractHarness.ts create mode 100644 vitest.api.config.ts diff --git a/apps/api/package.json b/apps/api/package.json index 6c0757ab3..c72a0a397 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,8 +7,9 @@ "build": "tsc --build --verbose", "start:dev": "ts-node-dev --respawn --inspect=0.0.0.0:9229 src/main.ts", "start:prod": "node dist/main", - "test": "vitest run", - "test:watch": "vitest", + "test": "vitest run --config ../../vitest.api.config.ts", + "test:coverage": "vitest run --config ../../vitest.api.config.ts --coverage", + "test:watch": "vitest --config ../../vitest.api.config.ts", "generate:openapi": "GENERATE_OPENAPI=true ts-node src/main.ts --exit" }, "keywords": [], @@ -22,7 +23,7 @@ "@nestjs/common": "^10.4.20", "@nestjs/core": "^10.4.20", "@nestjs/platform-express": "^10.4.20", - "@nestjs/swagger": "^11.2.3", + "@nestjs/swagger": "^7.4.2", "@nestjs/typeorm": "^10.0.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", diff --git a/apps/api/src/domain/analytics/AnalyticsProviders.ts b/apps/api/src/domain/analytics/AnalyticsProviders.ts index 1fd297626..8de0b77a2 100644 --- a/apps/api/src/domain/analytics/AnalyticsProviders.ts +++ b/apps/api/src/domain/analytics/AnalyticsProviders.ts @@ -2,7 +2,7 @@ import type { GetAnalyticsMetricsOutput } from '@core/analytics/application/use- import type { RecordEngagementOutput } from '@core/analytics/application/use-cases/RecordEngagementUseCase'; import type { RecordPageViewOutput } from '@core/analytics/application/use-cases/RecordPageViewUseCase'; import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository'; -import type { IPageViewRepository } from '@core/analytics/domain/repositories/IPageViewRepository'; +import type { IPageViewRepository } from '@core/analytics/application/repositories/IPageViewRepository'; import type { Logger, UseCaseOutputPort } from '@core/shared/application'; import { Provider } from '@nestjs/common'; @@ -22,23 +22,31 @@ import { GetAnalyticsMetricsUseCase } from '@core/analytics/application/use-case import { GetDashboardDataOutput, GetDashboardDataUseCase } from '@core/analytics/application/use-cases/GetDashboardDataUseCase'; import { RecordEngagementUseCase } from '@core/analytics/application/use-cases/RecordEngagementUseCase'; import { RecordPageViewUseCase } from '@core/analytics/application/use-cases/RecordPageViewUseCase'; +import { AnalyticsService } from './AnalyticsService'; import { GetAnalyticsMetricsPresenter } from './presenters/GetAnalyticsMetricsPresenter'; import { GetDashboardDataPresenter } from './presenters/GetDashboardDataPresenter'; import { RecordEngagementPresenter } from './presenters/RecordEngagementPresenter'; import { RecordPageViewPresenter } from './presenters/RecordPageViewPresenter'; export const AnalyticsProviders: Provider[] = [ + AnalyticsService, + RecordPageViewPresenter, + RecordEngagementPresenter, + GetDashboardDataPresenter, + GetAnalyticsMetricsPresenter, { provide: Logger_TOKEN, useClass: ConsoleLogger, }, { provide: IPAGE_VIEW_REPO_TOKEN, - useClass: InMemoryPageViewRepository, + useFactory: (logger: Logger) => new InMemoryPageViewRepository(logger), + inject: [Logger_TOKEN], }, { provide: IENGAGEMENT_REPO_TOKEN, - useClass: InMemoryEngagementRepository, + useFactory: (logger: Logger) => new InMemoryEngagementRepository(logger), + inject: [Logger_TOKEN], }, { provide: RECORD_PAGE_VIEW_OUTPUT_PORT_TOKEN, @@ -76,8 +84,8 @@ export const AnalyticsProviders: Provider[] = [ }, { provide: GetAnalyticsMetricsUseCase, - useFactory: (repo: IPageViewRepository, logger: Logger, output: UseCaseOutputPort) => - new GetAnalyticsMetricsUseCase(repo, logger, output), - inject: [IPAGE_VIEW_REPO_TOKEN, Logger_TOKEN, GET_ANALYTICS_METRICS_OUTPUT_PORT_TOKEN], + useFactory: (logger: Logger, output: UseCaseOutputPort, repo: IPageViewRepository) => + new GetAnalyticsMetricsUseCase(logger, output, repo), + inject: [Logger_TOKEN, GET_ANALYTICS_METRICS_OUTPUT_PORT_TOKEN, IPAGE_VIEW_REPO_TOKEN], }, ]; \ No newline at end of file diff --git a/apps/api/src/domain/analytics/AnalyticsService.test.ts b/apps/api/src/domain/analytics/AnalyticsService.test.ts new file mode 100644 index 000000000..607b2593b --- /dev/null +++ b/apps/api/src/domain/analytics/AnalyticsService.test.ts @@ -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'); + }); +}); diff --git a/apps/api/src/domain/analytics/AnalyticsService.ts b/apps/api/src/domain/analytics/AnalyticsService.ts index 6bcd8e4d9..b62f4ff39 100644 --- a/apps/api/src/domain/analytics/AnalyticsService.ts +++ b/apps/api/src/domain/analytics/AnalyticsService.ts @@ -24,10 +24,14 @@ export class AnalyticsService { @Inject(RecordEngagementUseCase) private readonly recordEngagementUseCase: RecordEngagementUseCase, @Inject(GetDashboardDataUseCase) private readonly getDashboardDataUseCase: GetDashboardDataUseCase, @Inject(GetAnalyticsMetricsUseCase) private readonly getAnalyticsMetricsUseCase: GetAnalyticsMetricsUseCase, + @Inject(RecordPageViewPresenter) private readonly recordPageViewPresenter: RecordPageViewPresenter, + @Inject(RecordEngagementPresenter) private readonly recordEngagementPresenter: RecordEngagementPresenter, + @Inject(GetDashboardDataPresenter) private readonly getDashboardDataPresenter: GetDashboardDataPresenter, + @Inject(GetAnalyticsMetricsPresenter) private readonly getAnalyticsMetricsPresenter: GetAnalyticsMetricsPresenter, ) {} async recordPageView(input: RecordPageViewInput): Promise { - const presenter = new RecordPageViewPresenter(); + this.recordPageViewPresenter.reset(); const result = await this.recordPageViewUseCase.execute(input); if (result.isErr()) { @@ -35,11 +39,11 @@ export class AnalyticsService { throw new Error(error.details?.message ?? 'Failed to record page view'); } - return presenter.responseModel; + return this.recordPageViewPresenter.responseModel; } async recordEngagement(input: RecordEngagementInput): Promise { - const presenter = new RecordEngagementPresenter(); + this.recordEngagementPresenter.reset(); const result = await this.recordEngagementUseCase.execute(input); if (result.isErr()) { @@ -47,23 +51,23 @@ export class AnalyticsService { throw new Error(error.details?.message ?? 'Failed to record engagement'); } - return presenter.responseModel; + return this.recordEngagementPresenter.responseModel; } async getDashboardData(): Promise { - const presenter = new GetDashboardDataPresenter(); + this.getDashboardDataPresenter.reset(); - const result = await this.getDashboardDataUseCase.execute({}); + const result = await this.getDashboardDataUseCase.execute(); if (result.isErr()) { const error = result.unwrapErr(); throw new Error(error.details?.message ?? 'Failed to get dashboard data'); } - return presenter.responseModel; + return this.getDashboardDataPresenter.responseModel; } async getAnalyticsMetrics(): Promise { - const presenter = new GetAnalyticsMetricsPresenter(); + this.getAnalyticsMetricsPresenter.reset(); const result = await this.getAnalyticsMetricsUseCase.execute({}); if (result.isErr()) { @@ -71,6 +75,6 @@ export class AnalyticsService { throw new Error(error.details?.message ?? 'Failed to get analytics metrics'); } - return presenter.responseModel; + return this.getAnalyticsMetricsPresenter.responseModel; } } diff --git a/apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.ts b/apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.ts index ed314c2f9..1b7f659a7 100644 --- a/apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.ts +++ b/apps/api/src/domain/analytics/presenters/GetAnalyticsMetricsPresenter.ts @@ -5,6 +5,10 @@ import type { GetAnalyticsMetricsOutputDTO } from '../dtos/GetAnalyticsMetricsOu export class GetAnalyticsMetricsPresenter implements UseCaseOutputPort { private model: GetAnalyticsMetricsOutputDTO | null = null; + reset(): void { + this.model = null; + } + present(result: GetAnalyticsMetricsOutput): void { this.model = { pageViews: result.pageViews, diff --git a/apps/api/src/domain/analytics/presenters/GetDashboardDataPresenter.ts b/apps/api/src/domain/analytics/presenters/GetDashboardDataPresenter.ts index 472f8e897..aa531a14b 100644 --- a/apps/api/src/domain/analytics/presenters/GetDashboardDataPresenter.ts +++ b/apps/api/src/domain/analytics/presenters/GetDashboardDataPresenter.ts @@ -5,6 +5,10 @@ import type { GetDashboardDataOutputDTO } from '../dtos/GetDashboardDataOutputDT export class GetDashboardDataPresenter implements UseCaseOutputPort { private model: GetDashboardDataOutputDTO | null = null; + reset(): void { + this.model = null; + } + present(result: GetDashboardDataOutput): void { this.model = { totalUsers: result.totalUsers, diff --git a/apps/api/src/domain/analytics/presenters/RecordEngagementPresenter.ts b/apps/api/src/domain/analytics/presenters/RecordEngagementPresenter.ts index 63bf1ab97..4783d3e68 100644 --- a/apps/api/src/domain/analytics/presenters/RecordEngagementPresenter.ts +++ b/apps/api/src/domain/analytics/presenters/RecordEngagementPresenter.ts @@ -5,6 +5,10 @@ import type { RecordEngagementOutputDTO } from '../dtos/RecordEngagementOutputDT export class RecordEngagementPresenter implements UseCaseOutputPort { private model: RecordEngagementOutputDTO | null = null; + reset(): void { + this.model = null; + } + present(result: RecordEngagementOutput): void { this.model = { eventId: result.eventId, diff --git a/apps/api/src/domain/analytics/presenters/RecordPageViewPresenter.ts b/apps/api/src/domain/analytics/presenters/RecordPageViewPresenter.ts index 05f5a2471..e5a071731 100644 --- a/apps/api/src/domain/analytics/presenters/RecordPageViewPresenter.ts +++ b/apps/api/src/domain/analytics/presenters/RecordPageViewPresenter.ts @@ -5,6 +5,10 @@ import type { RecordPageViewOutputDTO } from '../dtos/RecordPageViewOutputDTO'; export class RecordPageViewPresenter implements UseCaseOutputPort { private model: RecordPageViewOutputDTO | null = null; + reset(): void { + this.model = null; + } + present(result: RecordPageViewOutput): void { this.model = { pageViewId: result.pageViewId, diff --git a/apps/api/src/domain/auth/AuthService.test.ts b/apps/api/src/domain/auth/AuthService.test.ts new file mode 100644 index 000000000..357ad5e65 --- /dev/null +++ b/apps/api/src/domain/auth/AuthService.test.ts @@ -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'); + }); +}); diff --git a/apps/api/src/domain/auth/AuthService.ts b/apps/api/src/domain/auth/AuthService.ts index a0d175ede..ca1a39fe5 100644 --- a/apps/api/src/domain/auth/AuthService.ts +++ b/apps/api/src/domain/auth/AuthService.ts @@ -133,7 +133,7 @@ export class AuthService { this.commandResultPresenter.reset(); - const result = await this.logoutUseCase.execute({}); + const result = await this.logoutUseCase.execute(); if (result.isErr()) { const error = result.unwrapErr() as LogoutApplicationError; diff --git a/apps/api/src/domain/dashboard/DashboardService.test.ts b/apps/api/src/domain/dashboard/DashboardService.test.ts new file mode 100644 index 000000000..61f378947 --- /dev/null +++ b/apps/api/src/domain/dashboard/DashboardService.test.ts @@ -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'); + }); +}); diff --git a/apps/api/src/domain/driver/DriverController.test.ts b/apps/api/src/domain/driver/DriverController.test.ts index ee6cd367a..e6b8847ea 100644 --- a/apps/api/src/domain/driver/DriverController.test.ts +++ b/apps/api/src/domain/driver/DriverController.test.ts @@ -46,7 +46,7 @@ describe('DriverController', () => { describe('getDriversLeaderboard', () => { it('should return drivers leaderboard', async () => { const leaderboard: DriversLeaderboardDTO = { drivers: [], totalRaces: 0, totalWins: 0, activeCount: 0 }; - service.getDriversLeaderboard.mockResolvedValue({ viewModel: leaderboard } as never); + service.getDriversLeaderboard.mockResolvedValue(leaderboard); const result = await controller.getDriversLeaderboard(); @@ -58,7 +58,7 @@ describe('DriverController', () => { describe('getTotalDrivers', () => { it('should return total drivers stats', async () => { const stats: DriverStatsDTO = { totalDrivers: 100 }; - service.getTotalDrivers.mockResolvedValue({ viewModel: stats } as never); + service.getTotalDrivers.mockResolvedValue(stats); const result = await controller.getTotalDrivers(); @@ -71,7 +71,7 @@ describe('DriverController', () => { it('should return current driver if userId exists', async () => { const userId = 'user-123'; const driver: GetDriverOutputDTO = { id: 'driver-123', name: 'Driver' } as GetDriverOutputDTO; - service.getCurrentDriver.mockResolvedValue({ viewModel: driver } as never); + service.getCurrentDriver.mockResolvedValue(driver); const mockReq: Partial = { user: { userId } }; @@ -96,7 +96,7 @@ describe('DriverController', () => { const userId = 'user-123'; const input: CompleteOnboardingInputDTO = { someField: 'value' } as unknown as CompleteOnboardingInputDTO; const output: CompleteOnboardingOutputDTO = { success: true }; - service.completeOnboarding.mockResolvedValue({ viewModel: output } as never); + service.completeOnboarding.mockResolvedValue(output); const mockReq: Partial = { user: { userId } }; @@ -112,7 +112,7 @@ describe('DriverController', () => { const driverId = 'driver-123'; const raceId = 'race-456'; const status: DriverRegistrationStatusDTO = { registered: true } as unknown as DriverRegistrationStatusDTO; - service.getDriverRegistrationStatus.mockResolvedValue({ viewModel: status } as never); + service.getDriverRegistrationStatus.mockResolvedValue(status); const result = await controller.getDriverRegistrationStatus(driverId, raceId); @@ -125,7 +125,7 @@ describe('DriverController', () => { it('should return driver by id', async () => { const driverId = 'driver-123'; const driver: GetDriverOutputDTO = { id: driverId, name: 'Driver' } as GetDriverOutputDTO; - service.getDriver.mockResolvedValue({ viewModel: driver } as never); + service.getDriver.mockResolvedValue(driver); const result = await controller.getDriver(driverId); diff --git a/apps/api/src/domain/driver/DriverController.ts b/apps/api/src/domain/driver/DriverController.ts index 8ad72cd3f..4820fdb22 100644 --- a/apps/api/src/domain/driver/DriverController.ts +++ b/apps/api/src/domain/driver/DriverController.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Post, Put, Req } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Put, Req, Inject } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { Request } from 'express'; import { DriverService } from './DriverService'; @@ -17,7 +17,7 @@ interface AuthenticatedRequest extends Request { @ApiTags('drivers') @Controller('drivers') export class DriverController { - constructor(private readonly driverService: DriverService) {} + constructor(@Inject(DriverService) private readonly driverService: DriverService) {} @Get('leaderboard') @ApiOperation({ summary: 'Get drivers leaderboard' }) diff --git a/apps/api/src/domain/driver/DriverModule.ts b/apps/api/src/domain/driver/DriverModule.ts index bd2bfdb55..71dd128c7 100644 --- a/apps/api/src/domain/driver/DriverModule.ts +++ b/apps/api/src/domain/driver/DriverModule.ts @@ -5,7 +5,7 @@ import { DriverProviders } from './DriverProviders'; @Module({ controllers: [DriverController], - providers: DriverProviders, + providers: [DriverService, ...DriverProviders], exports: [DriverService], }) export class DriverModule {} diff --git a/apps/api/src/domain/driver/DriverProviders.ts b/apps/api/src/domain/driver/DriverProviders.ts index 98699b759..c71536fd5 100644 --- a/apps/api/src/domain/driver/DriverProviders.ts +++ b/apps/api/src/domain/driver/DriverProviders.ts @@ -46,38 +46,36 @@ import { DriverStatsPresenter } from './presenters/DriverStatsPresenter'; // Import types for output ports import type { UseCaseOutputPort } from '@core/shared/application'; -// Define injection tokens -export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository'; -export const RANKING_SERVICE_TOKEN = 'IRankingService'; -export const DRIVER_STATS_SERVICE_TOKEN = 'IDriverStatsService'; -export const DRIVER_RATING_PROVIDER_TOKEN = 'DriverRatingProvider'; -export const DRIVER_EXTENDED_PROFILE_PROVIDER_TOKEN = 'DriverExtendedProfileProvider'; -export const IMAGE_SERVICE_PORT_TOKEN = 'IImageServicePort'; -export const RACE_REGISTRATION_REPOSITORY_TOKEN = 'IRaceRegistrationRepository'; -export const NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN = 'INotificationPreferenceRepository'; -export const TEAM_REPOSITORY_TOKEN = 'ITeamRepository'; -export const TEAM_MEMBERSHIP_REPOSITORY_TOKEN = 'ITeamMembershipRepository'; -export const SOCIAL_GRAPH_REPOSITORY_TOKEN = 'ISocialGraphRepository'; -export const LOGGER_TOKEN = 'Logger'; +import { + DRIVER_REPOSITORY_TOKEN, + RANKING_SERVICE_TOKEN, + DRIVER_STATS_SERVICE_TOKEN, + DRIVER_RATING_PROVIDER_TOKEN, + DRIVER_EXTENDED_PROFILE_PROVIDER_TOKEN, + IMAGE_SERVICE_PORT_TOKEN, + RACE_REGISTRATION_REPOSITORY_TOKEN, + NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN, + TEAM_REPOSITORY_TOKEN, + TEAM_MEMBERSHIP_REPOSITORY_TOKEN, + SOCIAL_GRAPH_REPOSITORY_TOKEN, + LOGGER_TOKEN, + GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN, + GET_TOTAL_DRIVERS_USE_CASE_TOKEN, + COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN, + IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN, + UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN, + GET_PROFILE_OVERVIEW_USE_CASE_TOKEN, + GET_DRIVERS_LEADERBOARD_OUTPUT_PORT_TOKEN, + GET_TOTAL_DRIVERS_OUTPUT_PORT_TOKEN, + COMPLETE_DRIVER_ONBOARDING_OUTPUT_PORT_TOKEN, + IS_DRIVER_REGISTERED_FOR_RACE_OUTPUT_PORT_TOKEN, + UPDATE_DRIVER_PROFILE_OUTPUT_PORT_TOKEN, + GET_PROFILE_OVERVIEW_OUTPUT_PORT_TOKEN, +} from './DriverTokens'; -// Use case tokens -export const GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN = 'GetDriversLeaderboardUseCase'; -export const GET_TOTAL_DRIVERS_USE_CASE_TOKEN = 'GetTotalDriversUseCase'; -export const COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN = 'CompleteDriverOnboardingUseCase'; -export const IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN = 'IsDriverRegisteredForRaceUseCase'; -export const UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN = 'UpdateDriverProfileUseCase'; -export const GET_PROFILE_OVERVIEW_USE_CASE_TOKEN = 'GetProfileOverviewUseCase'; - -// Output port tokens -export const GET_DRIVERS_LEADERBOARD_OUTPUT_PORT_TOKEN = 'GetDriversLeaderboardOutputPort_TOKEN'; -export const GET_TOTAL_DRIVERS_OUTPUT_PORT_TOKEN = 'GetTotalDriversOutputPort_TOKEN'; -export const COMPLETE_DRIVER_ONBOARDING_OUTPUT_PORT_TOKEN = 'CompleteDriverOnboardingOutputPort_TOKEN'; -export const IS_DRIVER_REGISTERED_FOR_RACE_OUTPUT_PORT_TOKEN = 'IsDriverRegisteredForRaceOutputPort_TOKEN'; -export const UPDATE_DRIVER_PROFILE_OUTPUT_PORT_TOKEN = 'UpdateDriverProfileOutputPort_TOKEN'; -export const GET_PROFILE_OVERVIEW_OUTPUT_PORT_TOKEN = 'GetProfileOverviewOutputPort_TOKEN'; +export * from './DriverTokens'; export const DriverProviders: Provider[] = [ - DriverService, // Presenters DriversLeaderboardPresenter, @@ -207,9 +205,9 @@ export const DriverProviders: Provider[] = [ }, { provide: UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN, - useFactory: (driverRepo: IDriverRepository, logger: Logger) => - new UpdateDriverProfileUseCase(driverRepo, logger), - inject: [DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN], + useFactory: (driverRepo: IDriverRepository, logger: Logger, output: UseCaseOutputPort) => + new UpdateDriverProfileUseCase(driverRepo, logger, output), + inject: [DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN, UPDATE_DRIVER_PROFILE_OUTPUT_PORT_TOKEN], }, { provide: GET_PROFILE_OVERVIEW_USE_CASE_TOKEN, @@ -218,17 +216,16 @@ export const DriverProviders: Provider[] = [ teamRepository: ITeamRepository, teamMembershipRepository: ITeamMembershipRepository, socialRepository: ISocialGraphRepository, - imageService: IImageServicePort, driverExtendedProfileProvider: DriverExtendedProfileProvider, driverStatsService: IDriverStatsService, rankingService: IRankingService, + output: UseCaseOutputPort, ) => new GetProfileOverviewUseCase( driverRepo, teamRepository, teamMembershipRepository, socialRepository, - imageService, driverExtendedProfileProvider, (driverId: string) => { const stats = driverStatsService.getDriverStats(driverId); @@ -240,7 +237,7 @@ export const DriverProviders: Provider[] = [ rating: stats.rating, wins: stats.wins, podiums: stats.podiums, - dnfs: 0, + dnfs: (stats as { dnfs?: number }).dnfs ?? 0, totalRaces: stats.totalRaces, avgFinish: null, bestFinish: null, @@ -256,16 +253,17 @@ export const DriverProviders: Provider[] = [ rating: ranking.rating, overallRank: ranking.overallRank, })), + output, ), inject: [ DRIVER_REPOSITORY_TOKEN, TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, SOCIAL_GRAPH_REPOSITORY_TOKEN, - IMAGE_SERVICE_PORT_TOKEN, DRIVER_EXTENDED_PROFILE_PROVIDER_TOKEN, DRIVER_STATS_SERVICE_TOKEN, RANKING_SERVICE_TOKEN, + GET_PROFILE_OVERVIEW_OUTPUT_PORT_TOKEN, ], }, ]; \ No newline at end of file diff --git a/apps/api/src/domain/driver/DriverService.test.ts b/apps/api/src/domain/driver/DriverService.test.ts index 6a7b2f6b8..a42f741a5 100644 --- a/apps/api/src/domain/driver/DriverService.test.ts +++ b/apps/api/src/domain/driver/DriverService.test.ts @@ -1,31 +1,262 @@ -import { vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { DriverService } from './DriverService'; describe('DriverService', () => { - let service: DriverService; + const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; - beforeEach(() => { - // Mock all dependencies - service = new DriverService( - {} as any, // getDriversLeaderboardUseCase - {} as any, // getTotalDriversUseCase - {} as any, // completeDriverOnboardingUseCase - {} as any, // isDriverRegisteredForRaceUseCase - {} as any, // updateDriverProfileUseCase - {} as any, // getProfileOverviewUseCase - {} as any, // driverRepository - {} as any, // logger - // Presenters - {} as any, // driversLeaderboardPresenter - {} as any, // driverStatsPresenter - {} as any, // completeOnboardingPresenter - {} as any, // driverRegistrationStatusPresenter - {} as any, // driverPresenter - {} as any, // driverProfilePresenter + it('getDriversLeaderboard executes use case and returns presenter model', async () => { + const getDriversLeaderboardUseCase = { execute: vi.fn(async () => {}) }; + const driversLeaderboardPresenter = { getResponseModel: vi.fn(() => ({ items: [] })) }; + + const service = new DriverService( + getDriversLeaderboardUseCase as any, + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + { findById: vi.fn() } as any, + logger as any, + driversLeaderboardPresenter as any, + { getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any, + { getResponseModel: vi.fn(() => ({ success: true })) } as any, + { getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any, + { getResponseModel: vi.fn(() => null) } as any, + { getResponseModel: vi.fn(() => ({ profile: {} })) } as any, ); + + await expect(service.getDriversLeaderboard()).resolves.toEqual({ items: [] }); + expect(getDriversLeaderboardUseCase.execute).toHaveBeenCalledWith({}); + expect(driversLeaderboardPresenter.getResponseModel).toHaveBeenCalled(); }); - it('should be created', () => { - expect(service).toBeDefined(); + it('getTotalDrivers executes use case and returns presenter model', async () => { + const getTotalDriversUseCase = { execute: vi.fn(async () => {}) }; + const driverStatsPresenter = { getResponseModel: vi.fn(() => ({ totalDrivers: 123 })) }; + + const service = new DriverService( + { execute: vi.fn() } as any, + getTotalDriversUseCase as any, + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + { findById: vi.fn() } as any, + logger as any, + { getResponseModel: vi.fn(() => ({ items: [] })) } as any, + driverStatsPresenter as any, + { getResponseModel: vi.fn(() => ({ success: true })) } as any, + { getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any, + { getResponseModel: vi.fn(() => null) } as any, + { getResponseModel: vi.fn(() => ({ profile: {} })) } as any, + ); + + await expect(service.getTotalDrivers()).resolves.toEqual({ totalDrivers: 123 }); + expect(getTotalDriversUseCase.execute).toHaveBeenCalledWith({}); + expect(driverStatsPresenter.getResponseModel).toHaveBeenCalled(); + }); + + it('completeOnboarding passes optional bio only when provided', async () => { + const completeDriverOnboardingUseCase = { execute: vi.fn(async () => {}) }; + + const service = new DriverService( + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + completeDriverOnboardingUseCase as any, + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + { findById: vi.fn() } as any, + logger as any, + { getResponseModel: vi.fn(() => ({ items: [] })) } as any, + { getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any, + { getResponseModel: vi.fn(() => ({ success: true })) } as any, + { getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any, + { getResponseModel: vi.fn(() => null) } as any, + { getResponseModel: vi.fn(() => ({ profile: {} })) } as any, + ); + + await service.completeOnboarding('u1', { + firstName: 'F', + lastName: 'L', + displayName: 'D', + country: 'DE', + } as any); + + expect(completeDriverOnboardingUseCase.execute).toHaveBeenCalledWith({ + userId: 'u1', + firstName: 'F', + lastName: 'L', + displayName: 'D', + country: 'DE', + }); + + completeDriverOnboardingUseCase.execute.mockClear(); + + await service.completeOnboarding('u1', { + firstName: 'F', + lastName: 'L', + displayName: 'D', + country: 'DE', + bio: 'bio', + } as any); + + expect(completeDriverOnboardingUseCase.execute).toHaveBeenCalledWith({ + userId: 'u1', + firstName: 'F', + lastName: 'L', + displayName: 'D', + country: 'DE', + bio: 'bio', + }); + }); + + it('getDriverRegistrationStatus passes raceId and driverId and returns presenter model', async () => { + const isDriverRegisteredForRaceUseCase = { execute: vi.fn(async () => {}) }; + const driverRegistrationStatusPresenter = { getResponseModel: vi.fn(() => ({ isRegistered: true })) }; + + const service = new DriverService( + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + isDriverRegisteredForRaceUseCase as any, + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + { findById: vi.fn() } as any, + logger as any, + { getResponseModel: vi.fn(() => ({ items: [] })) } as any, + { getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any, + { getResponseModel: vi.fn(() => ({ success: true })) } as any, + driverRegistrationStatusPresenter as any, + { getResponseModel: vi.fn(() => null) } as any, + { getResponseModel: vi.fn(() => ({ profile: {} })) } as any, + ); + + await expect( + service.getDriverRegistrationStatus({ raceId: 'r1', driverId: 'd1' } as any), + ).resolves.toEqual({ isRegistered: true }); + + expect(isDriverRegisteredForRaceUseCase.execute).toHaveBeenCalledWith({ raceId: 'r1', driverId: 'd1' }); + expect(driverRegistrationStatusPresenter.getResponseModel).toHaveBeenCalled(); + }); + + it('getCurrentDriver calls repository and returns presenter model', async () => { + const driverRepository = { findById: vi.fn(async () => null) }; + const driverPresenter = { getResponseModel: vi.fn(() => null) }; + + const service = new DriverService( + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + driverRepository as any, + logger as any, + { getResponseModel: vi.fn(() => ({ items: [] })) } as any, + { getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any, + { getResponseModel: vi.fn(() => ({ success: true })) } as any, + { getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any, + driverPresenter as any, + { getResponseModel: vi.fn(() => ({ profile: {} })) } as any, + ); + + await expect(service.getCurrentDriver('u1')).resolves.toBeNull(); + expect(driverRepository.findById).toHaveBeenCalledWith('u1'); + expect(driverPresenter.getResponseModel).toHaveBeenCalled(); + }); + + it('updateDriverProfile builds optional input and returns presenter model', async () => { + const updateDriverProfileUseCase = { execute: vi.fn(async () => {}) }; + const driverPresenter = { getResponseModel: vi.fn(() => ({ driver: { id: 'd1' } })) }; + + const service = new DriverService( + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + updateDriverProfileUseCase as any, + { execute: vi.fn() } as any, + { findById: vi.fn() } as any, + logger as any, + { getResponseModel: vi.fn(() => ({ items: [] })) } as any, + { getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any, + { getResponseModel: vi.fn(() => ({ success: true })) } as any, + { getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any, + driverPresenter as any, + { getResponseModel: vi.fn(() => ({ profile: {} })) } as any, + ); + + await service.updateDriverProfile('d1'); + expect(updateDriverProfileUseCase.execute).toHaveBeenCalledWith({ driverId: 'd1' }); + + updateDriverProfileUseCase.execute.mockClear(); + + await service.updateDriverProfile('d1', 'bio'); + expect(updateDriverProfileUseCase.execute).toHaveBeenCalledWith({ driverId: 'd1', bio: 'bio' }); + + updateDriverProfileUseCase.execute.mockClear(); + + await service.updateDriverProfile('d1', undefined, 'DE'); + expect(updateDriverProfileUseCase.execute).toHaveBeenCalledWith({ driverId: 'd1', country: 'DE' }); + + updateDriverProfileUseCase.execute.mockClear(); + + await service.updateDriverProfile('d1', 'bio', 'DE'); + expect(updateDriverProfileUseCase.execute).toHaveBeenCalledWith({ driverId: 'd1', bio: 'bio', country: 'DE' }); + + expect(driverPresenter.getResponseModel).toHaveBeenCalled(); + }); + + it('getDriver calls repository and returns presenter model', async () => { + const driverRepository = { findById: vi.fn(async () => null) }; + const driverPresenter = { getResponseModel: vi.fn(() => ({ driver: null })) }; + + const service = new DriverService( + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + driverRepository as any, + logger as any, + { getResponseModel: vi.fn(() => ({ items: [] })) } as any, + { getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any, + { getResponseModel: vi.fn(() => ({ success: true })) } as any, + { getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any, + driverPresenter as any, + { getResponseModel: vi.fn(() => ({ profile: {} })) } as any, + ); + + await expect(service.getDriver('d1')).resolves.toEqual({ driver: null }); + expect(driverRepository.findById).toHaveBeenCalledWith('d1'); + expect(driverPresenter.getResponseModel).toHaveBeenCalled(); + }); + + it('getDriverProfile executes use case and returns presenter model', async () => { + const getProfileOverviewUseCase = { execute: vi.fn(async () => {}) }; + const driverProfilePresenter = { getResponseModel: vi.fn(() => ({ profile: { id: 'd1' } })) }; + + const service = new DriverService( + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + { execute: vi.fn() } as any, + getProfileOverviewUseCase as any, + { findById: vi.fn() } as any, + logger as any, + { getResponseModel: vi.fn(() => ({ items: [] })) } as any, + { getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any, + { getResponseModel: vi.fn(() => ({ success: true })) } as any, + { getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any, + { getResponseModel: vi.fn(() => null) } as any, + driverProfilePresenter as any, + ); + + await expect(service.getDriverProfile('d1')).resolves.toEqual({ profile: { id: 'd1' } }); + expect(getProfileOverviewUseCase.execute).toHaveBeenCalledWith({ driverId: 'd1' }); + expect(driverProfilePresenter.getResponseModel).toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/apps/api/src/domain/driver/DriverService.ts b/apps/api/src/domain/driver/DriverService.ts index b0868e50e..b95d7a075 100644 --- a/apps/api/src/domain/driver/DriverService.ts +++ b/apps/api/src/domain/driver/DriverService.ts @@ -36,7 +36,7 @@ import { IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN, LOGGER_TOKEN, UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN, -} from './DriverProviders'; +} from './DriverTokens'; @Injectable() export class DriverService { diff --git a/apps/api/src/domain/driver/DriverTokens.ts b/apps/api/src/domain/driver/DriverTokens.ts new file mode 100644 index 000000000..59887a5ad --- /dev/null +++ b/apps/api/src/domain/driver/DriverTokens.ts @@ -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'; \ No newline at end of file diff --git a/apps/api/src/domain/hello/HelloService.test.ts b/apps/api/src/domain/hello/HelloService.test.ts new file mode 100644 index 000000000..ef134390c --- /dev/null +++ b/apps/api/src/domain/hello/HelloService.test.ts @@ -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'); + }); +}); \ No newline at end of file diff --git a/apps/api/src/domain/league/LeagueController.test.ts b/apps/api/src/domain/league/LeagueController.test.ts index 584950fc8..83262705e 100644 --- a/apps/api/src/domain/league/LeagueController.test.ts +++ b/apps/api/src/domain/league/LeagueController.test.ts @@ -1,41 +1,58 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { vi } from 'vitest'; import { LeagueController } from './LeagueController'; import { LeagueService } from './LeagueService'; -import { LeagueProviders } from './LeagueProviders'; -describe('LeagueController (integration)', () => { +describe('LeagueController', () => { let controller: LeagueController; + let leagueService: ReturnType>; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [LeagueController], - providers: [LeagueService, ...LeagueProviders], + providers: [ + { + provide: LeagueService, + useValue: { + getTotalLeagues: vi.fn(), + getAllLeaguesWithCapacity: vi.fn(), + getLeagueStandings: vi.fn(), + }, + }, + ], }).compile(); controller = module.get(LeagueController); + leagueService = vi.mocked(module.get(LeagueService)); }); - it('should get total leagues', async () => { + it('getTotalLeagues should return total leagues', async () => { + const mockResult = { totalLeagues: 1 }; + leagueService.getTotalLeagues.mockResolvedValue(mockResult as any); + const result = await controller.getTotalLeagues(); - expect(result).toHaveProperty('totalLeagues'); - expect(typeof result.totalLeagues).toBe('number'); + + expect(result).toEqual(mockResult); + expect(leagueService.getTotalLeagues).toHaveBeenCalledTimes(1); }); - it('should get all leagues with capacity', async () => { + it('getAllLeaguesWithCapacity should return leagues and totalCount', async () => { + const mockResult = { leagues: [], totalCount: 0 }; + leagueService.getAllLeaguesWithCapacity.mockResolvedValue(mockResult as any); + const result = await controller.getAllLeaguesWithCapacity(); - expect(result).toHaveProperty('leagues'); - expect(result).toHaveProperty('totalCount'); - expect(Array.isArray(result.leagues)).toBe(true); + + expect(result).toEqual(mockResult); + expect(leagueService.getAllLeaguesWithCapacity).toHaveBeenCalledTimes(1); }); - it('should get league standings', async () => { - try { - const result = await controller.getLeagueStandings('non-existent-league'); - expect(result).toHaveProperty('standings'); - expect(Array.isArray(result.standings)).toBe(true); - } catch (error) { - // Expected for non-existent league - expect(error).toBeInstanceOf(Error); - } + it('getLeagueStandings should return standings', async () => { + const mockResult = { standings: [] }; + leagueService.getLeagueStandings.mockResolvedValue(mockResult as any); + + const result = await controller.getLeagueStandings('league-1'); + + expect(result).toEqual(mockResult); + expect(leagueService.getLeagueStandings).toHaveBeenCalledWith('league-1'); }); }); \ No newline at end of file diff --git a/apps/api/src/domain/league/LeagueController.ts b/apps/api/src/domain/league/LeagueController.ts index ebc3993ae..9155693f4 100644 --- a/apps/api/src/domain/league/LeagueController.ts +++ b/apps/api/src/domain/league/LeagueController.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; +import { Body, Controller, Get, Param, Patch, Post, Inject } from '@nestjs/common'; import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { LeagueService } from './LeagueService'; import { AllLeaguesWithCapacityDTO } from './dtos/AllLeaguesWithCapacityDTO'; @@ -37,7 +37,7 @@ import { WithdrawFromLeagueWalletOutputDTO } from './dtos/WithdrawFromLeagueWall @ApiTags('leagues') @Controller('leagues') export class LeagueController { - constructor(private readonly leagueService: LeagueService) {} + constructor(@Inject(LeagueService) private readonly leagueService: LeagueService) {} @Get('all-with-capacity') @ApiOperation({ summary: 'Get all leagues with their capacity information' }) diff --git a/apps/api/src/domain/league/LeagueProviders.ts b/apps/api/src/domain/league/LeagueProviders.ts index da44853f2..4f11ce75b 100644 --- a/apps/api/src/domain/league/LeagueProviders.ts +++ b/apps/api/src/domain/league/LeagueProviders.ts @@ -241,10 +241,10 @@ export const LeagueProviders: Provider[] = [ }, // Use cases { - provide: GetAllLeaguesWithCapacityUseCase, - useFactory: (leagueRepo: ILeagueRepository, membershipRepo: ILeagueMembershipRepository, presenter: AllLeaguesWithCapacityPresenter) => - new GetAllLeaguesWithCapacityUseCase(leagueRepo, membershipRepo, presenter), - inject: [LEAGUE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, 'AllLeaguesWithCapacityPresenter'], + provide: GET_ALL_LEAGUES_WITH_CAPACITY_USE_CASE, + useFactory: (leagueRepo: ILeagueRepository, membershipRepo: ILeagueMembershipRepository) => + new GetAllLeaguesWithCapacityUseCase(leagueRepo, membershipRepo), + inject: [LEAGUE_REPOSITORY_TOKEN, LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN], }, { provide: GET_LEAGUE_STANDINGS_USE_CASE, diff --git a/apps/api/src/domain/league/LeagueService.test.ts b/apps/api/src/domain/league/LeagueService.test.ts new file mode 100644 index 000000000..8e4844547 --- /dev/null +++ b/apps/api/src/domain/league/LeagueService.test.ts @@ -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(); + }); +}); diff --git a/apps/api/src/domain/league/LeagueService.ts b/apps/api/src/domain/league/LeagueService.ts index 41431952e..4cc5bee30 100644 --- a/apps/api/src/domain/league/LeagueService.ts +++ b/apps/api/src/domain/league/LeagueService.ts @@ -204,7 +204,17 @@ export class LeagueService { async getAllLeaguesWithCapacity(): Promise { 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(); } diff --git a/apps/api/src/domain/league/presenters/GetLeagueMembershipsPresenter.test.ts b/apps/api/src/domain/league/presenters/GetLeagueMembershipsPresenter.test.ts index 2aecbeabb..8c9111237 100644 --- a/apps/api/src/domain/league/presenters/GetLeagueMembershipsPresenter.test.ts +++ b/apps/api/src/domain/league/presenters/GetLeagueMembershipsPresenter.test.ts @@ -13,12 +13,17 @@ describe('GetLeagueMembershipsPresenter', () => { membership: { driverId: 'driver-1', role: 'member', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - joinedAt: {} as any, + joinedAt: { toDate: () => new Date('2024-01-01T00:00:00Z') }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any - driver: { id: 'driver-1', name: 'John Doe' } as any, + driver: { + id: 'driver-1', + iracingId: '12345', + name: 'John Doe', + country: 'US', + joinedAt: { toDate: () => new Date('2024-01-01T00:00:00Z') }, + } as any, }, ], }; diff --git a/apps/api/src/domain/league/presenters/GetLeagueSeasonsPresenter.ts b/apps/api/src/domain/league/presenters/GetLeagueSeasonsPresenter.ts index 96ae1e0c6..2c43b07bb 100644 --- a/apps/api/src/domain/league/presenters/GetLeagueSeasonsPresenter.ts +++ b/apps/api/src/domain/league/presenters/GetLeagueSeasonsPresenter.ts @@ -10,15 +10,20 @@ export class GetLeagueSeasonsPresenter implements Presenter ({ - seasonId: seasonSummary.season.id.toString(), - name: seasonSummary.season.name.toString(), - status: seasonSummary.season.status.toString(), - startDate: seasonSummary.season.startDate.toISOString(), - endDate: seasonSummary.season.endDate?.toISOString(), - isPrimary: seasonSummary.isPrimary, - isParallelActive: seasonSummary.isParallelActive, - })); + this.result = input.seasons.map((seasonSummary) => { + const dto = new LeagueSeasonSummaryDTO(); + dto.seasonId = seasonSummary.season.id.toString(); + dto.name = seasonSummary.season.name.toString(); + dto.status = seasonSummary.season.status.toString(); + + if (seasonSummary.season.startDate) dto.startDate = seasonSummary.season.startDate; + if (seasonSummary.season.endDate) dto.endDate = seasonSummary.season.endDate; + + dto.isPrimary = seasonSummary.isPrimary; + dto.isParallelActive = seasonSummary.isParallelActive; + + return dto; + }); } getResponseModel(): LeagueSeasonSummaryDTO[] | null { diff --git a/apps/api/src/domain/league/presenters/LeagueOwnerSummaryPresenter.test.ts b/apps/api/src/domain/league/presenters/LeagueOwnerSummaryPresenter.test.ts index b1b819590..42f102379 100644 --- a/apps/api/src/domain/league/presenters/LeagueOwnerSummaryPresenter.test.ts +++ b/apps/api/src/domain/league/presenters/LeagueOwnerSummaryPresenter.test.ts @@ -14,8 +14,7 @@ describe('LeagueOwnerSummaryPresenter', () => { name: 'John Doe', country: 'US', bio: 'Racing enthusiast', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - joinedAt: {} as any, + joinedAt: { toDate: () => new Date('2024-01-01T00:00:00Z') } as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, rating: 1500, diff --git a/apps/api/src/domain/media/MediaController.ts b/apps/api/src/domain/media/MediaController.ts index e8bcabb5d..76722c927 100644 --- a/apps/api/src/domain/media/MediaController.ts +++ b/apps/api/src/domain/media/MediaController.ts @@ -1,4 +1,4 @@ -import { Controller, Post, Get, Delete, Put, Body, HttpStatus, Res, Param, UseInterceptors, UploadedFile } from '@nestjs/common'; +import { Controller, Post, Get, Delete, Put, Body, HttpStatus, Res, Param, UseInterceptors, UploadedFile, Inject } from '@nestjs/common'; import { ApiTags, ApiResponse, ApiOperation, ApiParam, ApiConsumes } from '@nestjs/swagger'; import type { Response } from 'express'; import { FileInterceptor } from '@nestjs/platform-express'; @@ -21,7 +21,7 @@ type UpdateAvatarInput = UpdateAvatarInputDTO; @ApiTags('media') @Controller('media') export class MediaController { - constructor(private readonly mediaService: MediaService) {} + constructor(@Inject(MediaService) private readonly mediaService: MediaService) {} @Post('avatar/generate') @ApiOperation({ summary: 'Request avatar generation' }) diff --git a/apps/api/src/domain/media/MediaModule.ts b/apps/api/src/domain/media/MediaModule.ts index 7d52a9a0d..75d02da0f 100644 --- a/apps/api/src/domain/media/MediaModule.ts +++ b/apps/api/src/domain/media/MediaModule.ts @@ -5,7 +5,7 @@ import { MediaProviders } from './MediaProviders'; @Module({ controllers: [MediaController], - providers: MediaProviders, + providers: [MediaService, ...MediaProviders], exports: [MediaService], }) export class MediaModule {} diff --git a/apps/api/src/domain/media/MediaProviders.ts b/apps/api/src/domain/media/MediaProviders.ts index af254c24f..4d69652d4 100644 --- a/apps/api/src/domain/media/MediaProviders.ts +++ b/apps/api/src/domain/media/MediaProviders.ts @@ -34,30 +34,29 @@ import { DeleteMediaPresenter } from './presenters/DeleteMediaPresenter'; import { GetAvatarPresenter } from './presenters/GetAvatarPresenter'; import { UpdateAvatarPresenter } from './presenters/UpdateAvatarPresenter'; -// Define injection tokens -export const AVATAR_GENERATION_REPOSITORY_TOKEN = 'IAvatarGenerationRepository'; -export const MEDIA_REPOSITORY_TOKEN = 'IMediaRepository'; -export const AVATAR_REPOSITORY_TOKEN = 'IAvatarRepository'; -export const FACE_VALIDATION_PORT_TOKEN = 'FaceValidationPort'; -export const AVATAR_GENERATION_PORT_TOKEN = 'AvatarGenerationPort'; -export const MEDIA_STORAGE_PORT_TOKEN = 'MediaStoragePort'; -export const LOGGER_TOKEN = 'Logger'; +import { + AVATAR_GENERATION_REPOSITORY_TOKEN, + MEDIA_REPOSITORY_TOKEN, + AVATAR_REPOSITORY_TOKEN, + FACE_VALIDATION_PORT_TOKEN, + AVATAR_GENERATION_PORT_TOKEN, + MEDIA_STORAGE_PORT_TOKEN, + LOGGER_TOKEN, + REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN, + UPLOAD_MEDIA_USE_CASE_TOKEN, + GET_MEDIA_USE_CASE_TOKEN, + DELETE_MEDIA_USE_CASE_TOKEN, + GET_AVATAR_USE_CASE_TOKEN, + UPDATE_AVATAR_USE_CASE_TOKEN, + REQUEST_AVATAR_GENERATION_OUTPUT_PORT_TOKEN, + UPLOAD_MEDIA_OUTPUT_PORT_TOKEN, + GET_MEDIA_OUTPUT_PORT_TOKEN, + DELETE_MEDIA_OUTPUT_PORT_TOKEN, + GET_AVATAR_OUTPUT_PORT_TOKEN, + UPDATE_AVATAR_OUTPUT_PORT_TOKEN, +} from './MediaTokens'; -// Use case tokens -export const REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN = 'RequestAvatarGenerationUseCase'; -export const UPLOAD_MEDIA_USE_CASE_TOKEN = 'UploadMediaUseCase'; -export const GET_MEDIA_USE_CASE_TOKEN = 'GetMediaUseCase'; -export const DELETE_MEDIA_USE_CASE_TOKEN = 'DeleteMediaUseCase'; -export const GET_AVATAR_USE_CASE_TOKEN = 'GetAvatarUseCase'; -export const UPDATE_AVATAR_USE_CASE_TOKEN = 'UpdateAvatarUseCase'; - -// Output port tokens -export const REQUEST_AVATAR_GENERATION_OUTPUT_PORT_TOKEN = 'RequestAvatarGenerationOutputPort'; -export const UPLOAD_MEDIA_OUTPUT_PORT_TOKEN = 'UploadMediaOutputPort'; -export const GET_MEDIA_OUTPUT_PORT_TOKEN = 'GetMediaOutputPort'; -export const DELETE_MEDIA_OUTPUT_PORT_TOKEN = 'DeleteMediaOutputPort'; -export const GET_AVATAR_OUTPUT_PORT_TOKEN = 'GetAvatarOutputPort'; -export const UPDATE_AVATAR_OUTPUT_PORT_TOKEN = 'UpdateAvatarOutputPort'; +export * from './MediaTokens'; import type { AvatarGenerationRequest } from '@core/media/domain/entities/AvatarGenerationRequest'; import type { Media } from '@core/media/domain/entities/Media'; @@ -133,7 +132,6 @@ class MockLogger implements Logger { } export const MediaProviders: Provider[] = [ - MediaService, // Provide the service itself RequestAvatarGenerationPresenter, UploadMediaPresenter, GetMediaPresenter, diff --git a/apps/api/src/domain/media/MediaService.test.ts b/apps/api/src/domain/media/MediaService.test.ts new file mode 100644 index 000000000..2b3e3ef8b --- /dev/null +++ b/apps/api/src/domain/media/MediaService.test.ts @@ -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', + }); + }); +}); diff --git a/apps/api/src/domain/media/MediaService.ts b/apps/api/src/domain/media/MediaService.ts index e42cf48b6..32f8a1339 100644 --- a/apps/api/src/domain/media/MediaService.ts +++ b/apps/api/src/domain/media/MediaService.ts @@ -31,7 +31,6 @@ import { DeleteMediaPresenter } from './presenters/DeleteMediaPresenter'; import { GetAvatarPresenter } from './presenters/GetAvatarPresenter'; import { UpdateAvatarPresenter } from './presenters/UpdateAvatarPresenter'; -// Tokens import { REQUEST_AVATAR_GENERATION_USE_CASE_TOKEN, UPLOAD_MEDIA_USE_CASE_TOKEN, @@ -40,7 +39,7 @@ import { GET_AVATAR_USE_CASE_TOKEN, UPDATE_AVATAR_USE_CASE_TOKEN, LOGGER_TOKEN, -} from './MediaProviders'; +} from './MediaTokens'; import type { Logger } from '@core/shared/application'; @Injectable() diff --git a/apps/api/src/domain/media/MediaTokens.ts b/apps/api/src/domain/media/MediaTokens.ts new file mode 100644 index 000000000..f060e76eb --- /dev/null +++ b/apps/api/src/domain/media/MediaTokens.ts @@ -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'; \ No newline at end of file diff --git a/apps/api/src/domain/payments/PaymentsController.ts b/apps/api/src/domain/payments/PaymentsController.ts index f9b56ced3..95cf06aef 100644 --- a/apps/api/src/domain/payments/PaymentsController.ts +++ b/apps/api/src/domain/payments/PaymentsController.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Patch, Delete, Body, Query, HttpCode, HttpStatus } from '@nestjs/common'; +import { Controller, Get, Post, Patch, Delete, Body, Query, HttpCode, HttpStatus, Inject } from '@nestjs/common'; import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger'; import { PaymentsService } from './PaymentsService'; import { CreatePaymentInput, CreatePaymentOutput, UpdatePaymentStatusInput, UpdatePaymentStatusOutput, GetPaymentsQuery, GetPaymentsOutput, GetMembershipFeesQuery, GetMembershipFeesOutput, UpsertMembershipFeeInput, UpsertMembershipFeeOutput, UpdateMemberPaymentInput, UpdateMemberPaymentOutput, GetPrizesQuery, GetPrizesOutput, CreatePrizeInput, CreatePrizeOutput, AwardPrizeInput, AwardPrizeOutput, DeletePrizeInput, DeletePrizeOutput, GetWalletQuery, GetWalletOutput, ProcessWalletTransactionInput, ProcessWalletTransactionOutput } from './dtos/PaymentsDto'; @@ -6,7 +6,7 @@ import { CreatePaymentInput, CreatePaymentOutput, UpdatePaymentStatusInput, Upda @ApiTags('payments') @Controller('payments') export class PaymentsController { - constructor(private readonly paymentsService: PaymentsService) {} + constructor(@Inject(PaymentsService) private readonly paymentsService: PaymentsService) {} @Get() @ApiOperation({ summary: 'Get payments based on filters' }) diff --git a/apps/api/src/domain/payments/PaymentsModule.ts b/apps/api/src/domain/payments/PaymentsModule.ts index d6b4b601a..01b495a80 100644 --- a/apps/api/src/domain/payments/PaymentsModule.ts +++ b/apps/api/src/domain/payments/PaymentsModule.ts @@ -5,7 +5,7 @@ import { PaymentsProviders } from './PaymentsProviders'; @Module({ controllers: [PaymentsController], - providers: PaymentsProviders, + providers: [PaymentsService, ...PaymentsProviders], exports: [PaymentsService], }) export class PaymentsModule {} diff --git a/apps/api/src/domain/payments/PaymentsProviders.ts b/apps/api/src/domain/payments/PaymentsProviders.ts index ee0b7b1ad..7dfc22322 100644 --- a/apps/api/src/domain/payments/PaymentsProviders.ts +++ b/apps/api/src/domain/payments/PaymentsProviders.ts @@ -43,45 +43,43 @@ import { DeletePrizePresenter } from './presenters/DeletePrizePresenter'; import { GetWalletPresenter } from './presenters/GetWalletPresenter'; import { ProcessWalletTransactionPresenter } from './presenters/ProcessWalletTransactionPresenter'; -// Repository injection tokens -export const PAYMENT_REPOSITORY_TOKEN = 'IPaymentRepository'; -export const MEMBERSHIP_FEE_REPOSITORY_TOKEN = 'IMembershipFeeRepository'; -export const MEMBER_PAYMENT_REPOSITORY_TOKEN = 'IMemberPaymentRepository'; -export const PRIZE_REPOSITORY_TOKEN = 'IPrizeRepository'; -export const WALLET_REPOSITORY_TOKEN = 'IWalletRepository'; -export const TRANSACTION_REPOSITORY_TOKEN = 'ITransactionRepository'; -export const LOGGER_TOKEN = 'Logger'; +import { + PAYMENT_REPOSITORY_TOKEN, + MEMBERSHIP_FEE_REPOSITORY_TOKEN, + MEMBER_PAYMENT_REPOSITORY_TOKEN, + PRIZE_REPOSITORY_TOKEN, + WALLET_REPOSITORY_TOKEN, + TRANSACTION_REPOSITORY_TOKEN, + LOGGER_TOKEN, + GET_PAYMENTS_USE_CASE_TOKEN, + CREATE_PAYMENT_USE_CASE_TOKEN, + UPDATE_PAYMENT_STATUS_USE_CASE_TOKEN, + GET_MEMBERSHIP_FEES_USE_CASE_TOKEN, + UPSERT_MEMBERSHIP_FEE_USE_CASE_TOKEN, + UPDATE_MEMBER_PAYMENT_USE_CASE_TOKEN, + GET_PRIZES_USE_CASE_TOKEN, + CREATE_PRIZE_USE_CASE_TOKEN, + AWARD_PRIZE_USE_CASE_TOKEN, + DELETE_PRIZE_USE_CASE_TOKEN, + GET_WALLET_USE_CASE_TOKEN, + PROCESS_WALLET_TRANSACTION_USE_CASE_TOKEN, + GET_PAYMENTS_OUTPUT_PORT_TOKEN, + CREATE_PAYMENT_OUTPUT_PORT_TOKEN, + UPDATE_PAYMENT_STATUS_OUTPUT_PORT_TOKEN, + GET_MEMBERSHIP_FEES_OUTPUT_PORT_TOKEN, + UPSERT_MEMBERSHIP_FEE_OUTPUT_PORT_TOKEN, + UPDATE_MEMBER_PAYMENT_OUTPUT_PORT_TOKEN, + GET_PRIZES_OUTPUT_PORT_TOKEN, + CREATE_PRIZE_OUTPUT_PORT_TOKEN, + AWARD_PRIZE_OUTPUT_PORT_TOKEN, + DELETE_PRIZE_OUTPUT_PORT_TOKEN, + GET_WALLET_OUTPUT_PORT_TOKEN, + PROCESS_WALLET_TRANSACTION_OUTPUT_PORT_TOKEN, +} from './PaymentsTokens'; -// Use case injection tokens -export const GET_PAYMENTS_USE_CASE_TOKEN = 'GetPaymentsUseCase'; -export const CREATE_PAYMENT_USE_CASE_TOKEN = 'CreatePaymentUseCase'; -export const UPDATE_PAYMENT_STATUS_USE_CASE_TOKEN = 'UpdatePaymentStatusUseCase'; -export const GET_MEMBERSHIP_FEES_USE_CASE_TOKEN = 'GetMembershipFeesUseCase'; -export const UPSERT_MEMBERSHIP_FEE_USE_CASE_TOKEN = 'UpsertMembershipFeeUseCase'; -export const UPDATE_MEMBER_PAYMENT_USE_CASE_TOKEN = 'UpdateMemberPaymentUseCase'; -export const GET_PRIZES_USE_CASE_TOKEN = 'GetPrizesUseCase'; -export const CREATE_PRIZE_USE_CASE_TOKEN = 'CreatePrizeUseCase'; -export const AWARD_PRIZE_USE_CASE_TOKEN = 'AwardPrizeUseCase'; -export const DELETE_PRIZE_USE_CASE_TOKEN = 'DeletePrizeUseCase'; -export const GET_WALLET_USE_CASE_TOKEN = 'GetWalletUseCase'; -export const PROCESS_WALLET_TRANSACTION_USE_CASE_TOKEN = 'ProcessWalletTransactionUseCase'; - -// Output port tokens -export const GET_PAYMENTS_OUTPUT_PORT_TOKEN = 'GetPaymentsOutputPort_TOKEN'; -export const CREATE_PAYMENT_OUTPUT_PORT_TOKEN = 'CreatePaymentOutputPort_TOKEN'; -export const UPDATE_PAYMENT_STATUS_OUTPUT_PORT_TOKEN = 'UpdatePaymentStatusOutputPort_TOKEN'; -export const GET_MEMBERSHIP_FEES_OUTPUT_PORT_TOKEN = 'GetMembershipFeesOutputPort_TOKEN'; -export const UPSERT_MEMBERSHIP_FEE_OUTPUT_PORT_TOKEN = 'UpsertMembershipFeeOutputPort_TOKEN'; -export const UPDATE_MEMBER_PAYMENT_OUTPUT_PORT_TOKEN = 'UpdateMemberPaymentOutputPort_TOKEN'; -export const GET_PRIZES_OUTPUT_PORT_TOKEN = 'GetPrizesOutputPort_TOKEN'; -export const CREATE_PRIZE_OUTPUT_PORT_TOKEN = 'CreatePrizeOutputPort_TOKEN'; -export const AWARD_PRIZE_OUTPUT_PORT_TOKEN = 'AwardPrizeOutputPort_TOKEN'; -export const DELETE_PRIZE_OUTPUT_PORT_TOKEN = 'DeletePrizeOutputPort_TOKEN'; -export const GET_WALLET_OUTPUT_PORT_TOKEN = 'GetWalletOutputPort_TOKEN'; -export const PROCESS_WALLET_TRANSACTION_OUTPUT_PORT_TOKEN = 'ProcessWalletTransactionOutputPort_TOKEN'; +export * from './PaymentsTokens'; export const PaymentsProviders: Provider[] = [ - PaymentsService, // Presenters GetPaymentsPresenter, diff --git a/apps/api/src/domain/payments/PaymentsService.test.ts b/apps/api/src/domain/payments/PaymentsService.test.ts new file mode 100644 index 000000000..ab50be5b3 --- /dev/null +++ b/apps/api/src/domain/payments/PaymentsService.test.ts @@ -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>) { + 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'); + }); +}); diff --git a/apps/api/src/domain/payments/PaymentsService.ts b/apps/api/src/domain/payments/PaymentsService.ts index 5061866ae..a6348a4f8 100644 --- a/apps/api/src/domain/payments/PaymentsService.ts +++ b/apps/api/src/domain/payments/PaymentsService.ts @@ -72,7 +72,7 @@ import { UPDATE_MEMBER_PAYMENT_USE_CASE_TOKEN, UPDATE_PAYMENT_STATUS_USE_CASE_TOKEN, UPSERT_MEMBERSHIP_FEE_USE_CASE_TOKEN, -} from './PaymentsProviders'; +} from './PaymentsTokens'; @Injectable() export class PaymentsService { diff --git a/apps/api/src/domain/payments/PaymentsTokens.ts b/apps/api/src/domain/payments/PaymentsTokens.ts new file mode 100644 index 000000000..59fdbadb4 --- /dev/null +++ b/apps/api/src/domain/payments/PaymentsTokens.ts @@ -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'; \ No newline at end of file diff --git a/apps/api/src/domain/protests/ProtestsController.ts b/apps/api/src/domain/protests/ProtestsController.ts index 77f7afd8c..3a0351805 100644 --- a/apps/api/src/domain/protests/ProtestsController.ts +++ b/apps/api/src/domain/protests/ProtestsController.ts @@ -1,4 +1,4 @@ -import { Body, Controller, ForbiddenException, HttpCode, HttpStatus, InternalServerErrorException, NotFoundException, Param, Post } from '@nestjs/common'; +import { Body, Controller, ForbiddenException, HttpCode, HttpStatus, Inject, InternalServerErrorException, NotFoundException, Param, Post } from '@nestjs/common'; import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ProtestsService } from './ProtestsService'; import { ReviewProtestCommandDTO } from '../race/dtos/ReviewProtestCommandDTO'; @@ -7,7 +7,7 @@ import type { ReviewProtestResponseDTO } from './presenters/ReviewProtestPresent @ApiTags('protests') @Controller('protests') export class ProtestsController { - constructor(private readonly protestsService: ProtestsService) {} + constructor(@Inject(ProtestsService) private readonly protestsService: ProtestsService) {} @Post(':protestId/review') @HttpCode(HttpStatus.OK) diff --git a/apps/api/src/domain/protests/ProtestsProviders.ts b/apps/api/src/domain/protests/ProtestsProviders.ts index b3c1f0047..935959715 100644 --- a/apps/api/src/domain/protests/ProtestsProviders.ts +++ b/apps/api/src/domain/protests/ProtestsProviders.ts @@ -1,5 +1,4 @@ import { Provider } from '@nestjs/common'; -import { ProtestsService } from './ProtestsService'; // Import core interfaces import type { Logger } from '@core/shared/application/Logger'; @@ -24,7 +23,6 @@ export const LOGGER_TOKEN = 'Logger'; export const REVIEW_PROTEST_PRESENTER_TOKEN = 'ReviewProtestPresenter'; export const ProtestsProviders: Provider[] = [ - ProtestsService, // Provide the service itself { provide: PROTEST_REPOSITORY_TOKEN, useFactory: (logger: Logger) => new InMemoryProtestRepository(logger), diff --git a/apps/api/src/domain/protests/ProtestsService.test.ts b/apps/api/src/domain/protests/ProtestsService.test.ts index 09e11e4d9..79c41ee97 100644 --- a/apps/api/src/domain/protests/ProtestsService.test.ts +++ b/apps/api/src/domain/protests/ProtestsService.test.ts @@ -4,34 +4,23 @@ import type { Logger } from '@core/shared/application/Logger'; import type { ReviewProtestUseCase, ReviewProtestApplicationError, + ReviewProtestResult, } from '@core/racing/application/use-cases/ReviewProtestUseCase'; import { ProtestsService } from './ProtestsService'; -import type { ReviewProtestPresenter } from './presenters/ReviewProtestPresenter'; -import type { ReviewProtestResponseDTO } from './presenters/ReviewProtestPresenter'; +import { ReviewProtestPresenter, type ReviewProtestResponseDTO } from './presenters/ReviewProtestPresenter'; describe('ProtestsService', () => { let service: ProtestsService; let executeMock: MockedFunction; let logger: Logger; + let presenter: ReviewProtestPresenter; beforeEach(() => { executeMock = vi.fn(); const reviewProtestUseCase = { execute: executeMock } as unknown as ReviewProtestUseCase; - const reviewProtestPresenter = { - reset: vi.fn(), - setCommand: vi.fn(), - present: vi.fn(), - presentError: vi.fn(), - getResponseModel: vi.fn(), - get responseModel() { - return { - success: true, - protestId: 'test', - stewardId: 'test', - decision: 'uphold' as const, - }; - }, - } as unknown as ReviewProtestPresenter; + + presenter = new ReviewProtestPresenter(); + logger = { debug: vi.fn(), info: vi.fn(), @@ -39,7 +28,7 @@ describe('ProtestsService', () => { error: vi.fn(), } as unknown as Logger; - service = new ProtestsService(reviewProtestUseCase, reviewProtestPresenter, logger); + service = new ProtestsService(reviewProtestUseCase, presenter, logger); }); const baseCommand = { @@ -50,10 +39,14 @@ describe('ProtestsService', () => { }; it('returns DTO with success model on success', async () => { - executeMock.mockResolvedValue(Result.ok(undefined)); + executeMock.mockImplementation(async (command) => { + presenter.present({ protestId: command.protestId } as ReviewProtestResult); + return Result.ok(undefined); + }); const dto = await service.reviewProtest(baseCommand); + expect(presenter.getResponseModel()).not.toBeNull(); expect(executeMock).toHaveBeenCalledWith(baseCommand); expect(dto).toEqual({ success: true, @@ -69,7 +62,10 @@ describe('ProtestsService', () => { details: { message: 'Protest not found' }, }; - executeMock.mockResolvedValue(Result.err(error)); + executeMock.mockImplementation(async () => { + presenter.presentError(error); + return Result.err(error); + }); const dto = await service.reviewProtest(baseCommand); @@ -86,7 +82,10 @@ describe('ProtestsService', () => { details: { message: 'Race not found for protest' }, }; - executeMock.mockResolvedValue(Result.err(error)); + executeMock.mockImplementation(async () => { + presenter.presentError(error); + return Result.err(error); + }); const dto = await service.reviewProtest(baseCommand); @@ -103,7 +102,10 @@ describe('ProtestsService', () => { details: { message: 'Steward is not authorized to review this protest' }, }; - executeMock.mockResolvedValue(Result.err(error)); + executeMock.mockImplementation(async () => { + presenter.presentError(error); + return Result.err(error); + }); const dto = await service.reviewProtest(baseCommand); @@ -121,7 +123,10 @@ describe('ProtestsService', () => { details: { message: 'Failed to review protest' }, }; - executeMock.mockResolvedValue(Result.err(error)); + executeMock.mockImplementation(async () => { + presenter.presentError(error); + return Result.err(error); + }); const dto = await service.reviewProtest(baseCommand); diff --git a/apps/api/src/domain/race/RaceController.ts b/apps/api/src/domain/race/RaceController.ts index 995a7479b..a124529b7 100644 --- a/apps/api/src/domain/race/RaceController.ts +++ b/apps/api/src/domain/race/RaceController.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, HttpCode, HttpStatus, InternalServerErrorException, Param, Post, Query } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, HttpStatus, InternalServerErrorException, Param, Post, Query, Inject } from '@nestjs/common'; import { ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { RaceService } from './RaceService'; import { AllRacesPageDTO } from './dtos/AllRacesPageDTO'; @@ -21,7 +21,7 @@ import { RequestProtestDefenseCommandDTO } from './dtos/RequestProtestDefenseCom @ApiTags('races') @Controller('races') export class RaceController { - constructor(private readonly raceService: RaceService) {} + constructor(@Inject(RaceService) private readonly raceService: RaceService) {} @Get('all') @ApiOperation({ summary: 'Get all races' }) diff --git a/apps/api/src/domain/race/RaceModule.ts b/apps/api/src/domain/race/RaceModule.ts index 1457eaa70..fc8e3f841 100644 --- a/apps/api/src/domain/race/RaceModule.ts +++ b/apps/api/src/domain/race/RaceModule.ts @@ -5,7 +5,7 @@ import { RaceProviders } from './RaceProviders'; @Module({ controllers: [RaceController], - providers: RaceProviders, + providers: [RaceService, ...RaceProviders], exports: [RaceService], }) export class RaceModule {} diff --git a/apps/api/src/domain/race/RaceProviders.ts b/apps/api/src/domain/race/RaceProviders.ts index 1b35f8e9a..cdd8247c9 100644 --- a/apps/api/src/domain/race/RaceProviders.ts +++ b/apps/api/src/domain/race/RaceProviders.ts @@ -90,32 +90,33 @@ import { RaceResultsDetailPresenter } from './presenters/RaceResultsDetailPresen import { RacesPageDataPresenter } from './presenters/RacesPageDataPresenter'; import { RaceWithSOFPresenter } from './presenters/RaceWithSOFPresenter'; -// Define injection tokens -export const RACE_REPOSITORY_TOKEN = 'IRaceRepository'; -export const LEAGUE_REPOSITORY_TOKEN = 'ILeagueRepository'; -export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository'; -export const RACE_REGISTRATION_REPOSITORY_TOKEN = 'IRaceRegistrationRepository'; -export const RESULT_REPOSITORY_TOKEN = 'IResultRepository'; -export const LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN = 'ILeagueMembershipRepository'; -export const PENALTY_REPOSITORY_TOKEN = 'IPenaltyRepository'; -export const PROTEST_REPOSITORY_TOKEN = 'IProtestRepository'; -export const STANDING_REPOSITORY_TOKEN = 'IStandingRepository'; -export const DRIVER_RATING_PROVIDER_TOKEN = 'DriverRatingProvider'; -export const IMAGE_SERVICE_TOKEN = 'IImageServicePort'; -export const LOGGER_TOKEN = 'Logger'; +import { + RACE_REPOSITORY_TOKEN, + LEAGUE_REPOSITORY_TOKEN, + DRIVER_REPOSITORY_TOKEN, + RACE_REGISTRATION_REPOSITORY_TOKEN, + RESULT_REPOSITORY_TOKEN, + LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN, + PENALTY_REPOSITORY_TOKEN, + PROTEST_REPOSITORY_TOKEN, + STANDING_REPOSITORY_TOKEN, + DRIVER_RATING_PROVIDER_TOKEN, + IMAGE_SERVICE_TOKEN, + LOGGER_TOKEN, + GET_ALL_RACES_PRESENTER_TOKEN, + GET_TOTAL_RACES_PRESENTER_TOKEN, + IMPORT_RACE_RESULTS_API_PRESENTER_TOKEN, + RACE_DETAIL_PRESENTER_TOKEN, + RACES_PAGE_DATA_PRESENTER_TOKEN, + ALL_RACES_PAGE_DATA_PRESENTER_TOKEN, + RACE_RESULTS_DETAIL_PRESENTER_TOKEN, + RACE_WITH_SOF_PRESENTER_TOKEN, + RACE_PROTESTS_PRESENTER_TOKEN, + RACE_PENALTIES_PRESENTER_TOKEN, + COMMAND_RESULT_PRESENTER_TOKEN, +} from './RaceTokens'; -// Presenter tokens -export const GET_ALL_RACES_PRESENTER_TOKEN = 'GetAllRacesPresenter'; -export const GET_TOTAL_RACES_PRESENTER_TOKEN = 'GetTotalRacesPresenter'; -export const IMPORT_RACE_RESULTS_API_PRESENTER_TOKEN = 'ImportRaceResultsApiPresenter'; -export const RACE_DETAIL_PRESENTER_TOKEN = 'RaceDetailPresenter'; -export const RACES_PAGE_DATA_PRESENTER_TOKEN = 'RacesPageDataPresenter'; -export const ALL_RACES_PAGE_DATA_PRESENTER_TOKEN = 'AllRacesPageDataPresenter'; -export const RACE_RESULTS_DETAIL_PRESENTER_TOKEN = 'RaceResultsDetailPresenter'; -export const RACE_WITH_SOF_PRESENTER_TOKEN = 'RaceWithSOFPresenter'; -export const RACE_PROTESTS_PRESENTER_TOKEN = 'RaceProtestsPresenter'; -export const RACE_PENALTIES_PRESENTER_TOKEN = 'RacePenaltiesPresenter'; -export const COMMAND_RESULT_PRESENTER_TOKEN = 'CommandResultPresenter'; +export * from './RaceTokens'; // Adapter classes to bridge presenters with UseCaseOutputPort interface class GetAllRacesOutputAdapter implements UseCaseOutputPort { @@ -295,7 +296,6 @@ class ReviewProtestOutputAdapter implements UseCaseOutputPort new InMemoryRaceRepository(logger), @@ -471,16 +471,15 @@ export const RaceProviders: Provider[] = [ leagueMembershipRepo: ILeagueMembershipRepository, presenter: RaceDetailPresenter, ) => { - const useCase = new GetRaceDetailUseCase( + return new GetRaceDetailUseCase( raceRepo, leagueRepo, driverRepo, raceRegRepo, resultRepo, leagueMembershipRepo, + new RaceDetailOutputAdapter(presenter), ); - useCase.setOutput(new RaceDetailOutputAdapter(presenter)); - return useCase; }, inject: [ RACE_REPOSITORY_TOKEN, diff --git a/apps/api/src/domain/race/RaceService.test.ts b/apps/api/src/domain/race/RaceService.test.ts new file mode 100644 index 000000000..f7c3ed369 --- /dev/null +++ b/apps/api/src/domain/race/RaceService.test.ts @@ -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(); + }); +}); diff --git a/apps/api/src/domain/race/RaceService.ts b/apps/api/src/domain/race/RaceService.ts index 6be012e56..7ae077ba1 100644 --- a/apps/api/src/domain/race/RaceService.ts +++ b/apps/api/src/domain/race/RaceService.ts @@ -66,7 +66,7 @@ import { RACE_RESULTS_DETAIL_PRESENTER_TOKEN, RACE_WITH_SOF_PRESENTER_TOKEN, RACES_PAGE_DATA_PRESENTER_TOKEN -} from './RaceProviders'; +} from './RaceTokens'; @Injectable() export class RaceService { diff --git a/apps/api/src/domain/race/RaceTokens.ts b/apps/api/src/domain/race/RaceTokens.ts new file mode 100644 index 000000000..fbd5cb638 --- /dev/null +++ b/apps/api/src/domain/race/RaceTokens.ts @@ -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'; \ No newline at end of file diff --git a/apps/api/src/domain/sponsor/SponsorController.test.ts b/apps/api/src/domain/sponsor/SponsorController.test.ts index bd5d28001..17d4b6c61 100644 --- a/apps/api/src/domain/sponsor/SponsorController.test.ts +++ b/apps/api/src/domain/sponsor/SponsorController.test.ts @@ -40,7 +40,7 @@ describe('SponsorController', () => { describe('getEntitySponsorshipPricing', () => { it('should return sponsorship pricing', async () => { const mockResult = { entityType: 'season', entityId: 'season-1', pricing: [] }; - sponsorService.getEntitySponsorshipPricing.mockResolvedValue({ viewModel: mockResult } as any); + sponsorService.getEntitySponsorshipPricing.mockResolvedValue(mockResult as any); const result = await controller.getEntitySponsorshipPricing(); @@ -52,7 +52,7 @@ describe('SponsorController', () => { describe('getSponsors', () => { it('should return sponsors list', async () => { const mockResult = { sponsors: [] }; - sponsorService.getSponsors.mockResolvedValue({ viewModel: mockResult } as any); + sponsorService.getSponsors.mockResolvedValue(mockResult as any); const result = await controller.getSponsors(); @@ -65,7 +65,7 @@ describe('SponsorController', () => { it('should create sponsor', async () => { const input = { name: 'Test Sponsor', contactEmail: 'test@example.com' }; const mockResult = { sponsor: { id: 's1', name: 'Test Sponsor' } }; - sponsorService.createSponsor.mockResolvedValue({ viewModel: mockResult } as any); + sponsorService.createSponsor.mockResolvedValue(mockResult as any); const result = await controller.createSponsor(input as any); @@ -78,7 +78,7 @@ describe('SponsorController', () => { it('should return sponsor dashboard', async () => { const sponsorId = 's1'; const mockResult = { sponsorId, metrics: {} as any, sponsoredLeagues: [], investment: {} as any }; - sponsorService.getSponsorDashboard.mockResolvedValue({ viewModel: mockResult } as any); + sponsorService.getSponsorDashboard.mockResolvedValue(mockResult as any); const result = await controller.getSponsorDashboard(sponsorId); @@ -86,13 +86,11 @@ describe('SponsorController', () => { expect(sponsorService.getSponsorDashboard).toHaveBeenCalledWith({ sponsorId }); }); - it('should return null when sponsor not found', async () => { + it('should throw when sponsor not found', async () => { const sponsorId = 's1'; - sponsorService.getSponsorDashboard.mockResolvedValue({ viewModel: null } as any); + sponsorService.getSponsorDashboard.mockRejectedValue(new Error('Sponsor dashboard not found')); - const result = await controller.getSponsorDashboard(sponsorId); - - expect(result).toBeNull(); + await expect(controller.getSponsorDashboard(sponsorId)).rejects.toBeInstanceOf(Error); }); }); @@ -111,7 +109,7 @@ describe('SponsorController', () => { currency: 'USD', }, }; - sponsorService.getSponsorSponsorships.mockResolvedValue({ viewModel: mockResult } as any); + sponsorService.getSponsorSponsorships.mockResolvedValue(mockResult as any); const result = await controller.getSponsorSponsorships(sponsorId); @@ -119,13 +117,11 @@ describe('SponsorController', () => { expect(sponsorService.getSponsorSponsorships).toHaveBeenCalledWith({ sponsorId }); }); - it('should return null when sponsor not found', async () => { + it('should throw when sponsor not found', async () => { const sponsorId = 's1'; - sponsorService.getSponsorSponsorships.mockResolvedValue({ viewModel: null } as any); + sponsorService.getSponsorSponsorships.mockRejectedValue(new Error('Sponsor sponsorships not found')); - const result = await controller.getSponsorSponsorships(sponsorId); - - expect(result).toBeNull(); + await expect(controller.getSponsorSponsorships(sponsorId)).rejects.toBeInstanceOf(Error); }); }); @@ -133,7 +129,7 @@ describe('SponsorController', () => { it('should return sponsor', async () => { const sponsorId = 's1'; const mockResult = { sponsor: { id: sponsorId, name: 'S1' } }; - sponsorService.getSponsor.mockResolvedValue({ viewModel: mockResult } as any); + sponsorService.getSponsor.mockResolvedValue(mockResult as any); const result = await controller.getSponsor(sponsorId); @@ -141,13 +137,11 @@ describe('SponsorController', () => { expect(sponsorService.getSponsor).toHaveBeenCalledWith(sponsorId); }); - it('should return null when sponsor not found', async () => { + it('should throw when sponsor not found', async () => { const sponsorId = 's1'; - sponsorService.getSponsor.mockResolvedValue({ viewModel: null } as any); + sponsorService.getSponsor.mockRejectedValue(new Error('Sponsor not found')); - const result = await controller.getSponsor(sponsorId); - - expect(result).toBeNull(); + await expect(controller.getSponsor(sponsorId)).rejects.toBeInstanceOf(Error); }); }); @@ -160,7 +154,7 @@ describe('SponsorController', () => { requests: [], totalCount: 0, }; - sponsorService.getPendingSponsorshipRequests.mockResolvedValue({ viewModel: mockResult } as any); + sponsorService.getPendingSponsorshipRequests.mockResolvedValue(mockResult as any); const result = await controller.getPendingSponsorshipRequests(query); @@ -181,7 +175,7 @@ describe('SponsorController', () => { platformFee: 10, netAmount: 90, }; - sponsorService.acceptSponsorshipRequest.mockResolvedValue({ viewModel: mockResult } as any); + sponsorService.acceptSponsorshipRequest.mockResolvedValue(mockResult as any); const result = await controller.acceptSponsorshipRequest(requestId, input as any); @@ -192,14 +186,12 @@ describe('SponsorController', () => { ); }); - it('should return null on error', async () => { + it('should throw on error', async () => { const requestId = 'r1'; const input = { respondedBy: 'u1' }; - sponsorService.acceptSponsorshipRequest.mockResolvedValue({ viewModel: null } as any); + sponsorService.acceptSponsorshipRequest.mockRejectedValue(new Error('Accept sponsorship request failed')); - const result = await controller.acceptSponsorshipRequest(requestId, input as any); - - expect(result).toBeNull(); + await expect(controller.acceptSponsorshipRequest(requestId, input as any)).rejects.toBeInstanceOf(Error); }); }); @@ -213,7 +205,7 @@ describe('SponsorController', () => { rejectedAt: new Date(), reason: 'Not interested', }; - sponsorService.rejectSponsorshipRequest.mockResolvedValue({ viewModel: mockResult } as any); + sponsorService.rejectSponsorshipRequest.mockResolvedValue(mockResult as any); const result = await controller.rejectSponsorshipRequest(requestId, input as any); @@ -225,14 +217,12 @@ describe('SponsorController', () => { ); }); - it('should return null on error', async () => { + it('should throw on error', async () => { const requestId = 'r1'; const input = { respondedBy: 'u1' }; - sponsorService.rejectSponsorshipRequest.mockResolvedValue({ viewModel: null } as any); + sponsorService.rejectSponsorshipRequest.mockRejectedValue(new Error('Reject sponsorship request failed')); - const result = await controller.rejectSponsorshipRequest(requestId, input as any); - - expect(result).toBeNull(); + await expect(controller.rejectSponsorshipRequest(requestId, input as any)).rejects.toBeInstanceOf(Error); }); }); @@ -251,7 +241,7 @@ describe('SponsorController', () => { averageMonthlySpend: 0, }, }; - sponsorService.getSponsorBilling.mockResolvedValue({ viewModel: mockResult } as any); + sponsorService.getSponsorBilling.mockResolvedValue(mockResult as any); const result = await controller.getSponsorBilling(sponsorId); diff --git a/apps/api/src/domain/sponsor/SponsorController.ts b/apps/api/src/domain/sponsor/SponsorController.ts index 88a8adfee..1bc76a4e7 100644 --- a/apps/api/src/domain/sponsor/SponsorController.ts +++ b/apps/api/src/domain/sponsor/SponsorController.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Put, Body, HttpCode, HttpStatus, Param, Query } from '@nestjs/common'; +import { Controller, Get, Post, Put, Body, HttpCode, HttpStatus, Param, Query, Inject } from '@nestjs/common'; import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger'; import { SponsorService } from './SponsorService'; import { GetEntitySponsorshipPricingResultDTO } from './dtos/GetEntitySponsorshipPricingResultDTO'; @@ -29,7 +29,7 @@ import type { RejectSponsorshipRequestResult } from '@core/racing/application/us @ApiTags('sponsors') @Controller('sponsors') export class SponsorController { - constructor(private readonly sponsorService: SponsorService) {} + constructor(@Inject(SponsorService) private readonly sponsorService: SponsorService) {} @Get('pricing') @ApiOperation({ summary: 'Get sponsorship pricing for an entity' }) diff --git a/apps/api/src/domain/sponsor/SponsorProviders.ts b/apps/api/src/domain/sponsor/SponsorProviders.ts index 555fa3206..460637946 100644 --- a/apps/api/src/domain/sponsor/SponsorProviders.ts +++ b/apps/api/src/domain/sponsor/SponsorProviders.ts @@ -273,15 +273,11 @@ export const SponsorProviders: Provider[] = [ provide: GET_ENTITY_SPONSORSHIP_PRICING_USE_CASE_TOKEN, useFactory: ( sponsorshipPricingRepo: ISponsorshipPricingRepository, - sponsorshipRequestRepo: ISponsorshipRequestRepository, - seasonSponsorshipRepo: ISeasonSponsorshipRepository, logger: Logger, output: UseCaseOutputPort, - ) => new GetEntitySponsorshipPricingUseCase(sponsorshipPricingRepo, sponsorshipRequestRepo, seasonSponsorshipRepo, logger, output), + ) => new GetEntitySponsorshipPricingUseCase(sponsorshipPricingRepo, logger, output), inject: [ SPONSORSHIP_PRICING_REPOSITORY_TOKEN, - SPONSORSHIP_REQUEST_REPOSITORY_TOKEN, - SEASON_SPONSORSHIP_REPOSITORY_TOKEN, LOGGER_TOKEN, GET_ENTITY_SPONSORSHIP_PRICING_OUTPUT_PORT_TOKEN, ], diff --git a/apps/api/src/domain/sponsor/SponsorService.test.ts b/apps/api/src/domain/sponsor/SponsorService.test.ts index 1a586e820..9a26a5f5d 100644 --- a/apps/api/src/domain/sponsor/SponsorService.test.ts +++ b/apps/api/src/domain/sponsor/SponsorService.test.ts @@ -12,6 +12,8 @@ import type { Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; import type { CreateSponsorInputDTO } from './dtos/CreateSponsorInputDTO'; +import { Sponsor } from '@core/racing/domain/entities/sponsor/Sponsor'; +import { Money } from '@core/racing/domain/value-objects/Money'; import { AcceptSponsorshipRequestPresenter } from './presenters/AcceptSponsorshipRequestPresenter'; import { CreateSponsorPresenter } from './presenters/CreateSponsorPresenter'; import { GetEntitySponsorshipPricingPresenter } from './presenters/GetEntitySponsorshipPricingPresenter'; @@ -110,20 +112,20 @@ describe('SponsorService', () => { const outputPort = { entityType: 'season', entityId: 'season-1', - tiers: [ - { name: 'Gold', price: 500, benefits: ['Main slot'] }, - ], + tiers: [{ name: 'Gold', price: { amount: 500, currency: 'USD' }, benefits: ['Main slot'] }], }; - getSponsorshipPricingUseCase.execute.mockResolvedValue(Result.ok(outputPort)); + + getSponsorshipPricingUseCase.execute.mockImplementation(async () => { + getEntitySponsorshipPricingPresenter.present(outputPort as any); + return Result.ok(undefined); + }); const result = await service.getEntitySponsorshipPricing(); expect(result).toEqual({ entityType: 'season', entityId: 'season-1', - pricing: [ - { id: 'Gold', level: 'Gold', price: 500, currency: 'USD' }, - ], + pricing: [{ id: 'Gold', level: 'Gold', price: 500, currency: 'USD' }], }); }); @@ -142,13 +144,32 @@ describe('SponsorService', () => { describe('getSponsors', () => { it('returns sponsors on success', async () => { - const sponsors = [{ id: 's1', name: 'S1', contactEmail: 's1@test', createdAt: new Date() }]; - const outputPort = { sponsors }; - getSponsorsUseCase.execute.mockResolvedValue(Result.ok(outputPort)); + const sponsors = [ + Sponsor.create({ + id: 'sponsor-1', + name: 'S1', + contactEmail: 's1@test.com', + createdAt: new Date('2024-01-01T00:00:00Z'), + }), + ]; + + getSponsorsUseCase.execute.mockImplementation(async () => { + getSponsorsPresenter.present(sponsors); + return Result.ok(undefined); + }); const result = await service.getSponsors(); - expect(result).toEqual({ sponsors }); + expect(result).toEqual({ + sponsors: [ + { + id: 'sponsor-1', + name: 'S1', + contactEmail: 's1@test.com', + createdAt: new Date('2024-01-01T00:00:00Z'), + }, + ], + }); }); it('returns empty list on error', async () => { @@ -163,18 +184,28 @@ describe('SponsorService', () => { describe('createSponsor', () => { it('returns created sponsor on success', async () => { const input: CreateSponsorInputDTO = { name: 'Test', contactEmail: 'test@example.com' }; - const sponsor = { - id: 's1', + const sponsor = Sponsor.create({ + id: 'sponsor-1', name: 'Test', contactEmail: 'test@example.com', - createdAt: new Date(), - }; - const outputPort = { sponsor }; - createSponsorUseCase.execute.mockResolvedValue(Result.ok(outputPort)); + createdAt: new Date('2024-01-01T00:00:00Z'), + }); + + createSponsorUseCase.execute.mockImplementation(async () => { + createSponsorPresenter.present(sponsor); + return Result.ok(undefined); + }); const result = await service.createSponsor(input); - expect(result).toEqual({ sponsor }); + expect(result).toEqual({ + sponsor: { + id: 'sponsor-1', + name: 'Test', + contactEmail: 'test@example.com', + createdAt: new Date('2024-01-01T00:00:00Z'), + }, + }); }); it('throws on error', async () => { @@ -185,6 +216,20 @@ describe('SponsorService', () => { await expect(service.createSponsor(input)).rejects.toThrow('Invalid'); }); + + it('throws using error.message when details.message is missing', async () => { + const input: CreateSponsorInputDTO = { name: 'Test', contactEmail: 'test@example.com' }; + createSponsorUseCase.execute.mockResolvedValue(Result.err({ code: 'VALIDATION_ERROR', message: 'Boom' } as any)); + + await expect(service.createSponsor(input)).rejects.toThrow('Boom'); + }); + + it('throws default message when details.message and message are missing', async () => { + const input: CreateSponsorInputDTO = { name: 'Test', contactEmail: 'test@example.com' }; + createSponsorUseCase.execute.mockResolvedValue(Result.err({ code: 'VALIDATION_ERROR' } as any)); + + await expect(service.createSponsor(input)).rejects.toThrow('Failed to create sponsor'); + }); }); describe('getSponsorDashboard', () => { @@ -206,15 +251,38 @@ describe('SponsorService', () => { sponsoredLeagues: [], investment: { activeSponsorships: 0, - totalInvestment: 0, + totalInvestment: Money.create(0, 'USD'), costPerThousandViews: 0, }, }; - getSponsorDashboardUseCase.execute.mockResolvedValue(Result.ok(outputPort)); + + getSponsorDashboardUseCase.execute.mockImplementation(async () => { + getSponsorDashboardPresenter.present(outputPort as any); + return Result.ok(undefined); + }); const result = await service.getSponsorDashboard(params); - expect(result).toEqual(outputPort); + expect(result).toEqual({ + sponsorId: 's1', + sponsorName: 'S1', + metrics: outputPort.metrics, + sponsoredLeagues: [], + investment: { + activeSponsorships: 0, + totalInvestment: 0, + costPerThousandViews: 0, + }, + sponsorships: { + leagues: [], + teams: [], + drivers: [], + races: [], + platform: [], + }, + recentActivity: [], + upcomingRenewals: [], + }); }); it('throws on error', async () => { @@ -229,6 +297,28 @@ describe('SponsorService', () => { it('returns sponsorships on success', async () => { const params: GetSponsorSponsorshipsInput = { sponsorId: 's1' }; const outputPort = { + sponsor: Sponsor.create({ + id: 's1', + name: 'S1', + contactEmail: 's1@example.com', + }), + sponsorships: [], + summary: { + totalSponsorships: 0, + activeSponsorships: 0, + totalInvestment: Money.create(0, 'USD'), + totalPlatformFees: Money.create(0, 'USD'), + }, + }; + + getSponsorSponsorshipsUseCase.execute.mockImplementation(async () => { + getSponsorSponsorshipsPresenter.present(outputPort as any); + return Result.ok(undefined); + }); + + const result = await service.getSponsorSponsorships(params); + + expect(result).toEqual({ sponsorId: 's1', sponsorName: 'S1', sponsorships: [], @@ -239,39 +329,44 @@ describe('SponsorService', () => { totalPlatformFees: 0, currency: 'USD', }, - }; - getSponsorSponsorshipsUseCase.execute.mockResolvedValue(Result.ok(outputPort)); - - const result = await service.getSponsorSponsorships(params); - - expect(result).toEqual(outputPort); + }); }); it('throws on error', async () => { const params: GetSponsorSponsorshipsInput = { sponsorId: 's1' }; - getSponsorSponsorshipsUseCase.execute.mockResolvedValue( - Result.err({ code: 'REPOSITORY_ERROR' }), - ); + getSponsorSponsorshipsUseCase.execute.mockResolvedValue(Result.err({ code: 'REPOSITORY_ERROR' })); - await expect(service.getSponsorSponsorships(params)).rejects.toThrow('Sponsor sponsorships not found'); + await expect(service.getSponsorSponsorships(params)).rejects.toThrow( + 'Sponsor sponsorships not found', + ); }); }); describe('getSponsor', () => { it('returns sponsor when found', async () => { const sponsorId = 's1'; - const sponsor = { id: sponsorId, name: 'S1' }; - const output = { sponsor }; - getSponsorUseCase.execute.mockResolvedValue(Result.ok(output)); + const output = { sponsor: { id: sponsorId, name: 'S1' } }; + + getSponsorUseCase.execute.mockImplementation(async () => { + getSponsorPresenter.present(output); + return Result.ok(undefined); + }); const result = await service.getSponsor(sponsorId); - expect(result).toEqual({ sponsor }); + expect(result).toEqual(output); }); it('throws when not found', async () => { const sponsorId = 's1'; - getSponsorUseCase.execute.mockResolvedValue(Result.ok(null)); + getSponsorUseCase.execute.mockResolvedValue(Result.err({ code: 'SPONSOR_NOT_FOUND' })); + + await expect(service.getSponsor(sponsorId)).rejects.toThrow('Sponsor not found'); + }); + + it('throws when viewModel is missing even if use case succeeds', async () => { + const sponsorId = 's1'; + getSponsorUseCase.execute.mockResolvedValue(Result.ok(undefined)); await expect(service.getSponsor(sponsorId)).rejects.toThrow('Sponsor not found'); }); @@ -286,7 +381,11 @@ describe('SponsorService', () => { requests: [], totalCount: 0, }; - getPendingSponsorshipRequestsUseCase.execute.mockResolvedValue(Result.ok(outputPort)); + + getPendingSponsorshipRequestsUseCase.execute.mockImplementation(async () => { + getPendingSponsorshipRequestsPresenter.present(outputPort as any); + return Result.ok(undefined); + }); const result = await service.getPendingSponsorshipRequests(params); @@ -295,9 +394,7 @@ describe('SponsorService', () => { it('returns empty result on error', async () => { const params = { entityType: 'season' as const, entityId: 'season-1' }; - getPendingSponsorshipRequestsUseCase.execute.mockResolvedValue( - Result.err({ code: 'REPOSITORY_ERROR' }), - ); + getPendingSponsorshipRequestsUseCase.execute.mockResolvedValue(Result.err({ code: 'REPOSITORY_ERROR' })); const result = await service.getPendingSponsorshipRequests(params); @@ -308,6 +405,13 @@ describe('SponsorService', () => { totalCount: 0, }); }); + + it('throws when presenter viewModel is missing on success', async () => { + const params = { entityType: 'season' as const, entityId: 'season-1' }; + getPendingSponsorshipRequestsUseCase.execute.mockResolvedValue(Result.ok(undefined)); + + await expect(service.getPendingSponsorshipRequests(params)).rejects.toThrow('Pending sponsorship requests not found'); + }); }); describe('SponsorshipRequest', () => { @@ -322,7 +426,11 @@ describe('SponsorService', () => { platformFee: 10, netAmount: 90, }; - acceptSponsorshipRequestUseCase.execute.mockResolvedValue(Result.ok(outputPort)); + + acceptSponsorshipRequestUseCase.execute.mockImplementation(async () => { + acceptSponsorshipRequestPresenter.present(outputPort as any); + return Result.ok(undefined); + }); const result = await service.acceptSponsorshipRequest(requestId, respondedBy); @@ -336,7 +444,19 @@ describe('SponsorService', () => { Result.err({ code: 'SPONSORSHIP_REQUEST_NOT_FOUND' }), ); - await expect(service.acceptSponsorshipRequest(requestId, respondedBy)).rejects.toThrow('Accept sponsorship request failed'); + await expect(service.acceptSponsorshipRequest(requestId, respondedBy)).rejects.toThrow( + 'Accept sponsorship request failed', + ); + }); + + it('throws when presenter viewModel is missing on success', async () => { + const requestId = 'r1'; + const respondedBy = 'u1'; + acceptSponsorshipRequestUseCase.execute.mockResolvedValue(Result.ok(undefined)); + + await expect(service.acceptSponsorshipRequest(requestId, respondedBy)).rejects.toThrow( + 'Accept sponsorship request failed', + ); }); }); @@ -351,13 +471,38 @@ describe('SponsorService', () => { respondedAt: new Date(), rejectionReason: reason, }; - rejectSponsorshipRequestUseCase.execute.mockResolvedValue(Result.ok(output)); + + rejectSponsorshipRequestUseCase.execute.mockImplementation(async () => { + rejectSponsorshipRequestPresenter.present(output as any); + return Result.ok(undefined); + }); const result = await service.rejectSponsorshipRequest(requestId, respondedBy, reason); expect(result).toEqual(output); }); + it('passes no reason when reason is undefined', async () => { + const requestId = 'r1'; + const respondedBy = 'u1'; + + rejectSponsorshipRequestUseCase.execute.mockImplementation(async (input: any) => { + expect(input).toEqual({ requestId, respondedBy }); + rejectSponsorshipRequestPresenter.present({ + requestId, + status: 'rejected' as const, + respondedAt: new Date(), + rejectionReason: '', + } as any); + return Result.ok(undefined); + }); + + await expect(service.rejectSponsorshipRequest(requestId, respondedBy)).resolves.toMatchObject({ + requestId, + status: 'rejected', + }); + }); + it('throws on error', async () => { const requestId = 'r1'; const respondedBy = 'u1'; @@ -365,7 +510,19 @@ describe('SponsorService', () => { Result.err({ code: 'SPONSORSHIP_REQUEST_NOT_FOUND' }), ); - await expect(service.rejectSponsorshipRequest(requestId, respondedBy)).rejects.toThrow('Reject sponsorship request failed'); + await expect(service.rejectSponsorshipRequest(requestId, respondedBy)).rejects.toThrow( + 'Reject sponsorship request failed', + ); + }); + + it('throws when presenter viewModel is missing on success', async () => { + const requestId = 'r1'; + const respondedBy = 'u1'; + rejectSponsorshipRequestUseCase.execute.mockResolvedValue(Result.ok(undefined)); + + await expect(service.rejectSponsorshipRequest(requestId, respondedBy)).rejects.toThrow( + 'Reject sponsorship request failed', + ); }); }); @@ -395,6 +552,11 @@ describe('SponsorService', () => { expect(result.invoices).toBeInstanceOf(Array); expect(result.stats).toBeDefined(); }); + + it('throws on error', async () => { + getSponsorBillingUseCase.execute.mockResolvedValue(Result.err({ code: 'REPOSITORY_ERROR' } as any)); + await expect(service.getSponsorBilling('s1')).rejects.toThrow('Sponsor billing not found'); + }); }); describe('getAvailableLeagues', () => { diff --git a/apps/api/src/domain/sponsor/SponsorService.ts b/apps/api/src/domain/sponsor/SponsorService.ts index 437300428..038fc3aab 100644 --- a/apps/api/src/domain/sponsor/SponsorService.ts +++ b/apps/api/src/domain/sponsor/SponsorService.ts @@ -130,19 +130,39 @@ export class SponsorService { async getEntitySponsorshipPricing(): Promise { this.logger.debug('[SponsorService] Fetching sponsorship pricing.'); - await this.getSponsorshipPricingUseCase.execute({}); + const result = await this.getSponsorshipPricingUseCase.execute({}); + + if (result.isErr()) { + return { + entityType: 'season', + entityId: '', + pricing: [], + }; + } + return this.getEntitySponsorshipPricingPresenter.viewModel; } async getSponsors(): Promise { this.logger.debug('[SponsorService] Fetching sponsors.'); - await this.getSponsorsUseCase.execute(); + const result = await this.getSponsorsUseCase.execute(); + + if (result.isErr()) { + return { sponsors: [] }; + } + return this.getSponsorsPresenter.responseModel; } async createSponsor(input: CreateSponsorInputDTO): Promise { this.logger.debug('[SponsorService] Creating sponsor.', { input }); - await this.createSponsorUseCase.execute(input); + const result = await this.createSponsorUseCase.execute(input); + + if (result.isErr()) { + const error = result.unwrapErr() as { details?: { message?: string }; message?: string }; + throw new Error(error.details?.message ?? error.message ?? 'Failed to create sponsor'); + } + return this.createSponsorPresenter.viewModel; } @@ -150,34 +170,41 @@ export class SponsorService { params: GetSponsorDashboardQueryParamsDTO, ): Promise { this.logger.debug('[SponsorService] Fetching sponsor dashboard.', { params }); - await this.getSponsorDashboardUseCase.execute(params); - const result = this.getSponsorDashboardPresenter.viewModel; - if (!result) { + const result = await this.getSponsorDashboardUseCase.execute(params); + + if (result.isErr()) { throw new Error('Sponsor dashboard not found'); } - return result; + + return this.getSponsorDashboardPresenter.viewModel; } async getSponsorSponsorships( params: GetSponsorSponsorshipsQueryParamsDTO, ): Promise { this.logger.debug('[SponsorService] Fetching sponsor sponsorships.', { params }); - await this.getSponsorSponsorshipsUseCase.execute(params); - const result = this.getSponsorSponsorshipsPresenter.viewModel; - if (!result) { + const result = await this.getSponsorSponsorshipsUseCase.execute(params); + + if (result.isErr()) { throw new Error('Sponsor sponsorships not found'); } - return result; + + return this.getSponsorSponsorshipsPresenter.viewModel; } async getSponsor(sponsorId: string): Promise { this.logger.debug('[SponsorService] Fetching sponsor.', { sponsorId }); - await this.getSponsorUseCase.execute({ sponsorId }); - const result = this.getSponsorPresenter.viewModel; - if (!result) { + const result = await this.getSponsorUseCase.execute({ sponsorId }); + + if (result.isErr()) { throw new Error('Sponsor not found'); } - return result; + + const viewModel = this.getSponsorPresenter.viewModel; + if (!viewModel) { + throw new Error('Sponsor not found'); + } + return viewModel; } async getPendingSponsorshipRequests(params: { @@ -185,14 +212,26 @@ export class SponsorService { entityId: string; }): Promise { this.logger.debug('[SponsorService] Fetching pending sponsorship requests.', { params }); - await this.getPendingSponsorshipRequestsUseCase.execute( + + const result = await this.getPendingSponsorshipRequestsUseCase.execute( params as GetPendingSponsorshipRequestsInput, ); - const result = this.getPendingSponsorshipRequestsPresenter.viewModel; - if (!result) { + + if (result.isErr()) { + return { + entityType: params.entityType, + entityId: params.entityId, + requests: [], + totalCount: 0, + }; + } + + const viewModel = this.getPendingSponsorshipRequestsPresenter.viewModel; + if (!viewModel) { throw new Error('Pending sponsorship requests not found'); } - return result; + + return viewModel; } async acceptSponsorshipRequest( @@ -203,15 +242,22 @@ export class SponsorService { requestId, respondedBy, }); - await this.acceptSponsorshipRequestUseCase.execute({ + + const result = await this.acceptSponsorshipRequestUseCase.execute({ requestId, respondedBy, }); - const result = this.acceptSponsorshipRequestPresenter.viewModel; - if (!result) { + + if (result.isErr()) { throw new Error('Accept sponsorship request failed'); } - return result; + + const viewModel = this.acceptSponsorshipRequestPresenter.viewModel; + if (!viewModel) { + throw new Error('Accept sponsorship request failed'); + } + + return viewModel; } async rejectSponsorshipRequest( @@ -231,12 +277,19 @@ export class SponsorService { if (reason !== undefined) { input.reason = reason; } - await this.rejectSponsorshipRequestUseCase.execute(input); - const result = this.rejectSponsorshipRequestPresenter.viewModel; - if (!result) { + + const result = await this.rejectSponsorshipRequestUseCase.execute(input); + + if (result.isErr()) { throw new Error('Reject sponsorship request failed'); } - return result; + + const viewModel = this.rejectSponsorshipRequestPresenter.viewModel; + if (!viewModel) { + throw new Error('Reject sponsorship request failed'); + } + + return viewModel; } async getSponsorBilling(sponsorId: string): Promise<{ @@ -245,12 +298,13 @@ export class SponsorService { stats: BillingStatsDTO; }> { this.logger.debug('[SponsorService] Fetching sponsor billing.', { sponsorId }); - await this.getSponsorBillingUseCase.execute({ sponsorId }); - const result = this.sponsorBillingPresenter.viewModel; - if (!result) { + const result = await this.getSponsorBillingUseCase.execute({ sponsorId }); + + if (result.isErr()) { throw new Error('Sponsor billing not found'); } - return result; + + return this.sponsorBillingPresenter.viewModel; } async getAvailableLeagues(): Promise { diff --git a/apps/api/src/domain/sponsor/presenters/GetEntitySponsorshipPricingPresenter.ts b/apps/api/src/domain/sponsor/presenters/GetEntitySponsorshipPricingPresenter.ts index b3710bde5..db0d80c65 100644 --- a/apps/api/src/domain/sponsor/presenters/GetEntitySponsorshipPricingPresenter.ts +++ b/apps/api/src/domain/sponsor/presenters/GetEntitySponsorshipPricingPresenter.ts @@ -24,8 +24,8 @@ export class GetEntitySponsorshipPricingPresenter { pricing: output.tiers.map(item => ({ id: item.name, level: item.name, - price: item.price, - currency: 'USD', + price: item.price.amount, + currency: item.price.currency, })), }; } diff --git a/apps/api/src/domain/sponsor/presenters/GetSponsorDashboardPresenter.ts b/apps/api/src/domain/sponsor/presenters/GetSponsorDashboardPresenter.ts index f2d6dd882..1843dbdbd 100644 --- a/apps/api/src/domain/sponsor/presenters/GetSponsorDashboardPresenter.ts +++ b/apps/api/src/domain/sponsor/presenters/GetSponsorDashboardPresenter.ts @@ -48,7 +48,8 @@ export class GetSponsorDashboardPresenter { return this.result; } - get viewModel(): SponsorDashboardDTO | null { + get viewModel(): SponsorDashboardDTO { + if (!this.result) throw new Error('Presenter not presented'); return this.result; } } diff --git a/apps/api/src/domain/sponsor/presenters/GetSponsorSponsorshipsPresenter.ts b/apps/api/src/domain/sponsor/presenters/GetSponsorSponsorshipsPresenter.ts index 552352d2b..9d3a38732 100644 --- a/apps/api/src/domain/sponsor/presenters/GetSponsorSponsorshipsPresenter.ts +++ b/apps/api/src/domain/sponsor/presenters/GetSponsorSponsorshipsPresenter.ts @@ -66,7 +66,8 @@ export class GetSponsorSponsorshipsPresenter { return this.result; } - get viewModel(): SponsorSponsorshipsDTO | null { + get viewModel(): SponsorSponsorshipsDTO { + if (!this.result) throw new Error('Presenter not presented'); return this.result; } } diff --git a/apps/api/src/domain/sponsor/presenters/GetSponsorsPresenter.test.ts b/apps/api/src/domain/sponsor/presenters/GetSponsorsPresenter.test.ts index a4db24c36..3ac076c43 100644 --- a/apps/api/src/domain/sponsor/presenters/GetSponsorsPresenter.test.ts +++ b/apps/api/src/domain/sponsor/presenters/GetSponsorsPresenter.test.ts @@ -27,7 +27,7 @@ describe('GetSponsorsPresenter', () => { id: 'sponsor-1', name: 'Sponsor One', contactEmail: 's1@example.com', - logoUrl: 'logo1.png', + logoUrl: 'https://one.example.com/logo1.png', websiteUrl: 'https://one.example.com', createdAt: new Date('2024-01-01T00:00:00Z'), }), @@ -46,7 +46,7 @@ describe('GetSponsorsPresenter', () => { id: 'sponsor-1', name: 'Sponsor One', contactEmail: 's1@example.com', - logoUrl: 'logo1.png', + logoUrl: 'https://one.example.com/logo1.png', websiteUrl: 'https://one.example.com', createdAt: new Date('2024-01-01T00:00:00Z'), }, diff --git a/apps/api/src/domain/team/TeamController.ts b/apps/api/src/domain/team/TeamController.ts index ab59394e7..a81f26493 100644 --- a/apps/api/src/domain/team/TeamController.ts +++ b/apps/api/src/domain/team/TeamController.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Post, Patch, Body, Req, Param } from '@nestjs/common'; +import { Controller, Get, Post, Patch, Body, Req, Param, Inject } from '@nestjs/common'; import { Request } from 'express'; import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger'; import { TeamService } from './TeamService'; @@ -16,7 +16,7 @@ import { GetTeamMembershipOutputDTO } from './dtos/GetTeamMembershipOutputDTO'; @ApiTags('teams') @Controller('teams') export class TeamController { - constructor(private readonly teamService: TeamService) {} + constructor(@Inject(TeamService) private readonly teamService: TeamService) {} @Get('all') @ApiOperation({ summary: 'Get all teams' }) diff --git a/apps/api/src/domain/team/TeamModule.ts b/apps/api/src/domain/team/TeamModule.ts index 84eeb9515..d82943e3d 100644 --- a/apps/api/src/domain/team/TeamModule.ts +++ b/apps/api/src/domain/team/TeamModule.ts @@ -5,7 +5,7 @@ import { TeamProviders } from './TeamProviders'; @Module({ controllers: [TeamController], - providers: TeamProviders, + providers: [TeamService, ...TeamProviders], exports: [TeamService], }) export class TeamModule {} \ No newline at end of file diff --git a/apps/api/src/domain/team/TeamProviders.ts b/apps/api/src/domain/team/TeamProviders.ts index f62d7e6ca..12741a985 100644 --- a/apps/api/src/domain/team/TeamProviders.ts +++ b/apps/api/src/domain/team/TeamProviders.ts @@ -1,5 +1,20 @@ import { Provider } from '@nestjs/common'; -import { TeamService } from './TeamService'; + +import { + TEAM_REPOSITORY_TOKEN, + TEAM_MEMBERSHIP_REPOSITORY_TOKEN, + DRIVER_REPOSITORY_TOKEN, + IMAGE_SERVICE_TOKEN, + LOGGER_TOKEN, +} from './TeamTokens'; + +export { + TEAM_REPOSITORY_TOKEN, + TEAM_MEMBERSHIP_REPOSITORY_TOKEN, + DRIVER_REPOSITORY_TOKEN, + IMAGE_SERVICE_TOKEN, + LOGGER_TOKEN, +} from './TeamTokens'; // Import core interfaces import type { Logger } from '@core/shared/application/Logger'; @@ -13,15 +28,7 @@ import { ConsoleLogger } from '@adapters/logging/ConsoleLogger'; // Use cases are imported and used directly in the service -// Define injection tokens -export const TEAM_REPOSITORY_TOKEN = 'ITeamRepository'; -export const TEAM_MEMBERSHIP_REPOSITORY_TOKEN = 'ITeamMembershipRepository'; -export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository'; -export const IMAGE_SERVICE_TOKEN = 'IImageServicePort'; -export const LOGGER_TOKEN = 'Logger'; - export const TeamProviders: Provider[] = [ - TeamService, // Provide the service itself { provide: TEAM_REPOSITORY_TOKEN, useFactory: (logger: Logger) => new InMemoryTeamRepository(logger), diff --git a/apps/api/src/domain/team/TeamService.test.ts b/apps/api/src/domain/team/TeamService.test.ts index 57d57f17a..1a61ec59e 100644 --- a/apps/api/src/domain/team/TeamService.test.ts +++ b/apps/api/src/domain/team/TeamService.test.ts @@ -1,107 +1,531 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { vi } from 'vitest'; -import { TeamService } from './TeamService'; +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import type { Logger } from '@core/shared/application/Logger'; +import { Result } from '@core/shared/application/Result'; import { GetAllTeamsUseCase } from '@core/racing/application/use-cases/GetAllTeamsUseCase'; +import { GetTeamDetailsUseCase } from '@core/racing/application/use-cases/GetTeamDetailsUseCase'; +import { GetTeamMembersUseCase } from '@core/racing/application/use-cases/GetTeamMembersUseCase'; +import { GetTeamJoinRequestsUseCase } from '@core/racing/application/use-cases/GetTeamJoinRequestsUseCase'; +import { CreateTeamUseCase } from '@core/racing/application/use-cases/CreateTeamUseCase'; +import { UpdateTeamUseCase } from '@core/racing/application/use-cases/UpdateTeamUseCase'; import { GetDriverTeamUseCase } from '@core/racing/application/use-cases/GetDriverTeamUseCase'; +import { GetTeamMembershipUseCase } from '@core/racing/application/use-cases/GetTeamMembershipUseCase'; +import type { CreateTeamInputDTO } from './dtos/CreateTeamInputDTO'; +import type { UpdateTeamInputDTO } from './dtos/UpdateTeamInputDTO'; +import { TeamService } from './TeamService'; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { AllTeamsPresenter } from './presenters/AllTeamsPresenter'; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { DriverTeamPresenter } from './presenters/DriverTeamPresenter'; -import { DriverTeamViewModel } from './dtos/TeamDto'; +type ValueObjectStub = { props: string; toString(): string }; + +type TeamEntityStub = { + id: string; + name: ValueObjectStub; + tag: ValueObjectStub; + description: ValueObjectStub; + ownerId: ValueObjectStub; + leagues: ValueObjectStub[]; + createdAt: { toDate(): Date }; + update: Mock; +}; describe('TeamService', () => { - let service: TeamService; - let getAllTeamsUseCase: ReturnType>; - let getDriverTeamUseCase: ReturnType>; + const makeValueObject = (value: string): ValueObjectStub => ({ + props: value, + toString() { + return value; + }, + }); - beforeEach(async () => { - const mockGetAllTeamsUseCase = { - execute: vi.fn(), + const makeTeam = (overrides?: Partial>): TeamEntityStub => { + const base: Omit = { + 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> = { + ...(overrides ?? {}), + ...(updates.name !== undefined ? { name: makeValueObject(updates.name) } : {}), + ...(updates.tag !== undefined ? { tag: makeValueObject(updates.tag) } : {}), + ...(updates.description !== undefined ? { description: makeValueObject(updates.description) } : {}), + }; + + return makeTeam(next); + }), }; - const mockLogger = { + + return team; + }; + + let teamRepository: { + findAll: Mock; + findById: Mock; + create: Mock; + update: Mock; + }; + let membershipRepository: { + countByTeamId: Mock; + getActiveMembershipForDriver: Mock; + getMembership: Mock; + getTeamMembers: Mock; + getJoinRequests: Mock; + saveMembership: Mock; + }; + let driverRepository: { + findById: Mock; + }; + let logger: Logger; + let service: TeamService; + + beforeEach(() => { + teamRepository = { + findAll: vi.fn(), + findById: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }; + + membershipRepository = { + countByTeamId: vi.fn(), + getActiveMembershipForDriver: vi.fn(), + getMembership: vi.fn(), + getTeamMembers: vi.fn(), + getJoinRequests: vi.fn(), + saveMembership: vi.fn(), + }; + + driverRepository = { + findById: vi.fn(), + }; + + logger = { debug: vi.fn(), info: vi.fn(), + warn: vi.fn(), error: vi.fn(), - }; + } as unknown as Logger; - const module: TestingModule = await Test.createTestingModule({ - providers: [ - TeamService, + service = new TeamService(teamRepository as unknown as never, membershipRepository as unknown as never, driverRepository as unknown as never, logger); + }); + + it('getAll returns teams and totalCount on success', async () => { + teamRepository.findAll.mockResolvedValue([makeTeam()]); + membershipRepository.countByTeamId.mockResolvedValue(3); + + await expect(service.getAll()).resolves.toEqual({ + teams: [ { - provide: GetAllTeamsUseCase, - useValue: mockGetAllTeamsUseCase, - }, - { - provide: GetDriverTeamUseCase, - useValue: mockGetDriverTeamUseCase, - }, - { - provide: 'Logger', - useValue: mockLogger, + id: 'team-1', + name: 'Team One', + tag: 'T1', + description: 'Desc', + memberCount: 3, + leagues: ['league-1'], }, ], - }).compile(); - - service = module.get(TeamService); - getAllTeamsUseCase = module.get(GetAllTeamsUseCase); - getDriverTeamUseCase = module.get(GetDriverTeamUseCase); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('getAll', () => { - it('should call use case and return result', async () => { - const mockResult = { isOk: () => true, value: { teams: [], totalCount: 0 } }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getAllTeamsUseCase.execute.mockResolvedValue(mockResult as any); - - const mockPresenter = { - present: vi.fn(), - getViewModel: vi.fn().mockReturnValue({ teams: [], totalCount: 0 }), - }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (AllTeamsPresenter as any) = vi.fn().mockImplementation(() => mockPresenter); - - const result = await service.getAll(); - - expect(getAllTeamsUseCase.execute).toHaveBeenCalled(); - expect(result).toEqual({ teams: [], totalCount: 0 }); + totalCount: 1, }); }); - describe('getDriverTeam', () => { - it('should call use case and return result', async () => { - const mockResult = { isOk: () => true, value: {} }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getDriverTeamUseCase.execute.mockResolvedValue(mockResult as any); + it('getAll returns empty list when use case error message is empty (covers fallback)', async () => { + const executeSpy = vi + .spyOn(GetAllTeamsUseCase.prototype, 'execute') + .mockResolvedValueOnce(Result.err({ code: 'REPOSITORY_ERROR', details: { message: '' } })); - const mockPresenter = { - present: vi.fn(), - getViewModel: vi.fn().mockReturnValue({} as DriverTeamViewModel), - }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (DriverTeamPresenter as any) = vi.fn().mockImplementation(() => mockPresenter); + await expect(service.getAll()).resolves.toEqual({ teams: [], totalCount: 0 }); - const result = await service.getDriverTeam('driver1'); + executeSpy.mockRestore(); + }); - expect(getDriverTeamUseCase.execute).toHaveBeenCalledWith({ driverId: 'driver1' }); - expect(result).toEqual({}); + it('getAll returns empty list when repository throws', async () => { + teamRepository.findAll.mockRejectedValue(new Error('boom')); + await expect(service.getAll()).resolves.toEqual({ teams: [], totalCount: 0 }); + }); + + it('getDetails returns DTO on success', async () => { + teamRepository.findById.mockResolvedValue(makeTeam()); + membershipRepository.getMembership.mockResolvedValue({ + teamId: 'team-1', + driverId: 'driver-1', + role: 'driver', + status: 'active', + joinedAt: new Date('2023-02-01T00:00:00.000Z'), }); - it('should return null on error', async () => { - const mockResult = { isErr: () => true, error: {} }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getDriverTeamUseCase.execute.mockResolvedValue(mockResult as any); - - const result = await service.getDriverTeam('driver1'); - - expect(result).toBeNull(); + await expect(service.getDetails('team-1', 'driver-1')).resolves.toEqual({ + team: { + id: 'team-1', + name: 'Team One', + tag: 'T1', + description: 'Desc', + ownerId: 'owner-1', + leagues: ['league-1'], + createdAt: '2023-01-01T00:00:00.000Z', + }, + membership: { + role: 'member', + joinedAt: '2023-02-01T00:00:00.000Z', + isActive: true, + }, + canManage: false, }); }); + + it('getDetails passes empty driverId when userId missing', async () => { + const executeSpy = vi + .spyOn(GetTeamDetailsUseCase.prototype, 'execute') + .mockImplementationOnce(async (input) => { + expect(input).toEqual({ teamId: 'team-1', driverId: '' }); + return Result.err({ code: 'REPOSITORY_ERROR', details: { message: '' } }); + }); + + await expect(service.getDetails('team-1')).resolves.toBeNull(); + + executeSpy.mockRestore(); + }); + + it('getDetails returns null on TEAM_NOT_FOUND', async () => { + teamRepository.findById.mockResolvedValue(null); + await expect(service.getDetails('team-1', 'driver-1')).resolves.toBeNull(); + }); + + it('getMembers returns DTO on success (filters missing drivers)', async () => { + teamRepository.findById.mockResolvedValue(makeTeam()); + membershipRepository.getTeamMembers.mockResolvedValue([ + { + teamId: 'team-1', + driverId: 'd1', + role: 'driver', + status: 'active', + joinedAt: new Date('2023-02-01T00:00:00.000Z'), + }, + { + teamId: 'team-1', + driverId: 'd2', + role: 'owner', + status: 'active', + joinedAt: new Date('2023-02-02T00:00:00.000Z'), + }, + ]); + + driverRepository.findById.mockImplementation(async (id: string) => { + if (id === 'd1') return { id: 'd1', name: makeValueObject('Driver One') }; + return null; + }); + + await expect(service.getMembers('team-1')).resolves.toEqual({ + members: [ + { + driverId: 'd1', + driverName: 'Driver One', + role: 'member', + joinedAt: '2023-02-01T00:00:00.000Z', + isActive: true, + avatarUrl: '', + }, + ], + totalCount: 1, + ownerCount: 1, + managerCount: 0, + memberCount: 1, + }); + }); + + it('getMembers returns empty DTO on error', async () => { + teamRepository.findById.mockResolvedValue(null); + + await expect(service.getMembers('team-1')).resolves.toEqual({ + members: [], + totalCount: 0, + ownerCount: 0, + managerCount: 0, + memberCount: 0, + }); + }); + + it('getMembers returns empty DTO when use case error message is empty (covers fallback)', async () => { + const executeSpy = vi + .spyOn(GetTeamMembersUseCase.prototype, 'execute') + .mockResolvedValueOnce(Result.err({ code: 'REPOSITORY_ERROR', details: { message: '' } })); + + await expect(service.getMembers('team-1')).resolves.toEqual({ + members: [], + totalCount: 0, + ownerCount: 0, + managerCount: 0, + memberCount: 0, + }); + + executeSpy.mockRestore(); + }); + + it('getJoinRequests returns DTO on success', async () => { + teamRepository.findById.mockResolvedValue(makeTeam()); + membershipRepository.getJoinRequests.mockResolvedValue([ + { + id: 'jr1', + teamId: 'team-1', + driverId: 'd1', + requestedAt: new Date('2023-02-03T00:00:00.000Z'), + }, + ]); + driverRepository.findById.mockResolvedValue({ id: 'd1', name: makeValueObject('Driver One') }); + + await expect(service.getJoinRequests('team-1')).resolves.toEqual({ + requests: [ + { + requestId: 'jr1', + driverId: 'd1', + driverName: 'Driver One', + teamId: 'team-1', + status: 'pending', + requestedAt: '2023-02-03T00:00:00.000Z', + avatarUrl: '', + }, + ], + pendingCount: 1, + totalCount: 1, + }); + }); + + it('getJoinRequests returns empty DTO on error', async () => { + teamRepository.findById.mockResolvedValue(null); + + await expect(service.getJoinRequests('team-1')).resolves.toEqual({ + requests: [], + pendingCount: 0, + totalCount: 0, + }); + }); + + it('getJoinRequests returns empty DTO when use case error message is empty (covers fallback)', async () => { + const executeSpy = vi + .spyOn(GetTeamJoinRequestsUseCase.prototype, 'execute') + .mockResolvedValueOnce(Result.err({ code: 'REPOSITORY_ERROR', details: { message: '' } })); + + await expect(service.getJoinRequests('team-1')).resolves.toEqual({ + requests: [], + pendingCount: 0, + totalCount: 0, + }); + + executeSpy.mockRestore(); + }); + + it('create returns success on success', async () => { + membershipRepository.getActiveMembershipForDriver.mockResolvedValue(null); + teamRepository.create.mockImplementation(async (team: unknown) => team); + + const input: CreateTeamInputDTO = { name: 'N', tag: 'T', description: 'D' }; + + await expect(service.create(input, 'owner-1')).resolves.toEqual({ + id: expect.any(String), + success: true, + }); + expect(membershipRepository.saveMembership).toHaveBeenCalledTimes(1); + }); + + it('create returns failure DTO on error', async () => { + membershipRepository.getActiveMembershipForDriver.mockResolvedValue({ teamId: 'team-1' }); + + const input: CreateTeamInputDTO = { name: 'N', tag: 'T' }; + + await expect(service.create(input, 'owner-1')).resolves.toEqual({ + id: '', + success: false, + }); + }); + + it('create uses empty description and ownerId when missing', async () => { + const executeSpy = vi + .spyOn(CreateTeamUseCase.prototype, 'execute') + .mockImplementationOnce(async (command) => { + expect(command).toMatchObject({ description: '', ownerId: '' }); + return Result.err({ code: 'REPOSITORY_ERROR', details: { message: '' } }); + }); + + const input: CreateTeamInputDTO = { name: 'N', tag: 'T' }; + + await expect(service.create(input)).resolves.toEqual({ id: '', success: false }); + + executeSpy.mockRestore(); + }); + + it('update returns success on success', async () => { + membershipRepository.getMembership.mockResolvedValue({ role: 'owner' }); + teamRepository.findById.mockResolvedValue(makeTeam()); + teamRepository.update.mockResolvedValue(undefined); + + const input: UpdateTeamInputDTO = { name: 'New Name' }; + + await expect(service.update('team-1', input, 'owner-1')).resolves.toEqual({ success: true }); + + expect(teamRepository.update).toHaveBeenCalledTimes(1); + }); + + it('update returns failure DTO on error', async () => { + membershipRepository.getMembership.mockResolvedValue(null); + + const input: UpdateTeamInputDTO = { name: 'New Name' }; + + await expect(service.update('team-1', input, 'owner-1')).resolves.toEqual({ success: false }); + }); + + it('update uses empty updatedBy when userId missing', async () => { + const executeSpy = vi + .spyOn(UpdateTeamUseCase.prototype, 'execute') + .mockImplementationOnce(async (command) => { + expect(command.updatedBy).toBe(''); + return Result.err({ code: 'REPOSITORY_ERROR', details: { message: '' } }); + }); + + const input: UpdateTeamInputDTO = { name: 'New Name' }; + + await expect(service.update('team-1', input)).resolves.toEqual({ success: false }); + + executeSpy.mockRestore(); + }); + + it('update includes name when provided', async () => { + const executeSpy = vi + .spyOn(UpdateTeamUseCase.prototype, 'execute') + .mockImplementationOnce(async (command) => { + expect(command.updates).toEqual({ name: 'New Name' }); + return Result.err({ code: 'REPOSITORY_ERROR', details: { message: '' } }); + }); + + const input: UpdateTeamInputDTO = { name: 'New Name' }; + + await expect(service.update('team-1', input, 'owner-1')).resolves.toEqual({ success: false }); + + executeSpy.mockRestore(); + }); + + it('update includes tag when provided', async () => { + const executeSpy = vi + .spyOn(UpdateTeamUseCase.prototype, 'execute') + .mockImplementationOnce(async (command) => { + expect(command.updates).toEqual({ tag: 'NEW' }); + return Result.err({ code: 'REPOSITORY_ERROR', details: { message: '' } }); + }); + + const input: UpdateTeamInputDTO = { tag: 'NEW' }; + + await expect(service.update('team-1', input, 'owner-1')).resolves.toEqual({ success: false }); + + executeSpy.mockRestore(); + }); + + it('update includes description when provided', async () => { + const executeSpy = vi + .spyOn(UpdateTeamUseCase.prototype, 'execute') + .mockImplementationOnce(async (command) => { + expect(command.updates).toEqual({ description: 'D' }); + return Result.err({ code: 'REPOSITORY_ERROR', details: { message: '' } }); + }); + + const input: UpdateTeamInputDTO = { description: 'D' }; + + await expect(service.update('team-1', input, 'owner-1')).resolves.toEqual({ success: false }); + + executeSpy.mockRestore(); + }); + + it('update includes no updates when no fields are provided', async () => { + const executeSpy = vi + .spyOn(UpdateTeamUseCase.prototype, 'execute') + .mockImplementationOnce(async (command) => { + expect(command.updates).toEqual({}); + return Result.err({ code: 'REPOSITORY_ERROR', details: { message: '' } }); + }); + + const input: UpdateTeamInputDTO = {}; + + await expect(service.update('team-1', input, 'owner-1')).resolves.toEqual({ success: false }); + + executeSpy.mockRestore(); + }); + + it('getDriverTeam returns driver team DTO on success', async () => { + membershipRepository.getActiveMembershipForDriver.mockResolvedValue({ + teamId: 'team-1', + role: 'driver', + status: 'active', + joinedAt: new Date('2023-02-01T00:00:00.000Z'), + }); + teamRepository.findById.mockResolvedValue(makeTeam()); + + await expect(service.getDriverTeam('driver-1')).resolves.toEqual({ + team: { + id: 'team-1', + name: 'Team One', + tag: 'T1', + description: 'Desc', + ownerId: 'owner-1', + leagues: ['league-1'], + createdAt: '2023-01-01T00:00:00.000Z', + }, + membership: { + role: 'member', + joinedAt: '2023-02-01T00:00:00.000Z', + isActive: true, + }, + isOwner: false, + canManage: false, + }); + }); + + it('getDriverTeam returns null when membership is missing', async () => { + membershipRepository.getActiveMembershipForDriver.mockResolvedValue(null); + + await expect(service.getDriverTeam('driver-1')).resolves.toBeNull(); + expect(teamRepository.findById).not.toHaveBeenCalled(); + }); + + it('getDriverTeam returns null when use case error message is empty (covers fallback)', async () => { + const executeSpy = vi + .spyOn(GetDriverTeamUseCase.prototype, 'execute') + .mockResolvedValueOnce(Result.err({ code: 'REPOSITORY_ERROR', details: { message: '' } })); + + await expect(service.getDriverTeam('driver-1')).resolves.toBeNull(); + + executeSpy.mockRestore(); + }); + + it('getMembership returns presenter model on success', async () => { + membershipRepository.getMembership.mockResolvedValue({ + teamId: 'team-1', + driverId: 'd1', + role: 'driver', + status: 'active', + joinedAt: new Date('2023-02-01T00:00:00.000Z'), + }); + + await expect(service.getMembership('team-1', 'd1')).resolves.toEqual({ + role: 'member', + joinedAt: '2023-02-01T00:00:00.000Z', + isActive: true, + }); + }); + + it('getMembership returns null when missing', async () => { + membershipRepository.getMembership.mockResolvedValue(null); + await expect(service.getMembership('team-1', 'd1')).resolves.toBeNull(); + }); + + it('getMembership returns null when use case error message is empty (covers fallback)', async () => { + const executeSpy = vi + .spyOn(GetTeamMembershipUseCase.prototype, 'execute') + .mockResolvedValueOnce(Result.err({ code: 'REPOSITORY_ERROR', details: { message: '' } })); + + await expect(service.getMembership('team-1', 'd1')).resolves.toBeNull(); + + executeSpy.mockRestore(); + }); }); \ No newline at end of file diff --git a/apps/api/src/domain/team/TeamService.ts b/apps/api/src/domain/team/TeamService.ts index 30821b664..701211b10 100644 --- a/apps/api/src/domain/team/TeamService.ts +++ b/apps/api/src/domain/team/TeamService.ts @@ -37,7 +37,7 @@ import { CreateTeamPresenter } from './presenters/CreateTeamPresenter'; import { UpdateTeamPresenter } from './presenters/UpdateTeamPresenter'; // Tokens -import { TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN } from './TeamProviders'; +import { TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN } from './TeamTokens'; @Injectable() export class TeamService { @@ -187,6 +187,6 @@ export class TeamService { return null; } - return presenter.responseModel; + return presenter.getResponseModel(); } } \ No newline at end of file diff --git a/apps/api/src/domain/team/TeamTokens.ts b/apps/api/src/domain/team/TeamTokens.ts new file mode 100644 index 000000000..950f447f5 --- /dev/null +++ b/apps/api/src/domain/team/TeamTokens.ts @@ -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'; \ No newline at end of file diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index d6eac5e33..0d680a3ad 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,6 +1,7 @@ import 'reflect-metadata'; // For NestJS DI (before any other imports) +import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { writeFileSync } from 'fs'; @@ -10,6 +11,14 @@ import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule, process.env.GENERATE_OPENAPI ? { logger: false } : undefined); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + // Swagger/OpenAPI configuration const config = new DocumentBuilder() .setTitle('GridPilot API') diff --git a/apps/api/src/shared/testing/httpContractHarness.ts b/apps/api/src/shared/testing/httpContractHarness.ts new file mode 100644 index 000000000..e844a9dd8 --- /dev/null +++ b/apps/api/src/shared/testing/httpContractHarness.ts @@ -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; + close: () => Promise; +}; + +export async function createHttpContractHarness(options: { + controllers: Array>; + providers?: Provider[]; + overrides?: ProviderOverride[]; +}): Promise { + 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(); + }, + }; +} \ No newline at end of file diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 99138cb6d..3662362f7 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -39,7 +39,8 @@ "types": [ "node", "express", - "vitest/globals" + "vitest/globals", + "supertest" ] }, "exclude": [ diff --git a/core/analytics/application/use-cases/RecordPageViewUseCase.test.ts b/core/analytics/application/use-cases/RecordPageViewUseCase.test.ts index af0bfc295..041b24a3d 100644 --- a/core/analytics/application/use-cases/RecordPageViewUseCase.test.ts +++ b/core/analytics/application/use-cases/RecordPageViewUseCase.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, vi, type Mock } from 'vitest'; import { RecordPageViewUseCase, type RecordPageViewInput, type RecordPageViewOutput } from './RecordPageViewUseCase'; -import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository'; import { PageView } from '../../domain/entities/PageView'; import type { Logger, UseCaseOutputPort } from '@core/shared/application'; import type { EntityType, VisitorType } from '../../domain/types/PageView'; @@ -14,6 +13,8 @@ describe('RecordPageViewUseCase', () => { let useCase: RecordPageViewUseCase; beforeEach(() => { + type PageViewRepository = ConstructorParameters[0]; + pageViewRepository = { save: vi.fn(), }; @@ -30,7 +31,7 @@ describe('RecordPageViewUseCase', () => { }; useCase = new RecordPageViewUseCase( - pageViewRepository as unknown as IPageViewRepository, + pageViewRepository as unknown as PageViewRepository, logger, output, ); diff --git a/core/analytics/application/use-cases/RecordPageViewUseCase.ts b/core/analytics/application/use-cases/RecordPageViewUseCase.ts index e8554eb66..4884fca1a 100644 --- a/core/analytics/application/use-cases/RecordPageViewUseCase.ts +++ b/core/analytics/application/use-cases/RecordPageViewUseCase.ts @@ -1,5 +1,5 @@ import type { Logger, UseCaseOutputPort, UseCase } from '@core/shared/application'; -import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository'; +import type { IPageViewRepository } from '../repositories/IPageViewRepository'; import { PageView } from '../../domain/entities/PageView'; import type { EntityType, VisitorType } from '../../domain/types/PageView'; import { Result } from '@core/shared/application/Result'; diff --git a/package-lock.json b/package-lock.json index 041353f28..c94cfdc4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ ], "dependencies": { "@core/social": "file:core/social", - "@nestjs/swagger": "11.2.3", + "@nestjs/swagger": "7.4.2", "bcrypt": "^6.0.0", "electron-vite": "3.1.0", "next": "15.5.9", @@ -27,6 +27,10 @@ }, "devDependencies": { "@cucumber/cucumber": "^11.0.1", + "@nestjs/common": "10.4.20", + "@nestjs/core": "10.4.20", + "@nestjs/platform-express": "10.4.20", + "@nestjs/testing": "10.4.20", "@playwright/test": "^1.57.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", @@ -34,9 +38,11 @@ "@types/express": "^4.17.21", "@types/jsdom": "^27.0.0", "@types/node": "^24.10.1", + "@types/supertest": "^6.0.3", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", - "@vitest/ui": "^4.0.15", + "@vitest/coverage-v8": "^4.0.16", + "@vitest/ui": "^4.0.16", "cheerio": "^1.0.0", "commander": "^11.0.0", "electron": "^39.2.7", @@ -49,10 +55,11 @@ "openapi-typescript": "^7.4.3", "prettier": "^3.0.0", "puppeteer": "^24.31.0", + "supertest": "^7.1.4", "ts-node": "^10.9.2", "tsx": "^4.7.0", "typescript": "^5.9.3", - "vitest": "^4.0.15" + "vitest": "^4.0.16" }, "engines": { "node": ">=20.0.0" @@ -66,7 +73,7 @@ "@nestjs/common": "^10.4.20", "@nestjs/core": "^10.4.20", "@nestjs/platform-express": "^10.4.20", - "@nestjs/swagger": "^11.2.3", + "@nestjs/swagger": "^7.4.2", "@nestjs/typeorm": "^10.0.2", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", @@ -82,95 +89,6 @@ "ts-node-dev": "^2.0.0" } }, - "apps/api/node_modules/@nestjs/common": { - "version": "10.4.20", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.20.tgz", - "integrity": "sha512-hxJxZF7jcKGuUzM9EYbuES80Z/36piJbiqmPy86mk8qOn5gglFebBTvcx7PWVbRNSb4gngASYnefBj/Y2HAzpQ==", - "license": "MIT", - "dependencies": { - "file-type": "20.4.1", - "iterare": "1.2.1", - "tslib": "2.8.1", - "uid": "2.0.2" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nest" - }, - "peerDependencies": { - "class-transformer": "*", - "class-validator": "*", - "reflect-metadata": "^0.1.12 || ^0.2.0", - "rxjs": "^7.1.0" - }, - "peerDependenciesMeta": { - "class-transformer": { - "optional": true - }, - "class-validator": { - "optional": true - } - } - }, - "apps/api/node_modules/@nestjs/core": { - "version": "10.4.20", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.20.tgz", - "integrity": "sha512-kRdtyKA3+Tu70N3RQ4JgmO1E3LzAMs/eppj7SfjabC7TgqNWoS4RLhWl4BqmsNVmjj6D5jgfPVtHtgYkU3AfpQ==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@nuxtjs/opencollective": "0.3.2", - "fast-safe-stringify": "2.1.1", - "iterare": "1.2.1", - "path-to-regexp": "3.3.0", - "tslib": "2.8.1", - "uid": "2.0.2" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nest" - }, - "peerDependencies": { - "@nestjs/common": "^10.0.0", - "@nestjs/microservices": "^10.0.0", - "@nestjs/platform-express": "^10.0.0", - "@nestjs/websockets": "^10.0.0", - "reflect-metadata": "^0.1.12 || ^0.2.0", - "rxjs": "^7.1.0" - }, - "peerDependenciesMeta": { - "@nestjs/microservices": { - "optional": true - }, - "@nestjs/platform-express": { - "optional": true - }, - "@nestjs/websockets": { - "optional": true - } - } - }, - "apps/api/node_modules/@nestjs/platform-express": { - "version": "10.4.20", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.20.tgz", - "integrity": "sha512-rh97mX3rimyf4xLMLHuTOBKe6UD8LOJ14VlJ1F/PTd6C6ZK9Ak6EHuJvdaGcSFQhd3ZMBh3I6CuujKGW9pNdIg==", - "license": "MIT", - "dependencies": { - "body-parser": "1.20.3", - "cors": "2.8.5", - "express": "4.21.2", - "multer": "2.0.2", - "tslib": "2.8.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nest" - }, - "peerDependencies": { - "@nestjs/common": "^10.0.0", - "@nestjs/core": "^10.0.0" - } - }, "apps/api/node_modules/reflect-metadata": { "version": "0.1.14", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", @@ -198,6 +116,39 @@ "vite": "^6.4.1" } }, + "apps/companion/node_modules/@nestjs/swagger": { + "version": "11.2.3", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.2.3.tgz", + "integrity": "sha512-a0xFfjeqk69uHIUpP8u0ryn4cKuHdra2Ug96L858i0N200Hxho+n3j+TlQXyOF4EstLSGjTfxI1Xb2E1lUxeNg==", + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.16.0", + "@nestjs/mapped-types": "2.1.0", + "js-yaml": "4.1.1", + "lodash": "4.17.21", + "path-to-regexp": "8.3.0", + "swagger-ui-dist": "5.30.2" + }, + "peerDependencies": { + "@fastify/static": "^8.0.0", + "@nestjs/common": "^11.0.1", + "@nestjs/core": "^11.0.1", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "apps/companion/node_modules/@types/node": { "version": "22.19.1", "dev": true, @@ -206,6 +157,16 @@ "undici-types": "~6.21.0" } }, + "apps/companion/node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "apps/companion/node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -790,6 +751,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@borewit/text-codec": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.1.1.tgz", @@ -852,7 +823,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.9" @@ -865,7 +836,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", @@ -1583,6 +1554,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1615,6 +1587,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1647,6 +1620,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2497,15 +2471,13 @@ } }, "node_modules/@nestjs/common": { - "version": "11.1.9", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.9.tgz", - "integrity": "sha512-zDntUTReRbAThIfSp3dQZ9kKqI+LjgLp5YZN5c1bgNRDuoeLySAoZg46Bg1a+uV8TMgIRziHocglKGNzr6l+bQ==", + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.20.tgz", + "integrity": "sha512-hxJxZF7jcKGuUzM9EYbuES80Z/36piJbiqmPy86mk8qOn5gglFebBTvcx7PWVbRNSb4gngASYnefBj/Y2HAzpQ==", "license": "MIT", - "peer": true, "dependencies": { - "file-type": "21.1.0", + "file-type": "20.4.1", "iterare": "1.2.1", - "load-esm": "1.0.3", "tslib": "2.8.1", "uid": "2.0.2" }, @@ -2514,8 +2486,8 @@ "url": "https://opencollective.com/nest" }, "peerDependencies": { - "class-transformer": ">=0.4.1", - "class-validator": ">=0.13.2", + "class-transformer": "*", + "class-validator": "*", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, @@ -2528,71 +2500,29 @@ } } }, - "node_modules/@nestjs/common/node_modules/@tokenizer/inflate": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.3.1.tgz", - "integrity": "sha512-4oeoZEBQdLdt5WmP/hx1KZ6D3/Oid/0cUb2nk4F0pTDAWy+KCH3/EnAkZF/bvckWo8I33EqBm01lIPgmgc8rCA==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.1", - "fflate": "^0.8.2", - "token-types": "^6.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/@nestjs/common/node_modules/file-type": { - "version": "21.1.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.1.0.tgz", - "integrity": "sha512-boU4EHmP3JXkwDo4uhyBhTt5pPstxB6eEXKJBu2yu2l7aAMMm7QQYQEzssJmKReZYrFdFOJS8koVo6bXIBGDqA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@tokenizer/inflate": "^0.3.1", - "strtok3": "^10.3.1", - "token-types": "^6.0.0", - "uint8array-extras": "^1.4.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sindresorhus/file-type?sponsor=1" - } - }, "node_modules/@nestjs/core": { - "version": "11.1.9", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.9.tgz", - "integrity": "sha512-a00B0BM4X+9z+t3UxJqIZlemIwCQdYoPKrMcM+ky4z3pkqqG1eTWexjs+YXpGObnLnjtMPVKWlcZHp3adDYvUw==", + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.20.tgz", + "integrity": "sha512-kRdtyKA3+Tu70N3RQ4JgmO1E3LzAMs/eppj7SfjabC7TgqNWoS4RLhWl4BqmsNVmjj6D5jgfPVtHtgYkU3AfpQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { - "@nuxt/opencollective": "0.4.1", + "@nuxtjs/opencollective": "0.3.2", "fast-safe-stringify": "2.1.1", "iterare": "1.2.1", - "path-to-regexp": "8.3.0", + "path-to-regexp": "3.3.0", "tslib": "2.8.1", "uid": "2.0.2" }, - "engines": { - "node": ">= 20" - }, "funding": { "type": "opencollective", "url": "https://opencollective.com/nest" }, "peerDependencies": { - "@nestjs/common": "^11.0.0", - "@nestjs/microservices": "^11.0.0", - "@nestjs/platform-express": "^11.0.0", - "@nestjs/websockets": "^11.0.0", + "@nestjs/common": "^10.0.0", + "@nestjs/microservices": "^10.0.0", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/websockets": "^10.0.0", "reflect-metadata": "^0.1.12 || ^0.2.0", "rxjs": "^7.1.0" }, @@ -2608,17 +2538,6 @@ } } }, - "node_modules/@nestjs/core/node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", - "license": "MIT", - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/@nestjs/mapped-types": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.0.tgz", @@ -2639,23 +2558,44 @@ } } }, - "node_modules/@nestjs/swagger": { - "version": "11.2.3", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.2.3.tgz", - "integrity": "sha512-a0xFfjeqk69uHIUpP8u0ryn4cKuHdra2Ug96L858i0N200Hxho+n3j+TlQXyOF4EstLSGjTfxI1Xb2E1lUxeNg==", + "node_modules/@nestjs/platform-express": { + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.20.tgz", + "integrity": "sha512-rh97mX3rimyf4xLMLHuTOBKe6UD8LOJ14VlJ1F/PTd6C6ZK9Ak6EHuJvdaGcSFQhd3ZMBh3I6CuujKGW9pNdIg==", "license": "MIT", "dependencies": { - "@microsoft/tsdoc": "0.16.0", - "@nestjs/mapped-types": "2.1.0", - "js-yaml": "4.1.1", - "lodash": "4.17.21", - "path-to-regexp": "8.3.0", - "swagger-ui-dist": "5.30.2" + "body-parser": "1.20.3", + "cors": "2.8.5", + "express": "4.21.2", + "multer": "2.0.2", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" }, "peerDependencies": { - "@fastify/static": "^8.0.0", - "@nestjs/common": "^11.0.1", - "@nestjs/core": "^11.0.1", + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0" + } + }, + "node_modules/@nestjs/swagger": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.2.tgz", + "integrity": "sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==", + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "^0.15.0", + "@nestjs/mapped-types": "2.0.5", + "js-yaml": "4.1.0", + "lodash": "4.17.21", + "path-to-regexp": "3.3.0", + "swagger-ui-dist": "5.17.14" + }, + "peerDependencies": { + "@fastify/static": "^6.0.0 || ^7.0.0", + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", "class-transformer": "*", "class-validator": "*", "reflect-metadata": "^0.1.12 || ^0.2.0" @@ -2672,16 +2612,50 @@ } } }, - "node_modules/@nestjs/swagger/node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "node_modules/@nestjs/swagger/node_modules/@microsoft/tsdoc": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz", + "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==", + "license": "MIT" + }, + "node_modules/@nestjs/swagger/node_modules/@nestjs/mapped-types": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.5.tgz", + "integrity": "sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg==", "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } } }, + "node_modules/@nestjs/swagger/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@nestjs/swagger/node_modules/swagger-ui-dist": { + "version": "5.17.14", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz", + "integrity": "sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==", + "license": "Apache-2.0" + }, "node_modules/@nestjs/testing": { "version": "10.4.20", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.20.tgz", @@ -2883,6 +2857,19 @@ "node": ">= 10" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2931,23 +2918,6 @@ "node": ">=12.4.0" } }, - "node_modules/@nuxt/opencollective": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@nuxt/opencollective/-/opencollective-0.4.1.tgz", - "integrity": "sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "consola": "^3.2.3" - }, - "bin": { - "opencollective": "bin/opencollective.js" - }, - "engines": { - "node": "^14.18.0 || >=16.10.0", - "npm": ">=5.10.0" - } - }, "node_modules/@nuxtjs/opencollective": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz", @@ -2972,6 +2942,16 @@ "integrity": "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==", "license": "MIT" }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2986,7 +2966,7 @@ "version": "1.57.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "playwright": "1.57.0" @@ -3527,27 +3507,6 @@ "node": ">=14" } }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", @@ -3647,28 +3606,28 @@ "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tybys/wasm-util": { @@ -3682,14 +3641,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3789,6 +3740,13 @@ "@types/node": "*" } }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -3885,6 +3843,13 @@ "@types/node": "*" } }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -4025,6 +3990,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -4597,17 +4586,49 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.16.tgz", + "integrity": "sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.16", + "ast-v8-to-istanbul": "^0.3.8", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.16", + "vitest": "4.0.16" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.15.tgz", - "integrity": "sha512-Gfyva9/GxPAWXIWjyGDli9O+waHDC0Q0jaLdFP1qPAUUfo1FEXPXUfUkp3eZA0sSq340vPycSyOlYUeM15Ft1w==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.16.tgz", + "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.15", - "@vitest/utils": "4.0.15", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" }, @@ -4616,13 +4637,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.15.tgz", - "integrity": "sha512-CZ28GLfOEIFkvCFngN8Sfx5h+Se0zN+h4B7yOsPVCcgtiO7t5jt9xQh2E1UkFep+eb9fjyMfuC5gBypwb07fvQ==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.16.tgz", + "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.15", + "@vitest/spy": "4.0.16", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -4643,9 +4664,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.15.tgz", - "integrity": "sha512-SWdqR8vEv83WtZcrfLNqlqeQXlQLh2iilO1Wk1gv4eiHKjEzvgHb2OVc3mIPyhZE6F+CtfYjNlDJwP5MN6Km7A==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.16.tgz", + "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", "dev": true, "license": "MIT", "dependencies": { @@ -4656,13 +4677,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.15.tgz", - "integrity": "sha512-+A+yMY8dGixUhHmNdPUxOh0la6uVzun86vAbuMT3hIDxMrAOmn5ILBHm8ajrqHE0t8R9T1dGnde1A5DTnmi3qw==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.16.tgz", + "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.15", + "@vitest/utils": "4.0.16", "pathe": "^2.0.3" }, "funding": { @@ -4670,13 +4691,13 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.15.tgz", - "integrity": "sha512-A7Ob8EdFZJIBjLjeO0DZF4lqR6U7Ydi5/5LIZ0xcI+23lYlsYJAfGn8PrIWTYdZQRNnSRlzhg0zyGu37mVdy5g==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.16.tgz", + "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.15", + "@vitest/pretty-format": "4.0.16", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -4685,9 +4706,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.15.tgz", - "integrity": "sha512-+EIjOJmnY6mIfdXtE/bnozKEvTC4Uczg19yeZ2vtCz5Yyb0QQ31QWVQ8hswJ3Ysx/K2EqaNsVanjr//2+P3FHw==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.16.tgz", + "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==", "dev": true, "license": "MIT", "funding": { @@ -4695,13 +4716,13 @@ } }, "node_modules/@vitest/ui": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.15.tgz", - "integrity": "sha512-sxSyJMaKp45zI0u+lHrPuZM1ZJQ8FaVD35k+UxVrha1yyvQ+TZuUYllUixwvQXlB7ixoDc7skf3lQPopZIvaQw==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.16.tgz", + "integrity": "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.15", + "@vitest/utils": "4.0.16", "fflate": "^0.8.2", "flatted": "^3.3.3", "pathe": "^2.0.3", @@ -4713,17 +4734,17 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.0.15" + "vitest": "4.0.16" } }, "node_modules/@vitest/utils": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.15.tgz", - "integrity": "sha512-HXjPW2w5dxhTD0dLwtYHDnelK3j8sR8cWIaLxr22evTyY6q8pRCjZSmhRWVjBaOVXChQd6AwMzi9pucorXCPZA==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.16.tgz", + "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.15", + "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" }, "funding": { @@ -4755,7 +4776,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -4778,7 +4799,7 @@ "version": "8.3.4", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -4897,7 +4918,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/argparse": { @@ -5101,6 +5122,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -5142,6 +5170,25 @@ "dev": true, "license": "MIT" }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.9.tgz", + "integrity": "sha512-dSC6tJeOJxbZrPzPbv5mMd6CMiQ1ugaVXXPRad2fXUSsy1kstFn9XQWemV9VW7Y7kpxgQ/4WMoZfwdH8XSU48w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -5674,9 +5721,9 @@ } }, "node_modules/chai": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.1.tgz", - "integrity": "sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", "engines": { @@ -5946,6 +5993,16 @@ "node": ">=16" } }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -5967,16 +6024,6 @@ "typedarray": "^0.0.6" } }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "license": "MIT", - "peer": true, - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -6019,6 +6066,13 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -6036,7 +6090,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/cross-spawn": { @@ -6425,6 +6479,17 @@ "integrity": "sha512-26T91cV5dbOYnXdJi5qQHoTtUoNEqwkHcAyu/IKtjIAxiEqPMrDiRkDOPWVsGfNZGmlQVHQbZRSjD8sxagWVsQ==", "license": "BSD-3-Clause" }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -6436,7 +6501,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -6475,14 +6540,6 @@ "node": ">=6.0.0" } }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -8137,6 +8194,24 @@ "node": ">= 6" } }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -8372,7 +8447,7 @@ "version": "4.13.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -8776,6 +8851,13 @@ "node": ">=12" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/htmlparser2": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", @@ -9565,6 +9647,73 @@ "node": ">=0.10.0" } }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterare": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", @@ -9611,7 +9760,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -9907,26 +10056,6 @@ "dev": true, "license": "MIT" }, - "node_modules/load-esm": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz", - "integrity": "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - }, - { - "type": "buymeacoffee", - "url": "https://buymeacoffee.com/borewit" - } - ], - "license": "MIT", - "peer": true, - "engines": { - "node": ">=13.2.0" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -10029,17 +10158,6 @@ "node": ">=12" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" - } - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -10049,11 +10167,39 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", + "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/matcher": { @@ -11332,7 +11478,7 @@ "version": "1.57.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.57.0" @@ -11351,7 +11497,7 @@ "version": "1.57.0", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -11645,47 +11791,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -12123,44 +12228,6 @@ "node": ">= 0.8" } }, - "node_modules/react": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", - "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", - "license": "MIT", - "peer": true, - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.3" - } - }, - "node_modules/react-dom/node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT", - "peer": true - }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -12438,7 +12505,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "devOptional": true, + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" @@ -13683,6 +13750,54 @@ "node": ">= 8.0" } }, + "node_modules/superagent": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz", + "integrity": "sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.4", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.2" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/supertest": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.4.tgz", + "integrity": "sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^10.2.3" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -14014,7 +14129,7 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", @@ -14181,7 +14296,7 @@ "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "esbuild": "~0.27.0", @@ -14204,6 +14319,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14220,6 +14336,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14236,6 +14353,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14252,6 +14370,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14268,6 +14387,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14284,6 +14404,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14300,6 +14421,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14316,6 +14438,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14332,6 +14455,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14348,6 +14472,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14364,6 +14489,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14380,6 +14506,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14396,6 +14523,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14412,6 +14540,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14428,6 +14557,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14444,6 +14574,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14460,6 +14591,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14476,6 +14608,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14492,6 +14625,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14508,6 +14642,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14524,6 +14659,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14540,6 +14676,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14556,6 +14693,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -14569,7 +14707,7 @@ "version": "0.27.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz", "integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -14962,7 +15100,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -15217,7 +15355,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/validate-npm-package-license": { @@ -15324,19 +15462,19 @@ } }, "node_modules/vitest": { - "version": "4.0.15", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.15.tgz", - "integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==", + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.16.tgz", + "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.15", - "@vitest/mocker": "4.0.15", - "@vitest/pretty-format": "4.0.15", - "@vitest/runner": "4.0.15", - "@vitest/snapshot": "4.0.15", - "@vitest/spy": "4.0.15", - "@vitest/utils": "4.0.15", + "@vitest/expect": "4.0.16", + "@vitest/mocker": "4.0.16", + "@vitest/pretty-format": "4.0.16", + "@vitest/runner": "4.0.16", + "@vitest/snapshot": "4.0.16", + "@vitest/spy": "4.0.16", + "@vitest/utils": "4.0.16", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", @@ -15364,10 +15502,10 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.15", - "@vitest/browser-preview": "4.0.15", - "@vitest/browser-webdriverio": "4.0.15", - "@vitest/ui": "4.0.15", + "@vitest/browser-playwright": "4.0.16", + "@vitest/browser-preview": "4.0.16", + "@vitest/browser-webdriverio": "4.0.16", + "@vitest/ui": "4.0.16", "happy-dom": "*", "jsdom": "*" }, @@ -15790,7 +15928,7 @@ "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "devOptional": true, + "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -15850,7 +15988,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" diff --git a/package.json b/package.json index 9f96c27ab..1e4e58277 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "dependencies": { "@core/social": "file:core/social", - "@nestjs/swagger": "11.2.3", + "@nestjs/swagger": "7.4.2", "bcrypt": "^6.0.0", "electron-vite": "3.1.0", "next": "15.5.9", @@ -15,6 +15,10 @@ "description": "GridPilot - Clean Architecture monorepo for web platform and Electron companion app", "devDependencies": { "@cucumber/cucumber": "^11.0.1", + "@nestjs/common": "10.4.20", + "@nestjs/core": "10.4.20", + "@nestjs/platform-express": "10.4.20", + "@nestjs/testing": "10.4.20", "@playwright/test": "^1.57.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", @@ -22,9 +26,11 @@ "@types/express": "^4.17.21", "@types/jsdom": "^27.0.0", "@types/node": "^24.10.1", + "@types/supertest": "^6.0.3", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", - "@vitest/ui": "^4.0.15", + "@vitest/coverage-v8": "^4.0.16", + "@vitest/ui": "^4.0.16", "cheerio": "^1.0.0", "commander": "^11.0.0", "electron": "^39.2.7", @@ -37,10 +43,11 @@ "openapi-typescript": "^7.4.3", "prettier": "^3.0.0", "puppeteer": "^24.31.0", + "supertest": "^7.1.4", "ts-node": "^10.9.2", "tsx": "^4.7.0", "typescript": "^5.9.3", - "vitest": "^4.0.15" + "vitest": "^4.0.16" }, "engines": { "node": ">=20.0.0" @@ -52,6 +59,8 @@ "api:generate-spec": "tsx scripts/generate-openapi-spec.ts", "api:generate-types": "tsx scripts/generate-api-types.ts", "api:sync-types": "npm run api:generate-spec && npm run api:generate-types", + "api:test": "vitest run --config vitest.api.config.ts", + "api:coverage": "vitest run --config vitest.api.config.ts --coverage", "build": "echo 'Build all packages placeholder - to be configured'", "chrome:debug": "open -a 'Google Chrome' --args --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug", "companion:build": "npm run build --workspace=@gridpilot/companion", @@ -107,4 +116,4 @@ "apps/*", "testing/*" ] -} \ No newline at end of file +} diff --git a/vitest.api.config.ts b/vitest.api.config.ts new file mode 100644 index 000000000..e54b1c367 --- /dev/null +++ b/vitest.api.config.ts @@ -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'), + }, + }, +}); \ No newline at end of file