import { describe, it, expect, vi, type Mock } from 'vitest'; import type { Logger, UseCaseOutputPort } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import { SendNotificationUseCase, type SendNotificationCommand, type SendNotificationErrorCode, type SendNotificationResult, } from './SendNotificationUseCase'; import type { INotificationRepository } from '../../domain/repositories/INotificationRepository'; import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository'; import type { NotificationGatewayRegistry } from '../ports/NotificationGateway'; import type { NotificationChannel, NotificationType } from '../../domain/types/NotificationTypes'; vi.mock('uuid', () => ({ v4: () => 'notif-1', })); interface TestOutputPort extends UseCaseOutputPort { 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(() => { notificationRepository = { create: vi.fn(), }; preferenceRepository = { getOrCreateDefault: vi.fn(), }; gatewayRegistry = { send: vi.fn(), }; logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), 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, ); }); it('creates but does not deliver when type is disabled', async () => { const preferences = { isTypeEnabled: vi.fn().mockReturnValue(false), getEnabledChannelsForType: vi.fn().mockReturnValue(['email'] as NotificationChannel[]), isInQuietHours: vi.fn().mockReturnValue(false), }; preferenceRepository.getOrCreateDefault.mockResolvedValue(preferences); const command: SendNotificationCommand = { recipientId: 'driver-1', type: 'system_announcement' as NotificationType, title: 'Hello', body: 'World', }; const result = await useCase.execute(command); expect(result).toBeInstanceOf(Result); expect(result.isOk()).toBe(true); expect(preferenceRepository.getOrCreateDefault).toHaveBeenCalledWith('driver-1'); expect(preferences.isTypeEnabled).toHaveBeenCalledWith('system_announcement'); 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'); }); it('ensures in_app is used and sends external channels when enabled', async () => { const preferences = { isTypeEnabled: vi.fn().mockReturnValue(true), getEnabledChannelsForType: vi.fn().mockReturnValue(['email'] as NotificationChannel[]), isInQuietHours: vi.fn().mockReturnValue(false), }; preferenceRepository.getOrCreateDefault.mockResolvedValue(preferences); gatewayRegistry.send.mockResolvedValue({ success: true, channel: 'email' as NotificationChannel, attemptedAt: new Date(), externalId: 'email-1', }); const command: SendNotificationCommand = { recipientId: 'driver-1', type: 'system_announcement' as NotificationType, title: 'Hello', body: 'World', }; const result = await useCase.execute(command); expect(result.isOk()).toBe(true); // in_app notification must be created, email should be sent via gateway 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 channels = output.result!.deliveryResults.map(r => r.channel).sort(); expect(channels).toEqual(['email', 'in_app']); }); it('filters external channels during quiet hours', async () => { const preferences = { isTypeEnabled: vi.fn().mockReturnValue(true), getEnabledChannelsForType: vi.fn().mockReturnValue(['email', 'discord'] as NotificationChannel[]), isInQuietHours: vi.fn().mockReturnValue(true), }; preferenceRepository.getOrCreateDefault.mockResolvedValue(preferences); const command: SendNotificationCommand = { recipientId: 'driver-1', type: 'system_announcement' as NotificationType, title: 'Hello', body: 'World', }; const result = await useCase.execute(command); expect(result.isOk()).toBe(true); 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'); }); it('returns REPOSITORY_ERROR when preference repository throws', async () => { preferenceRepository.getOrCreateDefault.mockRejectedValue(new Error('DB error')); const command: SendNotificationCommand = { recipientId: 'driver-1', type: 'system_announcement' as NotificationType, title: 'Hello', body: 'World', }; const result: Result> = 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(); }); });