refactor use cases

This commit is contained in:
2025-12-21 01:20:27 +01:00
parent c12656d671
commit 8ecd638396
39 changed files with 2523 additions and 686 deletions

View File

@@ -1,16 +1,27 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { GetUnreadNotificationsUseCase } from './GetUnreadNotificationsUseCase';
import {
GetUnreadNotificationsUseCase,
type GetUnreadNotificationsInput,
type GetUnreadNotificationsResult,
} from './GetUnreadNotificationsUseCase';
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
import type { Logger } from '@core/shared/application';
import type { Logger, UseCaseOutputPort } 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';
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(() => {
@@ -25,8 +36,13 @@ 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,
);
});
@@ -37,7 +53,7 @@ describe('GetUnreadNotificationsUseCase', () => {
Notification.create({
id: 'n1',
recipientId,
type: 'info',
type: 'system_announcement',
title: 'Test',
body: 'Body',
channel: 'in_app',
@@ -46,19 +62,33 @@ describe('GetUnreadNotificationsUseCase', () => {
notificationRepository.findUnreadByRecipientId.mockResolvedValue(notifications);
const result = await useCase.execute(recipientId);
const input: GetUnreadNotificationsInput = { recipientId };
const result = await useCase.execute(input);
expect(notificationRepository.findUnreadByRecipientId).toHaveBeenCalledWith(recipientId);
expect(result.notifications).toEqual(notifications);
expect(result.totalCount).toBe(1);
expect(result).toBeInstanceOf(Result);
expect(result.isOk()).toBe(true);
expect(output.present).toHaveBeenCalledWith({
notifications,
totalCount: 1,
});
});
it('handles repository errors by logging and rethrowing', async () => {
it('handles repository errors by logging and returning error result', async () => {
const recipientId = 'driver-1';
const error = new Error('DB error');
notificationRepository.findUnreadByRecipientId.mockRejectedValue(error);
await expect(useCase.execute(recipientId)).rejects.toThrow('DB error');
const input: GetUnreadNotificationsInput = { recipientId };
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');
expect(err.details.message).toBe('DB error');
expect((logger.error as unknown as Mock)).toHaveBeenCalled();
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -1,41 +1,72 @@
/**
* Application Use Case: GetUnreadNotificationsUseCase
*
*
* Retrieves unread notifications for a recipient.
*/
import type { AsyncUseCase , Logger } from '@core/shared/application';
import type { Logger, UseCaseOutputPort } 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';
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
export interface UnreadNotificationsResult {
export type GetUnreadNotificationsInput = {
recipientId: string;
};
export interface GetUnreadNotificationsResult {
notifications: Notification[];
totalCount: number;
}
export class GetUnreadNotificationsUseCase implements AsyncUseCase<string, UnreadNotificationsResult> {
export type GetUnreadNotificationsErrorCode = 'REPOSITORY_ERROR';
export class GetUnreadNotificationsUseCase {
constructor(
private readonly notificationRepository: INotificationRepository,
private readonly output: UseCaseOutputPort<GetUnreadNotificationsResult>,
private readonly logger: Logger,
) {}
async execute(recipientId: string): Promise<UnreadNotificationsResult> {
this.logger.debug(`Attempting to retrieve unread notifications for recipient ID: ${recipientId}`);
async execute(
input: GetUnreadNotificationsInput,
): Promise<Result<void, ApplicationErrorCode<GetUnreadNotificationsErrorCode, { message: string }>>> {
const { recipientId } = input;
this.logger.debug(
`Attempting to retrieve unread notifications for recipient ID: ${recipientId}`,
);
try {
const notifications = await this.notificationRepository.findUnreadByRecipientId(recipientId);
this.logger.info(`Successfully retrieved ${notifications.length} unread notifications for recipient ID: ${recipientId}`);
const notifications = await this.notificationRepository.findUnreadByRecipientId(
recipientId,
);
this.logger.info(
`Successfully retrieved ${notifications.length} unread notifications for recipient ID: ${recipientId}`,
);
if (notifications.length === 0) {
this.logger.warn(`No unread notifications found for recipient ID: ${recipientId}`);
}
return {
this.output.present({
notifications,
totalCount: notifications.length,
};
});
return Result.ok<void, ApplicationErrorCode<GetUnreadNotificationsErrorCode, { message: string }>>(
undefined,
);
} catch (error) {
this.logger.error(`Failed to retrieve unread notifications for recipient ID: ${recipientId}`, error instanceof Error ? error : new Error(String(error)));
throw error;
const err = error instanceof Error ? error : new Error(String(error));
this.logger.error(
`Failed to retrieve unread notifications for recipient ID: ${recipientId}`,
err,
);
return Result.err<void, ApplicationErrorCode<GetUnreadNotificationsErrorCode, { message: string }>>({
code: 'REPOSITORY_ERROR',
details: { message: err.message },
});
}
}
}

View File

@@ -1,9 +1,14 @@
import { describe, it, expect, vi, type Mock } from 'vitest';
import { MarkNotificationReadUseCase } from './MarkNotificationReadUseCase';
import {
MarkNotificationReadUseCase,
type MarkNotificationReadCommand,
type MarkNotificationReadResult,
} from './MarkNotificationReadUseCase';
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
import type { Logger } from '@core/shared/application';
import type { Logger, UseCaseOutputPort } 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';
import { NotificationDomainError } from '../../domain/errors/NotificationDomainError';
interface NotificationRepositoryMock {
findById: Mock;
@@ -11,9 +16,14 @@ 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(() => {
@@ -30,27 +40,39 @@ 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,
);
});
it('throws when notification is not found', async () => {
it('returns NOTIFICATION_NOT_FOUND when notification is not found', async () => {
notificationRepository.findById.mockResolvedValue(null);
await expect(
useCase.execute({ notificationId: 'n1', recipientId: 'driver-1' }),
).rejects.toThrow(NotificationDomainError);
const command: MarkNotificationReadCommand = {
notificationId: 'n1',
recipientId: 'driver-1',
};
expect((logger.warn as unknown as Mock)).toHaveBeenCalled();
const result = await useCase.execute(command);
expect(result).toBeInstanceOf(Result);
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('throws when recipientId does not match', async () => {
it('returns RECIPIENT_MISMATCH when recipientId does not match', async () => {
const notification = Notification.create({
id: 'n1',
recipientId: 'driver-2',
type: 'info',
type: 'system_announcement',
title: 'Test',
body: 'Body',
channel: 'in_app',
@@ -58,16 +80,24 @@ describe('MarkNotificationReadUseCase', () => {
notificationRepository.findById.mockResolvedValue(notification);
await expect(
useCase.execute({ notificationId: 'n1', recipientId: 'driver-1' }),
).rejects.toThrow(NotificationDomainError);
const command: MarkNotificationReadCommand = {
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');
expect(output.present).not.toHaveBeenCalled();
});
it('marks notification as read when unread', async () => {
it('marks notification as read when unread and presents result', async () => {
const notification = Notification.create({
id: 'n1',
recipientId: 'driver-1',
type: 'info',
type: 'system_announcement',
title: 'Test',
body: 'Body',
channel: 'in_app',
@@ -75,9 +105,19 @@ describe('MarkNotificationReadUseCase', () => {
notificationRepository.findById.mockResolvedValue(notification);
await useCase.execute({ notificationId: 'n1', recipientId: 'driver-1' });
const command: MarkNotificationReadCommand = {
notificationId: 'n1',
recipientId: 'driver-1',
};
const result = await useCase.execute(command);
expect(result.isOk()).toBe(true);
expect(notificationRepository.update).toHaveBeenCalled();
expect((logger.info as unknown as Mock)).toHaveBeenCalled();
expect(output.present).toHaveBeenCalledWith({
notificationId: 'n1',
recipientId: 'driver-1',
wasAlreadyRead: false,
});
});
});

View File

@@ -4,7 +4,9 @@
* Marks a notification as read.
*/
import type { AsyncUseCase , Logger } from '@core/shared/application';
import type { Logger, UseCaseOutputPort } 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';
@@ -13,38 +15,86 @@ export interface MarkNotificationReadCommand {
recipientId: string; // For validation
}
export class MarkNotificationReadUseCase implements AsyncUseCase<MarkNotificationReadCommand, void> {
export interface MarkNotificationReadResult {
notificationId: string;
recipientId: string;
wasAlreadyRead: boolean;
}
export type MarkNotificationReadErrorCode =
| 'NOTIFICATION_NOT_FOUND'
| 'RECIPIENT_MISMATCH'
| 'REPOSITORY_ERROR';
export class MarkNotificationReadUseCase {
constructor(
private readonly notificationRepository: INotificationRepository,
private readonly output: UseCaseOutputPort<MarkNotificationReadResult>,
private readonly logger: Logger,
) {}
async execute(command: MarkNotificationReadCommand): Promise<void> {
this.logger.debug(`Attempting to mark notification ${command.notificationId} as read for recipient ${command.recipientId}`);
async execute(
command: MarkNotificationReadCommand,
): Promise<Result<void, ApplicationErrorCode<MarkNotificationReadErrorCode, { message: string }>>> {
this.logger.debug(
`Attempting to mark notification ${command.notificationId} as read for recipient ${command.recipientId}`,
);
try {
const notification = await this.notificationRepository.findById(command.notificationId);
if (!notification) {
this.logger.warn(`Notification not found for ID: ${command.notificationId}`);
throw new NotificationDomainError('Notification not found');
return Result.err({
code: 'NOTIFICATION_NOT_FOUND',
details: { message: 'Notification not found' },
});
}
if (notification.recipientId !== command.recipientId) {
this.logger.warn(`Unauthorized attempt to mark notification ${command.notificationId}. Recipient ID mismatch.`);
throw new NotificationDomainError('Cannot mark another user\'s notification as read');
this.logger.warn(
`Unauthorized attempt to mark notification ${command.notificationId}. Recipient ID mismatch.`,
);
return Result.err({
code: 'RECIPIENT_MISMATCH',
details: { message: "Cannot mark another user's notification as read" },
});
}
if (!notification.isUnread()) {
this.logger.info(`Notification ${command.notificationId} is already read. Skipping update.`);
return; // Already read, nothing to do
this.logger.info(
`Notification ${command.notificationId} is already read. Skipping update.`,
);
this.output.present({
notificationId: command.notificationId,
recipientId: command.recipientId,
wasAlreadyRead: true,
});
return Result.ok(undefined);
}
const updatedNotification = notification.markAsRead();
await this.notificationRepository.update(updatedNotification);
this.logger.info(`Notification ${command.notificationId} successfully marked as read.`);
this.logger.info(
`Notification ${command.notificationId} successfully marked as read.`,
);
this.output.present({
notificationId: command.notificationId,
recipientId: command.recipientId,
wasAlreadyRead: false,
});
return Result.ok(undefined);
} catch (error) {
this.logger.error(`Failed to mark notification ${command.notificationId} as read: ${error instanceof Error ? error.message : String(error)}`);
throw 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({
code: 'REPOSITORY_ERROR',
details: { message: err.message },
});
}
}
}
@@ -54,13 +104,36 @@ export class MarkNotificationReadUseCase implements AsyncUseCase<MarkNotificatio
*
* Marks all notifications as read for a recipient.
*/
export class MarkAllNotificationsReadUseCase implements AsyncUseCase<string, void> {
export interface MarkAllNotificationsReadInput {
recipientId: string;
}
export interface MarkAllNotificationsReadResult {
recipientId: string;
}
export type MarkAllNotificationsReadErrorCode = 'REPOSITORY_ERROR';
export class MarkAllNotificationsReadUseCase {
constructor(
private readonly notificationRepository: INotificationRepository,
private readonly output: UseCaseOutputPort<MarkAllNotificationsReadResult>,
) {}
async execute(recipientId: string): Promise<void> {
await this.notificationRepository.markAllAsReadByRecipientId(recipientId);
async execute(
input: MarkAllNotificationsReadInput,
): Promise<Result<void, ApplicationErrorCode<MarkAllNotificationsReadErrorCode, { message: string }>>> {
try {
await this.notificationRepository.markAllAsReadByRecipientId(input.recipientId);
this.output.present({ recipientId: input.recipientId });
return Result.ok(undefined);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: err.message },
});
}
}
}
@@ -74,27 +147,78 @@ export interface DismissNotificationCommand {
recipientId: string;
}
export class DismissNotificationUseCase implements AsyncUseCase<DismissNotificationCommand, void> {
export interface DismissNotificationResult {
notificationId: string;
recipientId: string;
wasAlreadyDismissed: boolean;
}
export type DismissNotificationErrorCode =
| 'NOTIFICATION_NOT_FOUND'
| 'RECIPIENT_MISMATCH'
| 'CANNOT_DISMISS_REQUIRING_RESPONSE'
| 'REPOSITORY_ERROR';
export class DismissNotificationUseCase {
constructor(
private readonly notificationRepository: INotificationRepository,
private readonly output: UseCaseOutputPort<DismissNotificationResult>,
) {}
async execute(command: DismissNotificationCommand): Promise<void> {
const notification = await this.notificationRepository.findById(command.notificationId);
if (!notification) {
throw new NotificationDomainError('Notification not found');
}
async execute(
command: DismissNotificationCommand,
): Promise<Result<void, ApplicationErrorCode<DismissNotificationErrorCode, { message: string }>>> {
try {
const notification = await this.notificationRepository.findById(
command.notificationId,
);
if (notification.recipientId !== command.recipientId) {
throw new NotificationDomainError('Cannot dismiss another user\'s notification');
}
if (!notification) {
return Result.err({
code: 'NOTIFICATION_NOT_FOUND',
details: { message: 'Notification not found' },
});
}
if (notification.isDismissed()) {
return; // Already dismissed
}
if (notification.recipientId !== command.recipientId) {
return Result.err({
code: 'RECIPIENT_MISMATCH',
details: { message: "Cannot dismiss another user's notification" },
});
}
const updatedNotification = notification.dismiss();
await this.notificationRepository.update(updatedNotification);
if (notification.isDismissed()) {
this.output.present({
notificationId: command.notificationId,
recipientId: command.recipientId,
wasAlreadyDismissed: true,
});
return Result.ok(undefined);
}
if (!notification.canDismiss()) {
return Result.err({
code: 'CANNOT_DISMISS_REQUIRING_RESPONSE',
details: { message: 'Cannot dismiss notification that requires response' },
});
}
const updatedNotification = notification.dismiss();
await this.notificationRepository.update(updatedNotification);
this.output.present({
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({
code: 'REPOSITORY_ERROR',
details: { message: err.message },
});
}
}
}

View File

@@ -5,10 +5,22 @@ import {
UpdateTypePreferenceUseCase,
UpdateQuietHoursUseCase,
SetDigestModeUseCase,
type GetNotificationPreferencesInput,
type GetNotificationPreferencesResult,
type UpdateChannelPreferenceCommand,
type UpdateChannelPreferenceResult,
type UpdateTypePreferenceCommand,
type UpdateTypePreferenceResult,
type UpdateQuietHoursCommand,
type UpdateQuietHoursResult,
type SetDigestModeCommand,
type SetDigestModeResult,
} from './NotificationPreferencesUseCases';
import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository';
import type { NotificationPreference , ChannelPreference, TypePreference } from '../../domain/entities/NotificationPreference';
import type { Logger } from '@core/shared/application';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { NotificationChannel, NotificationType } from '../../domain/types/NotificationTypes';
import { NotificationDomainError } from '../../domain/errors/NotificationDomainError';
@@ -19,38 +31,46 @@ describe('NotificationPreferencesUseCases', () => {
};
let logger: Logger;
beforeEach(() => {
preferenceRepository = {
getOrCreateDefault: vi.fn(),
save: vi.fn(),
} as unknown as INotificationPreferenceRepository as any;
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 result = await useCase.execute('driver-1');
expect(preferenceRepository.getOrCreateDefault).toHaveBeenCalledWith('driver-1');
expect(result).toBe(preference);
});
beforeEach(() => {
preferenceRepository = {
getOrCreateDefault: vi.fn(),
save: vi.fn(),
} as unknown as INotificationPreferenceRepository as any;
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 any;
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 });
});
it('UpdateChannelPreferenceUseCase updates channel preference', async () => {
const preference = {
updateChannel: vi.fn().mockReturnThis(),
@@ -58,19 +78,28 @@ describe('NotificationPreferencesUseCases', () => {
preferenceRepository.getOrCreateDefault.mockResolvedValue(preference);
const output: UseCaseOutputPort<UpdateChannelPreferenceResult> & { present: Mock } = {
present: vi.fn(),
} as any;
const useCase = new UpdateChannelPreferenceUseCase(
preferenceRepository as unknown as INotificationPreferenceRepository,
output,
logger,
);
await useCase.execute({
const command: UpdateChannelPreferenceCommand = {
driverId: 'driver-1',
channel: 'email' as NotificationChannel,
preference: 'enabled' as ChannelPreference,
});
preference: { enabled: true } as ChannelPreference,
};
const result = await useCase.execute(command);
expect(result.isOk()).toBe(true);
expect(preference.updateChannel).toHaveBeenCalled();
expect(preferenceRepository.save).toHaveBeenCalledWith(preference);
expect(output.present).toHaveBeenCalledWith({ driverId: 'driver-1', channel: 'email' });
});
it('UpdateTypePreferenceUseCase updates type preference', async () => {
@@ -80,19 +109,28 @@ describe('NotificationPreferencesUseCases', () => {
preferenceRepository.getOrCreateDefault.mockResolvedValue(preference);
const output: UseCaseOutputPort<UpdateTypePreferenceResult> & { present: Mock } = {
present: vi.fn(),
} as any;
const useCase = new UpdateTypePreferenceUseCase(
preferenceRepository as unknown as INotificationPreferenceRepository,
output,
logger,
);
await useCase.execute({
const command: UpdateTypePreferenceCommand = {
driverId: 'driver-1',
type: 'info' as NotificationType,
preference: 'enabled' as TypePreference,
});
type: 'system_announcement' as NotificationType,
preference: { enabled: true } as TypePreference,
};
const result = await useCase.execute(command);
expect(result.isOk()).toBe(true);
expect(preference.updateTypePreference).toHaveBeenCalled();
expect(preferenceRepository.save).toHaveBeenCalledWith(preference);
expect(output.present).toHaveBeenCalledWith({ driverId: 'driver-1', type: 'system_announcement' });
});
it('UpdateQuietHoursUseCase validates hours and updates preferences', async () => {
@@ -102,34 +140,56 @@ describe('NotificationPreferencesUseCases', () => {
preferenceRepository.getOrCreateDefault.mockResolvedValue(preference);
const output: UseCaseOutputPort<UpdateQuietHoursResult> & { present: Mock } = {
present: vi.fn(),
} as any;
const useCase = new UpdateQuietHoursUseCase(
preferenceRepository as unknown as INotificationPreferenceRepository,
output,
logger,
);
await useCase.execute({
const command: UpdateQuietHoursCommand = {
driverId: 'driver-1',
startHour: 22,
endHour: 7,
};
const result = await useCase.execute(command);
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,
});
expect(preference.updateQuietHours).toHaveBeenCalledWith(22, 7);
expect(preferenceRepository.save).toHaveBeenCalledWith(preference);
});
it('UpdateQuietHoursUseCase throws on invalid hours', async () => {
it('UpdateQuietHoursUseCase returns error on invalid hours', async () => {
const output: UseCaseOutputPort<UpdateQuietHoursResult> & { present: Mock } = {
present: vi.fn(),
} as any;
const useCase = new UpdateQuietHoursUseCase(
preferenceRepository as unknown as INotificationPreferenceRepository,
output,
logger,
);
await expect(
useCase.execute({ driverId: 'd1', startHour: -1, endHour: 10 }),
).rejects.toThrow(NotificationDomainError);
const badStart: UpdateQuietHoursCommand = { driverId: 'd1', startHour: -1, endHour: 10 };
const result1 = await useCase.execute(badStart);
expect(result1.isErr()).toBe(true);
const err1 = result1.unwrapErr() as ApplicationErrorCode<'INVALID_START_HOUR', { message: string }>;
expect(err1.code).toBe('INVALID_START_HOUR');
await expect(
useCase.execute({ driverId: 'd1', startHour: 10, endHour: 24 }),
).rejects.toThrow(NotificationDomainError);
const badEnd: UpdateQuietHoursCommand = { driverId: 'd1', startHour: 10, endHour: 24 };
const result2 = await useCase.execute(badEnd);
expect(result2.isErr()).toBe(true);
const err2 = result2.unwrapErr() as ApplicationErrorCode<'INVALID_END_HOUR', { message: string }>;
expect(err2.code).toBe('INVALID_END_HOUR');
});
it('SetDigestModeUseCase sets digest mode with valid frequency', async () => {
@@ -139,27 +199,52 @@ describe('NotificationPreferencesUseCases', () => {
preferenceRepository.getOrCreateDefault.mockResolvedValue(preference);
const output: UseCaseOutputPort<SetDigestModeResult> & { present: Mock } = {
present: vi.fn(),
} as any;
const useCase = new SetDigestModeUseCase(
preferenceRepository as unknown as INotificationPreferenceRepository,
output,
);
await useCase.execute({
const command: SetDigestModeCommand = {
driverId: 'driver-1',
enabled: true,
frequencyHours: 4,
};
const result = await useCase.execute(command);
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,
});
expect(preference.setDigestMode).toHaveBeenCalledWith(true, 4);
expect(preferenceRepository.save).toHaveBeenCalledWith(preference);
});
it('SetDigestModeUseCase throws on invalid frequency', async () => {
it('SetDigestModeUseCase returns error on invalid frequency', async () => {
const output: UseCaseOutputPort<SetDigestModeResult> & { present: Mock } = {
present: vi.fn(),
} as any;
const useCase = new SetDigestModeUseCase(
preferenceRepository as unknown as INotificationPreferenceRepository,
output,
);
await expect(
useCase.execute({ driverId: 'driver-1', enabled: true, frequencyHours: 0 }),
).rejects.toThrow(NotificationDomainError);
const command: SetDigestModeCommand = {
driverId: 'driver-1',
enabled: true,
frequencyHours: 0,
};
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<'INVALID_FREQUENCY', { message: string }>;
expect(err.code).toBe('INVALID_FREQUENCY');
});
});

View File

@@ -4,7 +4,9 @@
* Manages user notification preferences.
*/
import type { AsyncUseCase , Logger } from '@core/shared/application';
import type { Logger, UseCaseOutputPort } 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';
@@ -14,21 +16,40 @@ import { NotificationDomainError } from '../../domain/errors/NotificationDomainE
/**
* Query: GetNotificationPreferencesQuery
*/
export class GetNotificationPreferencesQuery implements AsyncUseCase<string, NotificationPreference> {
export interface GetNotificationPreferencesInput {
driverId: string;
}
export interface GetNotificationPreferencesResult {
preference: NotificationPreference;
}
export type GetNotificationPreferencesErrorCode = 'REPOSITORY_ERROR';
export class GetNotificationPreferencesQuery {
constructor(
private readonly preferenceRepository: INotificationPreferenceRepository,
private readonly output: UseCaseOutputPort<GetNotificationPreferencesResult>,
private readonly logger: Logger,
) {}
async execute(driverId: string): Promise<NotificationPreference> {
async execute(
input: GetNotificationPreferencesInput,
): Promise<Result<void, 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}`);
return preferences;
this.output.present({ preference: preferences });
return Result.ok(undefined);
} catch (error) {
this.logger.error(`Failed to fetch preferences for driver: ${driverId}`, error);
throw 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({
code: 'REPOSITORY_ERROR',
details: { message: err.message },
});
}
}
}
@@ -42,22 +63,48 @@ export interface UpdateChannelPreferenceCommand {
preference: ChannelPreference;
}
export class UpdateChannelPreferenceUseCase implements AsyncUseCase<UpdateChannelPreferenceCommand, void> {
export interface UpdateChannelPreferenceResult {
driverId: string;
channel: NotificationChannel;
}
export type UpdateChannelPreferenceErrorCode =
| 'REPOSITORY_ERROR';
export class UpdateChannelPreferenceUseCase {
constructor(
private readonly preferenceRepository: INotificationPreferenceRepository,
private readonly output: UseCaseOutputPort<UpdateChannelPreferenceResult>,
private readonly logger: Logger,
) {}
async execute(command: UpdateChannelPreferenceCommand): Promise<void> {
this.logger.debug(`Updating channel preference for driver: ${command.driverId}, channel: ${command.channel}, preference: ${command.preference}`);
async execute(
command: UpdateChannelPreferenceCommand,
): Promise<Result<void, ApplicationErrorCode<UpdateChannelPreferenceErrorCode, { message: string }>>> {
this.logger.debug(
`Updating channel preference for driver: ${command.driverId}, channel: ${command.channel}, preference: ${JSON.stringify(command.preference)}`,
);
try {
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId);
const preferences = await this.preferenceRepository.getOrCreateDefault(
command.driverId,
);
const updated = preferences.updateChannel(command.channel, command.preference);
await this.preferenceRepository.save(updated);
this.logger.info(`Successfully updated channel preference for driver: ${command.driverId}`);
this.logger.info(
`Successfully updated channel preference for driver: ${command.driverId}`,
);
this.output.present({ driverId: command.driverId, channel: command.channel });
return Result.ok(undefined);
} catch (error) {
this.logger.error(`Failed to update channel preference for driver: ${command.driverId}, channel: ${command.channel}`, error);
throw 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({
code: 'REPOSITORY_ERROR',
details: { message: err.message },
});
}
}
}
@@ -71,22 +118,47 @@ export interface UpdateTypePreferenceCommand {
preference: TypePreference;
}
export class UpdateTypePreferenceUseCase implements AsyncUseCase<UpdateTypePreferenceCommand, void> {
export interface UpdateTypePreferenceResult {
driverId: string;
type: NotificationType;
}
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<void> {
this.logger.debug(`Updating type preference for driver: ${command.driverId}, type: ${command.type}, preference: ${command.preference}`);
async execute(
command: UpdateTypePreferenceCommand,
): Promise<Result<void, ApplicationErrorCode<UpdateTypePreferenceErrorCode, { message: string }>>> {
this.logger.debug(
`Updating type preference for driver: ${command.driverId}, type: ${command.type}, preference: ${JSON.stringify(command.preference)}`,
);
try {
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId);
const preferences = await this.preferenceRepository.getOrCreateDefault(
command.driverId,
);
const updated = preferences.updateTypePreference(command.type, command.preference);
await this.preferenceRepository.save(updated);
this.logger.info(`Successfully updated type preference for driver: ${command.driverId}`);
this.logger.info(
`Successfully updated type preference for driver: ${command.driverId}`,
);
this.output.present({ driverId: command.driverId, type: command.type });
return Result.ok(undefined);
} catch (error) {
this.logger.error(`Failed to update type preference for driver: ${command.driverId}, type: ${command.type}`, error);
throw 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({
code: 'REPOSITORY_ERROR',
details: { message: err.message },
});
}
}
}
@@ -100,32 +172,73 @@ export interface UpdateQuietHoursCommand {
endHour: number | undefined;
}
export class UpdateQuietHoursUseCase implements AsyncUseCase<UpdateQuietHoursCommand, void> {
export interface UpdateQuietHoursResult {
driverId: string;
startHour: number | undefined;
endHour: number | undefined;
}
export type UpdateQuietHoursErrorCode =
| 'INVALID_START_HOUR'
| 'INVALID_END_HOUR'
| 'REPOSITORY_ERROR';
export class UpdateQuietHoursUseCase {
constructor(
private readonly preferenceRepository: INotificationPreferenceRepository,
private readonly output: UseCaseOutputPort<UpdateQuietHoursResult>,
private readonly logger: Logger,
) {}
async execute(command: UpdateQuietHoursCommand): Promise<void> {
this.logger.debug(`Updating quiet hours for driver: ${command.driverId}, startHour: ${command.startHour}, endHour: ${command.endHour}`);
async execute(
command: UpdateQuietHoursCommand,
): Promise<Result<void, ApplicationErrorCode<UpdateQuietHoursErrorCode, { message: string }>>> {
this.logger.debug(
`Updating quiet hours for driver: ${command.driverId}, startHour: ${command.startHour}, endHour: ${command.endHour}`,
);
try {
// Validate hours if provided
if (command.startHour !== undefined && (command.startHour < 0 || command.startHour > 23)) {
this.logger.warn(`Invalid start hour provided for driver: ${command.driverId}. startHour: ${command.startHour}`);
throw new NotificationDomainError('Start hour must be between 0 and 23');
this.logger.warn(
`Invalid start hour provided for driver: ${command.driverId}. startHour: ${command.startHour}`,
);
return Result.err({
code: 'INVALID_START_HOUR',
details: { message: 'Start hour must be between 0 and 23' },
});
}
if (command.endHour !== undefined && (command.endHour < 0 || command.endHour > 23)) {
this.logger.warn(`Invalid end hour provided for driver: ${command.driverId}. endHour: ${command.endHour}`);
throw new NotificationDomainError('End hour must be between 0 and 23');
this.logger.warn(
`Invalid end hour provided for driver: ${command.driverId}. endHour: ${command.endHour}`,
);
return Result.err({
code: 'INVALID_END_HOUR',
details: { message: 'End hour must be between 0 and 23' },
});
}
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId);
const updated = preferences.updateQuietHours(command.startHour, command.endHour);
const preferences = await this.preferenceRepository.getOrCreateDefault(
command.driverId,
);
const updated = preferences.updateQuietHours(
command.startHour,
command.endHour,
);
await this.preferenceRepository.save(updated);
this.logger.info(`Successfully updated quiet hours for driver: ${command.driverId}`);
this.output.present({
driverId: command.driverId,
startHour: command.startHour,
endHour: command.endHour,
});
return Result.ok(undefined);
} catch (error) {
this.logger.error(`Failed to update quiet hours for driver: ${command.driverId}`, error);
throw 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({
code: 'REPOSITORY_ERROR',
details: { message: err.message },
});
}
}
}
@@ -139,18 +252,53 @@ export interface SetDigestModeCommand {
frequencyHours?: number;
}
export class SetDigestModeUseCase implements AsyncUseCase<SetDigestModeCommand, void> {
export interface SetDigestModeResult {
driverId: string;
enabled: boolean;
frequencyHours?: number;
}
export type SetDigestModeErrorCode =
| 'INVALID_FREQUENCY'
| 'REPOSITORY_ERROR';
export class SetDigestModeUseCase {
constructor(
private readonly preferenceRepository: INotificationPreferenceRepository,
private readonly output: UseCaseOutputPort<SetDigestModeResult>,
) {}
async execute(command: SetDigestModeCommand): Promise<void> {
async execute(
command: SetDigestModeCommand,
): Promise<Result<void, ApplicationErrorCode<SetDigestModeErrorCode, { message: string }>>> {
if (command.frequencyHours !== undefined && command.frequencyHours < 1) {
throw new NotificationDomainError('Digest frequency must be at least 1 hour');
return Result.err({
code: 'INVALID_FREQUENCY',
details: { message: 'Digest frequency must be at least 1 hour' },
});
}
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId);
const updated = preferences.setDigestMode(command.enabled, command.frequencyHours);
await this.preferenceRepository.save(updated);
try {
const preferences = await this.preferenceRepository.getOrCreateDefault(
command.driverId,
);
const updated = preferences.setDigestMode(
command.enabled,
command.frequencyHours,
);
await this.preferenceRepository.save(updated);
this.output.present({
driverId: command.driverId,
enabled: command.enabled,
frequencyHours: command.frequencyHours,
});
return Result.ok(undefined);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: err.message },
});
}
}
}

View File

@@ -5,7 +5,9 @@
* based on their preferences.
*/
import type { AsyncUseCase, Logger } from '@core/shared/application';
import type { Logger, UseCaseOutputPort } 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';
import type { NotificationData } from '../../domain/entities/Notification';
import { Notification } from '../../domain/entities/Notification';
@@ -43,17 +45,22 @@ export interface SendNotificationResult {
deliveryResults: NotificationDeliveryResult[];
}
export class SendNotificationUseCase implements AsyncUseCase<SendNotificationCommand, SendNotificationResult> {
export type SendNotificationErrorCode = 'REPOSITORY_ERROR';
export class SendNotificationUseCase {
constructor(
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.');
}
async execute(command: SendNotificationCommand): Promise<SendNotificationResult> {
async execute(
command: SendNotificationCommand,
): Promise<Result<void, ApplicationErrorCode<SendNotificationErrorCode, { message: string }>>> {
this.logger.debug('Executing SendNotificationUseCase', { command });
try {
// Get recipient's preferences
@@ -84,7 +91,8 @@ export class SendNotificationUseCase implements AsyncUseCase<SendNotificationCom
}
// Determine which channels to use
const channels = command.forceChannels ?? preferences.getEnabledChannelsForType(command.type);
const channels =
command.forceChannels ?? preferences.getEnabledChannelsForType(command.type);
// Check quiet hours (skip external channels during quiet hours)
const effectiveChannels = preferences.isInQuietHours()
@@ -133,13 +141,19 @@ export class SendNotificationUseCase implements AsyncUseCase<SendNotificationCom
}
}
return {
this.output.present({
notification: primaryNotification!,
deliveryResults,
};
});
return Result.ok(undefined);
} catch (error) {
this.logger.error('Error sending notification', error as Error);
throw error;
const err = error instanceof Error ? error : new Error(String(error));
this.logger.error('Error sending notification', err);
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: err.message },
});
}
}
}