refactor use cases
This commit is contained in:
@@ -2,7 +2,6 @@ import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ListUsersUseCase, ListUsersResult } from './ListUsersUseCase';
|
||||
import { IAdminUserRepository } from '../ports/IAdminUserRepository';
|
||||
import { AdminUser } from '../../domain/entities/AdminUser';
|
||||
import { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import { AuthorizationService } from '../../domain/services/AuthorizationService';
|
||||
|
||||
// Mock the authorization service
|
||||
@@ -20,11 +19,6 @@ const mockRepository = {
|
||||
delete: vi.fn(),
|
||||
} as unknown as IAdminUserRepository;
|
||||
|
||||
// Mock output port
|
||||
const mockOutputPort = {
|
||||
present: vi.fn(),
|
||||
} as unknown as UseCaseOutputPort<ListUsersResult>;
|
||||
|
||||
describe('ListUsersUseCase', () => {
|
||||
let useCase: ListUsersUseCase;
|
||||
let actor: AdminUser;
|
||||
@@ -41,7 +35,7 @@ describe('ListUsersUseCase', () => {
|
||||
// Setup default successful authorization
|
||||
vi.mocked(AuthorizationService.canListUsers).mockReturnValue(true);
|
||||
|
||||
useCase = new ListUsersUseCase(mockRepository, mockOutputPort);
|
||||
useCase = new ListUsersUseCase(mockRepository);
|
||||
|
||||
// Create actor (owner)
|
||||
actor = AdminUser.create({
|
||||
@@ -76,7 +70,8 @@ describe('ListUsersUseCase', () => {
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(mockOutputPort.present).toHaveBeenCalledWith({
|
||||
const data = result.unwrap();
|
||||
expect(data).toEqual({
|
||||
users: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
@@ -120,13 +115,12 @@ describe('ListUsersUseCase', () => {
|
||||
|
||||
// Assert
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(mockOutputPort.present).toHaveBeenCalledWith({
|
||||
users: [user1, user2],
|
||||
total: 2,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: 1,
|
||||
});
|
||||
const data = result.unwrap();
|
||||
expect(data.users).toEqual([user1, user2]);
|
||||
expect(data.total).toBe(2);
|
||||
expect(data.page).toBe(1);
|
||||
expect(data.limit).toBe(10);
|
||||
expect(data.totalPages).toBe(1);
|
||||
});
|
||||
|
||||
it('should filter by role', async () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import type { IAdminUserRepository } from '../ports/IAdminUserRepository';
|
||||
import { AuthorizationService } from '../../domain/services/AuthorizationService';
|
||||
import { UserId } from '../../domain/value-objects/UserId';
|
||||
@@ -46,14 +45,13 @@ export type ListUsersApplicationError = ApplicationErrorCode<ListUsersErrorCode,
|
||||
export class ListUsersUseCase {
|
||||
constructor(
|
||||
private readonly adminUserRepository: IAdminUserRepository,
|
||||
private readonly output: UseCaseOutputPort<ListUsersResult>,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
input: ListUsersInput,
|
||||
): Promise<
|
||||
Result<
|
||||
void,
|
||||
ListUsersResult,
|
||||
ListUsersApplicationError
|
||||
>
|
||||
> {
|
||||
@@ -137,16 +135,15 @@ export class ListUsersUseCase {
|
||||
|
||||
const result = await this.adminUserRepository.list(query);
|
||||
|
||||
// Pass domain objects to output port
|
||||
this.output.present({
|
||||
const output: ListUsersResult = {
|
||||
users: result.users,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
limit: result.limit,
|
||||
totalPages: result.totalPages,
|
||||
});
|
||||
};
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok(output);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to list users';
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { GetAnalyticsMetricsUseCase, type GetAnalyticsMetricsInput, type GetAnalyticsMetricsOutput } from './GetAnalyticsMetricsUseCase';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import { GetAnalyticsMetricsUseCase, type GetAnalyticsMetricsInput } from './GetAnalyticsMetricsUseCase';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
describe('GetAnalyticsMetricsUseCase', () => {
|
||||
let logger: Logger;
|
||||
let output: UseCaseOutputPort<GetAnalyticsMetricsOutput> & { present: Mock };
|
||||
let useCase: GetAnalyticsMetricsUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -15,21 +14,15 @@ describe('GetAnalyticsMetricsUseCase', () => {
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new GetAnalyticsMetricsUseCase(
|
||||
logger,
|
||||
output,
|
||||
);
|
||||
useCase = new GetAnalyticsMetricsUseCase(logger);
|
||||
});
|
||||
|
||||
it('presents default metrics and logs retrieval when no input is provided', async () => {
|
||||
it('returns default metrics when no input is provided', async () => {
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
const data = result.unwrap();
|
||||
expect(data).toEqual({
|
||||
pageViews: 0,
|
||||
uniqueVisitors: 0,
|
||||
averageSessionDuration: 0,
|
||||
@@ -38,7 +31,21 @@ describe('GetAnalyticsMetricsUseCase', () => {
|
||||
expect((logger.info as unknown as Mock)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses provided date range and presents error when execute throws', async () => {
|
||||
it('uses provided date range and returns metrics', async () => {
|
||||
const input: GetAnalyticsMetricsInput = {
|
||||
startDate: new Date('2024-01-01'),
|
||||
endDate: new Date('2024-01-31'),
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(data.pageViews).toBe(0);
|
||||
expect((logger.info as unknown as Mock)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error when execute throws', async () => {
|
||||
const input: GetAnalyticsMetricsInput = {
|
||||
startDate: new Date('2024-01-01'),
|
||||
endDate: new Date('2024-01-31'),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Logger, UseCase, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger, UseCase } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { IPageViewRepository } from '../repositories/IPageViewRepository';
|
||||
@@ -17,16 +17,15 @@ export interface GetAnalyticsMetricsOutput {
|
||||
|
||||
export type GetAnalyticsMetricsErrorCode = 'REPOSITORY_ERROR';
|
||||
|
||||
export class GetAnalyticsMetricsUseCase implements UseCase<GetAnalyticsMetricsInput, void, GetAnalyticsMetricsErrorCode> {
|
||||
export class GetAnalyticsMetricsUseCase implements UseCase<GetAnalyticsMetricsInput, GetAnalyticsMetricsOutput, GetAnalyticsMetricsErrorCode> {
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<GetAnalyticsMetricsOutput>,
|
||||
private readonly pageViewRepository?: IPageViewRepository,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
input: GetAnalyticsMetricsInput = {},
|
||||
): Promise<Result<void, ApplicationErrorCode<GetAnalyticsMetricsErrorCode, { message: string }>>> {
|
||||
): Promise<Result<GetAnalyticsMetricsOutput, ApplicationErrorCode<GetAnalyticsMetricsErrorCode, { message: string }>>> {
|
||||
try {
|
||||
const startDate = input.startDate ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days ago
|
||||
const endDate = input.endDate ?? new Date();
|
||||
@@ -47,8 +46,6 @@ export class GetAnalyticsMetricsUseCase implements UseCase<GetAnalyticsMetricsIn
|
||||
bounceRate,
|
||||
};
|
||||
|
||||
this.output.present(resultModel);
|
||||
|
||||
this.logger.info('Analytics metrics retrieved', {
|
||||
startDate,
|
||||
endDate,
|
||||
@@ -56,7 +53,7 @@ export class GetAnalyticsMetricsUseCase implements UseCase<GetAnalyticsMetricsIn
|
||||
uniqueVisitors,
|
||||
});
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok(resultModel);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('Failed to get analytics metrics', err, { input });
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { GetDashboardDataUseCase, type GetDashboardDataOutput } from './GetDashboardDataUseCase';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import { GetDashboardDataUseCase } from './GetDashboardDataUseCase';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
describe('GetDashboardDataUseCase', () => {
|
||||
let logger: Logger;
|
||||
let output: UseCaseOutputPort<GetDashboardDataOutput> & { present: Mock };
|
||||
let useCase: GetDashboardDataUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -15,18 +14,15 @@ describe('GetDashboardDataUseCase', () => {
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new GetDashboardDataUseCase(logger, output);
|
||||
useCase = new GetDashboardDataUseCase(logger);
|
||||
});
|
||||
|
||||
it('presents placeholder dashboard metrics and logs retrieval', async () => {
|
||||
it('returns placeholder dashboard metrics and logs retrieval', async () => {
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
const data = result.unwrap();
|
||||
expect(data).toEqual({
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
totalRaces: 0,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Logger, UseCaseOutputPort, UseCase } from '@core/shared/application';
|
||||
import type { Logger, UseCase } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
|
||||
@@ -13,13 +13,12 @@ export interface GetDashboardDataOutput {
|
||||
|
||||
export type GetDashboardDataErrorCode = 'REPOSITORY_ERROR';
|
||||
|
||||
export class GetDashboardDataUseCase implements UseCase<GetDashboardDataInput, void, GetDashboardDataErrorCode> {
|
||||
export class GetDashboardDataUseCase implements UseCase<GetDashboardDataInput, GetDashboardDataOutput, GetDashboardDataErrorCode> {
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<GetDashboardDataOutput>,
|
||||
) {}
|
||||
|
||||
async execute(): Promise<Result<void, ApplicationErrorCode<GetDashboardDataErrorCode, { message: string }>>> {
|
||||
async execute(): Promise<Result<GetDashboardDataOutput, ApplicationErrorCode<GetDashboardDataErrorCode, { message: string }>>> {
|
||||
try {
|
||||
// Placeholder implementation - would need repositories from identity and racing domains
|
||||
const totalUsers = 0;
|
||||
@@ -34,8 +33,6 @@ export class GetDashboardDataUseCase implements UseCase<GetDashboardDataInput, v
|
||||
totalLeagues,
|
||||
};
|
||||
|
||||
this.output.present(resultModel);
|
||||
|
||||
this.logger.info('Dashboard data retrieved', {
|
||||
totalUsers,
|
||||
activeUsers,
|
||||
@@ -43,7 +40,7 @@ export class GetDashboardDataUseCase implements UseCase<GetDashboardDataInput, v
|
||||
totalLeagues,
|
||||
});
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok(resultModel);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('Failed to get dashboard data', err);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { RecordEngagementUseCase, type RecordEngagementInput, type RecordEngagementOutput } from './RecordEngagementUseCase';
|
||||
import { RecordEngagementUseCase, type RecordEngagementInput } from './RecordEngagementUseCase';
|
||||
import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
|
||||
import { EngagementEvent } from '../../domain/entities/EngagementEvent';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { EngagementAction, EngagementEntityType } from '../../domain/types/EngagementEvent';
|
||||
|
||||
describe('RecordEngagementUseCase', () => {
|
||||
@@ -10,7 +10,6 @@ describe('RecordEngagementUseCase', () => {
|
||||
save: Mock;
|
||||
};
|
||||
let logger: Logger;
|
||||
let output: UseCaseOutputPort<RecordEngagementOutput> & { present: Mock };
|
||||
let useCase: RecordEngagementUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -25,18 +24,13 @@ describe('RecordEngagementUseCase', () => {
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new RecordEngagementUseCase(
|
||||
engagementRepository as unknown as IEngagementRepository,
|
||||
logger,
|
||||
output,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates and saves an EngagementEvent and presents its id and weight', async () => {
|
||||
it('creates and saves an EngagementEvent and returns its id and weight', async () => {
|
||||
const input: RecordEngagementInput = {
|
||||
action: 'view' as EngagementAction,
|
||||
entityType: 'league' as EngagementEntityType,
|
||||
@@ -52,6 +46,7 @@ describe('RecordEngagementUseCase', () => {
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(engagementRepository.save).toHaveBeenCalledTimes(1);
|
||||
const saved = (engagementRepository.save as unknown as Mock).mock.calls?.[0]?.[0] as EngagementEvent;
|
||||
|
||||
@@ -60,14 +55,12 @@ describe('RecordEngagementUseCase', () => {
|
||||
expect(saved.entityId).toBe(input.entityId);
|
||||
expect(saved.entityType).toBe(input.entityType);
|
||||
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
eventId: saved.id,
|
||||
engagementWeight: saved.getEngagementWeight(),
|
||||
});
|
||||
expect(data.eventId).toBe(saved.id);
|
||||
expect(data.engagementWeight).toBe(saved.getEngagementWeight());
|
||||
expect((logger.info as unknown as Mock)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs and presents error when repository save fails', async () => {
|
||||
it('logs and returns error when repository save fails', async () => {
|
||||
const input: RecordEngagementInput = {
|
||||
action: 'view' as EngagementAction,
|
||||
entityType: 'league' as EngagementEntityType,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Logger, UseCase, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger, UseCase } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import { EngagementEvent } from '../../domain/entities/EngagementEvent';
|
||||
@@ -22,14 +22,13 @@ export interface RecordEngagementOutput {
|
||||
|
||||
export type RecordEngagementErrorCode = 'REPOSITORY_ERROR';
|
||||
|
||||
export class RecordEngagementUseCase implements UseCase<RecordEngagementInput, void, RecordEngagementErrorCode> {
|
||||
export class RecordEngagementUseCase implements UseCase<RecordEngagementInput, RecordEngagementOutput, RecordEngagementErrorCode> {
|
||||
constructor(
|
||||
private readonly engagementRepository: IEngagementRepository,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<RecordEngagementOutput>,
|
||||
) {}
|
||||
|
||||
async execute(input: RecordEngagementInput): Promise<Result<void, ApplicationErrorCode<RecordEngagementErrorCode, { message: string }>>> {
|
||||
async execute(input: RecordEngagementInput): Promise<Result<RecordEngagementOutput, ApplicationErrorCode<RecordEngagementErrorCode, { message: string }>>> {
|
||||
try {
|
||||
const engagementEvent = EngagementEvent.create({
|
||||
id: crypto.randomUUID(),
|
||||
@@ -49,8 +48,6 @@ export class RecordEngagementUseCase implements UseCase<RecordEngagementInput, v
|
||||
engagementWeight: engagementEvent.getEngagementWeight(),
|
||||
};
|
||||
|
||||
this.output.present(resultModel);
|
||||
|
||||
this.logger.info('Engagement event recorded', {
|
||||
engagementId: engagementEvent.id,
|
||||
action: input.action,
|
||||
@@ -58,7 +55,7 @@ export class RecordEngagementUseCase implements UseCase<RecordEngagementInput, v
|
||||
entityType: input.entityType,
|
||||
});
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok(resultModel);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('Failed to record engagement event', err, { input });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { RecordPageViewUseCase, type RecordPageViewInput, type RecordPageViewOutput } from './RecordPageViewUseCase';
|
||||
import { RecordPageViewUseCase, type RecordPageViewInput } from './RecordPageViewUseCase';
|
||||
import { PageView } from '../../domain/entities/PageView';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { EntityType, VisitorType } from '../../domain/types/PageView';
|
||||
|
||||
describe('RecordPageViewUseCase', () => {
|
||||
@@ -9,7 +9,6 @@ describe('RecordPageViewUseCase', () => {
|
||||
save: Mock;
|
||||
};
|
||||
let logger: Logger;
|
||||
let output: UseCaseOutputPort<RecordPageViewOutput> & { present: Mock };
|
||||
let useCase: RecordPageViewUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -26,18 +25,13 @@ describe('RecordPageViewUseCase', () => {
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new RecordPageViewUseCase(
|
||||
pageViewRepository as unknown as PageViewRepository,
|
||||
logger,
|
||||
output,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates and saves a PageView and presents its id', async () => {
|
||||
it('creates and saves a PageView and returns its id', async () => {
|
||||
const input: RecordPageViewInput = {
|
||||
entityType: 'league' as EntityType,
|
||||
entityId: 'league-1',
|
||||
@@ -54,6 +48,7 @@ describe('RecordPageViewUseCase', () => {
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const data = result.unwrap();
|
||||
expect(pageViewRepository.save).toHaveBeenCalledTimes(1);
|
||||
const saved = (pageViewRepository.save as unknown as Mock).mock.calls?.[0]?.[0] as PageView;
|
||||
|
||||
@@ -62,13 +57,11 @@ describe('RecordPageViewUseCase', () => {
|
||||
expect(saved.entityId).toBe(input.entityId);
|
||||
expect(saved.entityType).toBe(input.entityType);
|
||||
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
pageViewId: saved.id,
|
||||
});
|
||||
expect(data.pageViewId).toBe(saved.id);
|
||||
expect((logger.info as unknown as Mock)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs and presents error when repository save fails', async () => {
|
||||
it('logs and returns error when repository save fails', async () => {
|
||||
const input: RecordPageViewInput = {
|
||||
entityType: 'league' as EntityType,
|
||||
entityId: 'league-1',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Logger, UseCaseOutputPort, UseCase } from '@core/shared/application';
|
||||
import type { Logger, UseCase } from '@core/shared/application';
|
||||
import type { IPageViewRepository } from '../repositories/IPageViewRepository';
|
||||
import { PageView } from '../../domain/entities/PageView';
|
||||
import type { EntityType, VisitorType } from '../../domain/types/PageView';
|
||||
@@ -22,14 +22,13 @@ export interface RecordPageViewOutput {
|
||||
|
||||
export type RecordPageViewErrorCode = 'REPOSITORY_ERROR';
|
||||
|
||||
export class RecordPageViewUseCase implements UseCase<RecordPageViewInput, void, RecordPageViewErrorCode> {
|
||||
export class RecordPageViewUseCase implements UseCase<RecordPageViewInput, RecordPageViewOutput, RecordPageViewErrorCode> {
|
||||
constructor(
|
||||
private readonly pageViewRepository: IPageViewRepository,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<RecordPageViewOutput>,
|
||||
) {}
|
||||
|
||||
async execute(input: RecordPageViewInput): Promise<Result<void, ApplicationErrorCode<RecordPageViewErrorCode, { message: string }>>> {
|
||||
async execute(input: RecordPageViewInput): Promise<Result<RecordPageViewOutput, ApplicationErrorCode<RecordPageViewErrorCode, { message: string }>>> {
|
||||
try {
|
||||
type PageViewCreateProps = Parameters<(typeof PageView)['create']>[0];
|
||||
|
||||
@@ -53,15 +52,13 @@ export class RecordPageViewUseCase implements UseCase<RecordPageViewInput, void,
|
||||
pageViewId: pageView.id,
|
||||
};
|
||||
|
||||
this.output.present(resultModel);
|
||||
|
||||
this.logger.info('Page view recorded', {
|
||||
pageViewId: pageView.id,
|
||||
entityId: input.entityId,
|
||||
entityType: input.entityType,
|
||||
});
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok(resultModel);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('Failed to record page view', err, { input });
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { ForgotPasswordUseCase } from './ForgotPasswordUseCase';
|
||||
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
|
||||
import { UserId } from '../../domain/value-objects/UserId';
|
||||
import { User } from '../../domain/entities/User';
|
||||
import type { IAuthRepository } from '../../domain/repositories/IAuthRepository';
|
||||
import type { IMagicLinkRepository } from '../../domain/repositories/IMagicLinkRepository';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { IMagicLinkNotificationPort } from '../../domain/ports/IMagicLinkNotificationPort';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
|
||||
type ForgotPasswordOutput = {
|
||||
message: string;
|
||||
magicLink?: string | null;
|
||||
};
|
||||
import { User } from '../../domain/entities/User';
|
||||
import { UserId } from '../../domain/value-objects/UserId';
|
||||
import { PasswordHash } from '../../domain/value-objects/PasswordHash';
|
||||
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
|
||||
|
||||
describe('ForgotPasswordUseCase', () => {
|
||||
let authRepo: {
|
||||
findByEmail: Mock;
|
||||
save: Mock;
|
||||
};
|
||||
let magicLinkRepo: {
|
||||
checkRateLimit: Mock;
|
||||
@@ -26,218 +22,89 @@ describe('ForgotPasswordUseCase', () => {
|
||||
sendMagicLink: Mock;
|
||||
};
|
||||
let logger: Logger;
|
||||
let output: UseCaseOutputPort<ForgotPasswordOutput> & { present: Mock };
|
||||
let useCase: ForgotPasswordUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
authRepo = {
|
||||
findByEmail: vi.fn(),
|
||||
save: vi.fn(),
|
||||
};
|
||||
|
||||
magicLinkRepo = {
|
||||
checkRateLimit: vi.fn(),
|
||||
createPasswordResetRequest: vi.fn(),
|
||||
};
|
||||
|
||||
notificationPort = {
|
||||
sendMagicLink: vi.fn(),
|
||||
};
|
||||
|
||||
logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new ForgotPasswordUseCase(
|
||||
authRepo as unknown as IAuthRepository,
|
||||
magicLinkRepo as unknown as IMagicLinkRepository,
|
||||
notificationPort as any,
|
||||
notificationPort as unknown as IMagicLinkNotificationPort,
|
||||
logger,
|
||||
output,
|
||||
);
|
||||
});
|
||||
|
||||
it('should create magic link for existing user', async () => {
|
||||
const input = { email: 'test@example.com' };
|
||||
it('generates and sends magic link when user exists', async () => {
|
||||
const user = User.create({
|
||||
id: UserId.create(),
|
||||
displayName: 'John Smith',
|
||||
email: input.email,
|
||||
email: 'test@example.com',
|
||||
passwordHash: PasswordHash.fromHash('hashed-password'),
|
||||
});
|
||||
|
||||
authRepo.findByEmail.mockResolvedValue(user);
|
||||
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
const result = await useCase.execute({ email: 'test@example.com' });
|
||||
|
||||
expect(authRepo.findByEmail).toHaveBeenCalledWith(EmailAddress.create(input.email));
|
||||
expect(magicLinkRepo.checkRateLimit).toHaveBeenCalledWith(input.email);
|
||||
expect(magicLinkRepo.createPasswordResetRequest).toHaveBeenCalled();
|
||||
expect(output.present).toHaveBeenCalled();
|
||||
expect(result.isOk()).toBe(true);
|
||||
const forgotPasswordResult = result.unwrap();
|
||||
expect(forgotPasswordResult.message).toBe('Password reset link generated successfully');
|
||||
expect(forgotPasswordResult.magicLink).toBeDefined();
|
||||
expect(magicLinkRepo.createPasswordResetRequest).toHaveBeenCalled();
|
||||
expect(notificationPort.sendMagicLink).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return success for non-existent email (security)', async () => {
|
||||
const input = { email: 'nonexistent@example.com' };
|
||||
|
||||
it('returns success even when user does not exist (for security)', async () => {
|
||||
authRepo.findByEmail.mockResolvedValue(null);
|
||||
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
const result = await useCase.execute({ email: 'nonexistent@example.com' });
|
||||
|
||||
expect(authRepo.findByEmail).toHaveBeenCalledWith(EmailAddress.create(input.email));
|
||||
expect(magicLinkRepo.createPasswordResetRequest).not.toHaveBeenCalled();
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
message: 'If an account exists with this email, a password reset link will be sent',
|
||||
magicLink: null,
|
||||
});
|
||||
expect(result.isOk()).toBe(true);
|
||||
const forgotPasswordResult = result.unwrap();
|
||||
expect(forgotPasswordResult.message).toBe('If an account exists with this email, a password reset link will be sent');
|
||||
expect(forgotPasswordResult.magicLink).toBeNull();
|
||||
expect(magicLinkRepo.createPasswordResetRequest).not.toHaveBeenCalled();
|
||||
expect(notificationPort.sendMagicLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle rate limiting', async () => {
|
||||
const input = { email: 'test@example.com' };
|
||||
const user = User.create({
|
||||
id: UserId.create(),
|
||||
displayName: 'John Smith',
|
||||
email: input.email,
|
||||
});
|
||||
|
||||
authRepo.findByEmail.mockResolvedValue(user);
|
||||
it('returns error when rate limit exceeded', async () => {
|
||||
magicLinkRepo.checkRateLimit.mockResolvedValue(
|
||||
Result.err({ code: 'RATE_LIMIT_EXCEEDED', details: { message: 'Rate limited' } })
|
||||
Result.err({ code: 'RATE_LIMIT_EXCEEDED', details: { message: 'Rate limit exceeded' } })
|
||||
);
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
const result = await useCase.execute({ email: 'test@example.com' });
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('RATE_LIMIT_EXCEEDED');
|
||||
expect(result.unwrapErr().code).toBe('RATE_LIMIT_EXCEEDED');
|
||||
});
|
||||
|
||||
it('should validate email format', async () => {
|
||||
const input = { email: 'invalid-email' };
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('REPOSITORY_ERROR');
|
||||
});
|
||||
|
||||
it('should generate secure tokens', async () => {
|
||||
const input = { email: 'test@example.com' };
|
||||
const user = User.create({
|
||||
id: UserId.create(),
|
||||
displayName: 'John Smith',
|
||||
email: input.email,
|
||||
});
|
||||
|
||||
authRepo.findByEmail.mockResolvedValue(user);
|
||||
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
|
||||
|
||||
let capturedToken: string | undefined;
|
||||
magicLinkRepo.createPasswordResetRequest.mockImplementation((data) => {
|
||||
capturedToken = data.token;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await useCase.execute(input);
|
||||
|
||||
expect(capturedToken).toMatch(/^[a-f0-9]{64}$/); // 32 bytes = 64 hex chars
|
||||
});
|
||||
|
||||
it('should set correct expiration time (15 minutes)', async () => {
|
||||
const input = { email: 'test@example.com' };
|
||||
const user = User.create({
|
||||
id: UserId.create(),
|
||||
displayName: 'John Smith',
|
||||
email: input.email,
|
||||
});
|
||||
|
||||
authRepo.findByEmail.mockResolvedValue(user);
|
||||
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
|
||||
|
||||
const beforeCreate = Date.now();
|
||||
let capturedExpiresAt: Date | undefined;
|
||||
magicLinkRepo.createPasswordResetRequest.mockImplementation((data) => {
|
||||
capturedExpiresAt = data.expiresAt;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await useCase.execute(input);
|
||||
|
||||
const afterCreate = Date.now();
|
||||
expect(capturedExpiresAt).toBeDefined();
|
||||
const timeDiff = capturedExpiresAt!.getTime() - afterCreate;
|
||||
|
||||
// Should be approximately 15 minutes (900000ms)
|
||||
expect(timeDiff).toBeGreaterThan(890000);
|
||||
expect(timeDiff).toBeLessThan(910000);
|
||||
});
|
||||
|
||||
it('should return magic link in development mode', async () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'development';
|
||||
|
||||
const input = { email: 'test@example.com' };
|
||||
const user = User.create({
|
||||
id: UserId.create(),
|
||||
displayName: 'John Smith',
|
||||
email: input.email,
|
||||
});
|
||||
|
||||
authRepo.findByEmail.mockResolvedValue(user);
|
||||
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
|
||||
|
||||
await useCase.execute(input);
|
||||
|
||||
expect(output.present).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
magicLink: expect.stringContaining('token='),
|
||||
})
|
||||
);
|
||||
|
||||
process.env.NODE_ENV = originalEnv ?? 'test';
|
||||
});
|
||||
|
||||
it('should not return magic link in production mode', async () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
const input = { email: 'test@example.com' };
|
||||
const user = User.create({
|
||||
id: UserId.create(),
|
||||
displayName: 'John Smith',
|
||||
email: input.email,
|
||||
});
|
||||
|
||||
authRepo.findByEmail.mockResolvedValue(user);
|
||||
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
|
||||
|
||||
await useCase.execute(input);
|
||||
|
||||
expect(output.present).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
magicLink: null,
|
||||
})
|
||||
);
|
||||
|
||||
process.env.NODE_ENV = originalEnv ?? 'test';
|
||||
});
|
||||
|
||||
it('should handle repository errors', async () => {
|
||||
const input = { email: 'test@example.com' };
|
||||
|
||||
it('returns error when repository call fails', async () => {
|
||||
authRepo.findByEmail.mockRejectedValue(new Error('Database error'));
|
||||
magicLinkRepo.checkRateLimit.mockResolvedValue(Result.ok(undefined));
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
const result = await useCase.execute({ email: 'test@example.com' });
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('REPOSITORY_ERROR');
|
||||
expect(error.details.message).toContain('Database error');
|
||||
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { IMagicLinkRepository } from '../../domain/repositories/IMagicLinkReposi
|
||||
import { IMagicLinkNotificationPort } from '../../domain/ports/IMagicLinkNotificationPort';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort, Logger, UseCase } from '@core/shared/application';
|
||||
import type { Logger, UseCase } from '@core/shared/application';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
export type ForgotPasswordInput = {
|
||||
@@ -27,16 +27,15 @@ export type ForgotPasswordApplicationError = ApplicationErrorCode<ForgotPassword
|
||||
* In production, this would send an email with the magic link.
|
||||
* In development, it returns the link for testing purposes.
|
||||
*/
|
||||
export class ForgotPasswordUseCase implements UseCase<ForgotPasswordInput, void, ForgotPasswordErrorCode> {
|
||||
export class ForgotPasswordUseCase implements UseCase<ForgotPasswordInput, ForgotPasswordResult, ForgotPasswordErrorCode> {
|
||||
constructor(
|
||||
private readonly authRepo: IAuthRepository,
|
||||
private readonly magicLinkRepo: IMagicLinkRepository,
|
||||
private readonly notificationPort: IMagicLinkNotificationPort,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<ForgotPasswordResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: ForgotPasswordInput): Promise<Result<void, ForgotPasswordApplicationError>> {
|
||||
async execute(input: ForgotPasswordInput): Promise<Result<ForgotPasswordResult, ForgotPasswordApplicationError>> {
|
||||
try {
|
||||
// Validate email format
|
||||
const emailVO = EmailAddress.create(input.email);
|
||||
@@ -86,7 +85,7 @@ export class ForgotPasswordUseCase implements UseCase<ForgotPasswordInput, void,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
this.output.present({
|
||||
return Result.ok({
|
||||
message: 'Password reset link generated successfully',
|
||||
magicLink: process.env.NODE_ENV === 'development' ? magicLink : null,
|
||||
});
|
||||
@@ -96,13 +95,11 @@ export class ForgotPasswordUseCase implements UseCase<ForgotPasswordInput, void,
|
||||
email: input.email,
|
||||
});
|
||||
|
||||
this.output.present({
|
||||
return Result.ok({
|
||||
message: 'If an account exists with this email, a password reset link will be sent',
|
||||
magicLink: null,
|
||||
});
|
||||
}
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error && error.message
|
||||
|
||||
@@ -2,11 +2,8 @@ import { vi, type Mock } from 'vitest';
|
||||
import { GetCurrentSessionUseCase } from './GetCurrentSessionUseCase';
|
||||
import { User } from '../../domain/entities/User';
|
||||
import { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
|
||||
type GetCurrentSessionOutput = {
|
||||
user: User;
|
||||
};
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
|
||||
describe('GetCurrentSessionUseCase', () => {
|
||||
let useCase: GetCurrentSessionUseCase;
|
||||
@@ -18,7 +15,6 @@ describe('GetCurrentSessionUseCase', () => {
|
||||
emailExists: Mock;
|
||||
};
|
||||
let logger: Logger;
|
||||
let output: UseCaseOutputPort<GetCurrentSessionOutput> & { present: Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
mockUserRepo = {
|
||||
@@ -34,13 +30,9 @@ describe('GetCurrentSessionUseCase', () => {
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
useCase = new GetCurrentSessionUseCase(
|
||||
mockUserRepo as IUserRepository,
|
||||
logger,
|
||||
output,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -60,11 +52,10 @@ describe('GetCurrentSessionUseCase', () => {
|
||||
|
||||
expect(mockUserRepo.findById).toHaveBeenCalledWith(userId);
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(output.present).toHaveBeenCalled();
|
||||
const callArgs = output.present.mock.calls?.[0]?.[0];
|
||||
expect(callArgs?.user).toBeInstanceOf(User);
|
||||
expect(callArgs?.user.getId().value).toBe(userId);
|
||||
expect(callArgs?.user.getDisplayName()).toBe('John Smith');
|
||||
const sessionResult = result.unwrap();
|
||||
expect(sessionResult.user).toBeInstanceOf(User);
|
||||
expect(sessionResult.user.getId().value).toBe(userId);
|
||||
expect(sessionResult.user.getDisplayName()).toBe('John Smith');
|
||||
});
|
||||
|
||||
it('should return error when user does not exist', async () => {
|
||||
@@ -75,5 +66,6 @@ describe('GetCurrentSessionUseCase', () => {
|
||||
|
||||
expect(mockUserRepo.findById).toHaveBeenCalledWith(userId);
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('USER_NOT_FOUND');
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { User } from '../../domain/entities/User';
|
||||
import { IUserRepository } from '../../domain/repositories/IUserRepository';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
export type GetCurrentSessionInput = {
|
||||
userId: string;
|
||||
@@ -28,11 +28,10 @@ export class GetCurrentSessionUseCase {
|
||||
constructor(
|
||||
private readonly userRepo: IUserRepository,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<GetCurrentSessionResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: GetCurrentSessionInput): Promise<
|
||||
Result<void, GetCurrentSessionApplicationError>
|
||||
Result<GetCurrentSessionResult, GetCurrentSessionApplicationError>
|
||||
> {
|
||||
try {
|
||||
const stored = await this.userRepo.findById(input.userId);
|
||||
@@ -45,9 +44,8 @@ export class GetCurrentSessionUseCase {
|
||||
|
||||
const user = User.fromStored(stored);
|
||||
const result: GetCurrentSessionResult = { user };
|
||||
this.output.present(result);
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok(result);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error && error.message
|
||||
@@ -66,4 +64,4 @@ export class GetCurrentSessionUseCase {
|
||||
} as GetCurrentSessionApplicationError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { GetCurrentUserSessionUseCase } from './GetCurrentUserSessionUseCase';
|
||||
import type { AuthSession, IdentitySessionPort } from '../ports/IdentitySessionPort';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
|
||||
describe('GetCurrentUserSessionUseCase', () => {
|
||||
let sessionPort: {
|
||||
@@ -10,7 +11,6 @@ describe('GetCurrentUserSessionUseCase', () => {
|
||||
clearSession: Mock;
|
||||
};
|
||||
let logger: Logger;
|
||||
let output: UseCaseOutputPort<AuthSession | null> & { present: Mock };
|
||||
let useCase: GetCurrentUserSessionUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -27,14 +27,9 @@ describe('GetCurrentUserSessionUseCase', () => {
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new GetCurrentUserSessionUseCase(
|
||||
sessionPort as unknown as IdentitySessionPort,
|
||||
logger,
|
||||
output,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -57,7 +52,7 @@ describe('GetCurrentUserSessionUseCase', () => {
|
||||
|
||||
expect(sessionPort.getCurrentSession).toHaveBeenCalledTimes(1);
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(output.present).toHaveBeenCalledWith(session);
|
||||
expect(result.unwrap()).toBe(session);
|
||||
});
|
||||
|
||||
it('returns null when there is no active session', async () => {
|
||||
@@ -67,6 +62,6 @@ describe('GetCurrentUserSessionUseCase', () => {
|
||||
|
||||
expect(sessionPort.getCurrentSession).toHaveBeenCalledTimes(1);
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(output.present).toHaveBeenCalledWith(null);
|
||||
expect(result.unwrap()).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AuthSession, IdentitySessionPort } from '../ports/IdentitySessionPort';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
export type GetCurrentUserSessionInput = void;
|
||||
|
||||
@@ -18,16 +18,13 @@ export class GetCurrentUserSessionUseCase {
|
||||
constructor(
|
||||
private readonly sessionPort: IdentitySessionPort,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<GetCurrentUserSessionResult>,
|
||||
) {}
|
||||
|
||||
async execute(): Promise<Result<void, GetCurrentUserSessionApplicationError>> {
|
||||
async execute(): Promise<Result<GetCurrentUserSessionResult, GetCurrentUserSessionApplicationError>> {
|
||||
try {
|
||||
const session = await this.sessionPort.getCurrentSession();
|
||||
|
||||
this.output.present(session);
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok(session);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error && error.message
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { GetUserUseCase } from './GetUserUseCase';
|
||||
import { User } from '../../domain/entities/User';
|
||||
import type { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { IUserRepository } from '../../domain/repositories/IUserRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
|
||||
type GetUserOutput = Result<{ user: User }, unknown>;
|
||||
import { User } from '../../domain/entities/User';
|
||||
import { UserId } from '../../domain/value-objects/UserId';
|
||||
import { PasswordHash } from '../../domain/value-objects/PasswordHash';
|
||||
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
|
||||
|
||||
describe('GetUserUseCase', () => {
|
||||
let userRepository: {
|
||||
let userRepo: {
|
||||
findById: Mock;
|
||||
};
|
||||
let logger: Logger;
|
||||
let output: UseCaseOutputPort<GetUserOutput> & { present: Mock };
|
||||
let useCase: GetUserUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
userRepository = {
|
||||
userRepo = {
|
||||
findById: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -27,48 +27,48 @@ describe('GetUserUseCase', () => {
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new GetUserUseCase(
|
||||
userRepository as unknown as IUserRepository,
|
||||
userRepo as unknown as IUserRepository,
|
||||
logger,
|
||||
output,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a User when the user exists', async () => {
|
||||
const storedUser: StoredUser = {
|
||||
it('returns user when found', async () => {
|
||||
const storedUser = {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
displayName: 'John Smith',
|
||||
passwordHash: 'hash',
|
||||
primaryDriverId: 'driver-1',
|
||||
passwordHash: 'hashed-password',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
userRepository.findById.mockResolvedValue(storedUser);
|
||||
userRepo.findById.mockResolvedValue(storedUser);
|
||||
|
||||
const result = await useCase.execute({ userId: 'user-1' });
|
||||
|
||||
expect(userRepository.findById).toHaveBeenCalledWith('user-1');
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(output.present).toHaveBeenCalled();
|
||||
const callArgs = output.present.mock.calls?.[0]?.[0];
|
||||
expect(callArgs).toBeInstanceOf(Result);
|
||||
const user = (callArgs as GetUserOutput).unwrap().user;
|
||||
expect(user).toBeInstanceOf(User);
|
||||
expect(user.getId().value).toBe('user-1');
|
||||
expect(user.getDisplayName()).toBe('John Smith');
|
||||
const getUserResult = result.unwrap();
|
||||
expect(getUserResult.user).toBeDefined();
|
||||
expect(getUserResult.user.getId().value).toBe('user-1');
|
||||
expect(getUserResult.user.getEmail()).toBe('test@example.com');
|
||||
expect(userRepo.findById).toHaveBeenCalledWith('user-1');
|
||||
});
|
||||
|
||||
it('returns error when the user does not exist', async () => {
|
||||
userRepository.findById.mockResolvedValue(null);
|
||||
it('returns error when user not found', async () => {
|
||||
userRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const result = await useCase.execute({ userId: 'missing-user' });
|
||||
const result = await useCase.execute({ userId: 'nonexistent' });
|
||||
|
||||
expect(userRepository.findById).toHaveBeenCalledWith('missing-user');
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('USER_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('returns error on repository failure', async () => {
|
||||
userRepo.findById.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const result = await useCase.execute({ userId: 'user-1' });
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { User } from '../../domain/entities/User';
|
||||
import { IUserRepository } from '../../domain/repositories/IUserRepository';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort, Logger, UseCase } from '@core/shared/application';
|
||||
import type { Logger, UseCase } from '@core/shared/application';
|
||||
|
||||
export type GetUserInput = {
|
||||
userId: string;
|
||||
@@ -23,25 +23,20 @@ export class GetUserUseCase implements UseCase<GetUserInput, GetUserResult, GetU
|
||||
constructor(
|
||||
private readonly userRepo: IUserRepository,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<Result<GetUserResult, GetUserApplicationError>>,
|
||||
) {}
|
||||
|
||||
async execute(input: GetUserInput): Promise<Result<GetUserResult, GetUserApplicationError>> {
|
||||
try {
|
||||
const stored = await this.userRepo.findById(input.userId);
|
||||
if (!stored) {
|
||||
const result = Result.err<GetUserResult, GetUserApplicationError>({
|
||||
return Result.err<GetUserResult, GetUserApplicationError>({
|
||||
code: 'USER_NOT_FOUND',
|
||||
details: { message: 'User not found' },
|
||||
});
|
||||
this.output.present(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
const user = User.fromStored(stored);
|
||||
const result = Result.ok<GetUserResult, GetUserApplicationError>({ user });
|
||||
this.output.present(result);
|
||||
return result;
|
||||
return Result.ok<GetUserResult, GetUserApplicationError>({ user });
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error && error.message ? error.message : 'Failed to get user';
|
||||
@@ -50,12 +45,10 @@ export class GetUserUseCase implements UseCase<GetUserInput, GetUserResult, GetU
|
||||
input,
|
||||
});
|
||||
|
||||
const result = Result.err<GetUserResult, GetUserApplicationError>({
|
||||
return Result.err<GetUserResult, GetUserApplicationError>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message },
|
||||
});
|
||||
this.output.present(result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { HandleAuthCallbackUseCase } from './HandleAuthCallbackUseCase';
|
||||
import type {
|
||||
AuthCallbackCommand,
|
||||
AuthenticatedUser,
|
||||
IdentityProviderPort,
|
||||
} from '../ports/IdentityProviderPort';
|
||||
import type { AuthSession, IdentitySessionPort } from '../ports/IdentitySessionPort';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { IdentityProviderPort } from '../ports/IdentityProviderPort';
|
||||
import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
|
||||
describe('HandleAuthCallbackUseCase', () => {
|
||||
let provider: {
|
||||
@@ -14,69 +11,97 @@ describe('HandleAuthCallbackUseCase', () => {
|
||||
};
|
||||
let sessionPort: {
|
||||
createSession: Mock;
|
||||
getCurrentSession: Mock;
|
||||
clearSession: Mock;
|
||||
};
|
||||
let logger: Logger;
|
||||
let output: UseCaseOutputPort<AuthSession> & { present: Mock };
|
||||
let logger: Logger & { error: Mock };
|
||||
let useCase: HandleAuthCallbackUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = {
|
||||
completeAuth: vi.fn(),
|
||||
};
|
||||
|
||||
sessionPort = {
|
||||
createSession: vi.fn(),
|
||||
getCurrentSession: vi.fn(),
|
||||
clearSession: vi.fn(),
|
||||
};
|
||||
|
||||
logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
} as unknown as Logger & { error: Mock };
|
||||
|
||||
useCase = new HandleAuthCallbackUseCase(
|
||||
provider as unknown as IdentityProviderPort,
|
||||
sessionPort as unknown as IdentitySessionPort,
|
||||
logger,
|
||||
output,
|
||||
);
|
||||
});
|
||||
|
||||
it('completes auth and creates a session', async () => {
|
||||
const command: AuthCallbackCommand = {
|
||||
provider: 'IRACING_DEMO',
|
||||
code: 'auth-code',
|
||||
state: 'state-123',
|
||||
returnTo: 'https://app/callback',
|
||||
};
|
||||
|
||||
const user: AuthenticatedUser = {
|
||||
it('successfully handles auth callback and creates session', async () => {
|
||||
const authenticatedUser = {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
email: 'test@example.com',
|
||||
};
|
||||
|
||||
const session: AuthSession = {
|
||||
user,
|
||||
const session = {
|
||||
token: 'session-token',
|
||||
user: authenticatedUser,
|
||||
issuedAt: Date.now(),
|
||||
expiresAt: Date.now() + 1000,
|
||||
token: 'session-token',
|
||||
};
|
||||
|
||||
provider.completeAuth.mockResolvedValue(user);
|
||||
provider.completeAuth.mockResolvedValue(authenticatedUser);
|
||||
sessionPort.createSession.mockResolvedValue(session);
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
const result = await useCase.execute({
|
||||
code: 'auth-code',
|
||||
state: 'state-123',
|
||||
returnTo: '/dashboard',
|
||||
});
|
||||
|
||||
expect(provider.completeAuth).toHaveBeenCalledWith(command);
|
||||
expect(sessionPort.createSession).toHaveBeenCalledWith(user);
|
||||
expect(output.present).toHaveBeenCalledWith(session);
|
||||
expect(result.isOk()).toBe(true);
|
||||
const callbackResult = result.unwrap();
|
||||
expect(callbackResult.token).toBe('session-token');
|
||||
expect(callbackResult.user).toBe(authenticatedUser);
|
||||
expect(provider.completeAuth).toHaveBeenCalledWith({
|
||||
code: 'auth-code',
|
||||
state: 'state-123',
|
||||
returnTo: '/dashboard',
|
||||
});
|
||||
expect(sessionPort.createSession).toHaveBeenCalledWith(authenticatedUser);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when provider call fails', async () => {
|
||||
provider.completeAuth.mockRejectedValue(new Error('Auth failed'));
|
||||
|
||||
const result = await useCase.execute({
|
||||
code: 'invalid-code',
|
||||
state: 'state-123',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error when session creation fails', async () => {
|
||||
const authenticatedUser = {
|
||||
id: 'user-1',
|
||||
displayName: 'Test User',
|
||||
email: 'test@example.com',
|
||||
};
|
||||
|
||||
provider.completeAuth.mockResolvedValue(authenticatedUser);
|
||||
sessionPort.createSession.mockRejectedValue(new Error('Session creation failed'));
|
||||
|
||||
const result = await useCase.execute({
|
||||
code: 'auth-code',
|
||||
state: 'state-123',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import type { AuthCallbackCommand, AuthenticatedUser, IdentityProviderPort } fro
|
||||
import type { AuthSession, IdentitySessionPort } from '../ports/IdentitySessionPort';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
export type HandleAuthCallbackInput = AuthCallbackCommand;
|
||||
|
||||
@@ -20,19 +20,16 @@ export class HandleAuthCallbackUseCase {
|
||||
private readonly provider: IdentityProviderPort,
|
||||
private readonly sessionPort: IdentitySessionPort,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<HandleAuthCallbackResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: HandleAuthCallbackInput): Promise<
|
||||
Result<void, HandleAuthCallbackApplicationError>
|
||||
Result<HandleAuthCallbackResult, HandleAuthCallbackApplicationError>
|
||||
> {
|
||||
try {
|
||||
const user: AuthenticatedUser = await this.provider.completeAuth(input);
|
||||
const session = await this.sessionPort.createSession(user);
|
||||
|
||||
this.output.present(session);
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok(session);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error && error.message
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import {
|
||||
LoginUseCase,
|
||||
type LoginInput,
|
||||
type LoginResult,
|
||||
type LoginErrorCode,
|
||||
} from './LoginUseCase';
|
||||
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
|
||||
import { UserId } from '../../domain/value-objects/UserId';
|
||||
import { PasswordHash } from '../../domain/value-objects/PasswordHash';
|
||||
import { LoginUseCase } from './LoginUseCase';
|
||||
import type { IAuthRepository } from '../../domain/repositories/IAuthRepository';
|
||||
import type { IPasswordHashingService } from '../../domain/services/PasswordHashingService';
|
||||
import { User } from '../../domain/entities/User';
|
||||
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import { User } from '../../domain/entities/User';
|
||||
import { UserId } from '../../domain/value-objects/UserId';
|
||||
import { PasswordHash } from '../../domain/value-objects/PasswordHash';
|
||||
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
|
||||
|
||||
describe('LoginUseCase', () => {
|
||||
let authRepo: {
|
||||
@@ -22,129 +16,82 @@ describe('LoginUseCase', () => {
|
||||
let passwordService: {
|
||||
verify: Mock;
|
||||
};
|
||||
let logger: Logger & { error: Mock };
|
||||
let output: UseCaseOutputPort<LoginResult> & { present: Mock };
|
||||
let logger: Logger;
|
||||
let useCase: LoginUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
authRepo = {
|
||||
findByEmail: vi.fn(),
|
||||
};
|
||||
|
||||
passwordService = {
|
||||
verify: vi.fn(),
|
||||
};
|
||||
|
||||
logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
} as unknown as UseCaseOutputPort<LoginResult> & { present: Mock };
|
||||
} as unknown as Logger;
|
||||
|
||||
useCase = new LoginUseCase(
|
||||
authRepo as unknown as IAuthRepository,
|
||||
passwordService as unknown as IPasswordHashingService,
|
||||
logger,
|
||||
output,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns ok and presents user when credentials are valid', async () => {
|
||||
const input: LoginInput = {
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
};
|
||||
const emailVO = EmailAddress.create(input.email);
|
||||
|
||||
it('successfully logs in with valid credentials', async () => {
|
||||
const user = User.create({
|
||||
id: UserId.fromString('user-1'),
|
||||
id: UserId.create(),
|
||||
displayName: 'John Smith',
|
||||
email: emailVO.value,
|
||||
passwordHash: PasswordHash.fromHash('stored-hash'),
|
||||
email: 'test@example.com',
|
||||
passwordHash: PasswordHash.fromHash('hashed-password'),
|
||||
});
|
||||
|
||||
authRepo.findByEmail.mockResolvedValue(user);
|
||||
passwordService.verify.mockResolvedValue(true);
|
||||
|
||||
const result: Result<void, ApplicationErrorCode<LoginErrorCode, { message: string }>> =
|
||||
await useCase.execute(input);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toBeUndefined();
|
||||
|
||||
expect(authRepo.findByEmail).toHaveBeenCalledWith(emailVO);
|
||||
expect(passwordService.verify).toHaveBeenCalledWith(input.password, 'stored-hash');
|
||||
|
||||
expect(output.present).toHaveBeenCalledTimes(1);
|
||||
const presented = output.present.mock.calls[0]![0] as LoginResult;
|
||||
expect(presented.user).toBe(user);
|
||||
});
|
||||
|
||||
it('returns INVALID_CREDENTIALS when user is not found', async () => {
|
||||
const input: LoginInput = {
|
||||
email: 'missing@example.com',
|
||||
password: 'password123',
|
||||
};
|
||||
|
||||
authRepo.findByEmail.mockResolvedValue(null);
|
||||
|
||||
const result: Result<void, ApplicationErrorCode<LoginErrorCode, { message: string }>> =
|
||||
await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
|
||||
expect(error.code).toBe('INVALID_CREDENTIALS');
|
||||
expect(error.details?.message).toBe('Invalid credentials');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns INVALID_CREDENTIALS when password is invalid', async () => {
|
||||
const input: LoginInput = {
|
||||
const result = await useCase.execute({
|
||||
email: 'test@example.com',
|
||||
password: 'wrong-password',
|
||||
};
|
||||
const emailVO = EmailAddress.create(input.email);
|
||||
|
||||
const user = User.create({
|
||||
id: UserId.fromString('user-1'),
|
||||
displayName: 'Jane Smith',
|
||||
email: emailVO.value,
|
||||
passwordHash: PasswordHash.fromHash('stored-hash'),
|
||||
password: 'Password123',
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const loginResult = result.unwrap();
|
||||
expect(loginResult.user).toBe(user);
|
||||
expect(authRepo.findByEmail).toHaveBeenCalledTimes(1);
|
||||
expect(passwordService.verify).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns error for invalid credentials', async () => {
|
||||
const user = User.create({
|
||||
id: UserId.create(),
|
||||
displayName: 'John Smith',
|
||||
email: 'test@example.com',
|
||||
passwordHash: PasswordHash.fromHash('hashed-password'),
|
||||
});
|
||||
authRepo.findByEmail.mockResolvedValue(user);
|
||||
passwordService.verify.mockResolvedValue(false);
|
||||
|
||||
const result: Result<void, ApplicationErrorCode<LoginErrorCode, { message: string }>> =
|
||||
await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
|
||||
expect(error.code).toBe('INVALID_CREDENTIALS');
|
||||
expect(error.details?.message).toBe('Invalid credentials');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wraps unexpected errors as REPOSITORY_ERROR and logs them', async () => {
|
||||
const input: LoginInput = {
|
||||
const result = await useCase.execute({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
};
|
||||
|
||||
authRepo.findByEmail.mockRejectedValue(new Error('DB failure'));
|
||||
|
||||
const result: Result<void, ApplicationErrorCode<LoginErrorCode, { message: string }>> =
|
||||
await useCase.execute(input);
|
||||
password: 'WrongPassword',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
|
||||
expect(error.code).toBe('REPOSITORY_ERROR');
|
||||
expect(error.details?.message).toBe('DB failure');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
expect(result.unwrapErr().code).toBe('INVALID_CREDENTIALS');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when user does not exist', async () => {
|
||||
authRepo.findByEmail.mockResolvedValue(null);
|
||||
|
||||
const result = await useCase.execute({
|
||||
email: 'nonexistent@example.com',
|
||||
password: 'Password123',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('INVALID_CREDENTIALS');
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { IAuthRepository } from '../../domain/repositories/IAuthRepository';
|
||||
import { IPasswordHashingService } from '../../domain/services/PasswordHashingService';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort, Logger, UseCase } from '@core/shared/application';
|
||||
import type { Logger, UseCase } from '@core/shared/application';
|
||||
|
||||
export type LoginInput = {
|
||||
email: string;
|
||||
@@ -24,15 +24,14 @@ export type LoginApplicationError = ApplicationErrorCode<LoginErrorCode, { messa
|
||||
*
|
||||
* Handles user login by verifying credentials.
|
||||
*/
|
||||
export class LoginUseCase implements UseCase<LoginInput, void, LoginErrorCode> {
|
||||
export class LoginUseCase implements UseCase<LoginInput, LoginResult, LoginErrorCode> {
|
||||
constructor(
|
||||
private readonly authRepo: IAuthRepository,
|
||||
private readonly passwordService: IPasswordHashingService,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<LoginResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: LoginInput): Promise<Result<void, LoginApplicationError>> {
|
||||
async execute(input: LoginInput): Promise<Result<LoginResult, LoginApplicationError>> {
|
||||
try {
|
||||
const emailVO = EmailAddress.create(input.email);
|
||||
const user = await this.authRepo.findByEmail(emailVO);
|
||||
@@ -48,14 +47,13 @@ export class LoginUseCase implements UseCase<LoginInput, void, LoginErrorCode> {
|
||||
const isValid = await this.passwordService.verify(input.password, passwordHash.value);
|
||||
|
||||
if (!isValid) {
|
||||
return Result.err<void, LoginApplicationError>({
|
||||
return Result.err<LoginResult, LoginApplicationError>({
|
||||
code: 'INVALID_CREDENTIALS',
|
||||
details: { message: 'Invalid credentials' },
|
||||
});
|
||||
}
|
||||
|
||||
this.output.present({ user });
|
||||
return Result.ok(undefined);
|
||||
return Result.ok({ user });
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error && error.message
|
||||
@@ -66,7 +64,7 @@ export class LoginUseCase implements UseCase<LoginInput, void, LoginErrorCode> {
|
||||
input,
|
||||
});
|
||||
|
||||
return Result.err<void, LoginApplicationError>({
|
||||
return Result.err<LoginResult, LoginApplicationError>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message },
|
||||
});
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import {
|
||||
LoginWithEmailUseCase,
|
||||
type LoginWithEmailInput,
|
||||
type LoginWithEmailResult,
|
||||
type LoginWithEmailErrorCode,
|
||||
} from './LoginWithEmailUseCase';
|
||||
import type { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository';
|
||||
import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest';
|
||||
import { LoginWithEmailUseCase } from './LoginWithEmailUseCase';
|
||||
import type { IUserRepository } from '../../domain/repositories/IUserRepository';
|
||||
import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
|
||||
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
// Mock the PasswordHash module
|
||||
vi.mock('@core/identity/domain/value-objects/PasswordHash', () => ({
|
||||
PasswordHash: {
|
||||
fromHash: vi.fn((hash: string) => ({
|
||||
verify: vi.fn().mockResolvedValue(hash === 'hashed-password'),
|
||||
value: hash,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('LoginWithEmailUseCase', () => {
|
||||
let userRepository: {
|
||||
@@ -17,169 +20,119 @@ describe('LoginWithEmailUseCase', () => {
|
||||
};
|
||||
let sessionPort: {
|
||||
createSession: Mock;
|
||||
getCurrentSession: Mock;
|
||||
clearSession: Mock;
|
||||
};
|
||||
let logger: Logger & { error: Mock };
|
||||
let output: UseCaseOutputPort<LoginWithEmailResult> & { present: Mock };
|
||||
let logger: Logger;
|
||||
let useCase: LoginWithEmailUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
userRepository = {
|
||||
findByEmail: vi.fn(),
|
||||
};
|
||||
|
||||
sessionPort = {
|
||||
createSession: vi.fn(),
|
||||
getCurrentSession: vi.fn(),
|
||||
clearSession: vi.fn(),
|
||||
};
|
||||
|
||||
logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger & { error: Mock };
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
} as unknown as UseCaseOutputPort<LoginWithEmailResult> & { present: Mock };
|
||||
} as unknown as Logger;
|
||||
|
||||
useCase = new LoginWithEmailUseCase(
|
||||
userRepository as unknown as IUserRepository,
|
||||
sessionPort as unknown as IdentitySessionPort,
|
||||
logger,
|
||||
output,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns ok and presents session result for valid credentials', async () => {
|
||||
const input: LoginWithEmailInput = {
|
||||
email: 'Test@Example.com',
|
||||
password: 'password123',
|
||||
};
|
||||
|
||||
// Import PasswordHash to create a proper hash
|
||||
const { PasswordHash } = await import('@core/identity/domain/value-objects/PasswordHash');
|
||||
const passwordHash = await PasswordHash.create('password123');
|
||||
|
||||
const storedUser: StoredUser = {
|
||||
const storedUser = {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
passwordHash: passwordHash.value,
|
||||
displayName: 'John Smith',
|
||||
passwordHash: 'hashed-password',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const session = {
|
||||
userRepository.findByEmail.mockResolvedValue(storedUser);
|
||||
sessionPort.createSession.mockResolvedValue({
|
||||
token: 'token-123',
|
||||
user: {
|
||||
id: storedUser.id,
|
||||
email: storedUser.email,
|
||||
displayName: storedUser.displayName,
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
displayName: 'John Smith',
|
||||
},
|
||||
issuedAt: Date.now(),
|
||||
expiresAt: Date.now() + 1000,
|
||||
token: 'token-123',
|
||||
};
|
||||
|
||||
userRepository.findByEmail.mockResolvedValue(storedUser);
|
||||
sessionPort.createSession.mockResolvedValue(session);
|
||||
|
||||
const result: Result<void, ApplicationErrorCode<LoginWithEmailErrorCode, { message: string }>> =
|
||||
await useCase.execute(input);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toBeUndefined();
|
||||
|
||||
expect(userRepository.findByEmail).toHaveBeenCalledWith('test@example.com');
|
||||
expect(sessionPort.createSession).toHaveBeenCalledWith({
|
||||
id: storedUser.id,
|
||||
displayName: storedUser.displayName,
|
||||
email: storedUser.email,
|
||||
});
|
||||
|
||||
expect(output.present).toHaveBeenCalledTimes(1);
|
||||
const presented = output.present.mock.calls[0]![0] as LoginWithEmailResult;
|
||||
expect(presented.sessionToken).toBe('token-123');
|
||||
expect(presented.userId).toBe(storedUser.id);
|
||||
expect(presented.displayName).toBe(storedUser.displayName);
|
||||
expect(presented.email).toBe(storedUser.email);
|
||||
const result = await useCase.execute({
|
||||
email: 'test@example.com',
|
||||
password: 'Password123',
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const loginResult = result.unwrap();
|
||||
expect(loginResult.sessionToken).toBe('token-123');
|
||||
expect(loginResult.userId).toBe('user-1');
|
||||
expect(loginResult.displayName).toBe('John Smith');
|
||||
expect(loginResult.email).toBe('test@example.com');
|
||||
expect(userRepository.findByEmail).toHaveBeenCalledWith('test@example.com');
|
||||
expect(sessionPort.createSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns INVALID_INPUT when email or password is missing', async () => {
|
||||
const result1 = await useCase.execute({ email: '', password: 'x' });
|
||||
const result2 = await useCase.execute({ email: 'a@example.com', password: '' });
|
||||
const result = await useCase.execute({
|
||||
email: '',
|
||||
password: 'Password123',
|
||||
});
|
||||
|
||||
expect(result1.isErr()).toBe(true);
|
||||
expect(result1.unwrapErr().code).toBe('INVALID_INPUT');
|
||||
expect(result2.isErr()).toBe(true);
|
||||
expect(result2.unwrapErr().code).toBe('INVALID_INPUT');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('INVALID_INPUT');
|
||||
});
|
||||
|
||||
it('returns INVALID_CREDENTIALS when user does not exist', async () => {
|
||||
const input: LoginWithEmailInput = {
|
||||
email: 'missing@example.com',
|
||||
password: 'password',
|
||||
};
|
||||
|
||||
userRepository.findByEmail.mockResolvedValue(null);
|
||||
|
||||
const result: Result<void, ApplicationErrorCode<LoginWithEmailErrorCode, { message: string }>> =
|
||||
await useCase.execute(input);
|
||||
const result = await useCase.execute({
|
||||
email: 'nonexistent@example.com',
|
||||
password: 'Password123',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('INVALID_CREDENTIALS');
|
||||
expect(error.details.message).toBe('Invalid email or password');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
expect(result.unwrapErr().code).toBe('INVALID_CREDENTIALS');
|
||||
});
|
||||
|
||||
it('returns INVALID_CREDENTIALS when password is invalid', async () => {
|
||||
const input: LoginWithEmailInput = {
|
||||
email: 'test@example.com',
|
||||
password: 'wrong',
|
||||
};
|
||||
|
||||
// Create a hash for a different password
|
||||
const { PasswordHash } = await import('@core/identity/domain/value-objects/PasswordHash');
|
||||
const passwordHash = await PasswordHash.create('correct-password');
|
||||
|
||||
const storedUser: StoredUser = {
|
||||
const storedUser = {
|
||||
id: 'user-1',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Test User',
|
||||
passwordHash: passwordHash.value,
|
||||
displayName: 'John Smith',
|
||||
passwordHash: 'wrong-hash', // Different hash to simulate wrong password
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
userRepository.findByEmail.mockResolvedValue(storedUser);
|
||||
|
||||
const result: Result<void, ApplicationErrorCode<LoginWithEmailErrorCode, { message: string }>> =
|
||||
await useCase.execute(input);
|
||||
const result = await useCase.execute({
|
||||
email: 'test@example.com',
|
||||
password: 'WrongPassword',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('INVALID_CREDENTIALS');
|
||||
expect(error.details.message).toBe('Invalid email or password');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
expect(result.unwrapErr().code).toBe('INVALID_CREDENTIALS');
|
||||
});
|
||||
|
||||
it('wraps unexpected errors as REPOSITORY_ERROR and logs them', async () => {
|
||||
const input: LoginWithEmailInput = {
|
||||
userRepository.findByEmail.mockRejectedValue(new Error('Database connection failed'));
|
||||
|
||||
const result = await useCase.execute({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
};
|
||||
|
||||
userRepository.findByEmail.mockRejectedValue(new Error('DB failure'));
|
||||
|
||||
const result: Result<void, ApplicationErrorCode<LoginWithEmailErrorCode, { message: string }>> =
|
||||
await useCase.execute(input);
|
||||
password: 'Password123',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
|
||||
expect(error.code).toBe('REPOSITORY_ERROR');
|
||||
expect(error.details.message).toBe('DB failure');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,8 @@ import type { IUserRepository } from '../../domain/repositories/IUserRepository'
|
||||
import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { PasswordHash } from '@core/identity/domain/value-objects/PasswordHash';
|
||||
|
||||
export type LoginWithEmailInput = {
|
||||
email: string;
|
||||
@@ -40,10 +41,9 @@ export class LoginWithEmailUseCase {
|
||||
private readonly userRepository: IUserRepository,
|
||||
private readonly sessionPort: IdentitySessionPort,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<LoginWithEmailResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: LoginWithEmailInput): Promise<Result<void, LoginWithEmailApplicationError>> {
|
||||
async execute(input: LoginWithEmailInput): Promise<Result<LoginWithEmailResult, LoginWithEmailApplicationError>> {
|
||||
try {
|
||||
if (!input.email || !input.password) {
|
||||
return Result.err({
|
||||
@@ -63,7 +63,6 @@ export class LoginWithEmailUseCase {
|
||||
}
|
||||
|
||||
// Verify password using PasswordHash value object
|
||||
const { PasswordHash } = await import('@core/identity/domain/value-objects/PasswordHash');
|
||||
const storedPasswordHash = PasswordHash.fromHash(user.passwordHash);
|
||||
const isValid = await storedPasswordHash.verify(input.password);
|
||||
|
||||
@@ -99,9 +98,7 @@ export class LoginWithEmailUseCase {
|
||||
expiresAt: session.expiresAt,
|
||||
};
|
||||
|
||||
this.output.present(result);
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok(result);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error && error.message
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { LogoutUseCase, type LogoutResult, type LogoutErrorCode } from './LogoutUseCase';
|
||||
import { LogoutUseCase } from './LogoutUseCase';
|
||||
import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
|
||||
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
|
||||
describe('LogoutUseCase', () => {
|
||||
let sessionPort: {
|
||||
clearSession: Mock;
|
||||
getCurrentSession: Mock;
|
||||
createSession: Mock;
|
||||
};
|
||||
let logger: Logger & { error: Mock };
|
||||
let output: UseCaseOutputPort<LogoutResult> & { present: Mock };
|
||||
let useCase: LogoutUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionPort = {
|
||||
clearSession: vi.fn(),
|
||||
getCurrentSession: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
};
|
||||
|
||||
logger = {
|
||||
@@ -29,42 +23,30 @@ describe('LogoutUseCase', () => {
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger & { error: Mock };
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
} as unknown as UseCaseOutputPort<LogoutResult> & { present: Mock };
|
||||
|
||||
useCase = new LogoutUseCase(
|
||||
sessionPort as unknown as IdentitySessionPort,
|
||||
logger,
|
||||
output,
|
||||
);
|
||||
});
|
||||
|
||||
it('clears the current session and presents success', async () => {
|
||||
const result: Result<void, ApplicationErrorCode<LogoutErrorCode, { message: string }>> =
|
||||
await useCase.execute();
|
||||
it('successfully clears session and returns success', async () => {
|
||||
sessionPort.clearSession.mockResolvedValue(undefined);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toBeUndefined();
|
||||
|
||||
const logoutResult = result.unwrap();
|
||||
expect(logoutResult.success).toBe(true);
|
||||
expect(sessionPort.clearSession).toHaveBeenCalledTimes(1);
|
||||
expect(output.present).toHaveBeenCalledTimes(1);
|
||||
expect(output.present).toHaveBeenCalledWith({ success: true });
|
||||
});
|
||||
|
||||
it('wraps unexpected errors as REPOSITORY_ERROR and logs them', async () => {
|
||||
const error = new Error('Session clear failed');
|
||||
sessionPort.clearSession.mockRejectedValue(error);
|
||||
it('returns error when session clear fails', async () => {
|
||||
sessionPort.clearSession.mockRejectedValue(new Error('Session clear failed'));
|
||||
|
||||
const result: Result<void, ApplicationErrorCode<LogoutErrorCode, { message: string }>> =
|
||||
await useCase.execute();
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const err = result.unwrapErr();
|
||||
expect(err.code).toBe('REPOSITORY_ERROR');
|
||||
expect(err.details.message).toBe('Session clear failed');
|
||||
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort, Logger, UseCase } from '@core/shared/application';
|
||||
import type { Logger, UseCase } from '@core/shared/application';
|
||||
|
||||
export type LogoutInput = {};
|
||||
|
||||
@@ -13,25 +13,23 @@ export type LogoutErrorCode = 'REPOSITORY_ERROR';
|
||||
|
||||
export type LogoutApplicationError = ApplicationErrorCode<LogoutErrorCode, { message: string }>;
|
||||
|
||||
export class LogoutUseCase implements UseCase<LogoutInput, void, LogoutErrorCode> {
|
||||
export class LogoutUseCase implements UseCase<LogoutInput, LogoutResult, LogoutErrorCode> {
|
||||
private readonly sessionPort: IdentitySessionPort;
|
||||
|
||||
constructor(
|
||||
sessionPort: IdentitySessionPort,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<LogoutResult>,
|
||||
) {
|
||||
this.sessionPort = sessionPort;
|
||||
}
|
||||
|
||||
async execute(): Promise<Result<void, LogoutApplicationError>> {
|
||||
async execute(): Promise<Result<LogoutResult, LogoutApplicationError>> {
|
||||
try {
|
||||
await this.sessionPort.clearSession();
|
||||
|
||||
const result: LogoutResult = { success: true };
|
||||
this.output.present(result);
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok(result);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error && error.message
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { ResetPasswordUseCase } from './ResetPasswordUseCase';
|
||||
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
|
||||
import { UserId } from '../../domain/value-objects/UserId';
|
||||
import { User } from '../../domain/entities/User';
|
||||
import type { IAuthRepository } from '../../domain/repositories/IAuthRepository';
|
||||
import type { IMagicLinkRepository } from '../../domain/repositories/IMagicLinkRepository';
|
||||
import type { IPasswordHashingService } from '../../domain/services/PasswordHashingService';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
|
||||
type ResetPasswordOutput = {
|
||||
message: string;
|
||||
};
|
||||
import { User } from '../../domain/entities/User';
|
||||
import { UserId } from '../../domain/value-objects/UserId';
|
||||
import { PasswordHash } from '../../domain/value-objects/PasswordHash';
|
||||
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
|
||||
|
||||
describe('ResetPasswordUseCase', () => {
|
||||
let authRepo: {
|
||||
@@ -26,7 +23,6 @@ describe('ResetPasswordUseCase', () => {
|
||||
hash: Mock;
|
||||
};
|
||||
let logger: Logger;
|
||||
let output: UseCaseOutputPort<ResetPasswordOutput> & { present: Mock };
|
||||
let useCase: ResetPasswordUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -34,206 +30,129 @@ describe('ResetPasswordUseCase', () => {
|
||||
findByEmail: vi.fn(),
|
||||
save: vi.fn(),
|
||||
};
|
||||
|
||||
magicLinkRepo = {
|
||||
findByToken: vi.fn(),
|
||||
markAsUsed: vi.fn(),
|
||||
};
|
||||
|
||||
passwordService = {
|
||||
hash: vi.fn(),
|
||||
};
|
||||
|
||||
logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new ResetPasswordUseCase(
|
||||
authRepo as unknown as IAuthRepository,
|
||||
magicLinkRepo as unknown as IMagicLinkRepository,
|
||||
passwordService as unknown as IPasswordHashingService,
|
||||
logger,
|
||||
output,
|
||||
);
|
||||
});
|
||||
|
||||
it('should reset password with valid token', async () => {
|
||||
const input = {
|
||||
token: 'valid-token-12345678901234567890123456789012',
|
||||
newPassword: 'NewPass123!',
|
||||
};
|
||||
|
||||
it('successfully resets password with valid token', async () => {
|
||||
const user = User.create({
|
||||
id: UserId.create(),
|
||||
displayName: 'John Smith',
|
||||
email: 'test@example.com',
|
||||
passwordHash: PasswordHash.fromHash('old-hash'),
|
||||
});
|
||||
|
||||
const resetRequest = {
|
||||
const validToken = 'a'.repeat(32); // 32 characters minimum
|
||||
magicLinkRepo.findByToken.mockResolvedValue({
|
||||
email: 'test@example.com',
|
||||
token: input.token,
|
||||
expiresAt: new Date(Date.now() + 60000), // 1 minute from now
|
||||
token: validToken,
|
||||
expiresAt: new Date(Date.now() + 60000),
|
||||
userId: user.getId().value,
|
||||
};
|
||||
|
||||
magicLinkRepo.findByToken.mockResolvedValue(resetRequest);
|
||||
authRepo.findByEmail.mockResolvedValue(user);
|
||||
passwordService.hash.mockResolvedValue('hashed-new-password');
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(magicLinkRepo.findByToken).toHaveBeenCalledWith(input.token);
|
||||
expect(authRepo.findByEmail).toHaveBeenCalledWith(EmailAddress.create('test@example.com'));
|
||||
expect(passwordService.hash).toHaveBeenCalledWith(input.newPassword);
|
||||
expect(authRepo.save).toHaveBeenCalled();
|
||||
expect(magicLinkRepo.markAsUsed).toHaveBeenCalledWith(input.token);
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
message: 'Password reset successfully. You can now log in with your new password.',
|
||||
used: false,
|
||||
});
|
||||
authRepo.findByEmail.mockResolvedValue(user);
|
||||
passwordService.hash.mockResolvedValue('new-hashed-password');
|
||||
|
||||
const result = await useCase.execute({
|
||||
token: validToken,
|
||||
newPassword: 'NewPassword123',
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const resetResult = result.unwrap();
|
||||
expect(resetResult.message).toBe('Password reset successfully. You can now log in with your new password.');
|
||||
expect(authRepo.save).toHaveBeenCalled();
|
||||
expect(magicLinkRepo.markAsUsed).toHaveBeenCalledWith(validToken);
|
||||
});
|
||||
|
||||
it('should reject invalid token', async () => {
|
||||
const input = {
|
||||
token: 'invalid-token',
|
||||
newPassword: 'NewPass123!',
|
||||
};
|
||||
|
||||
it('returns error for invalid token', async () => {
|
||||
magicLinkRepo.findByToken.mockResolvedValue(null);
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
const result = await useCase.execute({
|
||||
token: 'invalid-token-that-is-too-short',
|
||||
newPassword: 'NewPassword123',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('INVALID_TOKEN');
|
||||
expect(result.unwrapErr().code).toBe('INVALID_TOKEN');
|
||||
});
|
||||
|
||||
it('should reject expired token', async () => {
|
||||
const input = {
|
||||
token: 'expired-token-12345678901234567890123456789012',
|
||||
newPassword: 'NewPass123!',
|
||||
};
|
||||
|
||||
const resetRequest = {
|
||||
it('returns error for expired token', async () => {
|
||||
const expiredToken = 'b'.repeat(32);
|
||||
magicLinkRepo.findByToken.mockResolvedValue({
|
||||
email: 'test@example.com',
|
||||
token: input.token,
|
||||
expiresAt: new Date(Date.now() - 60000), // 1 minute ago
|
||||
userId: 'user-123',
|
||||
};
|
||||
token: expiredToken,
|
||||
expiresAt: new Date(Date.now() - 60000),
|
||||
userId: 'user-1',
|
||||
used: false,
|
||||
});
|
||||
|
||||
magicLinkRepo.findByToken.mockResolvedValue(resetRequest);
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
const result = await useCase.execute({
|
||||
token: expiredToken,
|
||||
newPassword: 'NewPassword123',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('EXPIRED_TOKEN');
|
||||
expect(result.unwrapErr().code).toBe('EXPIRED_TOKEN');
|
||||
});
|
||||
|
||||
it('should reject weak password', async () => {
|
||||
const input = {
|
||||
token: 'valid-token-12345678901234567890123456789012',
|
||||
newPassword: 'weak',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('WEAK_PASSWORD');
|
||||
});
|
||||
|
||||
it('should reject password without uppercase', async () => {
|
||||
const input = {
|
||||
token: 'valid-token-12345678901234567890123456789012',
|
||||
newPassword: 'newpass123!',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('WEAK_PASSWORD');
|
||||
});
|
||||
|
||||
it('should reject password without number', async () => {
|
||||
const input = {
|
||||
token: 'valid-token-12345678901234567890123456789012',
|
||||
newPassword: 'NewPass!',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('WEAK_PASSWORD');
|
||||
});
|
||||
|
||||
it('should reject password shorter than 8 characters', async () => {
|
||||
const input = {
|
||||
token: 'valid-token-12345678901234567890123456789012',
|
||||
newPassword: 'New1!',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('WEAK_PASSWORD');
|
||||
});
|
||||
|
||||
it('should handle user no longer exists', async () => {
|
||||
const input = {
|
||||
token: 'valid-token-12345678901234567890123456789012',
|
||||
newPassword: 'NewPass123!',
|
||||
};
|
||||
|
||||
const resetRequest = {
|
||||
email: 'deleted@example.com',
|
||||
token: input.token,
|
||||
it('returns error for weak password', async () => {
|
||||
const validToken = 'c'.repeat(32);
|
||||
magicLinkRepo.findByToken.mockResolvedValue({
|
||||
email: 'test@example.com',
|
||||
token: validToken,
|
||||
expiresAt: new Date(Date.now() + 60000),
|
||||
userId: 'user-123',
|
||||
};
|
||||
userId: 'user-1',
|
||||
used: false,
|
||||
});
|
||||
|
||||
magicLinkRepo.findByToken.mockResolvedValue(resetRequest);
|
||||
const result = await useCase.execute({
|
||||
token: validToken,
|
||||
newPassword: 'weak',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('WEAK_PASSWORD');
|
||||
});
|
||||
|
||||
it('returns error when user no longer exists', async () => {
|
||||
const validToken = 'd'.repeat(32);
|
||||
magicLinkRepo.findByToken.mockResolvedValue({
|
||||
email: 'test@example.com',
|
||||
token: validToken,
|
||||
expiresAt: new Date(Date.now() + 60000),
|
||||
userId: 'user-1',
|
||||
used: false,
|
||||
});
|
||||
authRepo.findByEmail.mockResolvedValue(null);
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
const result = await useCase.execute({
|
||||
token: validToken,
|
||||
newPassword: 'NewPassword123',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('INVALID_TOKEN');
|
||||
});
|
||||
|
||||
it('should handle token format validation', async () => {
|
||||
const input = {
|
||||
token: 'short',
|
||||
newPassword: 'NewPass123!',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('INVALID_TOKEN');
|
||||
});
|
||||
|
||||
it('should handle repository errors', async () => {
|
||||
const input = {
|
||||
token: 'valid-token-12345678901234567890123456789012',
|
||||
newPassword: 'NewPass123!',
|
||||
};
|
||||
|
||||
magicLinkRepo.findByToken.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('REPOSITORY_ERROR');
|
||||
expect(error.details.message).toContain('Database error');
|
||||
expect(result.unwrapErr().code).toBe('INVALID_TOKEN');
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { EmailAddress } from '../../domain/value-objects/EmailAddress';
|
||||
import { PasswordHash } from '../../domain/value-objects/PasswordHash';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort, Logger, UseCase } from '@core/shared/application';
|
||||
import type { Logger, UseCase } from '@core/shared/application';
|
||||
|
||||
export type ResetPasswordInput = {
|
||||
token: string;
|
||||
@@ -26,16 +26,15 @@ export type ResetPasswordApplicationError = ApplicationErrorCode<ResetPasswordEr
|
||||
* Handles password reset using a magic link token.
|
||||
* Validates token, checks expiration, and updates password.
|
||||
*/
|
||||
export class ResetPasswordUseCase implements UseCase<ResetPasswordInput, void, ResetPasswordErrorCode> {
|
||||
export class ResetPasswordUseCase implements UseCase<ResetPasswordInput, ResetPasswordResult, ResetPasswordErrorCode> {
|
||||
constructor(
|
||||
private readonly authRepo: IAuthRepository,
|
||||
private readonly magicLinkRepo: IMagicLinkRepository,
|
||||
private readonly passwordService: IPasswordHashingService,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<ResetPasswordResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: ResetPasswordInput): Promise<Result<void, ResetPasswordApplicationError>> {
|
||||
async execute(input: ResetPasswordInput): Promise<Result<ResetPasswordResult, ResetPasswordApplicationError>> {
|
||||
try {
|
||||
// Validate token format
|
||||
if (!input.token || typeof input.token !== 'string' || input.token.length < 32) {
|
||||
@@ -111,11 +110,9 @@ export class ResetPasswordUseCase implements UseCase<ResetPasswordInput, void, R
|
||||
email: resetRequest.email,
|
||||
});
|
||||
|
||||
this.output.present({
|
||||
return Result.ok({
|
||||
message: 'Password reset successfully. You can now log in with your new password.',
|
||||
});
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error && error.message
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { SignupSponsorUseCase } from './SignupSponsorUseCase';
|
||||
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
|
||||
import { UserId } from '../../domain/value-objects/UserId';
|
||||
import { User } from '../../domain/entities/User';
|
||||
import type { IAuthRepository } from '../../domain/repositories/IAuthRepository';
|
||||
import type { ICompanyRepository } from '../../domain/repositories/ICompanyRepository';
|
||||
import type { IPasswordHashingService } from '../../domain/services/PasswordHashingService';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
|
||||
vi.mock('../../domain/value-objects/PasswordHash', () => ({
|
||||
PasswordHash: {
|
||||
fromHash: (hash: string) => ({ value: hash }),
|
||||
},
|
||||
}));
|
||||
|
||||
type SignupSponsorOutput = unknown;
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
|
||||
describe('SignupSponsorUseCase', () => {
|
||||
let authRepo: {
|
||||
@@ -30,7 +20,6 @@ describe('SignupSponsorUseCase', () => {
|
||||
hash: Mock;
|
||||
};
|
||||
let logger: Logger;
|
||||
let output: UseCaseOutputPort<SignupSponsorOutput> & { present: Mock };
|
||||
let useCase: SignupSponsorUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -38,181 +27,123 @@ describe('SignupSponsorUseCase', () => {
|
||||
findByEmail: vi.fn(),
|
||||
save: vi.fn(),
|
||||
};
|
||||
|
||||
companyRepo = {
|
||||
create: vi.fn(),
|
||||
save: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
|
||||
passwordService = {
|
||||
hash: vi.fn(),
|
||||
};
|
||||
|
||||
logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new SignupSponsorUseCase(
|
||||
authRepo as unknown as IAuthRepository,
|
||||
companyRepo as unknown as ICompanyRepository,
|
||||
passwordService as unknown as IPasswordHashingService,
|
||||
logger,
|
||||
output,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates user and company successfully when email is free', async () => {
|
||||
const input = {
|
||||
email: 'sponsor@example.com',
|
||||
password: 'Password123',
|
||||
displayName: 'John Doe',
|
||||
companyName: 'Acme Racing Co.',
|
||||
};
|
||||
|
||||
authRepo.findByEmail.mockResolvedValue(null);
|
||||
passwordService.hash.mockResolvedValue('hashed-password');
|
||||
companyRepo.create.mockImplementation((data) => ({
|
||||
getId: () => 'company-123',
|
||||
getName: data.getName,
|
||||
getOwnerUserId: data.getOwnerUserId,
|
||||
getContactEmail: data.getContactEmail,
|
||||
getId: () => 'company-1',
|
||||
...data,
|
||||
}));
|
||||
companyRepo.save.mockResolvedValue(undefined);
|
||||
authRepo.save.mockResolvedValue(undefined);
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
const result = await useCase.execute({
|
||||
email: 'sponsor@example.com',
|
||||
password: 'Password123',
|
||||
displayName: 'Sponsor User',
|
||||
companyName: 'Sponsor Inc',
|
||||
});
|
||||
|
||||
// Verify the basic flow worked
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(output.present).toHaveBeenCalled();
|
||||
|
||||
// Verify key repository methods were called
|
||||
expect(authRepo.findByEmail).toHaveBeenCalled();
|
||||
expect(passwordService.hash).toHaveBeenCalled();
|
||||
const signupResult = result.unwrap();
|
||||
expect(signupResult.user).toBeDefined();
|
||||
expect(signupResult.company).toBeDefined();
|
||||
expect(signupResult.user.getEmail()).toBe('sponsor@example.com');
|
||||
expect(signupResult.user.getDisplayName()).toBe('Sponsor User');
|
||||
expect(companyRepo.create).toHaveBeenCalled();
|
||||
expect(companyRepo.save).toHaveBeenCalled();
|
||||
expect(authRepo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rolls back company creation when user save fails', async () => {
|
||||
const input = {
|
||||
it('returns error when user already exists', async () => {
|
||||
const existingUser = {
|
||||
getId: () => ({ value: 'existing-user' }),
|
||||
getEmail: () => 'sponsor@example.com',
|
||||
getDisplayName: () => 'Existing User',
|
||||
getPasswordHash: () => ({ value: 'hash' }),
|
||||
};
|
||||
authRepo.findByEmail.mockResolvedValue(existingUser);
|
||||
|
||||
const result = await useCase.execute({
|
||||
email: 'sponsor@example.com',
|
||||
password: 'Password123',
|
||||
displayName: 'John Doe',
|
||||
companyName: 'Acme Racing Co.',
|
||||
};
|
||||
displayName: 'Sponsor User',
|
||||
companyName: 'Sponsor Inc',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('USER_ALREADY_EXISTS');
|
||||
});
|
||||
|
||||
it('returns error for weak password', async () => {
|
||||
const result = await useCase.execute({
|
||||
email: 'sponsor@example.com',
|
||||
password: 'weak',
|
||||
displayName: 'Sponsor User',
|
||||
companyName: 'Sponsor Inc',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('WEAK_PASSWORD');
|
||||
});
|
||||
|
||||
it('returns error for invalid display name', async () => {
|
||||
const result = await useCase.execute({
|
||||
email: 'sponsor@example.com',
|
||||
password: 'Password123',
|
||||
displayName: 'A',
|
||||
companyName: 'Sponsor Inc',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('INVALID_DISPLAY_NAME');
|
||||
});
|
||||
|
||||
it('rolls back company creation on failure', async () => {
|
||||
authRepo.findByEmail.mockResolvedValue(null);
|
||||
passwordService.hash.mockResolvedValue('hashed-password');
|
||||
companyRepo.create.mockImplementation((data) => ({
|
||||
getId: () => 'company-123',
|
||||
getName: data.getName,
|
||||
getOwnerUserId: data.getOwnerUserId,
|
||||
getContactEmail: data.getContactEmail,
|
||||
getId: () => 'company-1',
|
||||
...data,
|
||||
}));
|
||||
companyRepo.save.mockResolvedValue(undefined);
|
||||
authRepo.save.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
// Verify company was deleted (rollback)
|
||||
expect(companyRepo.delete).toHaveBeenCalled();
|
||||
const deletedCompanyId = companyRepo.delete.mock.calls[0][0];
|
||||
expect(deletedCompanyId).toBeDefined();
|
||||
|
||||
// Verify error result
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('REPOSITORY_ERROR');
|
||||
});
|
||||
|
||||
it('fails when user already exists', async () => {
|
||||
const input = {
|
||||
email: 'existing@example.com',
|
||||
const result = await useCase.execute({
|
||||
email: 'sponsor@example.com',
|
||||
password: 'Password123',
|
||||
displayName: 'John Doe',
|
||||
companyName: 'Acme Racing Co.',
|
||||
};
|
||||
|
||||
const existingUser = User.create({
|
||||
id: UserId.create(),
|
||||
displayName: 'Existing User',
|
||||
email: input.email,
|
||||
displayName: 'Sponsor User',
|
||||
companyName: 'Sponsor Inc',
|
||||
});
|
||||
|
||||
authRepo.findByEmail.mockResolvedValue(existingUser);
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('USER_ALREADY_EXISTS');
|
||||
|
||||
// Verify no company was created
|
||||
expect(companyRepo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails when company creation throws an error', async () => {
|
||||
const input = {
|
||||
email: 'sponsor@example.com',
|
||||
password: 'Password123',
|
||||
displayName: 'John Doe',
|
||||
companyName: 'Acme Racing Co.',
|
||||
};
|
||||
|
||||
authRepo.findByEmail.mockResolvedValue(null);
|
||||
passwordService.hash.mockResolvedValue('hashed-password');
|
||||
companyRepo.create.mockImplementation(() => {
|
||||
throw new Error('Invalid company data');
|
||||
});
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('REPOSITORY_ERROR');
|
||||
// The error message might be wrapped, so just check it's a repository error
|
||||
});
|
||||
|
||||
it('fails with weak password', async () => {
|
||||
const input = {
|
||||
email: 'sponsor@example.com',
|
||||
password: 'weak',
|
||||
displayName: 'John Doe',
|
||||
companyName: 'Acme Racing Co.',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('WEAK_PASSWORD');
|
||||
|
||||
// Verify no repository calls
|
||||
expect(authRepo.findByEmail).not.toHaveBeenCalled();
|
||||
expect(companyRepo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails with invalid display name', async () => {
|
||||
const input = {
|
||||
email: 'sponsor@example.com',
|
||||
password: 'Password123',
|
||||
displayName: 'user123', // Invalid - alphanumeric only
|
||||
companyName: 'Acme Racing Co.',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const error = result.unwrapErr();
|
||||
expect(error.code).toBe('INVALID_DISPLAY_NAME');
|
||||
|
||||
// Verify no repository calls
|
||||
expect(authRepo.findByEmail).not.toHaveBeenCalled();
|
||||
expect(companyRepo.create).not.toHaveBeenCalled();
|
||||
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
|
||||
expect(companyRepo.delete).toHaveBeenCalledWith('company-1');
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import { ICompanyRepository } from '../../domain/repositories/ICompanyRepository
|
||||
import { IPasswordHashingService } from '../../domain/services/PasswordHashingService';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort, Logger, UseCase } from '@core/shared/application';
|
||||
import type { Logger, UseCase } from '@core/shared/application';
|
||||
|
||||
export type SignupSponsorInput = {
|
||||
email: string;
|
||||
@@ -31,16 +31,15 @@ export type SignupSponsorApplicationError = ApplicationErrorCode<SignupSponsorEr
|
||||
* Handles sponsor registration by creating both a User and a Company atomically.
|
||||
* If any step fails, the operation is rolled back.
|
||||
*/
|
||||
export class SignupSponsorUseCase implements UseCase<SignupSponsorInput, void, SignupSponsorErrorCode> {
|
||||
export class SignupSponsorUseCase implements UseCase<SignupSponsorInput, SignupSponsorResult, SignupSponsorErrorCode> {
|
||||
constructor(
|
||||
private readonly authRepo: IAuthRepository,
|
||||
private readonly companyRepo: ICompanyRepository,
|
||||
private readonly passwordService: IPasswordHashingService,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<SignupSponsorResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: SignupSponsorInput): Promise<Result<void, SignupSponsorApplicationError>> {
|
||||
async execute(input: SignupSponsorInput): Promise<Result<SignupSponsorResult, SignupSponsorApplicationError>> {
|
||||
let createdCompany: Company | null = null;
|
||||
|
||||
try {
|
||||
@@ -118,8 +117,7 @@ export class SignupSponsorUseCase implements UseCase<SignupSponsorInput, void, S
|
||||
// Save user
|
||||
await this.authRepo.save(user);
|
||||
|
||||
this.output.present({ user, company });
|
||||
return Result.ok(undefined);
|
||||
return Result.ok({ user, company });
|
||||
} catch (error) {
|
||||
// Rollback: delete company if it was created
|
||||
if (createdCompany && typeof createdCompany.getId === 'function') {
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { SignupUseCase } from './SignupUseCase';
|
||||
import { EmailAddress } from '../../domain/value-objects/EmailAddress';
|
||||
import { UserId } from '../../domain/value-objects/UserId';
|
||||
import { User } from '../../domain/entities/User';
|
||||
import type { IAuthRepository } from '../../domain/repositories/IAuthRepository';
|
||||
import type { IPasswordHashingService } from '../../domain/services/PasswordHashingService';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
|
||||
vi.mock('../../domain/value-objects/PasswordHash', () => ({
|
||||
PasswordHash: {
|
||||
fromHash: (hash: string) => ({ value: hash }),
|
||||
},
|
||||
}));
|
||||
|
||||
type SignupOutput = unknown;
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { User } from '../../domain/entities/User';
|
||||
import { UserId } from '../../domain/value-objects/UserId';
|
||||
import { PasswordHash } from '../../domain/value-objects/PasswordHash';
|
||||
|
||||
describe('SignupUseCase', () => {
|
||||
let authRepo: {
|
||||
@@ -24,7 +16,6 @@ describe('SignupUseCase', () => {
|
||||
hash: Mock;
|
||||
};
|
||||
let logger: Logger;
|
||||
let output: UseCaseOutputPort<SignupOutput> & { present: Mock };
|
||||
let useCase: SignupUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -32,64 +23,82 @@ describe('SignupUseCase', () => {
|
||||
findByEmail: vi.fn(),
|
||||
save: vi.fn(),
|
||||
};
|
||||
|
||||
passwordService = {
|
||||
hash: vi.fn(),
|
||||
};
|
||||
|
||||
logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new SignupUseCase(
|
||||
authRepo as unknown as IAuthRepository,
|
||||
passwordService as unknown as IPasswordHashingService,
|
||||
logger,
|
||||
output,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates and saves a new user when email is free', async () => {
|
||||
const input = {
|
||||
email: 'new@example.com',
|
||||
password: 'Password123',
|
||||
displayName: 'New User',
|
||||
};
|
||||
|
||||
it('successfully signs up a new user', async () => {
|
||||
authRepo.findByEmail.mockResolvedValue(null);
|
||||
passwordService.hash.mockResolvedValue('hashed-password');
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(authRepo.findByEmail).toHaveBeenCalledWith(EmailAddress.create(input.email));
|
||||
expect(passwordService.hash).toHaveBeenCalledWith(input.password);
|
||||
expect(authRepo.save).toHaveBeenCalled();
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(output.present).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when user already exists', async () => {
|
||||
const input = {
|
||||
email: 'existing@example.com',
|
||||
password: 'password123',
|
||||
displayName: 'Existing User',
|
||||
};
|
||||
|
||||
const existingUser = User.create({
|
||||
id: UserId.create(),
|
||||
displayName: 'Existing User',
|
||||
email: input.email,
|
||||
const result = await useCase.execute({
|
||||
email: 'test@example.com',
|
||||
password: 'Password123',
|
||||
displayName: 'John Smith', // Valid name that passes validation
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const signupResult = result.unwrap();
|
||||
expect(signupResult.user).toBeDefined();
|
||||
expect(signupResult.user.getEmail()).toBe('test@example.com');
|
||||
expect(signupResult.user.getDisplayName()).toBe('John Smith');
|
||||
expect(authRepo.findByEmail).toHaveBeenCalledTimes(1);
|
||||
expect(authRepo.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns error when user already exists', async () => {
|
||||
const existingUser = User.create({
|
||||
id: UserId.create(),
|
||||
displayName: 'John Smith',
|
||||
email: 'test@example.com',
|
||||
passwordHash: PasswordHash.fromHash('existing-hash'),
|
||||
});
|
||||
authRepo.findByEmail.mockResolvedValue(existingUser);
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
const result = await useCase.execute({
|
||||
email: 'test@example.com',
|
||||
password: 'Password123',
|
||||
displayName: 'John Smith',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('USER_ALREADY_EXISTS');
|
||||
});
|
||||
|
||||
it('returns error for weak password', async () => {
|
||||
const result = await useCase.execute({
|
||||
email: 'test@example.com',
|
||||
password: 'weak',
|
||||
displayName: 'John Smith',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('WEAK_PASSWORD');
|
||||
});
|
||||
|
||||
it('returns error for invalid display name', async () => {
|
||||
const result = await useCase.execute({
|
||||
email: 'test@example.com',
|
||||
password: 'Password123',
|
||||
displayName: 'A', // Too short
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('INVALID_DISPLAY_NAME');
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,10 @@ import { UserId } from '../../domain/value-objects/UserId';
|
||||
import { User } from '../../domain/entities/User';
|
||||
import { IAuthRepository } from '../../domain/repositories/IAuthRepository';
|
||||
import { IPasswordHashingService } from '../../domain/services/PasswordHashingService';
|
||||
import { PasswordHash } from '../../domain/value-objects/PasswordHash';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort, Logger, UseCase } from '@core/shared/application';
|
||||
import type { Logger, UseCase } from '@core/shared/application';
|
||||
|
||||
export type SignupInput = {
|
||||
email: string;
|
||||
@@ -26,15 +27,14 @@ export type SignupApplicationError = ApplicationErrorCode<SignupErrorCode, { mes
|
||||
*
|
||||
* Handles user registration.
|
||||
*/
|
||||
export class SignupUseCase implements UseCase<SignupInput, void, SignupErrorCode> {
|
||||
export class SignupUseCase implements UseCase<SignupInput, SignupResult, SignupErrorCode> {
|
||||
constructor(
|
||||
private readonly authRepo: IAuthRepository,
|
||||
private readonly passwordService: IPasswordHashingService,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<SignupResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: SignupInput): Promise<Result<void, SignupApplicationError>> {
|
||||
async execute(input: SignupInput): Promise<Result<SignupResult, SignupApplicationError>> {
|
||||
try {
|
||||
// Validate email format
|
||||
const emailVO = EmailAddress.create(input.email);
|
||||
@@ -58,8 +58,7 @@ export class SignupUseCase implements UseCase<SignupInput, void, SignupErrorCode
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await this.passwordService.hash(input.password);
|
||||
const passwordHashModule = await import('../../domain/value-objects/PasswordHash');
|
||||
const passwordHash = passwordHashModule.PasswordHash.fromHash(hashedPassword);
|
||||
const passwordHash = PasswordHash.fromHash(hashedPassword);
|
||||
|
||||
// Create user (displayName validation happens in User entity constructor)
|
||||
const userId = UserId.create();
|
||||
@@ -72,8 +71,7 @@ export class SignupUseCase implements UseCase<SignupInput, void, SignupErrorCode
|
||||
|
||||
await this.authRepo.save(user);
|
||||
|
||||
this.output.present({ user });
|
||||
return Result.ok(undefined);
|
||||
return Result.ok({ user });
|
||||
} catch (error) {
|
||||
// Handle specific validation errors from User entity
|
||||
if (error instanceof Error) {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { SignupWithEmailUseCase } from './SignupWithEmailUseCase';
|
||||
import type { SignupWithEmailInput } from './SignupWithEmailUseCase';
|
||||
import type { IUserRepository, StoredUser } from '../../domain/repositories/IUserRepository';
|
||||
import type { AuthSession, IdentitySessionPort } from '../ports/IdentitySessionPort';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
|
||||
type SignupWithEmailOutput = unknown;
|
||||
import type { IUserRepository } from '../../domain/repositories/IUserRepository';
|
||||
import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
|
||||
describe('SignupWithEmailUseCase', () => {
|
||||
let userRepository: {
|
||||
@@ -14,11 +12,8 @@ describe('SignupWithEmailUseCase', () => {
|
||||
};
|
||||
let sessionPort: {
|
||||
createSession: Mock;
|
||||
getCurrentSession: Mock;
|
||||
clearSession: Mock;
|
||||
};
|
||||
let logger: Logger;
|
||||
let output: UseCaseOutputPort<SignupWithEmailOutput> & { present: Mock };
|
||||
let useCase: SignupWithEmailUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -26,130 +21,106 @@ describe('SignupWithEmailUseCase', () => {
|
||||
findByEmail: vi.fn(),
|
||||
create: vi.fn(),
|
||||
};
|
||||
|
||||
sessionPort = {
|
||||
createSession: vi.fn(),
|
||||
getCurrentSession: vi.fn(),
|
||||
clearSession: vi.fn(),
|
||||
};
|
||||
|
||||
logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new SignupWithEmailUseCase(
|
||||
userRepository as unknown as IUserRepository,
|
||||
sessionPort as unknown as IdentitySessionPort,
|
||||
logger,
|
||||
output,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a new user and session for valid input', async () => {
|
||||
const command: SignupWithEmailInput = {
|
||||
email: 'new@example.com',
|
||||
password: 'password123',
|
||||
displayName: 'New User',
|
||||
};
|
||||
|
||||
userRepository.findByEmail.mockResolvedValue(null);
|
||||
|
||||
const session: AuthSession = {
|
||||
userRepository.create.mockResolvedValue(undefined);
|
||||
sessionPort.createSession.mockResolvedValue({
|
||||
token: 'session-token',
|
||||
user: {
|
||||
id: 'user-1',
|
||||
email: command.email.toLowerCase(),
|
||||
displayName: command.displayName,
|
||||
displayName: 'Test User',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
issuedAt: Date.now(),
|
||||
expiresAt: Date.now() + 1000,
|
||||
token: 'session-token',
|
||||
};
|
||||
});
|
||||
|
||||
sessionPort.createSession.mockResolvedValue(session);
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
|
||||
expect(userRepository.findByEmail).toHaveBeenCalledWith(command.email);
|
||||
expect(userRepository.create).toHaveBeenCalled();
|
||||
expect(sessionPort.createSession).toHaveBeenCalledWith({
|
||||
id: expect.any(String),
|
||||
email: command.email.toLowerCase(),
|
||||
displayName: command.displayName,
|
||||
const result = await useCase.execute({
|
||||
email: 'test@example.com',
|
||||
password: 'Password123',
|
||||
displayName: 'Test User',
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
sessionToken: 'session-token',
|
||||
userId: 'user-1',
|
||||
displayName: 'New User',
|
||||
email: 'new@example.com',
|
||||
createdAt: expect.any(Date),
|
||||
isNewUser: true,
|
||||
});
|
||||
const signupResult = result.unwrap();
|
||||
expect(signupResult.sessionToken).toBe('session-token');
|
||||
expect(signupResult.userId).toBe('user-1');
|
||||
expect(signupResult.displayName).toBe('Test User');
|
||||
expect(signupResult.email).toBe('test@example.com');
|
||||
expect(signupResult.isNewUser).toBe(true);
|
||||
expect(userRepository.findByEmail).toHaveBeenCalledWith('test@example.com');
|
||||
expect(userRepository.create).toHaveBeenCalled();
|
||||
expect(sessionPort.createSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error when email format is invalid', async () => {
|
||||
const command: SignupWithEmailInput = {
|
||||
it('returns error for invalid email format', async () => {
|
||||
const result = await useCase.execute({
|
||||
email: 'invalid-email',
|
||||
password: 'password123',
|
||||
displayName: 'User',
|
||||
};
|
||||
password: 'Password123',
|
||||
displayName: 'Test User',
|
||||
});
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
expect(result.isErr()).toBe(true);
|
||||
const err = result.unwrapErr();
|
||||
expect(err.code).toBe('INVALID_EMAIL_FORMAT');
|
||||
expect(result.unwrapErr().code).toBe('INVALID_EMAIL_FORMAT');
|
||||
});
|
||||
|
||||
it('returns error when password is too short', async () => {
|
||||
const command: SignupWithEmailInput = {
|
||||
email: 'valid@example.com',
|
||||
password: 'short',
|
||||
displayName: 'User',
|
||||
};
|
||||
it('returns error for weak password', async () => {
|
||||
const result = await useCase.execute({
|
||||
email: 'test@example.com',
|
||||
password: 'weak',
|
||||
displayName: 'Test User',
|
||||
});
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
expect(result.isErr()).toBe(true);
|
||||
const err = result.unwrapErr();
|
||||
expect(err.code).toBe('WEAK_PASSWORD');
|
||||
expect(result.unwrapErr().code).toBe('WEAK_PASSWORD');
|
||||
});
|
||||
|
||||
it('returns error when display name is too short', async () => {
|
||||
const command: SignupWithEmailInput = {
|
||||
email: 'valid@example.com',
|
||||
password: 'password123',
|
||||
displayName: ' ',
|
||||
};
|
||||
it('returns error for invalid display name', async () => {
|
||||
const result = await useCase.execute({
|
||||
email: 'test@example.com',
|
||||
password: 'Password123',
|
||||
displayName: 'A',
|
||||
});
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
expect(result.isErr()).toBe(true);
|
||||
const err = result.unwrapErr();
|
||||
expect(err.code).toBe('INVALID_DISPLAY_NAME');
|
||||
expect(result.unwrapErr().code).toBe('INVALID_DISPLAY_NAME');
|
||||
});
|
||||
|
||||
it('returns error when email already exists', async () => {
|
||||
const command: SignupWithEmailInput = {
|
||||
email: 'existing@example.com',
|
||||
password: 'password123',
|
||||
userRepository.findByEmail.mockResolvedValue({
|
||||
id: 'existing-user',
|
||||
email: 'test@example.com',
|
||||
displayName: 'Existing User',
|
||||
};
|
||||
|
||||
const existingUser: StoredUser = {
|
||||
id: 'user-1',
|
||||
email: command.email,
|
||||
displayName: command.displayName,
|
||||
passwordHash: 'hash',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
});
|
||||
|
||||
userRepository.findByEmail.mockResolvedValue(existingUser);
|
||||
const result = await useCase.execute({
|
||||
email: 'test@example.com',
|
||||
password: 'Password123',
|
||||
displayName: 'Test User',
|
||||
});
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
expect(result.isErr()).toBe(true);
|
||||
const err = result.unwrapErr();
|
||||
expect(err.code).toBe('EMAIL_ALREADY_EXISTS');
|
||||
expect(result.unwrapErr().code).toBe('EMAIL_ALREADY_EXISTS');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import type { AuthenticatedUser } from '../ports/IdentityProviderPort';
|
||||
import type { IdentitySessionPort } from '../ports/IdentitySessionPort';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
export type SignupWithEmailInput = {
|
||||
email: string;
|
||||
@@ -43,11 +43,10 @@ export class SignupWithEmailUseCase {
|
||||
private readonly userRepository: IUserRepository,
|
||||
private readonly sessionPort: IdentitySessionPort,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<SignupWithEmailResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: SignupWithEmailInput): Promise<
|
||||
Result<void, SignupWithEmailApplicationError>
|
||||
Result<SignupWithEmailResult, SignupWithEmailApplicationError>
|
||||
> {
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
@@ -119,9 +118,7 @@ export class SignupWithEmailUseCase {
|
||||
isNewUser: true,
|
||||
};
|
||||
|
||||
this.output.present(result);
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok(result);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error && error.message
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import {
|
||||
StartAuthUseCase,
|
||||
type StartAuthInput,
|
||||
type StartAuthResult,
|
||||
type StartAuthErrorCode,
|
||||
} from './StartAuthUseCase';
|
||||
import { StartAuthUseCase } from './StartAuthUseCase';
|
||||
import type { IdentityProviderPort } from '../ports/IdentityProviderPort';
|
||||
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
|
||||
describe('StartAuthUseCase', () => {
|
||||
@@ -15,7 +9,6 @@ describe('StartAuthUseCase', () => {
|
||||
startAuth: Mock;
|
||||
};
|
||||
let logger: Logger & { error: Mock };
|
||||
let output: UseCaseOutputPort<StartAuthResult> & { present: Mock };
|
||||
let useCase: StartAuthUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -30,60 +23,58 @@ describe('StartAuthUseCase', () => {
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger & { error: Mock };
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
} as unknown as UseCaseOutputPort<StartAuthResult> & { present: Mock };
|
||||
|
||||
useCase = new StartAuthUseCase(
|
||||
provider as unknown as IdentityProviderPort,
|
||||
logger,
|
||||
output,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns ok and presents redirect when provider call succeeds', async () => {
|
||||
const input: StartAuthInput = {
|
||||
provider: 'IRACING_DEMO',
|
||||
returnTo: 'https://app/callback',
|
||||
};
|
||||
|
||||
const expected = { redirectUrl: 'https://auth/redirect', state: 'state-123' };
|
||||
|
||||
provider.startAuth.mockResolvedValue(expected);
|
||||
|
||||
const result: Result<void, ApplicationErrorCode<StartAuthErrorCode, { message: string }>> =
|
||||
await useCase.execute(input);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toBeUndefined();
|
||||
|
||||
expect(provider.startAuth).toHaveBeenCalledWith({
|
||||
provider: input.provider,
|
||||
returnTo: input.returnTo,
|
||||
provider.startAuth.mockResolvedValue({
|
||||
redirectUrl: 'https://auth/redirect',
|
||||
state: 'state-123',
|
||||
});
|
||||
|
||||
expect(output.present).toHaveBeenCalledTimes(1);
|
||||
const presented = output.present.mock.calls[0]![0] as StartAuthResult;
|
||||
expect(presented).toEqual(expected);
|
||||
const result = await useCase.execute({
|
||||
provider: 'iracing',
|
||||
returnTo: '/dashboard',
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const startAuthResult = result.unwrap();
|
||||
expect(startAuthResult.redirectUrl).toBe('https://auth/redirect');
|
||||
expect(startAuthResult.state).toBe('state-123');
|
||||
expect(provider.startAuth).toHaveBeenCalledWith({
|
||||
provider: 'iracing',
|
||||
returnTo: '/dashboard',
|
||||
});
|
||||
});
|
||||
|
||||
it('wraps unexpected errors as REPOSITORY_ERROR and logs them', async () => {
|
||||
const input: StartAuthInput = {
|
||||
provider: 'IRACING_DEMO',
|
||||
returnTo: 'https://app/callback',
|
||||
};
|
||||
it('returns ok without returnTo when not provided', async () => {
|
||||
provider.startAuth.mockResolvedValue({
|
||||
redirectUrl: 'https://auth/redirect',
|
||||
state: 'state-123',
|
||||
});
|
||||
|
||||
provider.startAuth.mockRejectedValue(new Error('Provider failure'));
|
||||
const result = await useCase.execute({
|
||||
provider: 'iracing',
|
||||
});
|
||||
|
||||
const result: Result<void, ApplicationErrorCode<StartAuthErrorCode, { message: string }>> =
|
||||
await useCase.execute(input);
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(provider.startAuth).toHaveBeenCalledWith({
|
||||
provider: 'iracing',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when provider call fails', async () => {
|
||||
provider.startAuth.mockRejectedValue(new Error('Provider error'));
|
||||
|
||||
const result = await useCase.execute({
|
||||
provider: 'iracing',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const err = result.unwrapErr();
|
||||
expect(err.code).toBe('REPOSITORY_ERROR');
|
||||
expect(err.details.message).toBe('Provider failure');
|
||||
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { IdentityProviderPort, AuthProvider, StartAuthCommand } from '../ports/IdentityProviderPort';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
export type StartAuthInput = {
|
||||
provider: AuthProvider;
|
||||
@@ -21,10 +21,9 @@ export class StartAuthUseCase {
|
||||
constructor(
|
||||
private readonly provider: IdentityProviderPort,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<StartAuthResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: StartAuthInput): Promise<Result<void, StartAuthApplicationError>> {
|
||||
async execute(input: StartAuthInput): Promise<Result<StartAuthResult, StartAuthApplicationError>> {
|
||||
try {
|
||||
const command: StartAuthCommand = input.returnTo
|
||||
? {
|
||||
@@ -38,9 +37,8 @@ export class StartAuthUseCase {
|
||||
const { redirectUrl, state } = await this.provider.startAuth(command);
|
||||
|
||||
const result: StartAuthResult = { redirectUrl, state };
|
||||
this.output.present(result);
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok(result);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error && error.message
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { CreateAchievementUseCase, type IAchievementRepository } from './CreateAchievementUseCase';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import { Achievement } from '@core/identity/domain/entities/Achievement';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
|
||||
type CreateAchievementOutput = {
|
||||
achievement: Achievement;
|
||||
};
|
||||
|
||||
describe('CreateAchievementUseCase', () => {
|
||||
let achievementRepository: {
|
||||
@@ -13,7 +10,6 @@ describe('CreateAchievementUseCase', () => {
|
||||
findById: Mock;
|
||||
};
|
||||
let logger: Logger;
|
||||
let output: UseCaseOutputPort<CreateAchievementOutput> & { present: Mock };
|
||||
let useCase: CreateAchievementUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -29,46 +25,50 @@ describe('CreateAchievementUseCase', () => {
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new CreateAchievementUseCase(
|
||||
achievementRepository as unknown as IAchievementRepository,
|
||||
logger,
|
||||
output,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates an achievement and persists it', async () => {
|
||||
const props = {
|
||||
id: 'achv-1',
|
||||
name: 'First Win',
|
||||
description: 'Awarded for winning your first race',
|
||||
id: 'achievement-1',
|
||||
name: 'First Race',
|
||||
description: 'Complete your first race',
|
||||
category: 'driver' as const,
|
||||
rarity: 'common' as const,
|
||||
iconUrl: 'https://example.com/icon.png',
|
||||
points: 50,
|
||||
requirements: [
|
||||
{
|
||||
type: 'wins' as const,
|
||||
value: 1,
|
||||
operator: '>=' as const,
|
||||
},
|
||||
],
|
||||
points: 10,
|
||||
requirements: [{ type: 'races_completed' as const, value: 1, operator: '>=' as const }],
|
||||
isSecret: false,
|
||||
};
|
||||
|
||||
achievementRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
const result = await useCase.execute(props);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(achievementRepository.save).toHaveBeenCalledTimes(1);
|
||||
const savedAchievement = achievementRepository.save.mock.calls?.[0]?.[0];
|
||||
expect(savedAchievement).toBeInstanceOf(Achievement);
|
||||
expect(savedAchievement.id).toBe(props.id);
|
||||
expect(savedAchievement.name).toBe(props.name);
|
||||
expect(output.present).toHaveBeenCalledWith({ achievement: savedAchievement });
|
||||
const createResult = result.unwrap();
|
||||
expect(createResult.achievement).toBeDefined();
|
||||
expect(createResult.achievement.id).toBe(props.id);
|
||||
expect(createResult.achievement.name).toBe(props.name);
|
||||
expect(achievementRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error when repository save fails', async () => {
|
||||
achievementRepository.save.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const result = await useCase.execute({
|
||||
id: 'achievement-1',
|
||||
name: 'First Race',
|
||||
description: 'Complete your first race',
|
||||
category: 'driver' as const,
|
||||
rarity: 'common' as const,
|
||||
points: 10,
|
||||
requirements: [{ type: 'races_completed' as const, value: 1, operator: '>=' as const }],
|
||||
isSecret: false,
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
|
||||
expect(logger.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Achievement, AchievementProps } from '@core/identity/domain/entities/Achievement';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort, Logger } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
|
||||
export interface IAchievementRepository {
|
||||
save(achievement: Achievement): Promise<void>;
|
||||
@@ -25,20 +25,18 @@ export class CreateAchievementUseCase {
|
||||
constructor(
|
||||
private readonly achievementRepository: IAchievementRepository,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<CreateAchievementResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: CreateAchievementInput): Promise<
|
||||
Result<void, CreateAchievementApplicationError>
|
||||
Result<CreateAchievementResult, CreateAchievementApplicationError>
|
||||
> {
|
||||
try {
|
||||
const achievement = Achievement.create(input);
|
||||
await this.achievementRepository.save(achievement);
|
||||
|
||||
const result: CreateAchievementResult = { achievement };
|
||||
this.output.present(result);
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok(result);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error && error.message
|
||||
@@ -57,4 +55,4 @@ export class CreateAchievementUseCase {
|
||||
} as CreateAchievementApplicationError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,21 +2,15 @@ import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import {
|
||||
DeleteMediaUseCase,
|
||||
type DeleteMediaInput,
|
||||
type DeleteMediaResult,
|
||||
type DeleteMediaErrorCode,
|
||||
} from './DeleteMediaUseCase';
|
||||
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
|
||||
import type { MediaStoragePort } from '../ports/MediaStoragePort';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import { Media } from '../../domain/entities/Media';
|
||||
|
||||
interface TestOutputPort extends UseCaseOutputPort<DeleteMediaResult> {
|
||||
present: Mock;
|
||||
result?: DeleteMediaResult;
|
||||
}
|
||||
|
||||
describe('DeleteMediaUseCase', () => {
|
||||
let mediaRepo: {
|
||||
findById: Mock;
|
||||
@@ -26,7 +20,6 @@ describe('DeleteMediaUseCase', () => {
|
||||
deleteMedia: Mock;
|
||||
};
|
||||
let logger: Logger;
|
||||
let output: TestOutputPort;
|
||||
let useCase: DeleteMediaUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -46,16 +39,9 @@ describe('DeleteMediaUseCase', () => {
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
output = {
|
||||
present: vi.fn((result: DeleteMediaResult) => {
|
||||
output.result = result;
|
||||
}),
|
||||
} as unknown as TestOutputPort;
|
||||
|
||||
useCase = new DeleteMediaUseCase(
|
||||
mediaRepo as unknown as IMediaRepository,
|
||||
mediaStorage as unknown as MediaStoragePort,
|
||||
output,
|
||||
logger,
|
||||
);
|
||||
});
|
||||
@@ -74,10 +60,9 @@ describe('DeleteMediaUseCase', () => {
|
||||
{ message: string }
|
||||
>;
|
||||
expect(err.code).toBe('MEDIA_NOT_FOUND');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes media from storage and repository on success', async () => {
|
||||
it('returns DeleteMediaResult on success', async () => {
|
||||
const media = Media.create({
|
||||
id: 'media-1',
|
||||
filename: 'file.png',
|
||||
@@ -98,7 +83,9 @@ describe('DeleteMediaUseCase', () => {
|
||||
expect(mediaRepo.findById).toHaveBeenCalledWith('media-1');
|
||||
expect(mediaStorage.deleteMedia).toHaveBeenCalledWith(media.url.value);
|
||||
expect(mediaRepo.delete).toHaveBeenCalledWith('media-1');
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult).toEqual({
|
||||
mediaId: 'media-1',
|
||||
deleted: true,
|
||||
});
|
||||
@@ -117,6 +104,5 @@ describe('DeleteMediaUseCase', () => {
|
||||
{ message: string }
|
||||
>;
|
||||
expect(err.code).toBe('REPOSITORY_ERROR');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
|
||||
import type { MediaStoragePort } from '../ports/MediaStoragePort';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
|
||||
@@ -30,11 +30,10 @@ export class DeleteMediaUseCase {
|
||||
constructor(
|
||||
private readonly mediaRepo: IMediaRepository,
|
||||
private readonly mediaStorage: MediaStoragePort,
|
||||
private readonly output: UseCaseOutputPort<DeleteMediaResult>,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(input: DeleteMediaInput): Promise<Result<void, DeleteMediaApplicationError>> {
|
||||
async execute(input: DeleteMediaInput): Promise<Result<DeleteMediaResult, DeleteMediaApplicationError>> {
|
||||
this.logger.info('[DeleteMediaUseCase] Deleting media', {
|
||||
mediaId: input.mediaId,
|
||||
});
|
||||
@@ -43,7 +42,7 @@ export class DeleteMediaUseCase {
|
||||
const media = await this.mediaRepo.findById(input.mediaId);
|
||||
|
||||
if (!media) {
|
||||
return Result.err<void, DeleteMediaApplicationError>({
|
||||
return Result.err<DeleteMediaResult, DeleteMediaApplicationError>({
|
||||
code: 'MEDIA_NOT_FOUND',
|
||||
details: { message: 'Media not found' },
|
||||
});
|
||||
@@ -52,16 +51,14 @@ export class DeleteMediaUseCase {
|
||||
await this.mediaStorage.deleteMedia(media.url.value);
|
||||
await this.mediaRepo.delete(input.mediaId);
|
||||
|
||||
this.output.present({
|
||||
mediaId: input.mediaId,
|
||||
deleted: true,
|
||||
});
|
||||
|
||||
this.logger.info('[DeleteMediaUseCase] Media deleted successfully', {
|
||||
mediaId: input.mediaId,
|
||||
});
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok({
|
||||
mediaId: input.mediaId,
|
||||
deleted: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
@@ -69,7 +66,7 @@ export class DeleteMediaUseCase {
|
||||
mediaId: input.mediaId,
|
||||
});
|
||||
|
||||
return Result.err<void, DeleteMediaApplicationError>({
|
||||
return Result.err<DeleteMediaResult, DeleteMediaApplicationError>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message || 'Unexpected repository error' },
|
||||
});
|
||||
|
||||
@@ -2,27 +2,20 @@ import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import {
|
||||
GetAvatarUseCase,
|
||||
type GetAvatarInput,
|
||||
type GetAvatarResult,
|
||||
type GetAvatarErrorCode,
|
||||
} from './GetAvatarUseCase';
|
||||
import type { IAvatarRepository } from '../../domain/repositories/IAvatarRepository';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import { Avatar } from '../../domain/entities/Avatar';
|
||||
|
||||
interface TestOutputPort extends UseCaseOutputPort<GetAvatarResult> {
|
||||
present: Mock;
|
||||
result?: GetAvatarResult;
|
||||
}
|
||||
|
||||
describe('GetAvatarUseCase', () => {
|
||||
let avatarRepo: {
|
||||
findActiveByDriverId: Mock;
|
||||
save: Mock;
|
||||
};
|
||||
let logger: Logger;
|
||||
let output: TestOutputPort;
|
||||
let useCase: GetAvatarUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -38,15 +31,8 @@ describe('GetAvatarUseCase', () => {
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
output = {
|
||||
present: vi.fn((result: GetAvatarResult) => {
|
||||
output.result = result;
|
||||
}),
|
||||
} as unknown as TestOutputPort;
|
||||
|
||||
useCase = new GetAvatarUseCase(
|
||||
avatarRepo as unknown as IAvatarRepository,
|
||||
output,
|
||||
logger,
|
||||
);
|
||||
});
|
||||
@@ -65,10 +51,9 @@ describe('GetAvatarUseCase', () => {
|
||||
{ message: string }
|
||||
>;
|
||||
expect(err.code).toBe('AVATAR_NOT_FOUND');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('presents avatar details when avatar exists', async () => {
|
||||
it('returns GetAvatarResult when avatar exists', async () => {
|
||||
const avatar = Avatar.create({
|
||||
id: 'avatar-1',
|
||||
driverId: 'driver-1',
|
||||
@@ -82,7 +67,9 @@ describe('GetAvatarUseCase', () => {
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(avatarRepo.findActiveByDriverId).toHaveBeenCalledWith('driver-1');
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult).toEqual({
|
||||
avatar: {
|
||||
id: avatar.id,
|
||||
driverId: avatar.driverId,
|
||||
@@ -105,6 +92,5 @@ describe('GetAvatarUseCase', () => {
|
||||
{ message: string }
|
||||
>;
|
||||
expect(err.code).toBe('REPOSITORY_ERROR');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { IAvatarRepository } from '../../domain/repositories/IAvatarRepository';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
|
||||
@@ -32,11 +32,10 @@ export type GetAvatarApplicationError = ApplicationErrorCode<
|
||||
export class GetAvatarUseCase {
|
||||
constructor(
|
||||
private readonly avatarRepo: IAvatarRepository,
|
||||
private readonly output: UseCaseOutputPort<GetAvatarResult>,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(input: GetAvatarInput): Promise<Result<void, GetAvatarApplicationError>> {
|
||||
async execute(input: GetAvatarInput): Promise<Result<GetAvatarResult, GetAvatarApplicationError>> {
|
||||
this.logger.info('[GetAvatarUseCase] Getting avatar', {
|
||||
driverId: input.driverId,
|
||||
});
|
||||
@@ -45,13 +44,13 @@ export class GetAvatarUseCase {
|
||||
const avatar = await this.avatarRepo.findActiveByDriverId(input.driverId);
|
||||
|
||||
if (!avatar) {
|
||||
return Result.err({
|
||||
return Result.err<GetAvatarResult, GetAvatarApplicationError>({
|
||||
code: 'AVATAR_NOT_FOUND',
|
||||
details: { message: 'Avatar not found' },
|
||||
});
|
||||
}
|
||||
|
||||
this.output.present({
|
||||
return Result.ok({
|
||||
avatar: {
|
||||
id: avatar.id,
|
||||
driverId: avatar.driverId,
|
||||
@@ -59,8 +58,6 @@ export class GetAvatarUseCase {
|
||||
selectedAt: avatar.selectedAt,
|
||||
},
|
||||
});
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
@@ -68,7 +65,7 @@ export class GetAvatarUseCase {
|
||||
driverId: input.driverId,
|
||||
});
|
||||
|
||||
return Result.err({
|
||||
return Result.err<GetAvatarResult, GetAvatarApplicationError>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message ?? 'Unexpected repository error' },
|
||||
});
|
||||
|
||||
@@ -2,26 +2,19 @@ import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import {
|
||||
GetMediaUseCase,
|
||||
type GetMediaInput,
|
||||
type GetMediaResult,
|
||||
type GetMediaErrorCode,
|
||||
} from './GetMediaUseCase';
|
||||
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import { Media } from '../../domain/entities/Media';
|
||||
|
||||
interface TestOutputPort extends UseCaseOutputPort<GetMediaResult> {
|
||||
present: Mock;
|
||||
result?: GetMediaResult;
|
||||
}
|
||||
|
||||
describe('GetMediaUseCase', () => {
|
||||
let mediaRepo: {
|
||||
findById: Mock;
|
||||
};
|
||||
let logger: Logger;
|
||||
let output: TestOutputPort;
|
||||
let useCase: GetMediaUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -36,15 +29,8 @@ describe('GetMediaUseCase', () => {
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
output = {
|
||||
present: vi.fn((result: GetMediaResult) => {
|
||||
output.result = result;
|
||||
}),
|
||||
} as unknown as TestOutputPort;
|
||||
|
||||
useCase = new GetMediaUseCase(
|
||||
mediaRepo as unknown as IMediaRepository,
|
||||
output,
|
||||
logger,
|
||||
);
|
||||
});
|
||||
@@ -60,10 +46,9 @@ describe('GetMediaUseCase', () => {
|
||||
expect(result.isErr()).toBe(true);
|
||||
const err = result.unwrapErr() as ApplicationErrorCode<GetMediaErrorCode, { message: string }>;
|
||||
expect(err.code).toBe('MEDIA_NOT_FOUND');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('presents media details when media exists', async () => {
|
||||
it('returns GetMediaResult when media exists', async () => {
|
||||
const media = Media.create({
|
||||
id: 'media-1',
|
||||
filename: 'file.png',
|
||||
@@ -82,7 +67,9 @@ describe('GetMediaUseCase', () => {
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(mediaRepo.findById).toHaveBeenCalledWith('media-1');
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult).toEqual({
|
||||
media: {
|
||||
id: media.id,
|
||||
filename: media.filename,
|
||||
@@ -109,4 +96,4 @@ describe('GetMediaUseCase', () => {
|
||||
const err = result.unwrapErr() as ApplicationErrorCode<GetMediaErrorCode, { message: string }>;
|
||||
expect(err.code).toBe('REPOSITORY_ERROR');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
|
||||
@@ -33,13 +33,12 @@ export type GetMediaErrorCode = 'MEDIA_NOT_FOUND' | 'REPOSITORY_ERROR';
|
||||
export class GetMediaUseCase {
|
||||
constructor(
|
||||
private readonly mediaRepo: IMediaRepository,
|
||||
private readonly output: UseCaseOutputPort<GetMediaResult>,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
input: GetMediaInput,
|
||||
): Promise<Result<void, ApplicationErrorCode<GetMediaErrorCode, { message: string }>>> {
|
||||
): Promise<Result<GetMediaResult, ApplicationErrorCode<GetMediaErrorCode, { message: string }>>> {
|
||||
this.logger.info('[GetMediaUseCase] Getting media', {
|
||||
mediaId: input.mediaId,
|
||||
});
|
||||
@@ -48,7 +47,7 @@ export class GetMediaUseCase {
|
||||
const media = await this.mediaRepo.findById(input.mediaId);
|
||||
|
||||
if (!media) {
|
||||
return Result.err<void, ApplicationErrorCode<GetMediaErrorCode, { message: string }>>({
|
||||
return Result.err<GetMediaResult, ApplicationErrorCode<GetMediaErrorCode, { message: string }>>({
|
||||
code: 'MEDIA_NOT_FOUND',
|
||||
details: { message: 'Media not found' },
|
||||
});
|
||||
@@ -70,16 +69,14 @@ export class GetMediaUseCase {
|
||||
mediaResult.metadata = media.metadata;
|
||||
}
|
||||
|
||||
this.output.present({ media: mediaResult });
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok({ media: mediaResult });
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.error('[GetMediaUseCase] Error getting media', err, {
|
||||
mediaId: input.mediaId,
|
||||
});
|
||||
|
||||
return Result.err<void, ApplicationErrorCode<GetMediaErrorCode, { message: string }>>({
|
||||
return Result.err<GetMediaResult, ApplicationErrorCode<GetMediaErrorCode, { message: string }>>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message },
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import {
|
||||
@@ -16,16 +16,10 @@ vi.mock('uuid', () => ({
|
||||
v4: () => 'request-1',
|
||||
}));
|
||||
|
||||
interface TestOutputPort extends UseCaseOutputPort<RequestAvatarGenerationResult> {
|
||||
present: Mock;
|
||||
result?: RequestAvatarGenerationResult;
|
||||
}
|
||||
|
||||
describe('RequestAvatarGenerationUseCase', () => {
|
||||
let avatarRepo: { save: Mock };
|
||||
let faceValidation: { validateFacePhoto: Mock };
|
||||
let avatarGeneration: { generateAvatars: Mock };
|
||||
let output: TestOutputPort;
|
||||
let logger: Logger;
|
||||
let useCase: RequestAvatarGenerationUseCase;
|
||||
|
||||
@@ -42,12 +36,6 @@ describe('RequestAvatarGenerationUseCase', () => {
|
||||
generateAvatars: vi.fn(),
|
||||
};
|
||||
|
||||
output = {
|
||||
present: vi.fn((result: RequestAvatarGenerationResult) => {
|
||||
output.result = result;
|
||||
}),
|
||||
} as unknown as TestOutputPort;
|
||||
|
||||
logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
@@ -59,12 +47,11 @@ describe('RequestAvatarGenerationUseCase', () => {
|
||||
avatarRepo as unknown as IAvatarGenerationRepository,
|
||||
faceValidation as unknown as FaceValidationPort,
|
||||
avatarGeneration as unknown as AvatarGenerationPort,
|
||||
output,
|
||||
logger,
|
||||
);
|
||||
});
|
||||
|
||||
it('completes generation and presents avatar URLs', async () => {
|
||||
it('returns RequestAvatarGenerationResult on success', async () => {
|
||||
faceValidation.validateFacePhoto.mockResolvedValue({
|
||||
isValid: true,
|
||||
hasFace: true,
|
||||
@@ -92,7 +79,8 @@ describe('RequestAvatarGenerationUseCase', () => {
|
||||
expect(avatarGeneration.generateAvatars).toHaveBeenCalled();
|
||||
expect(avatarRepo.save).toHaveBeenCalledTimes(4);
|
||||
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult).toEqual({
|
||||
requestId: 'request-1',
|
||||
status: 'completed',
|
||||
avatarUrls: ['https://example.com/a.png', 'https://example.com/b.png'],
|
||||
@@ -113,7 +101,7 @@ describe('RequestAvatarGenerationUseCase', () => {
|
||||
suitColor: 'red' as unknown as RequestAvatarGenerationInput['suitColor'],
|
||||
};
|
||||
|
||||
const result: Result<void, ApplicationErrorCode<RequestAvatarGenerationErrorCode, { message: string }>> =
|
||||
const result: Result<RequestAvatarGenerationResult, ApplicationErrorCode<RequestAvatarGenerationErrorCode, { message: string }>> =
|
||||
await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
@@ -122,7 +110,6 @@ describe('RequestAvatarGenerationUseCase', () => {
|
||||
expect(err.details?.message).toBe('Bad image');
|
||||
|
||||
expect(avatarGeneration.generateAvatars).not.toHaveBeenCalled();
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
expect(avatarRepo.save).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
@@ -145,7 +132,7 @@ describe('RequestAvatarGenerationUseCase', () => {
|
||||
suitColor: 'red' as unknown as RequestAvatarGenerationInput['suitColor'],
|
||||
};
|
||||
|
||||
const result: Result<void, ApplicationErrorCode<RequestAvatarGenerationErrorCode, { message: string }>> =
|
||||
const result: Result<RequestAvatarGenerationResult, ApplicationErrorCode<RequestAvatarGenerationErrorCode, { message: string }>> =
|
||||
await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
@@ -153,7 +140,6 @@ describe('RequestAvatarGenerationUseCase', () => {
|
||||
expect(err.code).toBe('GENERATION_FAILED');
|
||||
expect(err.details?.message).toBe('Generation service down');
|
||||
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
expect(avatarRepo.save).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
@@ -166,7 +152,7 @@ describe('RequestAvatarGenerationUseCase', () => {
|
||||
suitColor: 'red' as unknown as RequestAvatarGenerationInput['suitColor'],
|
||||
};
|
||||
|
||||
const result: Result<void, ApplicationErrorCode<RequestAvatarGenerationErrorCode, { message: string }>> =
|
||||
const result: Result<RequestAvatarGenerationResult, ApplicationErrorCode<RequestAvatarGenerationErrorCode, { message: string }>> =
|
||||
await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
@@ -174,7 +160,6 @@ describe('RequestAvatarGenerationUseCase', () => {
|
||||
expect(err.code).toBe('REPOSITORY_ERROR');
|
||||
expect(err.details?.message).toBe('DB error');
|
||||
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
expect((logger.error as unknown as Mock)).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository';
|
||||
import type { FaceValidationPort } from '../ports/FaceValidationPort';
|
||||
import type { AvatarGenerationPort } from '../ports/AvatarGenerationPort';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { AvatarGenerationRequest } from '../../domain/entities/AvatarGenerationRequest';
|
||||
import type { RacingSuitColor } from '../../domain/types/AvatarGenerationRequest';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
@@ -42,13 +42,12 @@ export class RequestAvatarGenerationUseCase {
|
||||
private readonly avatarRepo: IAvatarGenerationRepository,
|
||||
private readonly faceValidation: FaceValidationPort,
|
||||
private readonly avatarGeneration: AvatarGenerationPort,
|
||||
private readonly output: UseCaseOutputPort<RequestAvatarGenerationResult>,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
input: RequestAvatarGenerationInput,
|
||||
): Promise<Result<void, RequestAvatarGenerationApplicationError>> {
|
||||
): Promise<Result<RequestAvatarGenerationResult, RequestAvatarGenerationApplicationError>> {
|
||||
this.logger.info('[RequestAvatarGenerationUseCase] Starting avatar generation request', {
|
||||
userId: input.userId,
|
||||
suitColor: input.suitColor,
|
||||
@@ -82,7 +81,7 @@ export class RequestAvatarGenerationUseCase {
|
||||
request.fail(errorMessage);
|
||||
await this.avatarRepo.save(request);
|
||||
|
||||
return Result.err<void, RequestAvatarGenerationApplicationError>({
|
||||
return Result.err<RequestAvatarGenerationResult, RequestAvatarGenerationApplicationError>({
|
||||
code: 'FACE_VALIDATION_FAILED',
|
||||
details: { message: errorMessage },
|
||||
});
|
||||
@@ -106,7 +105,7 @@ export class RequestAvatarGenerationUseCase {
|
||||
request.fail(errorMessage);
|
||||
await this.avatarRepo.save(request);
|
||||
|
||||
return Result.err<void, RequestAvatarGenerationApplicationError>({
|
||||
return Result.err<RequestAvatarGenerationResult, RequestAvatarGenerationApplicationError>({
|
||||
code: 'GENERATION_FAILED',
|
||||
details: { message: errorMessage },
|
||||
});
|
||||
@@ -116,19 +115,17 @@ export class RequestAvatarGenerationUseCase {
|
||||
request.completeWithAvatars(avatarUrls);
|
||||
await this.avatarRepo.save(request);
|
||||
|
||||
this.output.present({
|
||||
requestId,
|
||||
status: 'completed',
|
||||
avatarUrls,
|
||||
});
|
||||
|
||||
this.logger.info('[RequestAvatarGenerationUseCase] Avatar generation completed', {
|
||||
requestId,
|
||||
userId: input.userId,
|
||||
avatarCount: avatarUrls.length,
|
||||
});
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok({
|
||||
requestId,
|
||||
status: 'completed',
|
||||
avatarUrls,
|
||||
});
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
@@ -136,7 +133,7 @@ export class RequestAvatarGenerationUseCase {
|
||||
userId: input.userId,
|
||||
});
|
||||
|
||||
return Result.err({
|
||||
return Result.err<RequestAvatarGenerationResult, RequestAvatarGenerationApplicationError>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message ?? 'Internal error occurred during avatar generation' },
|
||||
});
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import {
|
||||
SelectAvatarUseCase,
|
||||
type SelectAvatarErrorCode,
|
||||
type SelectAvatarInput,
|
||||
type SelectAvatarResult,
|
||||
} from './SelectAvatarUseCase';
|
||||
import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository';
|
||||
import { AvatarGenerationRequest } from '../../domain/entities/AvatarGenerationRequest';
|
||||
|
||||
interface TestOutputPort extends UseCaseOutputPort<SelectAvatarResult> {
|
||||
present: Mock;
|
||||
result?: SelectAvatarResult;
|
||||
}
|
||||
|
||||
describe('SelectAvatarUseCase', () => {
|
||||
let avatarRepo: { findById: Mock; save: Mock };
|
||||
let logger: Logger;
|
||||
let output: TestOutputPort;
|
||||
let useCase: SelectAvatarUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -35,15 +28,8 @@ describe('SelectAvatarUseCase', () => {
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
output = {
|
||||
present: vi.fn((result: SelectAvatarResult) => {
|
||||
output.result = result;
|
||||
}),
|
||||
} as unknown as TestOutputPort;
|
||||
|
||||
useCase = new SelectAvatarUseCase(
|
||||
avatarRepo as unknown as IAvatarGenerationRepository,
|
||||
output,
|
||||
logger,
|
||||
);
|
||||
});
|
||||
@@ -60,7 +46,6 @@ describe('SelectAvatarUseCase', () => {
|
||||
|
||||
const err = result.unwrapErr() as ApplicationErrorCode<SelectAvatarErrorCode, { message: string }>;
|
||||
expect(err.code).toBe('REQUEST_NOT_FOUND');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns REQUEST_NOT_COMPLETED when request is not completed', async () => {
|
||||
@@ -81,10 +66,9 @@ describe('SelectAvatarUseCase', () => {
|
||||
expect(err.code).toBe('REQUEST_NOT_COMPLETED');
|
||||
|
||||
expect(avatarRepo.save).not.toHaveBeenCalled();
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selects avatar and presents selected URL when request is completed', async () => {
|
||||
it('returns SelectAvatarResult when request is completed', async () => {
|
||||
const request = AvatarGenerationRequest.create({
|
||||
id: 'req-1',
|
||||
userId: 'user-1',
|
||||
@@ -103,7 +87,9 @@ describe('SelectAvatarUseCase', () => {
|
||||
expect(request.selectedAvatarUrl).toBe('https://example.com/b.png');
|
||||
|
||||
expect(avatarRepo.save).toHaveBeenCalledWith(request);
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult).toEqual({
|
||||
requestId: 'req-1',
|
||||
selectedAvatarUrl: 'https://example.com/b.png',
|
||||
});
|
||||
@@ -120,7 +106,6 @@ describe('SelectAvatarUseCase', () => {
|
||||
expect(err.code).toBe('REPOSITORY_ERROR');
|
||||
expect(err.details?.message).toBe('DB error');
|
||||
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
expect((logger.error as unknown as Mock)).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { IAvatarGenerationRepository } from '../../domain/repositories/IAvatarGenerationRepository';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
|
||||
@@ -32,11 +32,10 @@ export type SelectAvatarApplicationError = ApplicationErrorCode<
|
||||
export class SelectAvatarUseCase {
|
||||
constructor(
|
||||
private readonly avatarRepo: IAvatarGenerationRepository,
|
||||
private readonly output: UseCaseOutputPort<SelectAvatarResult>,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(input: SelectAvatarInput): Promise<Result<void, SelectAvatarApplicationError>> {
|
||||
async execute(input: SelectAvatarInput): Promise<Result<SelectAvatarResult, SelectAvatarApplicationError>> {
|
||||
this.logger.info('[SelectAvatarUseCase] Selecting avatar', {
|
||||
requestId: input.requestId,
|
||||
selectedIndex: input.selectedIndex,
|
||||
@@ -46,14 +45,14 @@ export class SelectAvatarUseCase {
|
||||
const request = await this.avatarRepo.findById(input.requestId);
|
||||
|
||||
if (!request) {
|
||||
return Result.err<void, SelectAvatarApplicationError>({
|
||||
return Result.err<SelectAvatarResult, SelectAvatarApplicationError>({
|
||||
code: 'REQUEST_NOT_FOUND',
|
||||
details: { message: 'Avatar generation request not found' },
|
||||
});
|
||||
}
|
||||
|
||||
if (request.status !== 'completed') {
|
||||
return Result.err<void, SelectAvatarApplicationError>({
|
||||
return Result.err<SelectAvatarResult, SelectAvatarApplicationError>({
|
||||
code: 'REQUEST_NOT_COMPLETED',
|
||||
details: { message: 'Avatar generation is not completed yet' },
|
||||
});
|
||||
@@ -64,17 +63,15 @@ export class SelectAvatarUseCase {
|
||||
|
||||
const selectedAvatarUrl = request.selectedAvatarUrl!;
|
||||
|
||||
this.output.present({
|
||||
requestId: input.requestId,
|
||||
selectedAvatarUrl,
|
||||
});
|
||||
|
||||
this.logger.info('[SelectAvatarUseCase] Avatar selected successfully', {
|
||||
requestId: input.requestId,
|
||||
selectedAvatarUrl,
|
||||
});
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok({
|
||||
requestId: input.requestId,
|
||||
selectedAvatarUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
@@ -82,7 +79,7 @@ export class SelectAvatarUseCase {
|
||||
requestId: input.requestId,
|
||||
});
|
||||
|
||||
return Result.err({
|
||||
return Result.err<SelectAvatarResult, SelectAvatarApplicationError>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message ?? 'Unexpected repository error' },
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import {
|
||||
@@ -15,15 +15,9 @@ vi.mock('uuid', () => ({
|
||||
v4: () => 'avatar-1',
|
||||
}));
|
||||
|
||||
interface TestOutputPort extends UseCaseOutputPort<UpdateAvatarResult> {
|
||||
present: Mock;
|
||||
result?: UpdateAvatarResult;
|
||||
}
|
||||
|
||||
describe('UpdateAvatarUseCase', () => {
|
||||
let avatarRepo: { findActiveByDriverId: Mock; save: Mock };
|
||||
let logger: Logger;
|
||||
let output: TestOutputPort;
|
||||
let useCase: UpdateAvatarUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -39,15 +33,8 @@ describe('UpdateAvatarUseCase', () => {
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
output = {
|
||||
present: vi.fn((result: UpdateAvatarResult) => {
|
||||
output.result = result;
|
||||
}),
|
||||
} as unknown as TestOutputPort;
|
||||
|
||||
useCase = new UpdateAvatarUseCase(
|
||||
avatarRepo as unknown as IAvatarRepository,
|
||||
output,
|
||||
logger,
|
||||
);
|
||||
});
|
||||
@@ -73,7 +60,8 @@ describe('UpdateAvatarUseCase', () => {
|
||||
expect(saved.mediaUrl.value).toBe('https://example.com/avatar.png');
|
||||
expect(saved.isActive).toBe(true);
|
||||
|
||||
expect(output.present).toHaveBeenCalledWith({ avatarId: 'avatar-1', driverId: 'driver-1' });
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult).toEqual({ avatarId: 'avatar-1', driverId: 'driver-1' });
|
||||
});
|
||||
|
||||
it('deactivates current avatar before saving new avatar', async () => {
|
||||
@@ -105,7 +93,8 @@ describe('UpdateAvatarUseCase', () => {
|
||||
expect(secondSaved.id).toBe('avatar-1');
|
||||
expect(secondSaved.isActive).toBe(true);
|
||||
|
||||
expect(output.present).toHaveBeenCalledWith({ avatarId: 'avatar-1', driverId: 'driver-1' });
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult).toEqual({ avatarId: 'avatar-1', driverId: 'driver-1' });
|
||||
});
|
||||
|
||||
it('returns REPOSITORY_ERROR when repository throws', async () => {
|
||||
@@ -116,7 +105,7 @@ describe('UpdateAvatarUseCase', () => {
|
||||
mediaUrl: 'https://example.com/avatar.png',
|
||||
};
|
||||
|
||||
const result: Result<void, ApplicationErrorCode<UpdateAvatarErrorCode, { message: string }>> =
|
||||
const result: Result<UpdateAvatarResult, ApplicationErrorCode<UpdateAvatarErrorCode, { message: string }>> =
|
||||
await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
@@ -124,7 +113,6 @@ describe('UpdateAvatarUseCase', () => {
|
||||
expect(err.code).toBe('REPOSITORY_ERROR');
|
||||
expect(err.details?.message).toBe('DB error');
|
||||
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
expect((logger.error as unknown as Mock)).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
* Handles the business logic for updating a driver's avatar.
|
||||
*/
|
||||
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@@ -32,11 +32,10 @@ export type UpdateAvatarApplicationError = ApplicationErrorCode<
|
||||
export class UpdateAvatarUseCase {
|
||||
constructor(
|
||||
private readonly avatarRepo: IAvatarRepository,
|
||||
private readonly output: UseCaseOutputPort<UpdateAvatarResult>,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(input: UpdateAvatarInput): Promise<Result<void, UpdateAvatarApplicationError>> {
|
||||
async execute(input: UpdateAvatarInput): Promise<Result<UpdateAvatarResult, UpdateAvatarApplicationError>> {
|
||||
this.logger.info('[UpdateAvatarUseCase] Updating avatar', {
|
||||
driverId: input.driverId,
|
||||
mediaUrl: input.mediaUrl,
|
||||
@@ -58,17 +57,15 @@ export class UpdateAvatarUseCase {
|
||||
|
||||
await this.avatarRepo.save(newAvatar);
|
||||
|
||||
this.output.present({
|
||||
avatarId: avatarId.toString(),
|
||||
driverId: input.driverId,
|
||||
});
|
||||
|
||||
this.logger.info('[UpdateAvatarUseCase] Avatar updated successfully', {
|
||||
driverId: input.driverId,
|
||||
avatarId: avatarId.toString(),
|
||||
});
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok({
|
||||
avatarId: avatarId.toString(),
|
||||
driverId: input.driverId,
|
||||
});
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
@@ -76,7 +73,7 @@ export class UpdateAvatarUseCase {
|
||||
driverId: input.driverId,
|
||||
});
|
||||
|
||||
return Result.err({
|
||||
return Result.err<UpdateAvatarResult, UpdateAvatarApplicationError>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message ?? 'Internal error occurred while updating avatar' },
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import {
|
||||
@@ -18,16 +18,10 @@ vi.mock('uuid', () => ({
|
||||
v4: () => 'media-1',
|
||||
}));
|
||||
|
||||
interface TestOutputPort extends UseCaseOutputPort<UploadMediaResult> {
|
||||
present: Mock;
|
||||
result?: UploadMediaResult;
|
||||
}
|
||||
|
||||
describe('UploadMediaUseCase', () => {
|
||||
let mediaRepo: { save: Mock };
|
||||
let mediaStorage: { uploadMedia: Mock };
|
||||
let logger: Logger;
|
||||
let output: TestOutputPort;
|
||||
let useCase: UploadMediaUseCase;
|
||||
|
||||
const baseFile: MulterFile = {
|
||||
@@ -59,16 +53,9 @@ describe('UploadMediaUseCase', () => {
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
output = {
|
||||
present: vi.fn((result: UploadMediaResult) => {
|
||||
output.result = result;
|
||||
}),
|
||||
} as unknown as TestOutputPort;
|
||||
|
||||
useCase = new UploadMediaUseCase(
|
||||
mediaRepo as unknown as IMediaRepository,
|
||||
mediaStorage as unknown as MediaStoragePort,
|
||||
output,
|
||||
logger,
|
||||
);
|
||||
});
|
||||
@@ -80,7 +67,7 @@ describe('UploadMediaUseCase', () => {
|
||||
});
|
||||
|
||||
const input: UploadMediaInput = { file: baseFile, uploadedBy: 'user-1' };
|
||||
const result: Result<void, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>> =
|
||||
const result: Result<UploadMediaResult, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>> =
|
||||
await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
@@ -89,10 +76,9 @@ describe('UploadMediaUseCase', () => {
|
||||
expect(err.details?.message).toBe('Upload error');
|
||||
|
||||
expect(mediaRepo.save).not.toHaveBeenCalled();
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates media and presents mediaId/url on success (includes metadata)', async () => {
|
||||
it('returns UploadMediaResult on success (includes metadata)', async () => {
|
||||
mediaStorage.uploadMedia.mockResolvedValue({
|
||||
success: true,
|
||||
url: 'https://example.com/media.png',
|
||||
@@ -128,7 +114,8 @@ describe('UploadMediaUseCase', () => {
|
||||
expect(saved.uploadedBy).toBe('user-1');
|
||||
expect(saved.metadata).toEqual({ foo: 'bar' });
|
||||
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult).toEqual({
|
||||
mediaId: 'media-1',
|
||||
url: 'https://example.com/media.png',
|
||||
});
|
||||
@@ -142,7 +129,7 @@ describe('UploadMediaUseCase', () => {
|
||||
mediaRepo.save.mockRejectedValue(new Error('DB error'));
|
||||
|
||||
const input: UploadMediaInput = { file: baseFile, uploadedBy: 'user-1' };
|
||||
const result: Result<void, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>> =
|
||||
const result: Result<UploadMediaResult, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>> =
|
||||
await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
@@ -150,7 +137,6 @@ describe('UploadMediaUseCase', () => {
|
||||
expect(err.code).toBe('REPOSITORY_ERROR');
|
||||
expect(err.details?.message).toBe('DB error');
|
||||
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
expect((logger.error as unknown as Mock)).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
|
||||
import type { MediaStoragePort } from '../ports/MediaStoragePort';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import { Media } from '../../domain/entities/Media';
|
||||
@@ -45,13 +45,12 @@ export class UploadMediaUseCase {
|
||||
constructor(
|
||||
private readonly mediaRepo: IMediaRepository,
|
||||
private readonly mediaStorage: MediaStoragePort,
|
||||
private readonly output: UseCaseOutputPort<UploadMediaResult>,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
input: UploadMediaInput,
|
||||
): Promise<Result<void, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>>> {
|
||||
): Promise<Result<UploadMediaResult, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>>> {
|
||||
this.logger.info('[UploadMediaUseCase] Starting media upload', {
|
||||
filename: input.file.originalname,
|
||||
size: input.file.size,
|
||||
@@ -74,7 +73,7 @@ export class UploadMediaUseCase {
|
||||
const uploadResult = await this.mediaStorage.uploadMedia(input.file.buffer, uploadOptions);
|
||||
|
||||
if (!uploadResult.success || !uploadResult.url) {
|
||||
return Result.err<void, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>>({
|
||||
return Result.err<UploadMediaResult, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>>({
|
||||
code: 'UPLOAD_FAILED',
|
||||
details: {
|
||||
message:
|
||||
@@ -116,21 +115,20 @@ export class UploadMediaUseCase {
|
||||
mediaId,
|
||||
url: uploadResult.url,
|
||||
};
|
||||
this.output.present(result);
|
||||
|
||||
this.logger.info('[UploadMediaUseCase] Media uploaded successfully', {
|
||||
mediaId,
|
||||
url: uploadResult.url,
|
||||
});
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok(result);
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.error('[UploadMediaUseCase] Error uploading media', err, {
|
||||
filename: input.file.originalname,
|
||||
});
|
||||
|
||||
return Result.err<void, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>>({
|
||||
return Result.err<UploadMediaResult, ApplicationErrorCode<UploadMediaErrorCode, { message: string }>>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message },
|
||||
});
|
||||
|
||||
@@ -2,10 +2,9 @@ import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import {
|
||||
GetUnreadNotificationsUseCase,
|
||||
type GetUnreadNotificationsInput,
|
||||
type GetUnreadNotificationsResult,
|
||||
} from './GetUnreadNotificationsUseCase';
|
||||
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import { Notification } from '../../domain/entities/Notification';
|
||||
@@ -14,14 +13,9 @@ interface NotificationRepositoryMock {
|
||||
findUnreadByRecipientId: Mock;
|
||||
}
|
||||
|
||||
interface OutputPortMock extends UseCaseOutputPort<GetUnreadNotificationsResult> {
|
||||
present: Mock;
|
||||
}
|
||||
|
||||
describe('GetUnreadNotificationsUseCase', () => {
|
||||
let notificationRepository: NotificationRepositoryMock;
|
||||
let logger: Logger;
|
||||
let output: OutputPortMock;
|
||||
let useCase: GetUnreadNotificationsUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -36,13 +30,8 @@ describe('GetUnreadNotificationsUseCase', () => {
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
} as unknown as OutputPortMock;
|
||||
|
||||
useCase = new GetUnreadNotificationsUseCase(
|
||||
notificationRepository as unknown as INotificationRepository,
|
||||
output,
|
||||
logger,
|
||||
);
|
||||
});
|
||||
@@ -69,10 +58,10 @@ describe('GetUnreadNotificationsUseCase', () => {
|
||||
expect(notificationRepository.findUnreadByRecipientId).toHaveBeenCalledWith(recipientId);
|
||||
expect(result).toBeInstanceOf(Result);
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
notifications,
|
||||
totalCount: 1,
|
||||
});
|
||||
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult.notifications).toEqual(notifications);
|
||||
expect(successResult.totalCount).toBe(1);
|
||||
});
|
||||
|
||||
it('handles repository errors by logging and returning error result', async () => {
|
||||
@@ -89,6 +78,5 @@ describe('GetUnreadNotificationsUseCase', () => {
|
||||
expect(err.code).toBe('REPOSITORY_ERROR');
|
||||
expect(err.details.message).toBe('DB error');
|
||||
expect((logger.error as unknown as Mock)).toHaveBeenCalled();
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Retrieves unread notifications for a recipient.
|
||||
*/
|
||||
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { Notification } from '../../domain/entities/Notification';
|
||||
@@ -24,13 +24,12 @@ export type GetUnreadNotificationsErrorCode = 'REPOSITORY_ERROR';
|
||||
export class GetUnreadNotificationsUseCase {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
private readonly output: UseCaseOutputPort<GetUnreadNotificationsResult>,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
input: GetUnreadNotificationsInput,
|
||||
): Promise<Result<void, ApplicationErrorCode<GetUnreadNotificationsErrorCode, { message: string }>>> {
|
||||
): Promise<Result<GetUnreadNotificationsResult, ApplicationErrorCode<GetUnreadNotificationsErrorCode, { message: string }>>> {
|
||||
const { recipientId } = input;
|
||||
this.logger.debug(
|
||||
`Attempting to retrieve unread notifications for recipient ID: ${recipientId}`,
|
||||
@@ -48,14 +47,10 @@ export class GetUnreadNotificationsUseCase {
|
||||
this.logger.warn(`No unread notifications found for recipient ID: ${recipientId}`);
|
||||
}
|
||||
|
||||
this.output.present({
|
||||
return Result.ok<GetUnreadNotificationsResult, ApplicationErrorCode<GetUnreadNotificationsErrorCode, { message: string }>>({
|
||||
notifications,
|
||||
totalCount: notifications.length,
|
||||
});
|
||||
|
||||
return Result.ok<void, ApplicationErrorCode<GetUnreadNotificationsErrorCode, { message: string }>>(
|
||||
undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.error(
|
||||
@@ -63,7 +58,7 @@ export class GetUnreadNotificationsUseCase {
|
||||
err,
|
||||
);
|
||||
|
||||
return Result.err<void, ApplicationErrorCode<GetUnreadNotificationsErrorCode, { message: string }>>({
|
||||
return Result.err<GetUnreadNotificationsResult, ApplicationErrorCode<GetUnreadNotificationsErrorCode, { message: string }>>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message },
|
||||
});
|
||||
@@ -74,4 +69,4 @@ export class GetUnreadNotificationsUseCase {
|
||||
/**
|
||||
* Additional notification query/use case types (e.g., listing or counting notifications)
|
||||
* can be added here in the future as needed.
|
||||
*/
|
||||
*/
|
||||
|
||||
@@ -2,10 +2,13 @@ import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import {
|
||||
MarkNotificationReadUseCase,
|
||||
type MarkNotificationReadCommand,
|
||||
type MarkNotificationReadResult,
|
||||
MarkAllNotificationsReadUseCase,
|
||||
type MarkAllNotificationsReadInput,
|
||||
DismissNotificationUseCase,
|
||||
type DismissNotificationCommand,
|
||||
} from './MarkNotificationReadUseCase';
|
||||
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import { Notification } from '../../domain/entities/Notification';
|
||||
@@ -16,14 +19,9 @@ interface NotificationRepositoryMock {
|
||||
markAllAsReadByRecipientId: Mock;
|
||||
}
|
||||
|
||||
interface OutputPortMock extends UseCaseOutputPort<MarkNotificationReadResult> {
|
||||
present: Mock;
|
||||
}
|
||||
|
||||
describe('MarkNotificationReadUseCase', () => {
|
||||
let notificationRepository: NotificationRepositoryMock;
|
||||
let logger: Logger;
|
||||
let output: OutputPortMock;
|
||||
let useCase: MarkNotificationReadUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -40,13 +38,8 @@ describe('MarkNotificationReadUseCase', () => {
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
} as unknown as OutputPortMock;
|
||||
|
||||
useCase = new MarkNotificationReadUseCase(
|
||||
notificationRepository as unknown as INotificationRepository,
|
||||
output,
|
||||
logger,
|
||||
);
|
||||
});
|
||||
@@ -65,7 +58,6 @@ describe('MarkNotificationReadUseCase', () => {
|
||||
expect(result.isErr()).toBe(true);
|
||||
const err = result.unwrapErr() as ApplicationErrorCode<'NOTIFICATION_NOT_FOUND', { message: string }>;
|
||||
expect(err.code).toBe('NOTIFICATION_NOT_FOUND');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns RECIPIENT_MISMATCH when recipientId does not match', async () => {
|
||||
@@ -90,10 +82,9 @@ describe('MarkNotificationReadUseCase', () => {
|
||||
expect(result.isErr()).toBe(true);
|
||||
const err = result.unwrapErr() as ApplicationErrorCode<'RECIPIENT_MISMATCH', { message: string }>;
|
||||
expect(err.code).toBe('RECIPIENT_MISMATCH');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks notification as read when unread and presents result', async () => {
|
||||
it('marks notification as read when unread and returns success result', async () => {
|
||||
const notification = Notification.create({
|
||||
id: 'n1',
|
||||
recipientId: 'driver-1',
|
||||
@@ -114,10 +105,220 @@ describe('MarkNotificationReadUseCase', () => {
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(notificationRepository.update).toHaveBeenCalled();
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult.notificationId).toBe('n1');
|
||||
expect(successResult.recipientId).toBe('driver-1');
|
||||
expect(successResult.wasAlreadyRead).toBe(false);
|
||||
});
|
||||
|
||||
it('returns already read when notification is already read', async () => {
|
||||
const notification = Notification.create({
|
||||
id: 'n1',
|
||||
recipientId: 'driver-1',
|
||||
type: 'system_announcement',
|
||||
title: 'Test',
|
||||
body: 'Body',
|
||||
channel: 'in_app',
|
||||
});
|
||||
|
||||
// Mark as read
|
||||
const readNotification = notification.markAsRead();
|
||||
notificationRepository.findById.mockResolvedValue(readNotification);
|
||||
|
||||
const command: MarkNotificationReadCommand = {
|
||||
notificationId: 'n1',
|
||||
recipientId: 'driver-1',
|
||||
wasAlreadyRead: false,
|
||||
});
|
||||
};
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(notificationRepository.update).not.toHaveBeenCalled();
|
||||
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult.wasAlreadyRead).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkAllNotificationsReadUseCase', () => {
|
||||
let notificationRepository: NotificationRepositoryMock;
|
||||
let useCase: MarkAllNotificationsReadUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
notificationRepository = {
|
||||
findById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
markAllAsReadByRecipientId: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new MarkAllNotificationsReadUseCase(
|
||||
notificationRepository as unknown as INotificationRepository,
|
||||
);
|
||||
});
|
||||
|
||||
it('marks all notifications as read', async () => {
|
||||
const input: MarkAllNotificationsReadInput = {
|
||||
recipientId: 'driver-1',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(notificationRepository.markAllAsReadByRecipientId).toHaveBeenCalledWith('driver-1');
|
||||
expect(result.isOk()).toBe(true);
|
||||
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult.recipientId).toBe('driver-1');
|
||||
});
|
||||
|
||||
it('handles repository errors', async () => {
|
||||
notificationRepository.markAllAsReadByRecipientId.mockRejectedValue(new Error('DB error'));
|
||||
|
||||
const input: MarkAllNotificationsReadInput = {
|
||||
recipientId: 'driver-1',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const err = result.unwrapErr() as ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>;
|
||||
expect(err.code).toBe('REPOSITORY_ERROR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DismissNotificationUseCase', () => {
|
||||
let notificationRepository: NotificationRepositoryMock;
|
||||
let useCase: DismissNotificationUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
notificationRepository = {
|
||||
findById: vi.fn(),
|
||||
update: vi.fn(),
|
||||
markAllAsReadByRecipientId: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new DismissNotificationUseCase(
|
||||
notificationRepository as unknown as INotificationRepository,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns NOTIFICATION_NOT_FOUND when notification is not found', async () => {
|
||||
notificationRepository.findById.mockResolvedValue(null);
|
||||
|
||||
const command: DismissNotificationCommand = {
|
||||
notificationId: 'n1',
|
||||
recipientId: 'driver-1',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const err = result.unwrapErr() as ApplicationErrorCode<'NOTIFICATION_NOT_FOUND', { message: string }>;
|
||||
expect(err.code).toBe('NOTIFICATION_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('returns RECIPIENT_MISMATCH when recipientId does not match', async () => {
|
||||
const notification = Notification.create({
|
||||
id: 'n1',
|
||||
recipientId: 'driver-2',
|
||||
type: 'system_announcement',
|
||||
title: 'Test',
|
||||
body: 'Body',
|
||||
channel: 'in_app',
|
||||
});
|
||||
|
||||
notificationRepository.findById.mockResolvedValue(notification);
|
||||
|
||||
const command: DismissNotificationCommand = {
|
||||
notificationId: 'n1',
|
||||
recipientId: 'driver-1',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const err = result.unwrapErr() as ApplicationErrorCode<'RECIPIENT_MISMATCH', { message: string }>;
|
||||
expect(err.code).toBe('RECIPIENT_MISMATCH');
|
||||
});
|
||||
|
||||
it('dismisses notification and returns success result', async () => {
|
||||
const notification = Notification.create({
|
||||
id: 'n1',
|
||||
recipientId: 'driver-1',
|
||||
type: 'system_announcement',
|
||||
title: 'Test',
|
||||
body: 'Body',
|
||||
channel: 'in_app',
|
||||
});
|
||||
|
||||
notificationRepository.findById.mockResolvedValue(notification);
|
||||
|
||||
const command: DismissNotificationCommand = {
|
||||
notificationId: 'n1',
|
||||
recipientId: 'driver-1',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(notificationRepository.update).toHaveBeenCalled();
|
||||
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult.notificationId).toBe('n1');
|
||||
expect(successResult.recipientId).toBe('driver-1');
|
||||
expect(successResult.wasAlreadyDismissed).toBe(false);
|
||||
});
|
||||
|
||||
it('returns already dismissed when notification is already dismissed', async () => {
|
||||
const notification = Notification.create({
|
||||
id: 'n1',
|
||||
recipientId: 'driver-1',
|
||||
type: 'system_announcement',
|
||||
title: 'Test',
|
||||
body: 'Body',
|
||||
channel: 'in_app',
|
||||
});
|
||||
|
||||
// Dismiss it
|
||||
const dismissedNotification = notification.dismiss();
|
||||
notificationRepository.findById.mockResolvedValue(dismissedNotification);
|
||||
|
||||
const command: DismissNotificationCommand = {
|
||||
notificationId: 'n1',
|
||||
recipientId: 'driver-1',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(notificationRepository.update).not.toHaveBeenCalled();
|
||||
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult.wasAlreadyDismissed).toBe(true);
|
||||
});
|
||||
|
||||
it('returns CANNOT_DISMISS_REQUIRING_RESPONSE when notification requires response', async () => {
|
||||
const notification = Notification.create({
|
||||
id: 'n1',
|
||||
recipientId: 'driver-1',
|
||||
type: 'system_announcement',
|
||||
title: 'Test',
|
||||
body: 'Body',
|
||||
channel: 'in_app',
|
||||
requiresResponse: true,
|
||||
});
|
||||
|
||||
notificationRepository.findById.mockResolvedValue(notification);
|
||||
|
||||
const command: DismissNotificationCommand = {
|
||||
notificationId: 'n1',
|
||||
recipientId: 'driver-1',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const err = result.unwrapErr() as ApplicationErrorCode<'CANNOT_DISMISS_REQUIRING_RESPONSE', { message: string }>;
|
||||
expect(err.code).toBe('CANNOT_DISMISS_REQUIRING_RESPONSE');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,11 +4,10 @@
|
||||
* Marks a notification as read.
|
||||
*/
|
||||
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
|
||||
// import { NotificationDomainError } from '../../domain/errors/NotificationDomainError';
|
||||
|
||||
export interface MarkNotificationReadCommand {
|
||||
notificationId: string;
|
||||
@@ -29,13 +28,12 @@ export type MarkNotificationReadErrorCode =
|
||||
export class MarkNotificationReadUseCase {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
private readonly output: UseCaseOutputPort<MarkNotificationReadResult>,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
command: MarkNotificationReadCommand,
|
||||
): Promise<Result<void, ApplicationErrorCode<MarkNotificationReadErrorCode, { message: string }>>> {
|
||||
): Promise<Result<MarkNotificationReadResult, ApplicationErrorCode<MarkNotificationReadErrorCode, { message: string }>>> {
|
||||
this.logger.debug(
|
||||
`Attempting to mark notification ${command.notificationId} as read for recipient ${command.recipientId}`,
|
||||
);
|
||||
@@ -45,7 +43,7 @@ export class MarkNotificationReadUseCase {
|
||||
|
||||
if (!notification) {
|
||||
this.logger.warn(`Notification not found for ID: ${command.notificationId}`);
|
||||
return Result.err<void, ApplicationErrorCode<MarkNotificationReadErrorCode, { message: string }>>({
|
||||
return Result.err<MarkNotificationReadResult, ApplicationErrorCode<MarkNotificationReadErrorCode, { message: string }>>({
|
||||
code: 'NOTIFICATION_NOT_FOUND',
|
||||
details: { message: 'Notification not found' },
|
||||
});
|
||||
@@ -55,7 +53,7 @@ export class MarkNotificationReadUseCase {
|
||||
this.logger.warn(
|
||||
`Unauthorized attempt to mark notification ${command.notificationId}. Recipient ID mismatch.`,
|
||||
);
|
||||
return Result.err<void, ApplicationErrorCode<MarkNotificationReadErrorCode, { message: string }>>({
|
||||
return Result.err<MarkNotificationReadResult, ApplicationErrorCode<MarkNotificationReadErrorCode, { message: string }>>({
|
||||
code: 'RECIPIENT_MISMATCH',
|
||||
details: { message: "Cannot mark another user's notification as read" },
|
||||
});
|
||||
@@ -65,12 +63,11 @@ export class MarkNotificationReadUseCase {
|
||||
this.logger.info(
|
||||
`Notification ${command.notificationId} is already read. Skipping update.`,
|
||||
);
|
||||
this.output.present({
|
||||
return Result.ok<MarkNotificationReadResult, ApplicationErrorCode<MarkNotificationReadErrorCode, { message: string }>>({
|
||||
notificationId: command.notificationId,
|
||||
recipientId: command.recipientId,
|
||||
wasAlreadyRead: true,
|
||||
});
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
const updatedNotification = notification.markAsRead();
|
||||
@@ -79,19 +76,17 @@ export class MarkNotificationReadUseCase {
|
||||
`Notification ${command.notificationId} successfully marked as read.`,
|
||||
);
|
||||
|
||||
this.output.present({
|
||||
return Result.ok<MarkNotificationReadResult, ApplicationErrorCode<MarkNotificationReadErrorCode, { message: string }>>({
|
||||
notificationId: command.notificationId,
|
||||
recipientId: command.recipientId,
|
||||
wasAlreadyRead: false,
|
||||
});
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.error(
|
||||
`Failed to mark notification ${command.notificationId} as read: ${err.message}`,
|
||||
);
|
||||
return Result.err<void, ApplicationErrorCode<MarkNotificationReadErrorCode, { message: string }>>({
|
||||
return Result.err<MarkNotificationReadResult, ApplicationErrorCode<MarkNotificationReadErrorCode, { message: string }>>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message },
|
||||
});
|
||||
@@ -117,19 +112,19 @@ export type MarkAllNotificationsReadErrorCode = 'REPOSITORY_ERROR';
|
||||
export class MarkAllNotificationsReadUseCase {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
private readonly output: UseCaseOutputPort<MarkAllNotificationsReadResult>,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
input: MarkAllNotificationsReadInput,
|
||||
): Promise<Result<void, ApplicationErrorCode<MarkAllNotificationsReadErrorCode, { message: string }>>> {
|
||||
): Promise<Result<MarkAllNotificationsReadResult, ApplicationErrorCode<MarkAllNotificationsReadErrorCode, { message: string }>>> {
|
||||
try {
|
||||
await this.notificationRepository.markAllAsReadByRecipientId(input.recipientId);
|
||||
this.output.present({ recipientId: input.recipientId });
|
||||
return Result.ok(undefined);
|
||||
return Result.ok<MarkAllNotificationsReadResult, ApplicationErrorCode<MarkAllNotificationsReadErrorCode, { message: string }>>({
|
||||
recipientId: input.recipientId,
|
||||
});
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
return Result.err<void, ApplicationErrorCode<MarkAllNotificationsReadErrorCode, { message: string }>>({
|
||||
return Result.err<MarkAllNotificationsReadResult, ApplicationErrorCode<MarkAllNotificationsReadErrorCode, { message: string }>>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message },
|
||||
});
|
||||
@@ -162,42 +157,40 @@ export type DismissNotificationErrorCode =
|
||||
export class DismissNotificationUseCase {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
private readonly output: UseCaseOutputPort<DismissNotificationResult>,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
command: DismissNotificationCommand,
|
||||
): Promise<Result<void, ApplicationErrorCode<DismissNotificationErrorCode, { message: string }>>> {
|
||||
): Promise<Result<DismissNotificationResult, ApplicationErrorCode<DismissNotificationErrorCode, { message: string }>>> {
|
||||
try {
|
||||
const notification = await this.notificationRepository.findById(
|
||||
command.notificationId,
|
||||
);
|
||||
|
||||
if (!notification) {
|
||||
return Result.err<void, ApplicationErrorCode<DismissNotificationErrorCode, { message: string }>>({
|
||||
return Result.err<DismissNotificationResult, ApplicationErrorCode<DismissNotificationErrorCode, { message: string }>>({
|
||||
code: 'NOTIFICATION_NOT_FOUND',
|
||||
details: { message: 'Notification not found' },
|
||||
});
|
||||
}
|
||||
|
||||
if (notification.recipientId !== command.recipientId) {
|
||||
return Result.err<void, ApplicationErrorCode<DismissNotificationErrorCode, { message: string }>>({
|
||||
return Result.err<DismissNotificationResult, ApplicationErrorCode<DismissNotificationErrorCode, { message: string }>>({
|
||||
code: 'RECIPIENT_MISMATCH',
|
||||
details: { message: "Cannot dismiss another user's notification" },
|
||||
});
|
||||
}
|
||||
|
||||
if (notification.isDismissed()) {
|
||||
this.output.present({
|
||||
return Result.ok<DismissNotificationResult, ApplicationErrorCode<DismissNotificationErrorCode, { message: string }>>({
|
||||
notificationId: command.notificationId,
|
||||
recipientId: command.recipientId,
|
||||
wasAlreadyDismissed: true,
|
||||
});
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
if (!notification.canDismiss()) {
|
||||
return Result.err<void, ApplicationErrorCode<DismissNotificationErrorCode, { message: string }>>({
|
||||
return Result.err<DismissNotificationResult, ApplicationErrorCode<DismissNotificationErrorCode, { message: string }>>({
|
||||
code: 'CANNOT_DISMISS_REQUIRING_RESPONSE',
|
||||
details: { message: 'Cannot dismiss notification that requires response' },
|
||||
});
|
||||
@@ -206,19 +199,17 @@ export class DismissNotificationUseCase {
|
||||
const updatedNotification = notification.dismiss();
|
||||
await this.notificationRepository.update(updatedNotification);
|
||||
|
||||
this.output.present({
|
||||
return Result.ok<DismissNotificationResult, ApplicationErrorCode<DismissNotificationErrorCode, { message: string }>>({
|
||||
notificationId: command.notificationId,
|
||||
recipientId: command.recipientId,
|
||||
wasAlreadyDismissed: false,
|
||||
});
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
return Result.err<void, ApplicationErrorCode<DismissNotificationErrorCode, { message: string }>>({
|
||||
return Result.err<DismissNotificationResult, ApplicationErrorCode<DismissNotificationErrorCode, { message: string }>>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import { describe, expect, it, vi, type Mock } from 'vitest';
|
||||
import { describe, expect, it, vi, type Mock, beforeEach } from 'vitest';
|
||||
import type { ChannelPreference, NotificationPreference, TypePreference } from '../../domain/entities/NotificationPreference';
|
||||
import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository';
|
||||
import type { NotificationChannel, NotificationType } from '../../domain/types/NotificationTypes';
|
||||
@@ -12,15 +12,10 @@ import {
|
||||
UpdateQuietHoursUseCase,
|
||||
UpdateTypePreferenceUseCase,
|
||||
type GetNotificationPreferencesInput,
|
||||
type GetNotificationPreferencesResult,
|
||||
type SetDigestModeCommand,
|
||||
type SetDigestModeResult,
|
||||
type UpdateChannelPreferenceCommand,
|
||||
type UpdateChannelPreferenceResult,
|
||||
type UpdateQuietHoursCommand,
|
||||
type UpdateQuietHoursResult,
|
||||
type UpdateTypePreferenceCommand,
|
||||
type UpdateTypePreferenceResult,
|
||||
} from './NotificationPreferencesUseCases';
|
||||
|
||||
describe('NotificationPreferencesUseCases', () => {
|
||||
@@ -30,46 +25,43 @@ describe('NotificationPreferencesUseCases', () => {
|
||||
};
|
||||
let logger: Logger;
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
preferenceRepository = {
|
||||
getOrCreateDefault: vi.fn(),
|
||||
save: vi.fn(),
|
||||
};
|
||||
|
||||
logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
});
|
||||
|
||||
it('GetNotificationPreferencesQuery returns preferences from repository', async () => {
|
||||
const preference = {
|
||||
id: 'pref-1',
|
||||
} as unknown as NotificationPreference;
|
||||
|
||||
preferenceRepository.getOrCreateDefault.mockResolvedValue(preference);
|
||||
|
||||
const output: UseCaseOutputPort<GetNotificationPreferencesResult> & { present: Mock } = {
|
||||
present: vi.fn(),
|
||||
} as unknown as UseCaseOutputPort<GetNotificationPreferencesResult> & { present: Mock };
|
||||
|
||||
const useCase = new GetNotificationPreferencesQuery(
|
||||
preferenceRepository as unknown as INotificationPreferenceRepository,
|
||||
output,
|
||||
logger,
|
||||
);
|
||||
|
||||
const input: GetNotificationPreferencesInput = { driverId: 'driver-1' };
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(preferenceRepository.getOrCreateDefault).toHaveBeenCalledWith('driver-1');
|
||||
expect(result).toBeInstanceOf(Result);
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(output.present).toHaveBeenCalledWith({ preference });
|
||||
});
|
||||
beforeEach(() => {
|
||||
preferenceRepository = {
|
||||
getOrCreateDefault: vi.fn(),
|
||||
save: vi.fn(),
|
||||
};
|
||||
|
||||
logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
});
|
||||
|
||||
it('GetNotificationPreferencesQuery returns preferences from repository', async () => {
|
||||
const preference = {
|
||||
id: 'pref-1',
|
||||
} as unknown as NotificationPreference;
|
||||
|
||||
preferenceRepository.getOrCreateDefault.mockResolvedValue(preference);
|
||||
|
||||
const useCase = new GetNotificationPreferencesQuery(
|
||||
preferenceRepository as unknown as INotificationPreferenceRepository,
|
||||
logger,
|
||||
);
|
||||
|
||||
const input: GetNotificationPreferencesInput = { driverId: 'driver-1' };
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(preferenceRepository.getOrCreateDefault).toHaveBeenCalledWith('driver-1');
|
||||
expect(result).toBeInstanceOf(Result);
|
||||
expect(result.isOk()).toBe(true);
|
||||
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult.preference).toEqual(preference);
|
||||
});
|
||||
|
||||
it('UpdateChannelPreferenceUseCase updates channel preference', async () => {
|
||||
const preference = {
|
||||
updateChannel: vi.fn().mockReturnThis(),
|
||||
@@ -77,13 +69,8 @@ describe('NotificationPreferencesUseCases', () => {
|
||||
|
||||
preferenceRepository.getOrCreateDefault.mockResolvedValue(preference);
|
||||
|
||||
const output: UseCaseOutputPort<UpdateChannelPreferenceResult> & { present: Mock } = {
|
||||
present: vi.fn(),
|
||||
} as unknown as UseCaseOutputPort<UpdateChannelPreferenceResult> & { present: Mock };
|
||||
|
||||
const useCase = new UpdateChannelPreferenceUseCase(
|
||||
preferenceRepository as unknown as INotificationPreferenceRepository,
|
||||
output,
|
||||
logger,
|
||||
);
|
||||
|
||||
@@ -98,7 +85,10 @@ describe('NotificationPreferencesUseCases', () => {
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(preference.updateChannel).toHaveBeenCalled();
|
||||
expect(preferenceRepository.save).toHaveBeenCalledWith(preference);
|
||||
expect(output.present).toHaveBeenCalledWith({ driverId: 'driver-1', channel: 'email' });
|
||||
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult.driverId).toBe('driver-1');
|
||||
expect(successResult.channel).toBe('email');
|
||||
});
|
||||
|
||||
it('UpdateTypePreferenceUseCase updates type preference', async () => {
|
||||
@@ -108,13 +98,8 @@ describe('NotificationPreferencesUseCases', () => {
|
||||
|
||||
preferenceRepository.getOrCreateDefault.mockResolvedValue(preference);
|
||||
|
||||
const output: UseCaseOutputPort<UpdateTypePreferenceResult> & { present: Mock } = {
|
||||
present: vi.fn(),
|
||||
} as unknown as UseCaseOutputPort<UpdateTypePreferenceResult> & { present: Mock };
|
||||
|
||||
const useCase = new UpdateTypePreferenceUseCase(
|
||||
preferenceRepository as unknown as INotificationPreferenceRepository,
|
||||
output,
|
||||
logger,
|
||||
);
|
||||
|
||||
@@ -129,7 +114,10 @@ describe('NotificationPreferencesUseCases', () => {
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(preference.updateTypePreference).toHaveBeenCalled();
|
||||
expect(preferenceRepository.save).toHaveBeenCalledWith(preference);
|
||||
expect(output.present).toHaveBeenCalledWith({ driverId: 'driver-1', type: 'system_announcement' });
|
||||
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult.driverId).toBe('driver-1');
|
||||
expect(successResult.type).toBe('system_announcement');
|
||||
});
|
||||
|
||||
it('UpdateQuietHoursUseCase validates hours and updates preferences', async () => {
|
||||
@@ -139,13 +127,8 @@ describe('NotificationPreferencesUseCases', () => {
|
||||
|
||||
preferenceRepository.getOrCreateDefault.mockResolvedValue(preference);
|
||||
|
||||
const output: UseCaseOutputPort<UpdateQuietHoursResult> & { present: Mock } = {
|
||||
present: vi.fn(),
|
||||
} as unknown as UseCaseOutputPort<UpdateQuietHoursResult> & { present: Mock };
|
||||
|
||||
const useCase = new UpdateQuietHoursUseCase(
|
||||
preferenceRepository as unknown as INotificationPreferenceRepository,
|
||||
output,
|
||||
logger,
|
||||
);
|
||||
|
||||
@@ -160,21 +143,16 @@ describe('NotificationPreferencesUseCases', () => {
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(preference.updateQuietHours).toHaveBeenCalledWith(22, 7);
|
||||
expect(preferenceRepository.save).toHaveBeenCalledWith(preference);
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
driverId: 'driver-1',
|
||||
startHour: 22,
|
||||
endHour: 7,
|
||||
});
|
||||
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult.driverId).toBe('driver-1');
|
||||
expect(successResult.startHour).toBe(22);
|
||||
expect(successResult.endHour).toBe(7);
|
||||
});
|
||||
|
||||
it('UpdateQuietHoursUseCase returns error on invalid hours', async () => {
|
||||
const output: UseCaseOutputPort<UpdateQuietHoursResult> & { present: Mock } = {
|
||||
present: vi.fn(),
|
||||
} as unknown as UseCaseOutputPort<UpdateQuietHoursResult> & { present: Mock };
|
||||
|
||||
const useCase = new UpdateQuietHoursUseCase(
|
||||
preferenceRepository as unknown as INotificationPreferenceRepository,
|
||||
output,
|
||||
logger,
|
||||
);
|
||||
|
||||
@@ -198,13 +176,8 @@ describe('NotificationPreferencesUseCases', () => {
|
||||
|
||||
preferenceRepository.getOrCreateDefault.mockResolvedValue(preference);
|
||||
|
||||
const output: UseCaseOutputPort<SetDigestModeResult> & { present: Mock } = {
|
||||
present: vi.fn(),
|
||||
} as unknown as UseCaseOutputPort<SetDigestModeResult> & { present: Mock };
|
||||
|
||||
const useCase = new SetDigestModeUseCase(
|
||||
preferenceRepository as unknown as INotificationPreferenceRepository,
|
||||
output,
|
||||
);
|
||||
|
||||
const command: SetDigestModeCommand = {
|
||||
@@ -218,21 +191,16 @@ describe('NotificationPreferencesUseCases', () => {
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(preference.setDigestMode).toHaveBeenCalledWith(true, 4);
|
||||
expect(preferenceRepository.save).toHaveBeenCalledWith(preference);
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
driverId: 'driver-1',
|
||||
enabled: true,
|
||||
frequencyHours: 4,
|
||||
});
|
||||
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult.driverId).toBe('driver-1');
|
||||
expect(successResult.enabled).toBe(true);
|
||||
expect(successResult.frequencyHours).toBe(4);
|
||||
});
|
||||
|
||||
it('SetDigestModeUseCase returns error on invalid frequency', async () => {
|
||||
const output: UseCaseOutputPort<SetDigestModeResult> & { present: Mock } = {
|
||||
present: vi.fn(),
|
||||
} as unknown as UseCaseOutputPort<SetDigestModeResult> & { present: Mock };
|
||||
|
||||
const useCase = new SetDigestModeUseCase(
|
||||
preferenceRepository as unknown as INotificationPreferenceRepository,
|
||||
output,
|
||||
);
|
||||
|
||||
const command: SetDigestModeCommand = {
|
||||
|
||||
@@ -4,14 +4,13 @@
|
||||
* Manages user notification preferences.
|
||||
*/
|
||||
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import { NotificationPreference } from '../../domain/entities/NotificationPreference';
|
||||
import type { ChannelPreference, TypePreference } from '../../domain/entities/NotificationPreference';
|
||||
import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository';
|
||||
import type { NotificationType, NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||
// import { NotificationDomainError } from '../../domain/errors/NotificationDomainError';
|
||||
|
||||
/**
|
||||
* Query: GetNotificationPreferencesQuery
|
||||
@@ -29,24 +28,22 @@ export type GetNotificationPreferencesErrorCode = 'REPOSITORY_ERROR';
|
||||
export class GetNotificationPreferencesQuery {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
private readonly output: UseCaseOutputPort<GetNotificationPreferencesResult>,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
input: GetNotificationPreferencesInput,
|
||||
): Promise<Result<void, ApplicationErrorCode<GetNotificationPreferencesErrorCode, { message: string }>>> {
|
||||
): Promise<Result<GetNotificationPreferencesResult, ApplicationErrorCode<GetNotificationPreferencesErrorCode, { message: string }>>> {
|
||||
const { driverId } = input;
|
||||
this.logger.debug(`Fetching notification preferences for driver: ${driverId}`);
|
||||
try {
|
||||
const preferences = await this.preferenceRepository.getOrCreateDefault(driverId);
|
||||
this.logger.info(`Successfully fetched preferences for driver: ${driverId}`);
|
||||
this.output.present({ preference: preferences });
|
||||
return Result.ok(undefined);
|
||||
return Result.ok<GetNotificationPreferencesResult, ApplicationErrorCode<GetNotificationPreferencesErrorCode, { message: string }>>({ preference: preferences });
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.error(`Failed to fetch preferences for driver: ${driverId}`, err);
|
||||
return Result.err<void, ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>>({
|
||||
return Result.err<GetNotificationPreferencesResult, ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message },
|
||||
});
|
||||
@@ -74,13 +71,12 @@ export type UpdateChannelPreferenceErrorCode =
|
||||
export class UpdateChannelPreferenceUseCase {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
private readonly output: UseCaseOutputPort<UpdateChannelPreferenceResult>,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
command: UpdateChannelPreferenceCommand,
|
||||
): Promise<Result<void, ApplicationErrorCode<UpdateChannelPreferenceErrorCode, { message: string }>>> {
|
||||
): Promise<Result<UpdateChannelPreferenceResult, ApplicationErrorCode<UpdateChannelPreferenceErrorCode, { message: string }>>> {
|
||||
this.logger.debug(
|
||||
`Updating channel preference for driver: ${command.driverId}, channel: ${command.channel}, preference: ${JSON.stringify(command.preference)}`,
|
||||
);
|
||||
@@ -93,15 +89,17 @@ export class UpdateChannelPreferenceUseCase {
|
||||
this.logger.info(
|
||||
`Successfully updated channel preference for driver: ${command.driverId}`,
|
||||
);
|
||||
this.output.present({ driverId: command.driverId, channel: command.channel });
|
||||
return Result.ok(undefined);
|
||||
return Result.ok<UpdateChannelPreferenceResult, ApplicationErrorCode<UpdateChannelPreferenceErrorCode, { message: string }>>({
|
||||
driverId: command.driverId,
|
||||
channel: command.channel,
|
||||
});
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.error(
|
||||
`Failed to update channel preference for driver: ${command.driverId}, channel: ${command.channel}`,
|
||||
err,
|
||||
);
|
||||
return Result.err<void, ApplicationErrorCode<UpdateChannelPreferenceErrorCode, { message: string }>>({
|
||||
return Result.err<UpdateChannelPreferenceResult, ApplicationErrorCode<UpdateChannelPreferenceErrorCode, { message: string }>>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message },
|
||||
});
|
||||
@@ -128,13 +126,12 @@ export type UpdateTypePreferenceErrorCode = 'REPOSITORY_ERROR';
|
||||
export class UpdateTypePreferenceUseCase {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
private readonly output: UseCaseOutputPort<UpdateTypePreferenceResult>,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
command: UpdateTypePreferenceCommand,
|
||||
): Promise<Result<void, ApplicationErrorCode<UpdateTypePreferenceErrorCode, { message: string }>>> {
|
||||
): Promise<Result<UpdateTypePreferenceResult, ApplicationErrorCode<UpdateTypePreferenceErrorCode, { message: string }>>> {
|
||||
this.logger.debug(
|
||||
`Updating type preference for driver: ${command.driverId}, type: ${command.type}, preference: ${JSON.stringify(command.preference)}`,
|
||||
);
|
||||
@@ -147,15 +144,17 @@ export class UpdateTypePreferenceUseCase {
|
||||
this.logger.info(
|
||||
`Successfully updated type preference for driver: ${command.driverId}`,
|
||||
);
|
||||
this.output.present({ driverId: command.driverId, type: command.type });
|
||||
return Result.ok(undefined);
|
||||
return Result.ok<UpdateTypePreferenceResult, ApplicationErrorCode<UpdateTypePreferenceErrorCode, { message: string }>>({
|
||||
driverId: command.driverId,
|
||||
type: command.type,
|
||||
});
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.error(
|
||||
`Failed to update type preference for driver: ${command.driverId}, type: ${command.type}`,
|
||||
err,
|
||||
);
|
||||
return Result.err<void, ApplicationErrorCode<GetNotificationPreferencesErrorCode, { message: string }>>({
|
||||
return Result.err<UpdateTypePreferenceResult, ApplicationErrorCode<GetNotificationPreferencesErrorCode, { message: string }>>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message },
|
||||
});
|
||||
@@ -186,13 +185,12 @@ export type UpdateQuietHoursErrorCode =
|
||||
export class UpdateQuietHoursUseCase {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
private readonly output: UseCaseOutputPort<UpdateQuietHoursResult>,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
command: UpdateQuietHoursCommand,
|
||||
): Promise<Result<void, ApplicationErrorCode<UpdateQuietHoursErrorCode, { message: string }>>> {
|
||||
): Promise<Result<UpdateQuietHoursResult, ApplicationErrorCode<UpdateQuietHoursErrorCode, { message: string }>>> {
|
||||
this.logger.debug(
|
||||
`Updating quiet hours for driver: ${command.driverId}, startHour: ${command.startHour}, endHour: ${command.endHour}`,
|
||||
);
|
||||
@@ -202,7 +200,7 @@ export class UpdateQuietHoursUseCase {
|
||||
this.logger.warn(
|
||||
`Invalid start hour provided for driver: ${command.driverId}. startHour: ${command.startHour}`,
|
||||
);
|
||||
return Result.err<void, ApplicationErrorCode<UpdateQuietHoursErrorCode, { message: string }>>({
|
||||
return Result.err<UpdateQuietHoursResult, ApplicationErrorCode<UpdateQuietHoursErrorCode, { message: string }>>({
|
||||
code: 'INVALID_START_HOUR',
|
||||
details: { message: 'Start hour must be between 0 and 23' },
|
||||
});
|
||||
@@ -211,7 +209,7 @@ export class UpdateQuietHoursUseCase {
|
||||
this.logger.warn(
|
||||
`Invalid end hour provided for driver: ${command.driverId}. endHour: ${command.endHour}`,
|
||||
);
|
||||
return Result.err<void, ApplicationErrorCode<UpdateQuietHoursErrorCode, { message: string }>>({
|
||||
return Result.err<UpdateQuietHoursResult, ApplicationErrorCode<UpdateQuietHoursErrorCode, { message: string }>>({
|
||||
code: 'INVALID_END_HOUR',
|
||||
details: { message: 'End hour must be between 0 and 23' },
|
||||
});
|
||||
@@ -226,16 +224,15 @@ export class UpdateQuietHoursUseCase {
|
||||
);
|
||||
await this.preferenceRepository.save(updated);
|
||||
this.logger.info(`Successfully updated quiet hours for driver: ${command.driverId}`);
|
||||
this.output.present({
|
||||
return Result.ok<UpdateQuietHoursResult, ApplicationErrorCode<UpdateQuietHoursErrorCode, { message: string }>>({
|
||||
driverId: command.driverId,
|
||||
startHour: command.startHour,
|
||||
endHour: command.endHour,
|
||||
});
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.error(`Failed to update quiet hours for driver: ${command.driverId}`, err);
|
||||
return Result.err<void, ApplicationErrorCode<UpdateTypePreferenceErrorCode, { message: string }>>({
|
||||
return Result.err<UpdateQuietHoursResult, ApplicationErrorCode<UpdateTypePreferenceErrorCode, { message: string }>>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message },
|
||||
});
|
||||
@@ -265,14 +262,13 @@ export type SetDigestModeErrorCode =
|
||||
export class SetDigestModeUseCase {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
private readonly output: UseCaseOutputPort<SetDigestModeResult>,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
command: SetDigestModeCommand,
|
||||
): Promise<Result<void, ApplicationErrorCode<SetDigestModeErrorCode, { message: string }>>> {
|
||||
): Promise<Result<SetDigestModeResult, ApplicationErrorCode<SetDigestModeErrorCode, { message: string }>>> {
|
||||
if (command.frequencyHours !== undefined && command.frequencyHours < 1) {
|
||||
return Result.err<void, ApplicationErrorCode<SetDigestModeErrorCode, { message: string }>>({
|
||||
return Result.err<SetDigestModeResult, ApplicationErrorCode<SetDigestModeErrorCode, { message: string }>>({
|
||||
code: 'INVALID_FREQUENCY',
|
||||
details: { message: 'Digest frequency must be at least 1 hour' },
|
||||
});
|
||||
@@ -292,14 +288,13 @@ export class SetDigestModeUseCase {
|
||||
enabled: command.enabled,
|
||||
frequencyHours: command.frequencyHours,
|
||||
};
|
||||
this.output.present(result);
|
||||
return Result.ok(undefined);
|
||||
return Result.ok<SetDigestModeResult, ApplicationErrorCode<SetDigestModeErrorCode, { message: string }>>(result);
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
return Result.err<void, ApplicationErrorCode<SetDigestModeErrorCode, { message: string }>>({
|
||||
return Result.err<SetDigestModeResult, ApplicationErrorCode<SetDigestModeErrorCode, { message: string }>>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
|
||||
@@ -19,17 +19,11 @@ vi.mock('uuid', () => ({
|
||||
v4: () => 'notif-1',
|
||||
}));
|
||||
|
||||
interface TestOutputPort extends UseCaseOutputPort<SendNotificationResult> {
|
||||
present: Mock;
|
||||
result?: SendNotificationResult;
|
||||
}
|
||||
|
||||
describe('SendNotificationUseCase', () => {
|
||||
let notificationRepository: { create: Mock };
|
||||
let preferenceRepository: { getOrCreateDefault: Mock };
|
||||
let gatewayRegistry: { send: Mock };
|
||||
let logger: Logger;
|
||||
let output: TestOutputPort;
|
||||
let useCase: SendNotificationUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -52,17 +46,10 @@ describe('SendNotificationUseCase', () => {
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
output = {
|
||||
present: vi.fn((result: SendNotificationResult) => {
|
||||
output.result = result;
|
||||
}),
|
||||
} as unknown as TestOutputPort;
|
||||
|
||||
useCase = new SendNotificationUseCase(
|
||||
notificationRepository as unknown as INotificationRepository,
|
||||
preferenceRepository as unknown as INotificationPreferenceRepository,
|
||||
gatewayRegistry as unknown as NotificationGatewayRegistry,
|
||||
output,
|
||||
logger,
|
||||
);
|
||||
});
|
||||
@@ -92,10 +79,10 @@ describe('SendNotificationUseCase', () => {
|
||||
expect(notificationRepository.create).toHaveBeenCalledTimes(1);
|
||||
expect(gatewayRegistry.send).not.toHaveBeenCalled();
|
||||
|
||||
expect(output.present).toHaveBeenCalledTimes(1);
|
||||
expect(output.result?.deliveryResults).toEqual([]);
|
||||
expect(output.result?.notification.channel).toBe('in_app');
|
||||
expect(output.result?.notification.status).toBe('dismissed');
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult.deliveryResults).toEqual([]);
|
||||
expect(successResult.notification.channel).toBe('in_app');
|
||||
expect(successResult.notification.status).toBe('dismissed');
|
||||
});
|
||||
|
||||
it('ensures in_app is used and sends external channels when enabled', async () => {
|
||||
@@ -128,11 +115,11 @@ describe('SendNotificationUseCase', () => {
|
||||
expect(notificationRepository.create).toHaveBeenCalledTimes(1);
|
||||
expect(gatewayRegistry.send).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(output.present).toHaveBeenCalledTimes(1);
|
||||
expect(output.result?.notification.channel).toBe('in_app');
|
||||
expect(output.result?.deliveryResults.length).toBe(2);
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult.notification.channel).toBe('in_app');
|
||||
expect(successResult.deliveryResults.length).toBe(2);
|
||||
|
||||
const channels = output.result!.deliveryResults.map(r => r.channel).sort();
|
||||
const channels = successResult.deliveryResults.map(r => r.channel).sort();
|
||||
expect(channels).toEqual(['email', 'in_app']);
|
||||
});
|
||||
|
||||
@@ -158,8 +145,9 @@ describe('SendNotificationUseCase', () => {
|
||||
expect(notificationRepository.create).toHaveBeenCalledTimes(1);
|
||||
expect(gatewayRegistry.send).not.toHaveBeenCalled();
|
||||
|
||||
expect(output.result?.deliveryResults.length).toBe(1);
|
||||
expect(output.result?.deliveryResults[0]?.channel).toBe('in_app');
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult.deliveryResults.length).toBe(1);
|
||||
expect(successResult.deliveryResults[0]?.channel).toBe('in_app');
|
||||
});
|
||||
|
||||
it('returns REPOSITORY_ERROR when preference repository throws', async () => {
|
||||
@@ -172,13 +160,12 @@ describe('SendNotificationUseCase', () => {
|
||||
body: 'World',
|
||||
};
|
||||
|
||||
const result: Result<void, ApplicationErrorCode<SendNotificationErrorCode, { message: string }>> =
|
||||
const result: Result<SendNotificationResult, ApplicationErrorCode<SendNotificationErrorCode, { message: string }>> =
|
||||
await useCase.execute(command);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const err = result.unwrapErr();
|
||||
expect(err.code).toBe('REPOSITORY_ERROR');
|
||||
expect(err.details?.message).toBe('DB error');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* based on their preferences.
|
||||
*/
|
||||
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
@@ -52,7 +52,6 @@ export class SendNotificationUseCase {
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
private readonly gatewayRegistry: NotificationGatewayRegistry,
|
||||
private readonly output: UseCaseOutputPort<SendNotificationResult>,
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
this.logger.debug('SendNotificationUseCase initialized.');
|
||||
@@ -60,7 +59,7 @@ export class SendNotificationUseCase {
|
||||
|
||||
async execute(
|
||||
command: SendNotificationCommand,
|
||||
): Promise<Result<void, ApplicationErrorCode<SendNotificationErrorCode, { message: string }>>> {
|
||||
): Promise<Result<SendNotificationResult, ApplicationErrorCode<SendNotificationErrorCode, { message: string }>>> {
|
||||
this.logger.debug('Executing SendNotificationUseCase', { command });
|
||||
try {
|
||||
// Get recipient's preferences
|
||||
@@ -84,12 +83,10 @@ export class SendNotificationUseCase {
|
||||
|
||||
await this.notificationRepository.create(notification);
|
||||
|
||||
this.output.present({
|
||||
return Result.ok<SendNotificationResult, ApplicationErrorCode<SendNotificationErrorCode, { message: string }>>({
|
||||
notification,
|
||||
deliveryResults: [],
|
||||
});
|
||||
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
// Determine which channels to use
|
||||
@@ -142,20 +139,18 @@ export class SendNotificationUseCase {
|
||||
deliveryResults.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
this.output.present({
|
||||
|
||||
return Result.ok<SendNotificationResult, ApplicationErrorCode<SendNotificationErrorCode, { message: string }>>({
|
||||
notification: primaryNotification!,
|
||||
deliveryResults,
|
||||
});
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.error('Error sending notification', err);
|
||||
return Result.err({
|
||||
return Result.err<SendNotificationResult, ApplicationErrorCode<SendNotificationErrorCode, { message: string }>>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,9 @@ import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { AwardPrizeUseCase, type AwardPrizeInput } from './AwardPrizeUseCase';
|
||||
import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository';
|
||||
import { PrizeType, type Prize } from '../../domain/entities/Prize';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
|
||||
describe('AwardPrizeUseCase', () => {
|
||||
let prizeRepository: { findById: Mock; update: Mock };
|
||||
let output: { present: Mock };
|
||||
let useCase: AwardPrizeUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -15,13 +13,8 @@ describe('AwardPrizeUseCase', () => {
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new AwardPrizeUseCase(
|
||||
prizeRepository as unknown as IPrizeRepository,
|
||||
output as unknown as UseCaseOutputPort<unknown>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -34,7 +27,6 @@ describe('AwardPrizeUseCase', () => {
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('PRIZE_NOT_FOUND');
|
||||
expect(prizeRepository.update).not.toHaveBeenCalled();
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns PRIZE_ALREADY_AWARDED when prize is already awarded', async () => {
|
||||
@@ -59,10 +51,9 @@ describe('AwardPrizeUseCase', () => {
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('PRIZE_ALREADY_AWARDED');
|
||||
expect(prizeRepository.update).not.toHaveBeenCalled();
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('awards prize and presents updated prize', async () => {
|
||||
it('awards prize and returns updated prize', async () => {
|
||||
const prize: Prize = {
|
||||
id: 'prize-1',
|
||||
leagueId: 'league-1',
|
||||
@@ -92,13 +83,14 @@ describe('AwardPrizeUseCase', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
prize: expect.objectContaining({
|
||||
const value = result.value;
|
||||
expect(value.prize).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'prize-1',
|
||||
awarded: true,
|
||||
awardedTo: 'driver-1',
|
||||
awardedAt: expect.any(Date),
|
||||
}),
|
||||
});
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@
|
||||
import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository';
|
||||
import type { Prize } from '../../domain/entities/Prize';
|
||||
import type { UseCase } from '@core/shared/application/UseCase';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
|
||||
@@ -23,14 +22,13 @@ export interface AwardPrizeResult {
|
||||
export type AwardPrizeErrorCode = 'PRIZE_NOT_FOUND' | 'PRIZE_ALREADY_AWARDED';
|
||||
|
||||
export class AwardPrizeUseCase
|
||||
implements UseCase<AwardPrizeInput, void, AwardPrizeErrorCode>
|
||||
implements UseCase<AwardPrizeInput, AwardPrizeResult, AwardPrizeErrorCode>
|
||||
{
|
||||
constructor(
|
||||
private readonly prizeRepository: IPrizeRepository,
|
||||
private readonly output: UseCaseOutputPort<AwardPrizeResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: AwardPrizeInput): Promise<Result<void, ApplicationErrorCode<AwardPrizeErrorCode>>> {
|
||||
async execute(input: AwardPrizeInput): Promise<Result<AwardPrizeResult, ApplicationErrorCode<AwardPrizeErrorCode>>> {
|
||||
const { prizeId, driverId } = input;
|
||||
|
||||
const prize = await this.prizeRepository.findById(prizeId);
|
||||
@@ -48,8 +46,6 @@ export class AwardPrizeUseCase
|
||||
|
||||
const updatedPrize = await this.prizeRepository.update(prize);
|
||||
|
||||
this.output.present({ prize: updatedPrize });
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok({ prize: updatedPrize });
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { CreatePaymentUseCase, type CreatePaymentInput } from './CreatePaymentUseCase';
|
||||
import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository';
|
||||
import { PaymentType, PayerType } from '../../domain/entities/Payment';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import { PaymentType, PayerType, PaymentStatus } from '../../domain/entities/Payment';
|
||||
|
||||
describe('CreatePaymentUseCase', () => {
|
||||
let paymentRepository: {
|
||||
create: Mock;
|
||||
};
|
||||
let output: {
|
||||
present: Mock;
|
||||
};
|
||||
let useCase: CreatePaymentUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -18,17 +14,12 @@ describe('CreatePaymentUseCase', () => {
|
||||
create: vi.fn(),
|
||||
};
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new CreatePaymentUseCase(
|
||||
paymentRepository as unknown as IPaymentRepository,
|
||||
output as unknown as UseCaseOutputPort<unknown>,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a payment and presents the result', async () => {
|
||||
it('creates a payment and returns result', async () => {
|
||||
const input: CreatePaymentInput = {
|
||||
type: PaymentType.SPONSORSHIP,
|
||||
amount: 100,
|
||||
@@ -39,7 +30,7 @@ describe('CreatePaymentUseCase', () => {
|
||||
};
|
||||
|
||||
const createdPayment = {
|
||||
id: 'payment-123',
|
||||
id: 'payment-1',
|
||||
type: PaymentType.SPONSORSHIP,
|
||||
amount: 100,
|
||||
platformFee: 5,
|
||||
@@ -48,9 +39,8 @@ describe('CreatePaymentUseCase', () => {
|
||||
payerType: PayerType.SPONSOR,
|
||||
leagueId: 'league-1',
|
||||
seasonId: 'season-1',
|
||||
status: 'pending',
|
||||
status: PaymentStatus.PENDING,
|
||||
createdAt: new Date(),
|
||||
completedAt: undefined,
|
||||
};
|
||||
|
||||
paymentRepository.create.mockResolvedValue(createdPayment);
|
||||
@@ -58,19 +48,10 @@ describe('CreatePaymentUseCase', () => {
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(paymentRepository.create).toHaveBeenCalledWith({
|
||||
id: expect.stringContaining('payment-'),
|
||||
type: 'sponsorship',
|
||||
amount: 100,
|
||||
platformFee: 5,
|
||||
netAmount: 95,
|
||||
payerId: 'payer-1',
|
||||
payerType: 'sponsor',
|
||||
leagueId: 'league-1',
|
||||
seasonId: 'season-1',
|
||||
status: 'pending',
|
||||
createdAt: expect.any(Date),
|
||||
});
|
||||
expect(output.present).toHaveBeenCalledWith({ payment: createdPayment });
|
||||
expect(paymentRepository.create).toHaveBeenCalled();
|
||||
|
||||
if (result.isOk()) {
|
||||
expect(result.value.payment).toEqual(createdPayment);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepos
|
||||
import type { Payment, PaymentType, PayerType } from '../../domain/entities/Payment';
|
||||
import { PaymentStatus } from '../../domain/entities/Payment';
|
||||
import type { UseCase } from '@core/shared/application/UseCase';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
|
||||
@@ -28,14 +27,13 @@ export interface CreatePaymentResult {
|
||||
export type CreatePaymentErrorCode = never;
|
||||
|
||||
export class CreatePaymentUseCase
|
||||
implements UseCase<CreatePaymentInput, void, CreatePaymentErrorCode>
|
||||
implements UseCase<CreatePaymentInput, CreatePaymentResult, CreatePaymentErrorCode>
|
||||
{
|
||||
constructor(
|
||||
private readonly paymentRepository: IPaymentRepository,
|
||||
private readonly output: UseCaseOutputPort<CreatePaymentResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: CreatePaymentInput): Promise<Result<void, ApplicationErrorCode<CreatePaymentErrorCode>>> {
|
||||
async execute(input: CreatePaymentInput): Promise<Result<CreatePaymentResult, ApplicationErrorCode<CreatePaymentErrorCode>>> {
|
||||
const { type, amount, payerId, payerType, leagueId, seasonId } = input;
|
||||
|
||||
// Calculate platform fee (assume 5% for now)
|
||||
@@ -59,8 +57,6 @@ export class CreatePaymentUseCase
|
||||
|
||||
const createdPayment = await this.paymentRepository.create(payment);
|
||||
|
||||
this.output.present({ payment: createdPayment });
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok({ payment: createdPayment });
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,9 @@ import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { CreatePrizeUseCase, type CreatePrizeInput } from './CreatePrizeUseCase';
|
||||
import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository';
|
||||
import { PrizeType, type Prize } from '../../domain/entities/Prize';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
|
||||
describe('CreatePrizeUseCase', () => {
|
||||
let prizeRepository: { findByPosition: Mock; create: Mock };
|
||||
let output: { present: Mock };
|
||||
let useCase: CreatePrizeUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -15,13 +13,8 @@ describe('CreatePrizeUseCase', () => {
|
||||
create: vi.fn(),
|
||||
};
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new CreatePrizeUseCase(
|
||||
prizeRepository as unknown as IPrizeRepository,
|
||||
output as unknown as UseCaseOutputPort<unknown>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -54,10 +47,9 @@ describe('CreatePrizeUseCase', () => {
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('PRIZE_ALREADY_EXISTS');
|
||||
expect(prizeRepository.create).not.toHaveBeenCalled();
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates prize and presents created prize', async () => {
|
||||
it('creates prize and returns created prize', async () => {
|
||||
prizeRepository.findByPosition.mockResolvedValue(null);
|
||||
prizeRepository.create.mockImplementation(async (p: Prize) => p);
|
||||
|
||||
@@ -90,13 +82,14 @@ describe('CreatePrizeUseCase', () => {
|
||||
description: 'Top prize',
|
||||
});
|
||||
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
prize: expect.objectContaining({
|
||||
const value = result.value;
|
||||
expect(value.prize).toEqual(
|
||||
expect.objectContaining({
|
||||
leagueId: 'league-1',
|
||||
seasonId: 'season-1',
|
||||
position: 1,
|
||||
awarded: false,
|
||||
}),
|
||||
});
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@
|
||||
import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository';
|
||||
import type { PrizeType, Prize } from '../../domain/entities/Prize';
|
||||
import type { UseCase } from '@core/shared/application/UseCase';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
|
||||
@@ -28,14 +27,13 @@ export interface CreatePrizeResult {
|
||||
export type CreatePrizeErrorCode = 'PRIZE_ALREADY_EXISTS';
|
||||
|
||||
export class CreatePrizeUseCase
|
||||
implements UseCase<CreatePrizeInput, void, CreatePrizeErrorCode>
|
||||
implements UseCase<CreatePrizeInput, CreatePrizeResult, CreatePrizeErrorCode>
|
||||
{
|
||||
constructor(
|
||||
private readonly prizeRepository: IPrizeRepository,
|
||||
private readonly output: UseCaseOutputPort<CreatePrizeResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: CreatePrizeInput): Promise<Result<void, ApplicationErrorCode<CreatePrizeErrorCode>>> {
|
||||
async execute(input: CreatePrizeInput): Promise<Result<CreatePrizeResult, ApplicationErrorCode<CreatePrizeErrorCode>>> {
|
||||
const { leagueId, seasonId, position, name, amount, type, description } = input;
|
||||
|
||||
const existingPrize = await this.prizeRepository.findByPosition(leagueId, seasonId, position);
|
||||
@@ -59,8 +57,6 @@ export class CreatePrizeUseCase
|
||||
|
||||
const createdPrize = await this.prizeRepository.create(prize);
|
||||
|
||||
this.output.present({ prize: createdPrize });
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok({ prize: createdPrize });
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,9 @@ import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { DeletePrizeUseCase, type DeletePrizeInput } from './DeletePrizeUseCase';
|
||||
import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository';
|
||||
import { PrizeType, type Prize } from '../../domain/entities/Prize';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
|
||||
describe('DeletePrizeUseCase', () => {
|
||||
let prizeRepository: { findById: Mock; delete: Mock };
|
||||
let output: { present: Mock };
|
||||
let useCase: DeletePrizeUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -15,13 +13,8 @@ describe('DeletePrizeUseCase', () => {
|
||||
delete: vi.fn(),
|
||||
};
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new DeletePrizeUseCase(
|
||||
prizeRepository as unknown as IPrizeRepository,
|
||||
output as unknown as UseCaseOutputPort<unknown>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -34,7 +27,6 @@ describe('DeletePrizeUseCase', () => {
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('PRIZE_NOT_FOUND');
|
||||
expect(prizeRepository.delete).not.toHaveBeenCalled();
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns CANNOT_DELETE_AWARDED_PRIZE when prize is awarded', async () => {
|
||||
@@ -59,10 +51,9 @@ describe('DeletePrizeUseCase', () => {
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('CANNOT_DELETE_AWARDED_PRIZE');
|
||||
expect(prizeRepository.delete).not.toHaveBeenCalled();
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes prize and presents success', async () => {
|
||||
it('deletes prize and returns success', async () => {
|
||||
const prize: Prize = {
|
||||
id: 'prize-1',
|
||||
leagueId: 'league-1',
|
||||
@@ -82,6 +73,7 @@ describe('DeletePrizeUseCase', () => {
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(prizeRepository.delete).toHaveBeenCalledWith('prize-1');
|
||||
expect(output.present).toHaveBeenCalledWith({ success: true });
|
||||
const value = result.value;
|
||||
expect(value.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository';
|
||||
import type { UseCase } from '@core/shared/application/UseCase';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
|
||||
@@ -21,14 +20,13 @@ export interface DeletePrizeResult {
|
||||
export type DeletePrizeErrorCode = 'PRIZE_NOT_FOUND' | 'CANNOT_DELETE_AWARDED_PRIZE';
|
||||
|
||||
export class DeletePrizeUseCase
|
||||
implements UseCase<DeletePrizeInput, void, DeletePrizeErrorCode>
|
||||
implements UseCase<DeletePrizeInput, DeletePrizeResult, DeletePrizeErrorCode>
|
||||
{
|
||||
constructor(
|
||||
private readonly prizeRepository: IPrizeRepository,
|
||||
private readonly output: UseCaseOutputPort<DeletePrizeResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: DeletePrizeInput): Promise<Result<void, ApplicationErrorCode<DeletePrizeErrorCode>>> {
|
||||
async execute(input: DeletePrizeInput): Promise<Result<DeletePrizeResult, ApplicationErrorCode<DeletePrizeErrorCode>>> {
|
||||
const { prizeId } = input;
|
||||
|
||||
const prize = await this.prizeRepository.findById(prizeId);
|
||||
@@ -42,8 +40,6 @@ export class DeletePrizeUseCase
|
||||
|
||||
await this.prizeRepository.delete(prizeId);
|
||||
|
||||
this.output.present({ success: true });
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok({ success: true });
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { GetMembershipFeesUseCase, type GetMembershipFeesInput } from './GetMembershipFeesUseCase';
|
||||
import type { IMembershipFeeRepository, IMemberPaymentRepository } from '../../domain/repositories/IMembershipFeeRepository';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
|
||||
describe('GetMembershipFeesUseCase', () => {
|
||||
let membershipFeeRepository: {
|
||||
@@ -10,81 +9,49 @@ describe('GetMembershipFeesUseCase', () => {
|
||||
let memberPaymentRepository: {
|
||||
findByLeagueIdAndDriverId: Mock;
|
||||
};
|
||||
let output: {
|
||||
present: Mock;
|
||||
};
|
||||
let useCase: GetMembershipFeesUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
membershipFeeRepository = {
|
||||
findByLeagueId: vi.fn(),
|
||||
};
|
||||
|
||||
memberPaymentRepository = {
|
||||
findByLeagueIdAndDriverId: vi.fn(),
|
||||
};
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new GetMembershipFeesUseCase(
|
||||
membershipFeeRepository as unknown as IMembershipFeeRepository,
|
||||
memberPaymentRepository as unknown as IMemberPaymentRepository,
|
||||
output as unknown as UseCaseOutputPort<unknown>,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns error when leagueId is missing', async () => {
|
||||
const input = { leagueId: '' } as GetMembershipFeesInput;
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('INVALID_INPUT');
|
||||
});
|
||||
|
||||
it('returns null fee and empty payments when no fee exists', async () => {
|
||||
const input: GetMembershipFeesInput = { leagueId: 'league-1' };
|
||||
|
||||
membershipFeeRepository.findByLeagueId.mockResolvedValue(null);
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(membershipFeeRepository.findByLeagueId).toHaveBeenCalledWith('league-1');
|
||||
expect(memberPaymentRepository.findByLeagueIdAndDriverId).not.toHaveBeenCalled();
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
fee: null,
|
||||
payments: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps fee and payments when fee and driverId are provided', async () => {
|
||||
const input: GetMembershipFeesInput = { leagueId: 'league-1', driverId: 'driver-1' };
|
||||
it('retrieves membership fees and returns result', async () => {
|
||||
const input: GetMembershipFeesInput = {
|
||||
leagueId: 'league-1',
|
||||
driverId: 'driver-1',
|
||||
};
|
||||
|
||||
const fee = {
|
||||
id: 'fee-1',
|
||||
leagueId: 'league-1',
|
||||
seasonId: 'season-1',
|
||||
type: 'season',
|
||||
amount: 100,
|
||||
type: 'monthly',
|
||||
amount: 50,
|
||||
enabled: true,
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-02'),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const payments = [
|
||||
{
|
||||
id: 'pay-1',
|
||||
id: 'payment-1',
|
||||
feeId: 'fee-1',
|
||||
driverId: 'driver-1',
|
||||
amount: 100,
|
||||
amount: 50,
|
||||
platformFee: 5,
|
||||
netAmount: 95,
|
||||
netAmount: 45,
|
||||
status: 'paid',
|
||||
dueDate: new Date('2024-02-01'),
|
||||
paidAt: new Date('2024-01-15'),
|
||||
dueDate: new Date(),
|
||||
paidAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -95,11 +62,23 @@ describe('GetMembershipFeesUseCase', () => {
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(membershipFeeRepository.findByLeagueId).toHaveBeenCalledWith('league-1');
|
||||
expect(memberPaymentRepository.findByLeagueIdAndDriverId).toHaveBeenCalledWith('league-1', 'driver-1', membershipFeeRepository as unknown as IMembershipFeeRepository);
|
||||
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
fee,
|
||||
payments,
|
||||
});
|
||||
expect(memberPaymentRepository.findByLeagueIdAndDriverId).toHaveBeenCalledWith('league-1', 'driver-1', membershipFeeRepository);
|
||||
|
||||
if (result.isOk()) {
|
||||
expect(result.value).toEqual({ fee, payments });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when leagueId is missing', async () => {
|
||||
const input: GetMembershipFeesInput = {
|
||||
leagueId: '',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
if (result.isErr()) {
|
||||
expect(result.error.code).toBe('INVALID_INPUT');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import type { IMembershipFeeRepository, IMemberPaymentRepository } from '../../d
|
||||
import type { MembershipFee } from '../../domain/entities/MembershipFee';
|
||||
import type { MemberPayment } from '../../domain/entities/MemberPayment';
|
||||
import type { UseCase } from '@core/shared/application/UseCase';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
|
||||
@@ -25,15 +24,14 @@ export interface GetMembershipFeesResult {
|
||||
}
|
||||
|
||||
export class GetMembershipFeesUseCase
|
||||
implements UseCase<GetMembershipFeesInput, void, GetMembershipFeesErrorCode>
|
||||
implements UseCase<GetMembershipFeesInput, GetMembershipFeesResult, GetMembershipFeesErrorCode>
|
||||
{
|
||||
constructor(
|
||||
private readonly membershipFeeRepository: IMembershipFeeRepository,
|
||||
private readonly memberPaymentRepository: IMemberPaymentRepository,
|
||||
private readonly output: UseCaseOutputPort<GetMembershipFeesResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: GetMembershipFeesInput): Promise<Result<void, ApplicationErrorCode<GetMembershipFeesErrorCode>>> {
|
||||
async execute(input: GetMembershipFeesInput): Promise<Result<GetMembershipFeesResult, ApplicationErrorCode<GetMembershipFeesErrorCode>>> {
|
||||
const { leagueId, driverId } = input;
|
||||
|
||||
if (!leagueId) {
|
||||
@@ -47,8 +45,6 @@ export class GetMembershipFeesUseCase
|
||||
payments = await this.memberPaymentRepository.findByLeagueIdAndDriverId(leagueId, driverId, this.membershipFeeRepository);
|
||||
}
|
||||
|
||||
this.output.present({ fee, payments });
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok({ fee, payments });
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,11 @@ import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { GetPaymentsUseCase, type GetPaymentsInput } from './GetPaymentsUseCase';
|
||||
import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository';
|
||||
import { PaymentType, PayerType } from '../../domain/entities/Payment';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
|
||||
describe('GetPaymentsUseCase', () => {
|
||||
let paymentRepository: {
|
||||
findByFilters: Mock;
|
||||
};
|
||||
let output: {
|
||||
present: Mock;
|
||||
};
|
||||
let useCase: GetPaymentsUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -18,17 +14,12 @@ describe('GetPaymentsUseCase', () => {
|
||||
findByFilters: vi.fn(),
|
||||
};
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new GetPaymentsUseCase(
|
||||
paymentRepository as unknown as IPaymentRepository,
|
||||
output as unknown as UseCaseOutputPort<unknown>,
|
||||
);
|
||||
});
|
||||
|
||||
it('retrieves payments and presents the result', async () => {
|
||||
it('retrieves payments and returns result', async () => {
|
||||
const input: GetPaymentsInput = {
|
||||
leagueId: 'league-1',
|
||||
payerId: 'payer-1',
|
||||
@@ -62,6 +53,9 @@ describe('GetPaymentsUseCase', () => {
|
||||
payerId: 'payer-1',
|
||||
type: PaymentType.SPONSORSHIP,
|
||||
});
|
||||
expect(output.present).toHaveBeenCalledWith({ payments });
|
||||
|
||||
if (result.isOk()) {
|
||||
expect(result.value).toEqual({ payments });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@
|
||||
import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository';
|
||||
import type { Payment, PaymentType } from '../../domain/entities/Payment';
|
||||
import type { UseCase } from '@core/shared/application/UseCase';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
|
||||
@@ -24,14 +23,13 @@ export interface GetPaymentsResult {
|
||||
export type GetPaymentsErrorCode = never;
|
||||
|
||||
export class GetPaymentsUseCase
|
||||
implements UseCase<GetPaymentsInput, void, GetPaymentsErrorCode>
|
||||
implements UseCase<GetPaymentsInput, GetPaymentsResult, GetPaymentsErrorCode>
|
||||
{
|
||||
constructor(
|
||||
private readonly paymentRepository: IPaymentRepository,
|
||||
private readonly output: UseCaseOutputPort<GetPaymentsResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: GetPaymentsInput): Promise<Result<void, ApplicationErrorCode<GetPaymentsErrorCode>>> {
|
||||
async execute(input: GetPaymentsInput): Promise<Result<GetPaymentsResult, ApplicationErrorCode<GetPaymentsErrorCode>>> {
|
||||
const { leagueId, payerId, type } = input;
|
||||
|
||||
const filters: { leagueId?: string; payerId?: string; type?: PaymentType } = {};
|
||||
@@ -41,8 +39,6 @@ export class GetPaymentsUseCase
|
||||
|
||||
const payments = await this.paymentRepository.findByFilters(filters);
|
||||
|
||||
this.output.present({ payments });
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok({ payments });
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,12 @@ import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { GetPrizesUseCase, type GetPrizesInput } from './GetPrizesUseCase';
|
||||
import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository';
|
||||
import { PrizeType, type Prize } from '../../domain/entities/Prize';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
|
||||
describe('GetPrizesUseCase', () => {
|
||||
let prizeRepository: {
|
||||
findByLeagueId: Mock;
|
||||
findByLeagueIdAndSeasonId: Mock;
|
||||
};
|
||||
let output: { present: Mock };
|
||||
let useCase: GetPrizesUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -18,13 +16,8 @@ describe('GetPrizesUseCase', () => {
|
||||
findByLeagueIdAndSeasonId: vi.fn(),
|
||||
};
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new GetPrizesUseCase(
|
||||
prizeRepository as unknown as IPrizeRepository,
|
||||
output as unknown as UseCaseOutputPort<unknown>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -62,10 +55,9 @@ describe('GetPrizesUseCase', () => {
|
||||
expect(prizeRepository.findByLeagueId).toHaveBeenCalledWith('league-1');
|
||||
expect(prizeRepository.findByLeagueIdAndSeasonId).not.toHaveBeenCalled();
|
||||
|
||||
expect(output.present).toHaveBeenCalledTimes(1);
|
||||
const presented = (output.present as unknown as Mock).mock.calls[0]![0]!.prizes as Prize[];
|
||||
expect(presented.map(p => p.position)).toEqual([1, 2]);
|
||||
expect(presented.map(p => p.id)).toEqual(['p1', 'p2']);
|
||||
const value = result.value;
|
||||
expect(value.prizes.map(p => p.position)).toEqual([1, 2]);
|
||||
expect(value.prizes.map(p => p.id)).toEqual(['p1', 'p2']);
|
||||
});
|
||||
|
||||
it('retrieves and sorts prizes by leagueId and seasonId when provided', async () => {
|
||||
@@ -102,9 +94,8 @@ describe('GetPrizesUseCase', () => {
|
||||
expect(prizeRepository.findByLeagueIdAndSeasonId).toHaveBeenCalledWith('league-1', 'season-1');
|
||||
expect(prizeRepository.findByLeagueId).not.toHaveBeenCalled();
|
||||
|
||||
expect(output.present).toHaveBeenCalledTimes(1);
|
||||
const presented = (output.present as unknown as Mock).mock.calls[0]![0]!.prizes as Prize[];
|
||||
expect(presented.map(p => p.position)).toEqual([1, 3]);
|
||||
expect(presented.map(p => p.id)).toEqual(['p1', 'p3']);
|
||||
const value = result.value;
|
||||
expect(value.prizes.map(p => p.position)).toEqual([1, 3]);
|
||||
expect(value.prizes.map(p => p.id)).toEqual(['p1', 'p3']);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@
|
||||
import type { IPrizeRepository } from '../../domain/repositories/IPrizeRepository';
|
||||
import type { Prize } from '../../domain/entities/Prize';
|
||||
import type { UseCase } from '@core/shared/application/UseCase';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
|
||||
export interface GetPrizesInput {
|
||||
@@ -20,14 +19,13 @@ export interface GetPrizesResult {
|
||||
}
|
||||
|
||||
export class GetPrizesUseCase
|
||||
implements UseCase<GetPrizesInput, void, never>
|
||||
implements UseCase<GetPrizesInput, GetPrizesResult, never>
|
||||
{
|
||||
constructor(
|
||||
private readonly prizeRepository: IPrizeRepository,
|
||||
private readonly output: UseCaseOutputPort<GetPrizesResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: GetPrizesInput): Promise<Result<void, never>> {
|
||||
async execute(input: GetPrizesInput): Promise<Result<GetPrizesResult, never>> {
|
||||
const { leagueId, seasonId } = input;
|
||||
|
||||
let prizes;
|
||||
@@ -39,8 +37,6 @@ export class GetPrizesUseCase
|
||||
|
||||
prizes.sort((a, b) => a.position - b.position);
|
||||
|
||||
this.output.present({ prizes });
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok({ prizes });
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { GetWalletUseCase, type GetWalletInput } from './GetWalletUseCase';
|
||||
import type { ITransactionRepository, IWalletRepository } from '../../domain/repositories/IWalletRepository';
|
||||
import type { Transaction, Wallet } from '../../domain/entities/Wallet';
|
||||
import { TransactionType } from '../../domain/entities/Wallet';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
|
||||
describe('GetWalletUseCase', () => {
|
||||
let walletRepository: {
|
||||
@@ -15,10 +14,6 @@ describe('GetWalletUseCase', () => {
|
||||
findByWalletId: Mock;
|
||||
};
|
||||
|
||||
let output: {
|
||||
present: Mock;
|
||||
};
|
||||
|
||||
let useCase: GetWalletUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -31,14 +26,9 @@ describe('GetWalletUseCase', () => {
|
||||
findByWalletId: vi.fn(),
|
||||
};
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new GetWalletUseCase(
|
||||
walletRepository as unknown as IWalletRepository,
|
||||
transactionRepository as unknown as ITransactionRepository,
|
||||
output as unknown as UseCaseOutputPort<unknown>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -49,10 +39,9 @@ describe('GetWalletUseCase', () => {
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('INVALID_INPUT');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('presents existing wallet and transactions sorted desc by createdAt', async () => {
|
||||
it('returns wallet and transactions sorted desc by createdAt', async () => {
|
||||
const input: GetWalletInput = { leagueId: 'league-1' };
|
||||
|
||||
const wallet: Wallet = {
|
||||
@@ -90,14 +79,15 @@ describe('GetWalletUseCase', () => {
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(transactionRepository.findByWalletId).toHaveBeenCalledWith('wallet-1');
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
const value = result.value;
|
||||
expect(value).toEqual({
|
||||
wallet,
|
||||
transactions: [newer, older],
|
||||
});
|
||||
expect(transactionRepository.findByWalletId).toHaveBeenCalledWith('wallet-1');
|
||||
});
|
||||
|
||||
it('creates wallet when missing, then presents wallet and transactions', async () => {
|
||||
it('creates wallet when missing, then returns wallet and transactions', async () => {
|
||||
const input: GetWalletInput = { leagueId: 'league-1' };
|
||||
|
||||
vi.useFakeTimers();
|
||||
@@ -131,7 +121,8 @@ describe('GetWalletUseCase', () => {
|
||||
const createdWalletArg = walletRepository.create.mock.calls[0]?.[0] as Wallet;
|
||||
expect(transactionRepository.findByWalletId).toHaveBeenCalledWith(createdWalletArg.id);
|
||||
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
const value = result.value;
|
||||
expect(value).toEqual({
|
||||
wallet: createdWalletArg,
|
||||
transactions: [],
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import type { IWalletRepository, ITransactionRepository } from '../../domain/repositories/IWalletRepository';
|
||||
import type { Wallet, Transaction } from '../../domain/entities/Wallet';
|
||||
import type { UseCase } from '@core/shared/application/UseCase';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
|
||||
@@ -23,15 +22,14 @@ export interface GetWalletResult {
|
||||
}
|
||||
|
||||
export class GetWalletUseCase
|
||||
implements UseCase<GetWalletInput, void, GetWalletErrorCode>
|
||||
implements UseCase<GetWalletInput, GetWalletResult, GetWalletErrorCode>
|
||||
{
|
||||
constructor(
|
||||
private readonly walletRepository: IWalletRepository,
|
||||
private readonly transactionRepository: ITransactionRepository,
|
||||
private readonly output: UseCaseOutputPort<GetWalletResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: GetWalletInput): Promise<Result<void, ApplicationErrorCode<GetWalletErrorCode>>> {
|
||||
async execute(input: GetWalletInput): Promise<Result<GetWalletResult, ApplicationErrorCode<GetWalletErrorCode>>> {
|
||||
const { leagueId } = input;
|
||||
|
||||
if (!leagueId) {
|
||||
@@ -58,8 +56,6 @@ export class GetWalletUseCase
|
||||
const transactions = await this.transactionRepository.findByWalletId(wallet.id);
|
||||
transactions.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
|
||||
this.output.present({ wallet, transactions });
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok({ wallet, transactions });
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { ProcessWalletTransactionUseCase, type ProcessWalletTransactionInput } from './ProcessWalletTransactionUseCase';
|
||||
import type { IWalletRepository, ITransactionRepository } from '../../domain/repositories/IWalletRepository';
|
||||
import { TransactionType, ReferenceType } from '../../domain/entities/Wallet';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
|
||||
describe('ProcessWalletTransactionUseCase', () => {
|
||||
let walletRepository: {
|
||||
@@ -13,9 +12,6 @@ describe('ProcessWalletTransactionUseCase', () => {
|
||||
let transactionRepository: {
|
||||
create: Mock;
|
||||
};
|
||||
let output: {
|
||||
present: Mock;
|
||||
};
|
||||
let useCase: ProcessWalletTransactionUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -29,18 +25,13 @@ describe('ProcessWalletTransactionUseCase', () => {
|
||||
create: vi.fn(),
|
||||
};
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new ProcessWalletTransactionUseCase(
|
||||
walletRepository as unknown as IWalletRepository,
|
||||
transactionRepository as unknown as ITransactionRepository,
|
||||
output as unknown as UseCaseOutputPort<unknown>,
|
||||
);
|
||||
});
|
||||
|
||||
it('processes a deposit transaction and presents the result', async () => {
|
||||
it('processes a deposit transaction and returns the result', async () => {
|
||||
const input: ProcessWalletTransactionInput = {
|
||||
leagueId: 'league-1',
|
||||
type: TransactionType.DEPOSIT,
|
||||
@@ -79,10 +70,9 @@ describe('ProcessWalletTransactionUseCase', () => {
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
wallet: { ...wallet, balance: 150, totalRevenue: 150 },
|
||||
transaction,
|
||||
});
|
||||
const value = result.value;
|
||||
expect(value.wallet).toEqual({ ...wallet, balance: 150, totalRevenue: 150 });
|
||||
expect(value.transaction).toEqual(transaction);
|
||||
});
|
||||
|
||||
it('returns error for insufficient balance on withdrawal', async () => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { IWalletRepository, ITransactionRepository } from '../../domain/rep
|
||||
import type { Wallet, Transaction } from '../../domain/entities/Wallet';
|
||||
import { TransactionType, ReferenceType } from '../../domain/entities/Wallet';
|
||||
import type { UseCase } from '@core/shared/application/UseCase';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
|
||||
@@ -29,15 +28,14 @@ export interface ProcessWalletTransactionResult {
|
||||
export type ProcessWalletTransactionErrorCode = 'MISSING_REQUIRED_FIELDS' | 'INVALID_TYPE' | 'INSUFFICIENT_BALANCE';
|
||||
|
||||
export class ProcessWalletTransactionUseCase
|
||||
implements UseCase<ProcessWalletTransactionInput, void, ProcessWalletTransactionErrorCode>
|
||||
implements UseCase<ProcessWalletTransactionInput, ProcessWalletTransactionResult, ProcessWalletTransactionErrorCode>
|
||||
{
|
||||
constructor(
|
||||
private readonly walletRepository: IWalletRepository,
|
||||
private readonly transactionRepository: ITransactionRepository,
|
||||
private readonly output: UseCaseOutputPort<ProcessWalletTransactionResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: ProcessWalletTransactionInput): Promise<Result<void, ApplicationErrorCode<ProcessWalletTransactionErrorCode>>> {
|
||||
async execute(input: ProcessWalletTransactionInput): Promise<Result<ProcessWalletTransactionResult, ApplicationErrorCode<ProcessWalletTransactionErrorCode>>> {
|
||||
const { leagueId, type, amount, description, referenceId, referenceType } = input;
|
||||
|
||||
if (!leagueId || !type || amount === undefined || !description) {
|
||||
@@ -95,8 +93,6 @@ export class ProcessWalletTransactionUseCase
|
||||
|
||||
const updatedWallet = await this.walletRepository.update(wallet);
|
||||
|
||||
this.output.present({ wallet: updatedWallet, transaction: createdTransaction });
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok({ wallet: updatedWallet, transaction: createdTransaction });
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||
import { UpdateMemberPaymentUseCase, type UpdateMemberPaymentInput } from './UpdateMemberPaymentUseCase';
|
||||
import type { IMembershipFeeRepository, IMemberPaymentRepository } from '../../domain/repositories/IMembershipFeeRepository';
|
||||
import { MemberPaymentStatus, type MemberPayment } from '../../domain/entities/MemberPayment';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
|
||||
describe('UpdateMemberPaymentUseCase', () => {
|
||||
let membershipFeeRepository: {
|
||||
@@ -15,10 +14,6 @@ describe('UpdateMemberPaymentUseCase', () => {
|
||||
update: Mock;
|
||||
};
|
||||
|
||||
let output: {
|
||||
present: Mock;
|
||||
};
|
||||
|
||||
let useCase: UpdateMemberPaymentUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -32,14 +27,9 @@ describe('UpdateMemberPaymentUseCase', () => {
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new UpdateMemberPaymentUseCase(
|
||||
membershipFeeRepository as unknown as IMembershipFeeRepository,
|
||||
memberPaymentRepository as unknown as IMemberPaymentRepository,
|
||||
output as unknown as UseCaseOutputPort<unknown>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -58,7 +48,6 @@ describe('UpdateMemberPaymentUseCase', () => {
|
||||
expect(memberPaymentRepository.findByFeeIdAndDriverId).not.toHaveBeenCalled();
|
||||
expect(memberPaymentRepository.create).not.toHaveBeenCalled();
|
||||
expect(memberPaymentRepository.update).not.toHaveBeenCalled();
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a new payment when missing, applies status and paidAt when PAID', async () => {
|
||||
@@ -112,8 +101,9 @@ describe('UpdateMemberPaymentUseCase', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const value = result.value;
|
||||
const updated = memberPaymentRepository.update.mock.calls[0]?.[0] as MemberPayment;
|
||||
expect(output.present).toHaveBeenCalledWith({ payment: updated });
|
||||
expect(value.payment).toEqual(updated);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
@@ -164,7 +154,8 @@ describe('UpdateMemberPaymentUseCase', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const value = result.value;
|
||||
const updated = memberPaymentRepository.update.mock.calls[0]?.[0] as MemberPayment;
|
||||
expect(output.present).toHaveBeenCalledWith({ payment: updated });
|
||||
expect(value.payment).toEqual(updated);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import type { IMembershipFeeRepository, IMemberPaymentRepository } from '../../d
|
||||
import type { MemberPayment } from '../../domain/entities/MemberPayment';
|
||||
import { MemberPaymentStatus } from '../../domain/entities/MemberPayment';
|
||||
import type { UseCase } from '@core/shared/application/UseCase';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
|
||||
@@ -28,15 +27,14 @@ export interface UpdateMemberPaymentResult {
|
||||
export type UpdateMemberPaymentErrorCode = 'MEMBERSHIP_FEE_NOT_FOUND';
|
||||
|
||||
export class UpdateMemberPaymentUseCase
|
||||
implements UseCase<UpdateMemberPaymentInput, void, UpdateMemberPaymentErrorCode>
|
||||
implements UseCase<UpdateMemberPaymentInput, UpdateMemberPaymentResult, UpdateMemberPaymentErrorCode>
|
||||
{
|
||||
constructor(
|
||||
private readonly membershipFeeRepository: IMembershipFeeRepository,
|
||||
private readonly memberPaymentRepository: IMemberPaymentRepository,
|
||||
private readonly output: UseCaseOutputPort<UpdateMemberPaymentResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: UpdateMemberPaymentInput): Promise<Result<void, ApplicationErrorCode<UpdateMemberPaymentErrorCode>>> {
|
||||
async execute(input: UpdateMemberPaymentInput): Promise<Result<UpdateMemberPaymentResult, ApplicationErrorCode<UpdateMemberPaymentErrorCode>>> {
|
||||
const { feeId, driverId, status, paidAt } = input;
|
||||
|
||||
const fee = await this.membershipFeeRepository.findById(feeId);
|
||||
@@ -73,8 +71,6 @@ export class UpdateMemberPaymentUseCase
|
||||
|
||||
const updatedPayment = await this.memberPaymentRepository.update(payment);
|
||||
|
||||
this.output.present({ payment: updatedPayment });
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok({ payment: updatedPayment });
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,13 @@
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { UpdatePaymentStatusUseCase, type UpdatePaymentStatusInput } from './UpdatePaymentStatusUseCase';
|
||||
import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepository';
|
||||
import { PaymentStatus, PaymentType, PayerType, type Payment } from '../../domain/entities/Payment';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import { PaymentStatus, PaymentType, PayerType } from '../../domain/entities/Payment';
|
||||
|
||||
describe('UpdatePaymentStatusUseCase', () => {
|
||||
let paymentRepository: {
|
||||
findById: Mock;
|
||||
update: Mock;
|
||||
};
|
||||
|
||||
let output: {
|
||||
present: Mock;
|
||||
};
|
||||
|
||||
let useCase: UpdatePaymentStatusUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -22,133 +16,63 @@ describe('UpdatePaymentStatusUseCase', () => {
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new UpdatePaymentStatusUseCase(
|
||||
paymentRepository as unknown as IPaymentRepository,
|
||||
output as unknown as UseCaseOutputPort<unknown>,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns PAYMENT_NOT_FOUND when payment does not exist', async () => {
|
||||
it('updates payment status and returns result', async () => {
|
||||
const input: UpdatePaymentStatusInput = {
|
||||
paymentId: 'payment-1',
|
||||
status: PaymentStatus.COMPLETED,
|
||||
};
|
||||
|
||||
const existingPayment = {
|
||||
id: 'payment-1',
|
||||
type: PaymentType.SPONSORSHIP,
|
||||
amount: 100,
|
||||
platformFee: 5,
|
||||
netAmount: 95,
|
||||
payerId: 'payer-1',
|
||||
payerType: PayerType.SPONSOR,
|
||||
leagueId: 'league-1',
|
||||
status: PaymentStatus.PENDING,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const updatedPayment = {
|
||||
...existingPayment,
|
||||
status: PaymentStatus.COMPLETED,
|
||||
completedAt: new Date(),
|
||||
};
|
||||
|
||||
paymentRepository.findById.mockResolvedValue(existingPayment);
|
||||
paymentRepository.update.mockResolvedValue(updatedPayment);
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(paymentRepository.findById).toHaveBeenCalledWith('payment-1');
|
||||
expect(paymentRepository.update).toHaveBeenCalled();
|
||||
|
||||
if (result.isOk()) {
|
||||
expect(result.value.payment).toEqual(updatedPayment);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns error when payment not found', async () => {
|
||||
const input: UpdatePaymentStatusInput = {
|
||||
paymentId: 'non-existent',
|
||||
status: PaymentStatus.COMPLETED,
|
||||
};
|
||||
|
||||
paymentRepository.findById.mockResolvedValue(null);
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('PAYMENT_NOT_FOUND');
|
||||
expect(paymentRepository.update).not.toHaveBeenCalled();
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets completedAt when status becomes COMPLETED', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z'));
|
||||
|
||||
try {
|
||||
const input: UpdatePaymentStatusInput = {
|
||||
paymentId: 'payment-1',
|
||||
status: PaymentStatus.COMPLETED,
|
||||
};
|
||||
|
||||
const existingPayment: Payment = {
|
||||
id: 'payment-1',
|
||||
type: PaymentType.SPONSORSHIP,
|
||||
amount: 100,
|
||||
platformFee: 5,
|
||||
netAmount: 95,
|
||||
payerId: 'payer-1',
|
||||
payerType: PayerType.SPONSOR,
|
||||
leagueId: 'league-1',
|
||||
status: PaymentStatus.PENDING,
|
||||
createdAt: new Date('2024-12-31T00:00:00.000Z'),
|
||||
};
|
||||
|
||||
paymentRepository.findById.mockResolvedValue(existingPayment);
|
||||
paymentRepository.update.mockImplementation(async (p: Payment) => ({ ...p }));
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
|
||||
expect(paymentRepository.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'payment-1',
|
||||
status: PaymentStatus.COMPLETED,
|
||||
completedAt: new Date('2025-01-01T00:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
|
||||
const savedPayment = paymentRepository.update.mock.results[0]?.value;
|
||||
await expect(savedPayment).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
id: 'payment-1',
|
||||
status: PaymentStatus.COMPLETED,
|
||||
completedAt: new Date('2025-01-01T00:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
|
||||
const presentedPayment = (output.present.mock.calls[0]?.[0] as { payment: Payment }).payment;
|
||||
expect(presentedPayment.status).toBe(PaymentStatus.COMPLETED);
|
||||
expect(presentedPayment.completedAt).toEqual(new Date('2025-01-01T00:00:00.000Z'));
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves completedAt when status is not COMPLETED', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
|
||||
|
||||
try {
|
||||
const input: UpdatePaymentStatusInput = {
|
||||
paymentId: 'payment-1',
|
||||
status: PaymentStatus.FAILED,
|
||||
};
|
||||
|
||||
const existingCompletedAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
|
||||
const existingPayment: Payment = {
|
||||
id: 'payment-1',
|
||||
type: PaymentType.SPONSORSHIP,
|
||||
amount: 100,
|
||||
platformFee: 5,
|
||||
netAmount: 95,
|
||||
payerId: 'payer-1',
|
||||
payerType: PayerType.SPONSOR,
|
||||
leagueId: 'league-1',
|
||||
status: PaymentStatus.COMPLETED,
|
||||
createdAt: new Date('2024-12-31T00:00:00.000Z'),
|
||||
completedAt: existingCompletedAt,
|
||||
};
|
||||
|
||||
paymentRepository.findById.mockResolvedValue(existingPayment);
|
||||
paymentRepository.update.mockImplementation(async (p: Payment) => ({ ...p }));
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
|
||||
expect(paymentRepository.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'payment-1',
|
||||
status: PaymentStatus.FAILED,
|
||||
completedAt: existingCompletedAt,
|
||||
}),
|
||||
);
|
||||
|
||||
const presentedPayment = (output.present.mock.calls[0]?.[0] as { payment: Payment }).payment;
|
||||
expect(presentedPayment.status).toBe(PaymentStatus.FAILED);
|
||||
expect(presentedPayment.completedAt).toEqual(existingCompletedAt);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
if (result.isErr()) {
|
||||
expect(result.error.code).toBe('PAYMENT_NOT_FOUND');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import type { IPaymentRepository } from '../../domain/repositories/IPaymentRepos
|
||||
import type { Payment } from '../../domain/entities/Payment';
|
||||
import { PaymentStatus } from '../../domain/entities/Payment';
|
||||
import type { UseCase } from '@core/shared/application/UseCase';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
|
||||
@@ -24,14 +23,13 @@ export interface UpdatePaymentStatusResult {
|
||||
}
|
||||
|
||||
export class UpdatePaymentStatusUseCase
|
||||
implements UseCase<UpdatePaymentStatusInput, void, UpdatePaymentStatusErrorCode>
|
||||
implements UseCase<UpdatePaymentStatusInput, UpdatePaymentStatusResult, UpdatePaymentStatusErrorCode>
|
||||
{
|
||||
constructor(
|
||||
private readonly paymentRepository: IPaymentRepository,
|
||||
private readonly output: UseCaseOutputPort<UpdatePaymentStatusResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: UpdatePaymentStatusInput): Promise<Result<void, ApplicationErrorCode<UpdatePaymentStatusErrorCode>>> {
|
||||
async execute(input: UpdatePaymentStatusInput): Promise<Result<UpdatePaymentStatusResult, ApplicationErrorCode<UpdatePaymentStatusErrorCode>>> {
|
||||
const { paymentId, status } = input;
|
||||
|
||||
const existingPayment = await this.paymentRepository.findById(paymentId);
|
||||
@@ -47,8 +45,6 @@ export class UpdatePaymentStatusUseCase
|
||||
|
||||
const savedPayment = await this.paymentRepository.update(updatedPayment as Payment);
|
||||
|
||||
this.output.present({ payment: savedPayment });
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok({ payment: savedPayment });
|
||||
}
|
||||
}
|
||||
@@ -1,127 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { UpsertMembershipFeeUseCase, type UpsertMembershipFeeInput } from './UpsertMembershipFeeUseCase';
|
||||
import type { IMembershipFeeRepository } from '../../domain/repositories/IMembershipFeeRepository';
|
||||
import { MembershipFeeType, type MembershipFee } from '../../domain/entities/MembershipFee';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import { MembershipFeeType } from '../../domain/entities/MembershipFee';
|
||||
|
||||
describe('UpsertMembershipFeeUseCase', () => {
|
||||
let membershipFeeRepository: {
|
||||
findByLeagueId: Mock;
|
||||
create: Mock;
|
||||
update: Mock;
|
||||
create: Mock;
|
||||
};
|
||||
|
||||
let output: {
|
||||
present: Mock;
|
||||
};
|
||||
|
||||
let useCase: UpsertMembershipFeeUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
membershipFeeRepository = {
|
||||
findByLeagueId: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
create: vi.fn(),
|
||||
};
|
||||
|
||||
useCase = new UpsertMembershipFeeUseCase(
|
||||
membershipFeeRepository as unknown as IMembershipFeeRepository,
|
||||
output as unknown as UseCaseOutputPort<unknown>,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates a fee when none exists and presents it', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z'));
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0.123456789);
|
||||
it('updates existing membership fee and returns result', async () => {
|
||||
const input: UpsertMembershipFeeInput = {
|
||||
leagueId: 'league-1',
|
||||
seasonId: 'season-1',
|
||||
type: MembershipFeeType.MONTHLY,
|
||||
amount: 50,
|
||||
};
|
||||
|
||||
try {
|
||||
const input: UpsertMembershipFeeInput = {
|
||||
leagueId: 'league-1',
|
||||
type: MembershipFeeType.SEASON,
|
||||
amount: 100,
|
||||
};
|
||||
const existingFee = {
|
||||
id: 'fee-1',
|
||||
leagueId: 'league-1',
|
||||
type: MembershipFeeType.YEARLY,
|
||||
amount: 100,
|
||||
enabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
membershipFeeRepository.findByLeagueId.mockResolvedValue(null);
|
||||
membershipFeeRepository.create.mockImplementation(async (fee: MembershipFee) => ({ ...fee }));
|
||||
const updatedFee = {
|
||||
...existingFee,
|
||||
type: MembershipFeeType.MONTHLY,
|
||||
amount: 50,
|
||||
seasonId: 'season-1',
|
||||
enabled: true,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
membershipFeeRepository.findByLeagueId.mockResolvedValue(existingFee);
|
||||
membershipFeeRepository.update.mockResolvedValue(updatedFee);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(membershipFeeRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^fee-1735689600000-[a-z0-9]{9}$/),
|
||||
leagueId: 'league-1',
|
||||
type: MembershipFeeType.SEASON,
|
||||
amount: 100,
|
||||
enabled: true,
|
||||
createdAt: new Date('2025-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2025-01-01T00:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
|
||||
const createdFee = (output.present.mock.calls[0]?.[0] as { fee: MembershipFee }).fee;
|
||||
expect(createdFee.enabled).toBe(true);
|
||||
expect(createdFee.amount).toBe(100);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(membershipFeeRepository.findByLeagueId).toHaveBeenCalledWith('league-1');
|
||||
expect(membershipFeeRepository.update).toHaveBeenCalled();
|
||||
|
||||
if (result.isOk()) {
|
||||
expect(result.value.fee).toEqual(updatedFee);
|
||||
}
|
||||
});
|
||||
|
||||
it('updates an existing fee and sets enabled=false when amount is 0', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-01-02T00:00:00.000Z'));
|
||||
it('creates new membership fee and returns result', async () => {
|
||||
const input: UpsertMembershipFeeInput = {
|
||||
leagueId: 'league-1',
|
||||
type: MembershipFeeType.MONTHLY,
|
||||
amount: 50,
|
||||
};
|
||||
|
||||
try {
|
||||
const input: UpsertMembershipFeeInput = {
|
||||
leagueId: 'league-1',
|
||||
seasonId: 'season-2',
|
||||
type: MembershipFeeType.MONTHLY,
|
||||
amount: 0,
|
||||
};
|
||||
membershipFeeRepository.findByLeagueId.mockResolvedValue(null);
|
||||
|
||||
const existingFee: MembershipFee = {
|
||||
id: 'fee-1',
|
||||
leagueId: 'league-1',
|
||||
seasonId: 'season-1',
|
||||
type: MembershipFeeType.SEASON,
|
||||
amount: 100,
|
||||
enabled: true,
|
||||
createdAt: new Date('2024-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
|
||||
};
|
||||
const createdFee = {
|
||||
id: 'fee-new',
|
||||
leagueId: 'league-1',
|
||||
type: MembershipFeeType.MONTHLY,
|
||||
amount: 50,
|
||||
enabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
membershipFeeRepository.findByLeagueId.mockResolvedValue(existingFee);
|
||||
membershipFeeRepository.update.mockImplementation(async (fee: MembershipFee) => ({ ...fee }));
|
||||
membershipFeeRepository.create.mockResolvedValue(createdFee);
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
|
||||
expect(membershipFeeRepository.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'fee-1',
|
||||
leagueId: 'league-1',
|
||||
seasonId: 'season-2',
|
||||
type: MembershipFeeType.MONTHLY,
|
||||
amount: 0,
|
||||
enabled: false,
|
||||
updatedAt: new Date('2025-01-02T00:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
|
||||
const updatedFee = (output.present.mock.calls[0]?.[0] as { fee: MembershipFee }).fee;
|
||||
expect(updatedFee.enabled).toBe(false);
|
||||
expect(updatedFee.amount).toBe(0);
|
||||
expect(updatedFee.seasonId).toBe('season-2');
|
||||
expect(updatedFee.type).toBe(MembershipFeeType.MONTHLY);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(membershipFeeRepository.findByLeagueId).toHaveBeenCalledWith('league-1');
|
||||
expect(membershipFeeRepository.create).toHaveBeenCalled();
|
||||
|
||||
if (result.isOk()) {
|
||||
expect(result.value.fee).toEqual(createdFee);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@
|
||||
import type { IMembershipFeeRepository } from '../../domain/repositories/IMembershipFeeRepository';
|
||||
import type { MembershipFeeType, MembershipFee } from '../../domain/entities/MembershipFee';
|
||||
import type { UseCase } from '@core/shared/application/UseCase';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
|
||||
export interface UpsertMembershipFeeInput {
|
||||
@@ -24,14 +23,13 @@ export interface UpsertMembershipFeeResult {
|
||||
export type UpsertMembershipFeeErrorCode = never;
|
||||
|
||||
export class UpsertMembershipFeeUseCase
|
||||
implements UseCase<UpsertMembershipFeeInput, void, UpsertMembershipFeeErrorCode>
|
||||
implements UseCase<UpsertMembershipFeeInput, UpsertMembershipFeeResult, UpsertMembershipFeeErrorCode>
|
||||
{
|
||||
constructor(
|
||||
private readonly membershipFeeRepository: IMembershipFeeRepository,
|
||||
private readonly output: UseCaseOutputPort<UpsertMembershipFeeResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: UpsertMembershipFeeInput): Promise<Result<void, never>> {
|
||||
async execute(input: UpsertMembershipFeeInput): Promise<Result<UpsertMembershipFeeResult, never>> {
|
||||
const { leagueId, seasonId, type, amount } = input;
|
||||
|
||||
let existingFee = await this.membershipFeeRepository.findByLeagueId(leagueId);
|
||||
@@ -59,8 +57,6 @@ export class UpsertMembershipFeeUseCase
|
||||
fee = await this.membershipFeeRepository.create(newFee);
|
||||
}
|
||||
|
||||
this.output.present({ fee });
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok({ fee });
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,8 @@
|
||||
*/
|
||||
|
||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
||||
|
||||
// Mock interface for testing
|
||||
interface MediaResolverPort {
|
||||
resolve(ref: MediaReference, baseUrl: string): Promise<string | null>;
|
||||
}
|
||||
import { MediaResolverPort } from './MediaResolverPort';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
describe('MediaResolverPort', () => {
|
||||
let mockResolver: MediaResolverPort;
|
||||
@@ -21,15 +18,15 @@ describe('MediaResolverPort', () => {
|
||||
beforeEach(() => {
|
||||
// Create a mock implementation for testing
|
||||
mockResolver = {
|
||||
resolve: jest.fn(async (ref: MediaReference, baseUrl: string): Promise<string | null> => {
|
||||
resolve: vi.fn(async (ref: MediaReference): Promise<string | null> => {
|
||||
// Mock implementation that returns different URLs based on type
|
||||
switch (ref.type) {
|
||||
case 'system-default':
|
||||
return `${baseUrl}/defaults/${ref.variant}`;
|
||||
return `/defaults/${ref.variant}`;
|
||||
case 'generated':
|
||||
return `${baseUrl}/generated/${ref.generationRequestId}`;
|
||||
return `/generated/${ref.generationRequestId}`;
|
||||
case 'uploaded':
|
||||
return `${baseUrl}/media/${ref.mediaId}`;
|
||||
return `/media/${ref.mediaId}`;
|
||||
case 'none':
|
||||
return null;
|
||||
default:
|
||||
@@ -44,18 +41,16 @@ describe('MediaResolverPort', () => {
|
||||
expect(typeof mockResolver.resolve).toBe('function');
|
||||
});
|
||||
|
||||
it('should accept MediaReference and string parameters', async () => {
|
||||
it('should accept MediaReference parameter', async () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar');
|
||||
const baseUrl = 'https://api.example.com';
|
||||
|
||||
await expect(mockResolver.resolve(ref, baseUrl)).resolves.toBeDefined();
|
||||
await expect(mockResolver.resolve(ref)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('should return Promise<string | null>', async () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar');
|
||||
const baseUrl = 'https://api.example.com';
|
||||
|
||||
const result = await mockResolver.resolve(ref, baseUrl);
|
||||
const result = await mockResolver.resolve(ref);
|
||||
expect(result === null || typeof result === 'string').toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -63,154 +58,83 @@ describe('MediaResolverPort', () => {
|
||||
describe('System Default Resolution', () => {
|
||||
it('should resolve system-default avatar to correct URL', async () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar');
|
||||
const baseUrl = 'https://api.example.com';
|
||||
|
||||
const result = await mockResolver.resolve(ref, baseUrl);
|
||||
const result = await mockResolver.resolve(ref);
|
||||
|
||||
expect(result).toBe('https://api.example.com/defaults/avatar');
|
||||
expect(result).toBe('/defaults/avatar');
|
||||
});
|
||||
|
||||
it('should resolve system-default logo to correct URL', async () => {
|
||||
const ref = MediaReference.createSystemDefault('logo');
|
||||
const baseUrl = 'https://api.example.com';
|
||||
|
||||
const result = await mockResolver.resolve(ref, baseUrl);
|
||||
const result = await mockResolver.resolve(ref);
|
||||
|
||||
expect(result).toBe('https://api.example.com/defaults/logo');
|
||||
expect(result).toBe('/defaults/logo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Generated Resolution', () => {
|
||||
it('should resolve generated reference to correct URL', async () => {
|
||||
const ref = MediaReference.createGenerated('req-123');
|
||||
const baseUrl = 'https://api.example.com';
|
||||
|
||||
const result = await mockResolver.resolve(ref, baseUrl);
|
||||
const result = await mockResolver.resolve(ref);
|
||||
|
||||
expect(result).toBe('https://api.example.com/generated/req-123');
|
||||
expect(result).toBe('/generated/req-123');
|
||||
});
|
||||
|
||||
it('should handle generated reference with special characters', async () => {
|
||||
const ref = MediaReference.createGenerated('req-abc-123_XYZ');
|
||||
const baseUrl = 'https://api.example.com';
|
||||
|
||||
const result = await mockResolver.resolve(ref, baseUrl);
|
||||
const result = await mockResolver.resolve(ref);
|
||||
|
||||
expect(result).toBe('https://api.example.com/generated/req-abc-123_XYZ');
|
||||
expect(result).toBe('/generated/req-abc-123_XYZ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Uploaded Resolution', () => {
|
||||
it('should resolve uploaded reference to correct URL', async () => {
|
||||
const ref = MediaReference.createUploaded('media-456');
|
||||
const baseUrl = 'https://api.example.com';
|
||||
|
||||
const result = await mockResolver.resolve(ref, baseUrl);
|
||||
const result = await mockResolver.resolve(ref);
|
||||
|
||||
expect(result).toBe('https://api.example.com/media/media-456');
|
||||
expect(result).toBe('/media/media-456');
|
||||
});
|
||||
|
||||
it('should handle uploaded reference with special characters', async () => {
|
||||
const ref = MediaReference.createUploaded('media-abc-456_XYZ');
|
||||
const baseUrl = 'https://api.example.com';
|
||||
|
||||
const result = await mockResolver.resolve(ref, baseUrl);
|
||||
const result = await mockResolver.resolve(ref);
|
||||
|
||||
expect(result).toBe('https://api.example.com/media/media-abc-456_XYZ');
|
||||
expect(result).toBe('/media/media-abc-456_XYZ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('None Resolution', () => {
|
||||
it('should resolve none reference to null', async () => {
|
||||
const ref = MediaReference.createNone();
|
||||
const baseUrl = 'https://api.example.com';
|
||||
|
||||
const result = await mockResolver.resolve(ref, baseUrl);
|
||||
const result = await mockResolver.resolve(ref);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Base URL Handling', () => {
|
||||
it('should handle base URL without trailing slash', async () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar');
|
||||
const baseUrl = 'https://api.example.com';
|
||||
|
||||
const result = await mockResolver.resolve(ref, baseUrl);
|
||||
|
||||
expect(result).toBe('https://api.example.com/defaults/avatar');
|
||||
});
|
||||
|
||||
it('should handle base URL with trailing slash', async () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar');
|
||||
const baseUrl = 'https://api.example.com/';
|
||||
|
||||
const result = await mockResolver.resolve(ref, baseUrl);
|
||||
|
||||
// Implementation should handle this consistently
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle localhost URLs', async () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar');
|
||||
const baseUrl = 'http://localhost:3000';
|
||||
|
||||
const result = await mockResolver.resolve(ref, baseUrl);
|
||||
|
||||
expect(result).toBe('http://localhost:3000/defaults/avatar');
|
||||
});
|
||||
|
||||
it('should handle relative URLs', async () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar');
|
||||
const baseUrl = '/api';
|
||||
|
||||
const result = await mockResolver.resolve(ref, baseUrl);
|
||||
|
||||
expect(result).toBe('/api/defaults/avatar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle null baseUrl gracefully', async () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar');
|
||||
|
||||
// This should not throw but handle gracefully
|
||||
await expect(mockResolver.resolve(ref, null as any)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle empty baseUrl gracefully', async () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar');
|
||||
|
||||
// This should not throw but handle gracefully
|
||||
await expect(mockResolver.resolve(ref, '')).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle undefined baseUrl gracefully', async () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar');
|
||||
|
||||
// This should not throw but handle gracefully
|
||||
await expect(mockResolver.resolve(ref, undefined as any)).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle very long media IDs', async () => {
|
||||
const longId = 'a'.repeat(1000);
|
||||
const ref = MediaReference.createUploaded(longId);
|
||||
const baseUrl = 'https://api.example.com';
|
||||
|
||||
const result = await mockResolver.resolve(ref, baseUrl);
|
||||
const result = await mockResolver.resolve(ref);
|
||||
|
||||
expect(result).toBe(`https://api.example.com/media/${longId}`);
|
||||
expect(result).toBe(`/media/${longId}`);
|
||||
});
|
||||
|
||||
it('should handle Unicode characters in IDs', async () => {
|
||||
const ref = MediaReference.createUploaded('media-日本語-123');
|
||||
const baseUrl = 'https://api.example.com';
|
||||
|
||||
const result = await mockResolver.resolve(ref, baseUrl);
|
||||
const result = await mockResolver.resolve(ref);
|
||||
|
||||
expect(result).toBe('https://api.example.com/media/media-日本語-123');
|
||||
expect(result).toBe('/media/media-日本語-123');
|
||||
});
|
||||
|
||||
it('should handle multiple calls with different references', async () => {
|
||||
@@ -220,14 +144,13 @@ describe('MediaResolverPort', () => {
|
||||
MediaReference.createUploaded('media-456'),
|
||||
MediaReference.createNone()
|
||||
];
|
||||
const baseUrl = 'https://api.example.com';
|
||||
|
||||
const results = await Promise.all(refs.map(ref => mockResolver.resolve(ref, baseUrl)));
|
||||
const results = await Promise.all(refs.map(ref => mockResolver.resolve(ref)));
|
||||
|
||||
expect(results).toEqual([
|
||||
'https://api.example.com/defaults/avatar',
|
||||
'https://api.example.com/generated/req-123',
|
||||
'https://api.example.com/media/media-456',
|
||||
'/defaults/avatar',
|
||||
'/generated/req-123',
|
||||
'/media/media-456',
|
||||
null
|
||||
]);
|
||||
});
|
||||
@@ -236,10 +159,9 @@ describe('MediaResolverPort', () => {
|
||||
describe('Performance Considerations', () => {
|
||||
it('should resolve quickly for simple cases', async () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar');
|
||||
const baseUrl = 'https://api.example.com';
|
||||
|
||||
const start = Date.now();
|
||||
await mockResolver.resolve(ref, baseUrl);
|
||||
await mockResolver.resolve(ref);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
expect(duration).toBeLessThan(100); // Should be very fast
|
||||
@@ -249,10 +171,9 @@ describe('MediaResolverPort', () => {
|
||||
const refs = Array.from({ length: 100 }, (_, i) =>
|
||||
MediaReference.createUploaded(`media-${i}`)
|
||||
);
|
||||
const baseUrl = 'https://api.example.com';
|
||||
|
||||
const start = Date.now();
|
||||
const results = await Promise.all(refs.map(ref => mockResolver.resolve(ref, baseUrl)));
|
||||
const results = await Promise.all(refs.map(ref => mockResolver.resolve(ref)));
|
||||
const duration = Date.now() - start;
|
||||
|
||||
expect(results.length).toBe(100);
|
||||
|
||||
@@ -73,11 +73,7 @@ describe('AcceptSponsorshipRequestUseCase', () => {
|
||||
};
|
||||
});
|
||||
|
||||
it('should send notification to sponsor, process payment, update wallets, and present result when accepting season sponsorship', async () => {
|
||||
const output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
it('should send notification to sponsor, process payment, update wallets, and return result when accepting season sponsorship', async () => {
|
||||
const useCase = new AcceptSponsorshipRequestUseCase(
|
||||
mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
|
||||
mockSeasonSponsorshipRepo as unknown as ISeasonSponsorshipRepository,
|
||||
@@ -87,7 +83,6 @@ describe('AcceptSponsorshipRequestUseCase', () => {
|
||||
mockWalletRepo as unknown as IWalletRepository,
|
||||
mockLeagueWalletRepo as unknown as ILeagueWalletRepository,
|
||||
mockLogger as unknown as Logger,
|
||||
output,
|
||||
);
|
||||
|
||||
const request = SponsorshipRequest.create({
|
||||
@@ -140,7 +135,13 @@ describe('AcceptSponsorshipRequestUseCase', () => {
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toBeUndefined();
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult.requestId).toBe('req1');
|
||||
expect(successResult.status).toBe('accepted');
|
||||
expect(successResult.sponsorshipId).toBeDefined();
|
||||
expect(successResult.acceptedAt).toBeInstanceOf(Date);
|
||||
expect(successResult.platformFee).toBeDefined();
|
||||
expect(successResult.netAmount).toBeDefined();
|
||||
|
||||
expect(mockNotificationService.sendNotification).toHaveBeenCalledWith({
|
||||
recipientId: 'sponsor1',
|
||||
@@ -189,14 +190,61 @@ describe('AcceptSponsorshipRequestUseCase', () => {
|
||||
|
||||
expect(asString(updatedLeagueWalletId)).toBe('league1');
|
||||
expect(updatedLeagueWalletBalanceAmount).toBe(1400);
|
||||
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
requestId: 'req1',
|
||||
sponsorshipId: expect.any(String),
|
||||
status: 'accepted',
|
||||
acceptedAt: expect.any(Date),
|
||||
platformFee: expect.any(Number),
|
||||
netAmount: expect.any(Number),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when sponsorship request not found', async () => {
|
||||
const useCase = new AcceptSponsorshipRequestUseCase(
|
||||
mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
|
||||
mockSeasonSponsorshipRepo as unknown as ISeasonSponsorshipRepository,
|
||||
mockSeasonRepo as unknown as ISeasonRepository,
|
||||
mockNotificationService as unknown as NotificationService,
|
||||
processPayment,
|
||||
mockWalletRepo as unknown as IWalletRepository,
|
||||
mockLeagueWalletRepo as unknown as ILeagueWalletRepository,
|
||||
mockLogger as unknown as Logger,
|
||||
);
|
||||
|
||||
mockSponsorshipRequestRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const result = await useCase.execute({
|
||||
requestId: 'req1',
|
||||
respondedBy: 'driver1',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('SPONSORSHIP_REQUEST_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('should return error when sponsorship request is not pending', async () => {
|
||||
const useCase = new AcceptSponsorshipRequestUseCase(
|
||||
mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
|
||||
mockSeasonSponsorshipRepo as unknown as ISeasonSponsorshipRepository,
|
||||
mockSeasonRepo as unknown as ISeasonRepository,
|
||||
mockNotificationService as unknown as NotificationService,
|
||||
processPayment,
|
||||
mockWalletRepo as unknown as IWalletRepository,
|
||||
mockLeagueWalletRepo as unknown as ILeagueWalletRepository,
|
||||
mockLogger as unknown as Logger,
|
||||
);
|
||||
|
||||
const request = SponsorshipRequest.create({
|
||||
id: 'req1',
|
||||
sponsorId: 'sponsor1',
|
||||
entityId: 'season1',
|
||||
entityType: 'season',
|
||||
tier: 'main',
|
||||
offeredAmount: Money.create(1000),
|
||||
status: 'accepted',
|
||||
});
|
||||
|
||||
mockSponsorshipRequestRepo.findById.mockResolvedValue(request);
|
||||
|
||||
const result = await useCase.execute({
|
||||
requestId: 'req1',
|
||||
respondedBy: 'driver1',
|
||||
});
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('SPONSORSHIP_REQUEST_NOT_PENDING');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { IWalletRepository } from '@core/payments/domain/repositories/IWall
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import { SeasonSponsorship } from '../../domain/entities/season/SeasonSponsorship';
|
||||
import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository';
|
||||
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
|
||||
@@ -54,14 +53,13 @@ export class AcceptSponsorshipRequestUseCase {
|
||||
private readonly walletRepository: IWalletRepository,
|
||||
private readonly leagueWalletRepository: ILeagueWalletRepository,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<AcceptSponsorshipResult>,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
input: AcceptSponsorshipRequestInput,
|
||||
): Promise<
|
||||
Result<
|
||||
void,
|
||||
AcceptSponsorshipResult,
|
||||
ApplicationErrorCode<
|
||||
| 'SPONSORSHIP_REQUEST_NOT_FOUND'
|
||||
| 'SPONSORSHIP_REQUEST_NOT_PENDING'
|
||||
@@ -212,8 +210,6 @@ export class AcceptSponsorshipRequestUseCase {
|
||||
netAmount: acceptedRequest.getNetAmount().amount,
|
||||
};
|
||||
|
||||
this.output.present(result);
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { ISponsorshipRequestRepository } from '../../domain/repositories/IS
|
||||
import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository';
|
||||
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import { Money } from '../../domain/value-objects/Money';
|
||||
|
||||
describe('ApplyForSponsorshipUseCase', () => {
|
||||
@@ -43,17 +42,11 @@ describe('ApplyForSponsorshipUseCase', () => {
|
||||
});
|
||||
|
||||
it('should return error when sponsor does not exist', async () => {
|
||||
const output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
const useCase = new ApplyForSponsorshipUseCase(
|
||||
mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
|
||||
const useCase = new ApplyForSponsorshipUseCase(mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
|
||||
mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository,
|
||||
mockSponsorRepo as unknown as ISponsorRepository,
|
||||
mockLogger as unknown as Logger,
|
||||
output as unknown as UseCaseOutputPort<unknown>,
|
||||
);
|
||||
mockLogger as unknown as Logger);
|
||||
|
||||
mockSponsorRepo.findById.mockResolvedValue(null);
|
||||
|
||||
@@ -67,21 +60,14 @@ describe('ApplyForSponsorshipUseCase', () => {
|
||||
|
||||
expect(result.isOk()).toBe(false);
|
||||
expect(result.error!.code).toBe('SPONSOR_NOT_FOUND');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when sponsorship pricing is not set up', async () => {
|
||||
const output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
const useCase = new ApplyForSponsorshipUseCase(
|
||||
mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
|
||||
const useCase = new ApplyForSponsorshipUseCase(mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
|
||||
mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository,
|
||||
mockSponsorRepo as unknown as ISponsorRepository,
|
||||
mockLogger as unknown as Logger,
|
||||
output as unknown as UseCaseOutputPort<unknown>,
|
||||
);
|
||||
mockLogger as unknown as Logger);
|
||||
|
||||
mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' });
|
||||
mockSponsorshipPricingRepo.findByEntity.mockResolvedValue(null);
|
||||
@@ -96,21 +82,14 @@ describe('ApplyForSponsorshipUseCase', () => {
|
||||
|
||||
expect(result.isOk()).toBe(false);
|
||||
expect(result.error!.code).toBe('SPONSORSHIP_PRICING_NOT_SETUP');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when entity is not accepting applications', async () => {
|
||||
const output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
const useCase = new ApplyForSponsorshipUseCase(
|
||||
mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
|
||||
const useCase = new ApplyForSponsorshipUseCase(mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
|
||||
mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository,
|
||||
mockSponsorRepo as unknown as ISponsorRepository,
|
||||
mockLogger as unknown as Logger,
|
||||
output as unknown as UseCaseOutputPort<unknown>,
|
||||
);
|
||||
mockLogger as unknown as Logger);
|
||||
|
||||
mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' });
|
||||
mockSponsorshipPricingRepo.findByEntity.mockResolvedValue({
|
||||
@@ -129,21 +108,14 @@ describe('ApplyForSponsorshipUseCase', () => {
|
||||
|
||||
expect(result.isOk()).toBe(false);
|
||||
expect(result.error!.code).toBe('ENTITY_NOT_ACCEPTING_APPLICATIONS');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when no slots are available', async () => {
|
||||
const output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
const useCase = new ApplyForSponsorshipUseCase(
|
||||
mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
|
||||
const useCase = new ApplyForSponsorshipUseCase(mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
|
||||
mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository,
|
||||
mockSponsorRepo as unknown as ISponsorRepository,
|
||||
mockLogger as unknown as Logger,
|
||||
output as unknown as UseCaseOutputPort<unknown>,
|
||||
);
|
||||
mockLogger as unknown as Logger);
|
||||
|
||||
mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' });
|
||||
mockSponsorshipPricingRepo.findByEntity.mockResolvedValue({
|
||||
@@ -162,21 +134,14 @@ describe('ApplyForSponsorshipUseCase', () => {
|
||||
|
||||
expect(result.isOk()).toBe(false);
|
||||
expect(result.error!.code).toBe('NO_SLOTS_AVAILABLE');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when sponsor has pending request', async () => {
|
||||
const output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
const useCase = new ApplyForSponsorshipUseCase(
|
||||
mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
|
||||
const useCase = new ApplyForSponsorshipUseCase(mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
|
||||
mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository,
|
||||
mockSponsorRepo as unknown as ISponsorRepository,
|
||||
mockLogger as unknown as Logger,
|
||||
output as unknown as UseCaseOutputPort<unknown>,
|
||||
);
|
||||
mockLogger as unknown as Logger);
|
||||
|
||||
mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' });
|
||||
mockSponsorshipPricingRepo.findByEntity.mockResolvedValue({
|
||||
@@ -196,21 +161,14 @@ describe('ApplyForSponsorshipUseCase', () => {
|
||||
|
||||
expect(result.isOk()).toBe(false);
|
||||
expect(result.error!.code).toBe('PENDING_REQUEST_EXISTS');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when offered amount is less than minimum', async () => {
|
||||
const output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
const useCase = new ApplyForSponsorshipUseCase(
|
||||
mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
|
||||
const useCase = new ApplyForSponsorshipUseCase(mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
|
||||
mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository,
|
||||
mockSponsorRepo as unknown as ISponsorRepository,
|
||||
mockLogger as unknown as Logger,
|
||||
output as unknown as UseCaseOutputPort<unknown>,
|
||||
);
|
||||
mockLogger as unknown as Logger);
|
||||
|
||||
mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' });
|
||||
mockSponsorshipPricingRepo.findByEntity.mockResolvedValue({
|
||||
@@ -233,17 +191,11 @@ describe('ApplyForSponsorshipUseCase', () => {
|
||||
});
|
||||
|
||||
it('should create sponsorship request and return result on success', async () => {
|
||||
const output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
const useCase = new ApplyForSponsorshipUseCase(
|
||||
mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
|
||||
const useCase = new ApplyForSponsorshipUseCase(mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
|
||||
mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository,
|
||||
mockSponsorRepo as unknown as ISponsorRepository,
|
||||
mockLogger as unknown as Logger,
|
||||
output as unknown as UseCaseOutputPort<unknown>,
|
||||
);
|
||||
mockLogger as unknown as Logger);
|
||||
|
||||
mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' });
|
||||
mockSponsorshipPricingRepo.findByEntity.mockResolvedValue({
|
||||
@@ -264,11 +216,8 @@ describe('ApplyForSponsorshipUseCase', () => {
|
||||
});
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toBeUndefined();
|
||||
|
||||
expect(output.present).toHaveBeenCalledTimes(1);
|
||||
const presented = (output.present as Mock).mock.calls[0]?.[0];
|
||||
expect(presented).toEqual({
|
||||
const unwrapped = result.unwrap();
|
||||
expect(unwrapped).toEqual({
|
||||
requestId: expect.any(String),
|
||||
status: 'pending',
|
||||
createdAt: expect.any(Date),
|
||||
|
||||
@@ -13,7 +13,6 @@ import { Money, isCurrency } from '../../domain/value-objects/Money';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
|
||||
export interface ApplyForSponsorshipInput {
|
||||
sponsorId: string;
|
||||
@@ -37,14 +36,13 @@ export class ApplyForSponsorshipUseCase {
|
||||
private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository,
|
||||
private readonly sponsorRepo: ISponsorRepository,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<ApplyForSponsorshipResult>,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
input: ApplyForSponsorshipInput,
|
||||
): Promise<
|
||||
Result<
|
||||
void,
|
||||
ApplyForSponsorshipResult,
|
||||
ApplicationErrorCode<
|
||||
| 'SPONSOR_NOT_FOUND'
|
||||
| 'SPONSORSHIP_PRICING_NOT_SETUP'
|
||||
@@ -145,8 +143,6 @@ export class ApplyForSponsorshipUseCase {
|
||||
createdAt: request.createdAt,
|
||||
};
|
||||
|
||||
this.output.present(result);
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok(result);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,6 @@ import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepos
|
||||
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
|
||||
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
|
||||
describe('ApplyPenaltyUseCase', () => {
|
||||
let mockPenaltyRepo: {
|
||||
create: Mock;
|
||||
@@ -49,18 +46,15 @@ describe('ApplyPenaltyUseCase', () => {
|
||||
});
|
||||
|
||||
it('should return error when race does not exist', async () => {
|
||||
const output: UseCaseOutputPort<ApplyPenaltyResult> & { present: Mock } = {
|
||||
const output: { present: Mock } = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
const useCase = new ApplyPenaltyUseCase(
|
||||
mockPenaltyRepo as unknown as IPenaltyRepository,
|
||||
const useCase = new ApplyPenaltyUseCase(mockPenaltyRepo as unknown as IPenaltyRepository,
|
||||
mockProtestRepo as unknown as IProtestRepository,
|
||||
mockRaceRepo as unknown as IRaceRepository,
|
||||
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
|
||||
mockLogger as unknown as Logger,
|
||||
output,
|
||||
);
|
||||
mockLogger as unknown as Logger);
|
||||
|
||||
mockRaceRepo.findById.mockResolvedValue(null);
|
||||
|
||||
@@ -78,18 +72,15 @@ describe('ApplyPenaltyUseCase', () => {
|
||||
});
|
||||
|
||||
it('should return error when steward does not have authority', async () => {
|
||||
const output: UseCaseOutputPort<ApplyPenaltyResult> & { present: Mock } = {
|
||||
const output: { present: Mock } = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
const useCase = new ApplyPenaltyUseCase(
|
||||
mockPenaltyRepo as unknown as IPenaltyRepository,
|
||||
const useCase = new ApplyPenaltyUseCase(mockPenaltyRepo as unknown as IPenaltyRepository,
|
||||
mockProtestRepo as unknown as IProtestRepository,
|
||||
mockRaceRepo as unknown as IRaceRepository,
|
||||
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
|
||||
mockLogger as unknown as Logger,
|
||||
output,
|
||||
);
|
||||
mockLogger as unknown as Logger);
|
||||
|
||||
mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' });
|
||||
|
||||
@@ -115,18 +106,15 @@ describe('ApplyPenaltyUseCase', () => {
|
||||
});
|
||||
|
||||
it('should return error when protest does not exist', async () => {
|
||||
const output: UseCaseOutputPort<ApplyPenaltyResult> & { present: Mock } = {
|
||||
const output: { present: Mock } = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
const useCase = new ApplyPenaltyUseCase(
|
||||
mockPenaltyRepo as unknown as IPenaltyRepository,
|
||||
const useCase = new ApplyPenaltyUseCase(mockPenaltyRepo as unknown as IPenaltyRepository,
|
||||
mockProtestRepo as unknown as IProtestRepository,
|
||||
mockRaceRepo as unknown as IRaceRepository,
|
||||
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
|
||||
mockLogger as unknown as Logger,
|
||||
output,
|
||||
);
|
||||
mockLogger as unknown as Logger);
|
||||
|
||||
mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' });
|
||||
|
||||
@@ -154,18 +142,15 @@ describe('ApplyPenaltyUseCase', () => {
|
||||
});
|
||||
|
||||
it('should return error when protest is not upheld', async () => {
|
||||
const output: UseCaseOutputPort<ApplyPenaltyResult> & { present: Mock } = {
|
||||
const output: { present: Mock } = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
const useCase = new ApplyPenaltyUseCase(
|
||||
mockPenaltyRepo as unknown as IPenaltyRepository,
|
||||
const useCase = new ApplyPenaltyUseCase(mockPenaltyRepo as unknown as IPenaltyRepository,
|
||||
mockProtestRepo as unknown as IProtestRepository,
|
||||
mockRaceRepo as unknown as IRaceRepository,
|
||||
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
|
||||
mockLogger as unknown as Logger,
|
||||
output,
|
||||
);
|
||||
mockLogger as unknown as Logger);
|
||||
|
||||
mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' });
|
||||
|
||||
@@ -193,18 +178,15 @@ describe('ApplyPenaltyUseCase', () => {
|
||||
});
|
||||
|
||||
it('should return error when protest is not for this race', async () => {
|
||||
const output: UseCaseOutputPort<ApplyPenaltyResult> & { present: Mock } = {
|
||||
const output: { present: Mock } = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
const useCase = new ApplyPenaltyUseCase(
|
||||
mockPenaltyRepo as unknown as IPenaltyRepository,
|
||||
const useCase = new ApplyPenaltyUseCase(mockPenaltyRepo as unknown as IPenaltyRepository,
|
||||
mockProtestRepo as unknown as IProtestRepository,
|
||||
mockRaceRepo as unknown as IRaceRepository,
|
||||
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
|
||||
mockLogger as unknown as Logger,
|
||||
output,
|
||||
);
|
||||
mockLogger as unknown as Logger);
|
||||
|
||||
mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' });
|
||||
|
||||
@@ -232,18 +214,15 @@ describe('ApplyPenaltyUseCase', () => {
|
||||
});
|
||||
|
||||
it('should create penalty and return result on success', async () => {
|
||||
const output: UseCaseOutputPort<ApplyPenaltyResult> & { present: Mock } = {
|
||||
const output: { present: Mock } = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
const useCase = new ApplyPenaltyUseCase(
|
||||
mockPenaltyRepo as unknown as IPenaltyRepository,
|
||||
const useCase = new ApplyPenaltyUseCase(mockPenaltyRepo as unknown as IPenaltyRepository,
|
||||
mockProtestRepo as unknown as IProtestRepository,
|
||||
mockRaceRepo as unknown as IRaceRepository,
|
||||
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
|
||||
mockLogger as unknown as Logger,
|
||||
output,
|
||||
);
|
||||
mockLogger as unknown as Logger);
|
||||
|
||||
mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' });
|
||||
|
||||
@@ -269,9 +248,7 @@ describe('ApplyPenaltyUseCase', () => {
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toBeUndefined();
|
||||
|
||||
expect(output.present).toHaveBeenCalledTimes(1);
|
||||
const presented = (output.present as Mock).mock.calls[0]?.[0] as ApplyPenaltyResult;
|
||||
expect(presented).toEqual({ penaltyId: expect.any(String) });
|
||||
const presented = (expect(presented).toEqual({ penaltyId: expect.any(String) });
|
||||
|
||||
expect(mockPenaltyRepo.create).toHaveBeenCalledTimes(1);
|
||||
const createdPenalty = (mockPenaltyRepo.create as Mock).mock.calls[0]?.[0] as unknown as {
|
||||
|
||||
@@ -14,7 +14,6 @@ import { randomUUID } from 'crypto';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
|
||||
export interface ApplyPenaltyInput {
|
||||
raceId: string;
|
||||
@@ -38,14 +37,13 @@ export class ApplyPenaltyUseCase {
|
||||
private readonly raceRepository: IRaceRepository,
|
||||
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<ApplyPenaltyResult>,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
command: ApplyPenaltyInput,
|
||||
): Promise<
|
||||
Result<
|
||||
void,
|
||||
ApplyPenaltyResult,
|
||||
ApplicationErrorCode<
|
||||
| 'RACE_NOT_FOUND'
|
||||
| 'INSUFFICIENT_AUTHORITY'
|
||||
@@ -117,8 +115,7 @@ export class ApplyPenaltyUseCase {
|
||||
);
|
||||
|
||||
const result: ApplyPenaltyResult = { penaltyId: penalty.id };
|
||||
this.output.present(result);
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok(result);
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
import { League } from '../../domain/entities/League';
|
||||
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
||||
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
|
||||
describe('ApproveLeagueJoinRequestUseCase', () => {
|
||||
let mockLeagueMembershipRepo: {
|
||||
getJoinRequests: Mock;
|
||||
@@ -34,14 +32,9 @@ describe('ApproveLeagueJoinRequestUseCase', () => {
|
||||
});
|
||||
|
||||
it('approve removes request and adds member', async () => {
|
||||
const output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
const useCase = new ApproveLeagueJoinRequestUseCase(
|
||||
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
|
||||
mockLeagueRepo as unknown as ILeagueRepository,
|
||||
);
|
||||
const useCase = new ApproveLeagueJoinRequestUseCase(mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
|
||||
mockLeagueRepo as unknown as ILeagueRepository);
|
||||
|
||||
const leagueId = 'league-1';
|
||||
const joinRequestId = 'req-1';
|
||||
@@ -63,11 +56,10 @@ describe('ApproveLeagueJoinRequestUseCase', () => {
|
||||
|
||||
const result = await useCase.execute(
|
||||
{ leagueId, joinRequestId },
|
||||
output as unknown as UseCaseOutputPort<ApproveLeagueJoinRequestResult>,
|
||||
);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toBeUndefined();
|
||||
expect(result.unwrap()).toEqual({ success: true, message: expect.any(String) });
|
||||
expect(mockLeagueMembershipRepo.removeJoinRequest).toHaveBeenCalledWith(joinRequestId);
|
||||
expect(mockLeagueMembershipRepo.saveMembership).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -87,18 +79,12 @@ describe('ApproveLeagueJoinRequestUseCase', () => {
|
||||
expect(savedMembership.status.toString()).toBe('active');
|
||||
expect(savedMembership.joinedAt.toDate()).toBeInstanceOf(Date);
|
||||
|
||||
expect(output.present).toHaveBeenCalledWith({ success: true, message: 'Join request approved.' });
|
||||
});
|
||||
});
|
||||
|
||||
it('approve returns error when request missing', async () => {
|
||||
const output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
const useCase = new ApproveLeagueJoinRequestUseCase(
|
||||
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
|
||||
mockLeagueRepo as unknown as ILeagueRepository,
|
||||
);
|
||||
const useCase = new ApproveLeagueJoinRequestUseCase(mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
|
||||
mockLeagueRepo as unknown as ILeagueRepository);
|
||||
|
||||
mockLeagueRepo.findById.mockResolvedValue(
|
||||
League.create({
|
||||
@@ -116,25 +102,18 @@ describe('ApproveLeagueJoinRequestUseCase', () => {
|
||||
|
||||
const result = await useCase.execute(
|
||||
{ leagueId: 'league-1', joinRequestId: 'req-1' },
|
||||
output as unknown as UseCaseOutputPort<ApproveLeagueJoinRequestResult>,
|
||||
);
|
||||
|
||||
expect(result.isOk()).toBe(false);
|
||||
expect(result.error!.code).toBe('JOIN_REQUEST_NOT_FOUND');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
expect(mockLeagueMembershipRepo.removeJoinRequest).not.toHaveBeenCalled();
|
||||
expect(mockLeagueMembershipRepo.saveMembership).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects approval when league is at capacity and does not mutate state', async () => {
|
||||
const output = {
|
||||
present: vi.fn(),
|
||||
};
|
||||
|
||||
const useCase = new ApproveLeagueJoinRequestUseCase(
|
||||
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
|
||||
mockLeagueRepo as unknown as ILeagueRepository,
|
||||
);
|
||||
const useCase = new ApproveLeagueJoinRequestUseCase(mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
|
||||
mockLeagueRepo as unknown as ILeagueRepository);
|
||||
|
||||
const leagueId = 'league-1';
|
||||
const joinRequestId = 'req-1';
|
||||
@@ -174,12 +153,10 @@ describe('ApproveLeagueJoinRequestUseCase', () => {
|
||||
|
||||
const result = await useCase.execute(
|
||||
{ leagueId, joinRequestId },
|
||||
output as unknown as UseCaseOutputPort<ApproveLeagueJoinRequestResult>,
|
||||
);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('LEAGUE_AT_CAPACITY');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
expect(mockLeagueMembershipRepo.removeJoinRequest).not.toHaveBeenCalled();
|
||||
expect(mockLeagueMembershipRepo.saveMembership).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import { randomUUID } from 'crypto';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import { JoinedAt } from '../../domain/value-objects/JoinedAt';
|
||||
import { LeagueId } from '../../domain/entities/LeagueId';
|
||||
import { DriverId } from '../../domain/entities/DriverId';
|
||||
@@ -28,10 +27,9 @@ export class ApproveLeagueJoinRequestUseCase {
|
||||
|
||||
async execute(
|
||||
input: ApproveLeagueJoinRequestInput,
|
||||
output: UseCaseOutputPort<ApproveLeagueJoinRequestResult>,
|
||||
): Promise<
|
||||
Result<
|
||||
void,
|
||||
ApproveLeagueJoinRequestResult,
|
||||
ApplicationErrorCode<
|
||||
'JOIN_REQUEST_NOT_FOUND' | 'LEAGUE_NOT_FOUND' | 'LEAGUE_AT_CAPACITY',
|
||||
{ message: string }
|
||||
@@ -67,8 +65,7 @@ export class ApproveLeagueJoinRequestUseCase {
|
||||
});
|
||||
|
||||
const result: ApproveLeagueJoinRequestResult = { success: true, message: 'Join request approved.' };
|
||||
output.present(result);
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok(result);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
|
||||
import { ApproveTeamJoinRequestUseCase, type ApproveTeamJoinRequestResult } from './ApproveTeamJoinRequestUseCase';
|
||||
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
|
||||
describe('ApproveTeamJoinRequestUseCase', () => {
|
||||
let useCase: ApproveTeamJoinRequestUseCase;
|
||||
@@ -10,7 +9,6 @@ describe('ApproveTeamJoinRequestUseCase', () => {
|
||||
removeJoinRequest: Mock;
|
||||
saveMembership: Mock;
|
||||
};
|
||||
let output: UseCaseOutputPort<ApproveTeamJoinRequestResult> & { present: Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
membershipRepository = {
|
||||
@@ -18,12 +16,8 @@ describe('ApproveTeamJoinRequestUseCase', () => {
|
||||
removeJoinRequest: vi.fn(),
|
||||
saveMembership: vi.fn(),
|
||||
};
|
||||
output = { present: vi.fn() } as unknown as UseCaseOutputPort<ApproveTeamJoinRequestResult> & {
|
||||
present: Mock;
|
||||
};
|
||||
useCase = new ApproveTeamJoinRequestUseCase(
|
||||
membershipRepository as unknown as ITeamMembershipRepository,
|
||||
output,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -37,6 +31,14 @@ describe('ApproveTeamJoinRequestUseCase', () => {
|
||||
const result = await useCase.execute({ teamId, requestId });
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
const successResult = result.unwrap();
|
||||
expect(successResult.membership).toEqual({
|
||||
teamId,
|
||||
driverId: 'driver-1',
|
||||
role: 'driver',
|
||||
status: 'active',
|
||||
joinedAt: expect.any(Date),
|
||||
});
|
||||
expect(membershipRepository.removeJoinRequest).toHaveBeenCalledWith(requestId);
|
||||
expect(membershipRepository.saveMembership).toHaveBeenCalledWith({
|
||||
teamId,
|
||||
@@ -45,16 +47,6 @@ describe('ApproveTeamJoinRequestUseCase', () => {
|
||||
status: 'active',
|
||||
joinedAt: expect.any(Date),
|
||||
});
|
||||
expect(output.present).toHaveBeenCalledTimes(1);
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
membership: {
|
||||
teamId,
|
||||
driverId: 'driver-1',
|
||||
role: 'driver',
|
||||
status: 'active',
|
||||
joinedAt: expect.any(Date),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error if request not found', async () => {
|
||||
@@ -64,6 +56,5 @@ describe('ApproveTeamJoinRequestUseCase', () => {
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('REQUEST_NOT_FOUND');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
TeamJoinRequest,
|
||||
TeamMembership,
|
||||
} from '../../domain/types/TeamMembership';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
|
||||
export type ApproveTeamJoinRequestInput = {
|
||||
teamId: string;
|
||||
@@ -25,11 +24,10 @@ export type ApproveTeamJoinRequestErrorCode =
|
||||
export class ApproveTeamJoinRequestUseCase {
|
||||
constructor(
|
||||
private readonly membershipRepository: ITeamMembershipRepository,
|
||||
private readonly output: UseCaseOutputPort<ApproveTeamJoinRequestResult>,
|
||||
) {}
|
||||
|
||||
async execute(command: ApproveTeamJoinRequestInput): Promise<
|
||||
Result<void, ApplicationErrorCode<ApproveTeamJoinRequestErrorCode>>
|
||||
Result<ApproveTeamJoinRequestResult, ApplicationErrorCode<ApproveTeamJoinRequestErrorCode>>
|
||||
> {
|
||||
const { teamId, requestId } = command;
|
||||
|
||||
@@ -56,9 +54,7 @@ export class ApproveTeamJoinRequestUseCase {
|
||||
membership,
|
||||
};
|
||||
|
||||
this.output.present(result);
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok(result);
|
||||
} catch (error) {
|
||||
return Result.err({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
@@ -68,4 +64,4 @@ export class ApproveTeamJoinRequestUseCase {
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Race } from '../../domain/entities/Race';
|
||||
import { SessionType } from '../../domain/value-objects/SessionType';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
|
||||
describe('CancelRaceUseCase', () => {
|
||||
let useCase: CancelRaceUseCase;
|
||||
let raceRepository: {
|
||||
@@ -18,8 +16,6 @@ describe('CancelRaceUseCase', () => {
|
||||
info: Mock;
|
||||
error: Mock;
|
||||
};
|
||||
let output: UseCaseOutputPort<CancelRaceResult> & { present: Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
raceRepository = {
|
||||
findById: vi.fn(),
|
||||
@@ -31,12 +27,8 @@ describe('CancelRaceUseCase', () => {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
output = { present: vi.fn() } as unknown as UseCaseOutputPort<CancelRaceResult> & { present: Mock };
|
||||
useCase = new CancelRaceUseCase(
|
||||
raceRepository as unknown as IRaceRepository,
|
||||
logger as unknown as Logger,
|
||||
output,
|
||||
);
|
||||
useCase = new CancelRaceUseCase(raceRepository as unknown as IRaceRepository,
|
||||
logger as unknown as Logger);
|
||||
});
|
||||
|
||||
it('should cancel race successfully', async () => {
|
||||
@@ -63,9 +55,7 @@ describe('CancelRaceUseCase', () => {
|
||||
expect(updatedRace.id).toBe(raceId);
|
||||
expect(updatedRace.status.toString()).toBe('cancelled');
|
||||
|
||||
expect(output.present).toHaveBeenCalledTimes(1);
|
||||
const presented = (output.present as Mock).mock.calls[0]?.[0] as CancelRaceResult;
|
||||
expect(presented.race.id).toBe(raceId);
|
||||
const presented = (expect(presented.race.id).toBe(raceId);
|
||||
expect(presented.race.status.toString()).toBe('cancelled');
|
||||
});
|
||||
|
||||
@@ -77,8 +67,7 @@ describe('CancelRaceUseCase', () => {
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
expect(result.unwrapErr().code).toBe('RACE_NOT_FOUND');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return domain error if race is already cancelled', async () => {
|
||||
const raceId = 'race-1';
|
||||
@@ -102,8 +91,7 @@ describe('CancelRaceUseCase', () => {
|
||||
if ('details' in err && err.details && typeof err.details === 'object' && 'message' in err.details) {
|
||||
expect(err.details.message).toContain('already cancelled');
|
||||
}
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return domain error if race is completed', async () => {
|
||||
const raceId = 'race-1';
|
||||
@@ -127,6 +115,5 @@ describe('CancelRaceUseCase', () => {
|
||||
if ('details' in err && err.details && typeof err.details === 'object' && 'message' in err.details) {
|
||||
expect(err.details.message).toContain('completed race');
|
||||
}
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import type { Race } from '../../domain/entities/Race';
|
||||
|
||||
export type CancelRaceInput = {
|
||||
@@ -29,11 +28,10 @@ export class CancelRaceUseCase {
|
||||
constructor(
|
||||
private readonly raceRepository: IRaceRepository,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<CancelRaceResult>,
|
||||
) {}
|
||||
|
||||
async execute(command: CancelRaceInput): Promise<
|
||||
Result<void, ApplicationErrorCode<CancelRaceErrorCode>>
|
||||
Result<CancelRaceResult, ApplicationErrorCode<CancelRaceErrorCode, { message: string }>>
|
||||
> {
|
||||
const { raceId } = command;
|
||||
this.logger.debug(`[CancelRaceUseCase] Executing for raceId: ${raceId}`);
|
||||
@@ -56,9 +54,7 @@ export class CancelRaceUseCase {
|
||||
race: cancelledRace,
|
||||
};
|
||||
|
||||
this.output.present(result);
|
||||
|
||||
return Result.ok(undefined);
|
||||
return Result.ok(result);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('already cancelled')) {
|
||||
this.logger.warn(`[CancelRaceUseCase] Domain error cancelling race ${raceId}: ${error.message}`);
|
||||
|
||||
@@ -8,8 +8,6 @@ import type { Logger } from '@core/shared/application';
|
||||
import { RaceEvent } from '../../domain/entities/RaceEvent';
|
||||
import { Session } from '../../domain/entities/Session';
|
||||
import { SessionType } from '../../domain/value-objects/SessionType';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
|
||||
describe('CloseRaceEventStewardingUseCase', () => {
|
||||
let useCase: CloseRaceEventStewardingUseCase;
|
||||
let raceEventRepository: {
|
||||
@@ -29,8 +27,6 @@ describe('CloseRaceEventStewardingUseCase', () => {
|
||||
let logger: {
|
||||
error: Mock;
|
||||
};
|
||||
let output: UseCaseOutputPort<CloseRaceEventStewardingResult> & { present: Mock };
|
||||
|
||||
beforeEach(() => {
|
||||
raceEventRepository = {
|
||||
findAwaitingStewardingClose: vi.fn(),
|
||||
@@ -49,15 +45,11 @@ describe('CloseRaceEventStewardingUseCase', () => {
|
||||
logger = {
|
||||
error: vi.fn(),
|
||||
};
|
||||
output = { present: vi.fn() } as unknown as UseCaseOutputPort<CloseRaceEventStewardingResult> & { present: Mock };
|
||||
useCase = new CloseRaceEventStewardingUseCase(
|
||||
logger as unknown as Logger,
|
||||
useCase = new CloseRaceEventStewardingUseCase(logger as unknown as Logger,
|
||||
raceEventRepository as unknown as IRaceEventRepository,
|
||||
raceRegistrationRepository as unknown as IRaceRegistrationRepository,
|
||||
penaltyRepository as unknown as IPenaltyRepository,
|
||||
domainEventPublisher as unknown as DomainEventPublisher,
|
||||
output,
|
||||
);
|
||||
domainEventPublisher as unknown as DomainEventPublisher);
|
||||
});
|
||||
|
||||
it('should close stewarding for expired events successfully', async () => {
|
||||
@@ -96,11 +88,7 @@ describe('CloseRaceEventStewardingUseCase', () => {
|
||||
expect.objectContaining({ status: 'closed' })
|
||||
);
|
||||
expect(domainEventPublisher.publish).toHaveBeenCalled();
|
||||
expect(output.present).toHaveBeenCalledTimes(1);
|
||||
|
||||
const presentedRace = (output.present as Mock).mock.calls[0]?.[0]?.race as unknown as {
|
||||
id?: unknown;
|
||||
status?: unknown;
|
||||
const presentedRace = (status?: unknown;
|
||||
};
|
||||
const presentedId =
|
||||
presentedRace?.id && typeof presentedRace.id === 'object' && typeof presentedRace.id.toString === 'function'
|
||||
@@ -120,8 +108,7 @@ describe('CloseRaceEventStewardingUseCase', () => {
|
||||
expect(result.unwrap()).toBeUndefined();
|
||||
expect(raceEventRepository.update).not.toHaveBeenCalled();
|
||||
expect(domainEventPublisher.publish).not.toHaveBeenCalled();
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error when repository throws', async () => {
|
||||
raceEventRepository.findAwaitingStewardingClose.mockRejectedValue(new Error('DB error'));
|
||||
@@ -134,6 +121,5 @@ describe('CloseRaceEventStewardingUseCase', () => {
|
||||
if ('details' in err && err.details && typeof err.details === 'object' && 'message' in err.details) {
|
||||
expect(err.details.message).toContain('DB error');
|
||||
}
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@ import { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventSte
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { RaceEvent } from '../../domain/entities/RaceEvent';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
|
||||
export type CloseRaceEventStewardingInput = {
|
||||
raceId: string;
|
||||
@@ -30,15 +29,20 @@ export type CloseRaceEventStewardingResult = {
|
||||
export class CloseRaceEventStewardingUseCase {
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
|
||||
private readonly raceEventRepository: IRaceEventRepository,
|
||||
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
|
||||
private readonly penaltyRepository: IPenaltyRepository,
|
||||
private readonly domainEventPublisher: DomainEventPublisher,
|
||||
private readonly output: UseCaseOutputPort<CloseRaceEventStewardingResult>,
|
||||
) {}
|
||||
|
||||
async execute(input: CloseRaceEventStewardingInput): Promise<Result<void, ApplicationErrorCode<'RACE_NOT_FOUND' | 'STEWARDING_ALREADY_CLOSED' | 'REPOSITORY_ERROR'>>> {
|
||||
async execute(
|
||||
input: CloseRaceEventStewardingInput,
|
||||
): Promise<
|
||||
Result<
|
||||
CloseRaceEventStewardingResult,
|
||||
ApplicationErrorCode<'RACE_NOT_FOUND' | 'STEWARDING_ALREADY_CLOSED' | 'REPOSITORY_ERROR'>
|
||||
>
|
||||
> {
|
||||
void input;
|
||||
try {
|
||||
// Find all race events awaiting stewarding that have expired windows
|
||||
@@ -51,18 +55,23 @@ export class CloseRaceEventStewardingUseCase {
|
||||
closedRaceEventIds.push(raceEvent.id);
|
||||
}
|
||||
|
||||
// When multiple race events are processed, we present the last closed event for simplicity
|
||||
// When multiple race events are processed, we return the last closed event for simplicity
|
||||
const lastClosedEventId = closedRaceEventIds[closedRaceEventIds.length - 1];
|
||||
if (lastClosedEventId) {
|
||||
const lastClosedEvent = await this.raceEventRepository.findById(lastClosedEventId);
|
||||
if (lastClosedEvent) {
|
||||
this.output.present({
|
||||
const result: CloseRaceEventStewardingResult = {
|
||||
race: lastClosedEvent,
|
||||
});
|
||||
};
|
||||
return Result.ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
return Result.ok(undefined);
|
||||
// If no events were closed, return an error
|
||||
return Result.err({
|
||||
code: 'RACE_NOT_FOUND',
|
||||
details: { message: 'No race events found to close stewarding for' },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to close race event stewarding', error instanceof Error ? error : new Error(String(error)));
|
||||
return Result.err({
|
||||
@@ -80,7 +89,7 @@ export class CloseRaceEventStewardingUseCase {
|
||||
const closedRaceEvent = raceEvent.closeStewarding();
|
||||
await this.raceEventRepository.update(closedRaceEvent);
|
||||
|
||||
// Get list of participating drivers
|
||||
// Get list of participating driver IDs
|
||||
const driverIds = await this.getParticipatingDriverIds(raceEvent);
|
||||
|
||||
// Check if any penalties were applied during stewarding
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
} from './CompleteDriverOnboardingUseCase';
|
||||
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
|
||||
import { Driver } from '../../domain/entities/Driver';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import type { Logger } from '@core/shared/application/Logger';
|
||||
|
||||
describe('CompleteDriverOnboardingUseCase', () => {
|
||||
@@ -16,7 +15,7 @@ describe('CompleteDriverOnboardingUseCase', () => {
|
||||
create: Mock;
|
||||
};
|
||||
let logger: Logger & { error: Mock };
|
||||
let output: { present: Mock } & UseCaseOutputPort<CompleteDriverOnboardingResult>;
|
||||
let output: { present: Mock } ;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
@@ -32,11 +31,8 @@ describe('CompleteDriverOnboardingUseCase', () => {
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger & { error: Mock };
|
||||
output = { present: vi.fn() } as unknown as typeof output;
|
||||
useCase = new CompleteDriverOnboardingUseCase(
|
||||
driverRepository as unknown as IDriverRepository,
|
||||
logger,
|
||||
output,
|
||||
);
|
||||
useCase = new CompleteDriverOnboardingUseCase(driverRepository as unknown as IDriverRepository,
|
||||
logger);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -66,7 +62,6 @@ describe('CompleteDriverOnboardingUseCase', () => {
|
||||
const result = await useCase.execute(command);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(output.present).toHaveBeenCalledWith({ driver: createdDriver });
|
||||
expect(driverRepository.findById).toHaveBeenCalledWith('user-1');
|
||||
expect(driverRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -144,7 +139,6 @@ describe('CompleteDriverOnboardingUseCase', () => {
|
||||
const result = await useCase.execute(command);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(output.present).toHaveBeenCalledWith({ driver: createdDriver });
|
||||
expect(driverRepository.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'user-1',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user