refactor dashboard module

This commit is contained in:
2025-12-22 00:10:57 +01:00
parent 44ed8db555
commit b445d6dd37
6 changed files with 46 additions and 86 deletions

View File

@@ -2,8 +2,6 @@ import { Provider } from '@nestjs/common';
// Import core interfaces // Import core interfaces
import type { Logger } from '@core/shared/application/Logger'; import type { Logger } from '@core/shared/application/Logger';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { DashboardOverviewResult } from '@core/racing/application/use-cases/DashboardOverviewUseCase';
import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository'; import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
import { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository'; import { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
import { IResultRepository } from '@core/racing/domain/repositories/IResultRepository'; import { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
@@ -141,7 +139,6 @@ export const DashboardProviders: Provider[] = [
feedRepo: IFeedRepository, feedRepo: IFeedRepository,
socialRepo: ISocialGraphRepository, socialRepo: ISocialGraphRepository,
imageService: ImageServicePort, imageService: ImageServicePort,
output: UseCaseOutputPort<DashboardOverviewResult>,
) => ) =>
new DashboardOverviewUseCase( new DashboardOverviewUseCase(
driverRepo, driverRepo,
@@ -155,7 +152,6 @@ export const DashboardProviders: Provider[] = [
socialRepo, socialRepo,
async (driverId: string) => imageService.getDriverAvatar(driverId), async (driverId: string) => imageService.getDriverAvatar(driverId),
() => null, () => null,
output,
), ),
inject: [ inject: [
DRIVER_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN,
@@ -168,7 +164,6 @@ export const DashboardProviders: Provider[] = [
FEED_REPOSITORY_TOKEN, FEED_REPOSITORY_TOKEN,
SOCIAL_GRAPH_REPOSITORY_TOKEN, SOCIAL_GRAPH_REPOSITORY_TOKEN,
IMAGE_SERVICE_TOKEN, IMAGE_SERVICE_TOKEN,
DASHBOARD_OVERVIEW_OUTPUT_PORT_TOKEN,
], ],
}, },
]; ];

View File

@@ -7,14 +7,15 @@ import { DashboardOverviewPresenter } from './presenters/DashboardOverviewPresen
import type { Logger } from '@core/shared/application/Logger'; import type { Logger } from '@core/shared/application/Logger';
// Tokens // Tokens
import { DASHBOARD_OVERVIEW_OUTPUT_PORT_TOKEN, DASHBOARD_OVERVIEW_USE_CASE_TOKEN, LOGGER_TOKEN } from './DashboardProviders'; import { DASHBOARD_OVERVIEW_USE_CASE_TOKEN, LOGGER_TOKEN } from './DashboardProviders';
@Injectable() @Injectable()
export class DashboardService { export class DashboardService {
private readonly presenter = new DashboardOverviewPresenter();
constructor( constructor(
@Inject(LOGGER_TOKEN) private readonly logger: Logger, @Inject(LOGGER_TOKEN) private readonly logger: Logger,
@Inject(DASHBOARD_OVERVIEW_USE_CASE_TOKEN) private readonly dashboardOverviewUseCase: DashboardOverviewUseCase, @Inject(DASHBOARD_OVERVIEW_USE_CASE_TOKEN) private readonly dashboardOverviewUseCase: DashboardOverviewUseCase,
@Inject(DASHBOARD_OVERVIEW_OUTPUT_PORT_TOKEN) private readonly dashboardOverviewPresenter: DashboardOverviewPresenter, // TODO no presenter injection
) {} ) {}
async getDashboardOverview(driverId: string): Promise<DashboardOverviewDTO> { async getDashboardOverview(driverId: string): Promise<DashboardOverviewDTO> {
@@ -26,6 +27,8 @@ export class DashboardService {
throw new Error(result.unwrapErr().details?.message ?? 'Failed to get dashboard overview'); throw new Error(result.unwrapErr().details?.message ?? 'Failed to get dashboard overview');
} }
return this.dashboardOverviewPresenter.getResponseModel(); this.presenter.present(result);
return this.presenter.getResponseModel();
} }
} }

View File

@@ -5,6 +5,7 @@ import { Race } from '@core/racing/domain/entities/Race';
import { Standing } from '@core/racing/domain/entities/Standing'; import { Standing } from '@core/racing/domain/entities/Standing';
import { Result as RaceResult } from '@core/racing/domain/entities/result/Result'; import { Result as RaceResult } from '@core/racing/domain/entities/result/Result';
import type { FeedItem } from '@core/social/domain/types/FeedItem'; import type { FeedItem } from '@core/social/domain/types/FeedItem';
import { Result } from '@core/shared/application/Result';
import { beforeEach, describe, expect, it } from 'vitest'; import { beforeEach, describe, expect, it } from 'vitest';
import { DashboardOverviewPresenter } from './DashboardOverviewPresenter'; import { DashboardOverviewPresenter } from './DashboardOverviewPresenter';
@@ -143,7 +144,7 @@ describe('DashboardOverviewPresenter', () => {
it('maps DashboardOverviewResult to DashboardOverviewDTO correctly', () => { it('maps DashboardOverviewResult to DashboardOverviewDTO correctly', () => {
const output = createOutput(); const output = createOutput();
presenter.present(output); presenter.present(Result.ok(output));
const dto = presenter.getResponseModel(); const dto = presenter.getResponseModel();
expect(dto.activeLeaguesCount).toBe(2); expect(dto.activeLeaguesCount).toBe(2);

View File

@@ -1,4 +1,4 @@
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import { Result } from '@core/shared/application/Result';
import type { import type {
DashboardOverviewResult, DashboardOverviewResult,
} from '@core/racing/application/use-cases/DashboardOverviewUseCase'; } from '@core/racing/application/use-cases/DashboardOverviewUseCase';
@@ -13,22 +13,24 @@ import {
DashboardFriendSummaryDTO, DashboardFriendSummaryDTO,
} from '../dtos/DashboardOverviewDTO'; } from '../dtos/DashboardOverviewDTO';
export class DashboardOverviewPresenter implements UseCaseOutputPort<DashboardOverviewResult> { export class DashboardOverviewPresenter {
private responseModel: DashboardOverviewDTO | null = null; private responseModel: DashboardOverviewDTO | null = null;
present(result: DashboardOverviewResult): void { present(result: Result<DashboardOverviewResult, unknown>): void {
const currentDriver: DashboardDriverSummaryDTO | null = result.currentDriver const data = result.unwrap();
const currentDriver: DashboardDriverSummaryDTO | null = data.currentDriver
? { ? {
id: result.currentDriver.driver.id, id: data.currentDriver.driver.id,
name: String(result.currentDriver.driver.name), name: String(data.currentDriver.driver.name),
country: String(result.currentDriver.driver.country), country: String(data.currentDriver.driver.country),
avatarUrl: result.currentDriver.avatarUrl, avatarUrl: data.currentDriver.avatarUrl,
rating: result.currentDriver.rating, rating: data.currentDriver.rating,
globalRank: result.currentDriver.globalRank, globalRank: data.currentDriver.globalRank,
totalRaces: result.currentDriver.totalRaces, totalRaces: data.currentDriver.totalRaces,
wins: result.currentDriver.wins, wins: data.currentDriver.wins,
podiums: result.currentDriver.podiums, podiums: data.currentDriver.podiums,
consistency: result.currentDriver.consistency, consistency: data.currentDriver.consistency,
} }
: null; : null;
@@ -43,13 +45,13 @@ export class DashboardOverviewPresenter implements UseCaseOutputPort<DashboardOv
isMyLeague: raceSummary.isMyLeague, isMyLeague: raceSummary.isMyLeague,
}); });
const myUpcomingRaces: DashboardRaceSummaryDTO[] = result.myUpcomingRaces.map(mapRace); const myUpcomingRaces: DashboardRaceSummaryDTO[] = data.myUpcomingRaces.map(mapRace);
const otherUpcomingRaces: DashboardRaceSummaryDTO[] = result.otherUpcomingRaces.map(mapRace); const otherUpcomingRaces: DashboardRaceSummaryDTO[] = data.otherUpcomingRaces.map(mapRace);
const upcomingRaces: DashboardRaceSummaryDTO[] = result.upcomingRaces.map(mapRace); const upcomingRaces: DashboardRaceSummaryDTO[] = data.upcomingRaces.map(mapRace);
const nextRace: DashboardRaceSummaryDTO | null = result.nextRace ? mapRace(result.nextRace) : null; const nextRace: DashboardRaceSummaryDTO | null = data.nextRace ? mapRace(data.nextRace) : null;
const recentResults: DashboardRecentResultDTO[] = result.recentResults.map(resultSummary => ({ const recentResults: DashboardRecentResultDTO[] = data.recentResults.map(resultSummary => ({
raceId: resultSummary.race.id, raceId: resultSummary.race.id,
raceName: String(resultSummary.race.track), raceName: String(resultSummary.race.track),
leagueId: resultSummary.league?.id ? String(resultSummary.league.id) : null, leagueId: resultSummary.league?.id ? String(resultSummary.league.id) : null,
@@ -60,7 +62,7 @@ export class DashboardOverviewPresenter implements UseCaseOutputPort<DashboardOv
})); }));
const leagueStandingsSummaries: DashboardLeagueStandingSummaryDTO[] = const leagueStandingsSummaries: DashboardLeagueStandingSummaryDTO[] =
result.leagueStandingsSummaries.map(standing => ({ data.leagueStandingsSummaries.map(standing => ({
leagueId: String(standing.league.id), leagueId: String(standing.league.id),
leagueName: String(standing.league.name), leagueName: String(standing.league.name),
position: standing.standing?.position ? Number(standing.standing.position) : null, position: standing.standing?.position ? Number(standing.standing.position) : null,
@@ -68,7 +70,7 @@ export class DashboardOverviewPresenter implements UseCaseOutputPort<DashboardOv
points: standing.standing?.points ? Number(standing.standing.points) : null, points: standing.standing?.points ? Number(standing.standing.points) : null,
})); }));
const feedItems: DashboardFeedItemSummaryDTO[] = result.feedSummary.items.map(item => ({ const feedItems: DashboardFeedItemSummaryDTO[] = data.feedSummary.items.map(item => ({
id: item.id, id: item.id,
type: String(item.type), type: String(item.type),
headline: item.headline, headline: item.headline,
@@ -79,11 +81,11 @@ export class DashboardOverviewPresenter implements UseCaseOutputPort<DashboardOv
})); }));
const feedSummary: DashboardFeedSummaryDTO = { const feedSummary: DashboardFeedSummaryDTO = {
notificationCount: result.feedSummary.notificationCount, notificationCount: data.feedSummary.notificationCount,
items: feedItems, items: feedItems,
}; };
const friends: DashboardFriendSummaryDTO[] = result.friends.map(friend => ({ const friends: DashboardFriendSummaryDTO[] = data.friends.map(friend => ({
id: friend.driver.id, id: friend.driver.id,
name: String(friend.driver.name), name: String(friend.driver.name),
country: String(friend.driver.country), country: String(friend.driver.country),
@@ -95,7 +97,7 @@ export class DashboardOverviewPresenter implements UseCaseOutputPort<DashboardOv
myUpcomingRaces, myUpcomingRaces,
otherUpcomingRaces, otherUpcomingRaces,
upcomingRaces, upcomingRaces,
activeLeaguesCount: result.activeLeaguesCount, activeLeaguesCount: data.activeLeaguesCount,
nextRace, nextRace,
recentResults, recentResults,
leagueStandingsSummaries, leagueStandingsSummaries,

View File

@@ -19,10 +19,6 @@ import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPo
describe('DashboardOverviewUseCase', () => { describe('DashboardOverviewUseCase', () => {
it('partitions upcoming races into myUpcomingRaces and otherUpcomingRaces and selects nextRace from myUpcomingRaces', async () => { it('partitions upcoming races into myUpcomingRaces and otherUpcomingRaces and selects nextRace from myUpcomingRaces', async () => {
const output: UseCaseOutputPort<DashboardOverviewResult> = {
present: vi.fn(),
};
const driverId = 'driver-1'; const driverId = 'driver-1';
const driver = Driver.create({ id: driverId, iracingId: '12345', name: 'Alice Racer', country: 'US' }); const driver = Driver.create({ id: driverId, iracingId: '12345', name: 'Alice Racer', country: 'US' });
@@ -270,21 +266,17 @@ describe('DashboardOverviewUseCase', () => {
socialRepository, socialRepository,
getDriverAvatar, getDriverAvatar,
getDriverStats, getDriverStats,
output,
); );
const input: DashboardOverviewInput = { driverId }; const input: DashboardOverviewInput = { driverId };
const result: UseCaseResult< const result: UseCaseResult<
void, DashboardOverviewResult,
ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }> ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }>
> = await useCase.execute(input); > = await useCase.execute(input);
expect(result.isOk()).toBe(true); expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined(); const vm = result.unwrap();
expect(output.present).toHaveBeenCalledTimes(1);
const vm = (output.present as any).mock.calls[0][0] as DashboardOverviewResult;
expect(vm.myUpcomingRaces.map(r => r.race.id)).toEqual(['race-1', 'race-3']); expect(vm.myUpcomingRaces.map(r => r.race.id)).toEqual(['race-1', 'race-3']);
@@ -295,10 +287,6 @@ describe('DashboardOverviewUseCase', () => {
}); });
it('builds recentResults sorted by date descending and leagueStandingsSummaries from standings', async () => { it('builds recentResults sorted by date descending and leagueStandingsSummaries from standings', async () => {
const output: UseCaseOutputPort<DashboardOverviewResult> = {
present: vi.fn(),
};
const driverId = 'driver-2'; const driverId = 'driver-2';
const driver = Driver.create({ id: driverId, iracingId: '67890', name: 'Result Driver', country: 'DE' }); const driver = Driver.create({ id: driverId, iracingId: '67890', name: 'Result Driver', country: 'DE' });
@@ -551,21 +539,17 @@ describe('DashboardOverviewUseCase', () => {
socialRepository, socialRepository,
getDriverAvatar, getDriverAvatar,
getDriverStats, getDriverStats,
output,
); );
const input: DashboardOverviewInput = { driverId }; const input: DashboardOverviewInput = { driverId };
const result: UseCaseResult< const result: UseCaseResult<
void, DashboardOverviewResult,
ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }> ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }>
> = await useCase.execute(input); > = await useCase.execute(input);
expect(result.isOk()).toBe(true); expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined(); const vm = result.unwrap();
expect(output.present).toHaveBeenCalledTimes(1);
const vm = (output.present as any).mock.calls[0][0] as DashboardOverviewResult;
expect(vm.recentResults.length).toBe(2); expect(vm.recentResults.length).toBe(2);
expect(vm.recentResults[0]!.race.id).toBe('race-new'); expect(vm.recentResults[0]!.race.id).toBe('race-new');
@@ -590,10 +574,6 @@ describe('DashboardOverviewUseCase', () => {
}); });
it('returns empty collections and safe defaults when driver has no races or standings', async () => { it('returns empty collections and safe defaults when driver has no races or standings', async () => {
const output: UseCaseOutputPort<DashboardOverviewResult> = {
present: vi.fn(),
};
const driverId = 'driver-empty'; const driverId = 'driver-empty';
const driver = Driver.create({ id: driverId, iracingId: '11111', name: 'New Racer', country: 'FR' }); const driver = Driver.create({ id: driverId, iracingId: '11111', name: 'New Racer', country: 'FR' });
@@ -758,21 +738,17 @@ describe('DashboardOverviewUseCase', () => {
socialRepository, socialRepository,
getDriverAvatar, getDriverAvatar,
getDriverStats, getDriverStats,
output,
); );
const input: DashboardOverviewInput = { driverId }; const input: DashboardOverviewInput = { driverId };
const result: UseCaseResult< const result: UseCaseResult<
void, DashboardOverviewResult,
ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }> ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }>
> = await useCase.execute(input); > = await useCase.execute(input);
expect(result.isOk()).toBe(true); expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined(); const vm = result.unwrap();
expect(output.present).toHaveBeenCalledTimes(1);
const vm = (output.present as any).mock.calls[0][0] as DashboardOverviewResult;
expect(vm.myUpcomingRaces).toEqual([]); expect(vm.myUpcomingRaces).toEqual([]);
expect(vm.otherUpcomingRaces).toEqual([]); expect(vm.otherUpcomingRaces).toEqual([]);
@@ -784,11 +760,7 @@ describe('DashboardOverviewUseCase', () => {
expect(vm.feedSummary.items).toEqual([]); expect(vm.feedSummary.items).toEqual([]);
}); });
it('returns DRIVER_NOT_FOUND error and does not present when driver is missing', async () => { it('returns DRIVER_NOT_FOUND error when driver is missing', async () => {
const output: UseCaseOutputPort<DashboardOverviewResult> = {
present: vi.fn(),
};
const driverId = 'missing-driver'; const driverId = 'missing-driver';
const driverRepository = { const driverRepository = {
@@ -951,13 +923,12 @@ describe('DashboardOverviewUseCase', () => {
socialRepository, socialRepository,
getDriverAvatar, getDriverAvatar,
getDriverStats, getDriverStats,
output,
); );
const input: DashboardOverviewInput = { driverId }; const input: DashboardOverviewInput = { driverId };
const result: UseCaseResult< const result: UseCaseResult<
void, DashboardOverviewResult,
ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }> ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }>
> = await useCase.execute(input); > = await useCase.execute(input);
@@ -965,15 +936,9 @@ describe('DashboardOverviewUseCase', () => {
const err = result.unwrapErr(); const err = result.unwrapErr();
expect(err.code).toBe('DRIVER_NOT_FOUND'); expect(err.code).toBe('DRIVER_NOT_FOUND');
expect(err.details?.message).toBe('Driver not found'); expect(err.details?.message).toBe('Driver not found');
expect(output.present).not.toHaveBeenCalled();
}); });
it('returns REPOSITORY_ERROR when an unexpected error occurs and does not present', async () => { it('returns REPOSITORY_ERROR when an unexpected error occurs', async () => {
const output: UseCaseOutputPort<DashboardOverviewResult> = {
present: vi.fn(),
};
const driverId = 'driver-error'; const driverId = 'driver-error';
const driver = Driver.create({ id: driverId, iracingId: '99999', name: 'Error Driver', country: 'GB' }); const driver = Driver.create({ id: driverId, iracingId: '99999', name: 'Error Driver', country: 'GB' });
@@ -1140,13 +1105,12 @@ describe('DashboardOverviewUseCase', () => {
socialRepository, socialRepository,
getDriverAvatar, getDriverAvatar,
getDriverStats, getDriverStats,
output,
); );
const input: DashboardOverviewInput = { driverId }; const input: DashboardOverviewInput = { driverId };
const result: UseCaseResult< const result: UseCaseResult<
void, DashboardOverviewResult,
ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }> ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }>
> = await useCase.execute(input); > = await useCase.execute(input);
@@ -1154,7 +1118,5 @@ describe('DashboardOverviewUseCase', () => {
const err = result.unwrapErr(); const err = result.unwrapErr();
expect(err.code).toBe('REPOSITORY_ERROR'); expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details?.message).toBe('DB failure'); expect(err.details?.message).toBe('DB failure');
expect(output.present).not.toHaveBeenCalled();
}); });
}); });

View File

@@ -97,14 +97,13 @@ export class DashboardOverviewUseCase {
private readonly getDriverStats: ( private readonly getDriverStats: (
driverId: string, driverId: string,
) => DashboardDriverStatsAdapter | null, ) => DashboardDriverStatsAdapter | null,
private readonly output: UseCaseOutputPort<DashboardOverviewResult>,
) {} ) {}
async execute( async execute(
input: DashboardOverviewInput, input: DashboardOverviewInput,
): Promise< ): Promise<
Result< Result<
void, DashboardOverviewResult,
ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }> ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }>
> >
> { > {
@@ -209,9 +208,7 @@ export class DashboardOverviewUseCase {
friends: friendsSummary, friends: friendsSummary,
}; };
this.output.present(result); return Result.ok(result);
return Result.ok(undefined);
} catch (error) { } catch (error) {
return Result.err({ return Result.err({
code: 'REPOSITORY_ERROR', code: 'REPOSITORY_ERROR',