refactor use cases

This commit is contained in:
2026-01-08 15:34:51 +01:00
parent d984ab24a8
commit 52e9a2f6a7
362 changed files with 5192 additions and 8409 deletions

View File

@@ -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();
});
});

View File

@@ -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.
*/
*/

View File

@@ -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');
});
});

View File

@@ -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 },
});
}
}
}
}

View File

@@ -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 = {

View File

@@ -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 },
});
}
}
}
}

View File

@@ -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();
});
});
});

View File

@@ -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 },
});
}
}
}
}