import { AppLoggerService } from '../../infrastructure/logging/app-logger.service'; import { AppQueueService } from '../../infrastructure/queue/app-queue.service'; import { NotificationsService } from '../notifications/notifications.service'; import { OutboxService } from './outbox.service'; describe('OutboxService', () => { const model = { create: jest.fn(), findOne: jest.fn(), updateOne: jest.fn() }; const notifications = { createFollowNotification: jest.fn(), createFollowRequestApprovedNotification: jest.fn(), } as unknown as NotificationsService; const queue = { registerProcessor: jest.fn(), enqueue: jest.fn() } as unknown as AppQueueService; const logger = { warn: jest.fn() } as unknown as AppLoggerService; const service = new OutboxService(model as never, notifications, queue, logger); beforeEach(() => { jest.restoreAllMocks(); jest.clearAllMocks(); }); afterEach(() => { delete process.env.NOTIFICATION_SERVICE_ENABLED; }); it('registers its processor and enqueues persisted follow events', async () => { service.onModuleInit(); expect(queue.registerProcessor).toHaveBeenCalledWith('process_outbox_event', expect.any(Function)); const processor = (queue.registerProcessor as jest.Mock).mock.calls[0][1] as (value: unknown) => Promise; const processSpy = jest.spyOn(service, 'processEvent').mockResolvedValue(); await processor({ eventId: 9 }); expect(processSpy).toHaveBeenCalledWith('9'); await processor({}); expect(processSpy).toHaveBeenLastCalledWith(''); model.create.mockResolvedValue({ eventId: 'event-1' }); await service.enqueueFollowNotification('actor', 'recipient'); expect(model.create).toHaveBeenCalledWith(expect.objectContaining({ eventId: expect.any(String), eventType: 'follow_notification', payload: { actorId: 'actor', recipientId: 'recipient', referenceId: '' }, status: 'pending', })); expect(queue.enqueue).toHaveBeenCalledWith('process_outbox_event', { eventId: 'event-1' }); model.updateOne.mockReturnValue({ exec: jest.fn().mockResolvedValue({ upsertedCount: 1 }) }); await service.enqueueFollowRequestApprovedNotification('actor', 'recipient', 'request'); expect(model.updateOne).toHaveBeenCalledWith( { eventId: 'follow.request.approved:request' }, expect.objectContaining({ $setOnInsert: expect.objectContaining({ eventType: 'follow_request_approved_notification' }) }), { upsert: true, session: undefined }, ); }); it('processes follow request approval notifications once', async () => { const event = { id: 'e-approved', eventId: 'e-approved', eventType: 'follow_request_approved_notification', status: 'pending', attempts: 0, payload: { actorId: 'owner', recipientId: 'requester', requestId: 'request' }, save: jest.fn(), }; model.findOne.mockReturnValue({ exec: jest.fn().mockResolvedValue(event) }); await service.processEvent(event.id); expect(notifications.createFollowRequestApprovedNotification).toHaveBeenCalledWith({ actorId: 'owner', recipientId: 'requester', requestId: 'request', eventId: 'e-approved', }); expect(event.status).toBe('processed'); }); it('never lets a legacy Gateway worker create approval notifications in remote mode', async () => { process.env.NOTIFICATION_SERVICE_ENABLED = 'true'; const event = { id: 'legacy-job', eventId: 'approval-event', eventType: 'follow_request_approved_notification', status: 'pending', attempts: 0, payload: {}, save: jest.fn(), }; model.findOne.mockReturnValue({ exec: jest.fn().mockResolvedValue(event) }); await service.processEvent('approval-event'); expect(notifications.createFollowRequestApprovedNotification).not.toHaveBeenCalled(); expect(event.status).toBe('pending'); expect(event.save).not.toHaveBeenCalled(); }); it('ignores missing and already processed events', async () => { model.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null) }); await service.processEvent('missing'); model.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ status: 'processed' }) }); await service.processEvent('done'); expect(notifications.createFollowNotification).not.toHaveBeenCalled(); }); it('processes follow notifications and records completion atomically', async () => { const event = { id: 'e1', eventType: 'follow_notification', status: 'pending', attempts: 0, payload: { actorId: 'a', recipientId: 'r', referenceId: 'post' }, save: jest.fn(), }; model.findOne.mockReturnValue({ exec: jest.fn().mockResolvedValue(event) }); await service.processEvent('e1'); expect(notifications.createFollowNotification).toHaveBeenCalledWith('a', 'r', 'post'); expect(event).toEqual(expect.objectContaining({ status: 'processed', attempts: 1, lastError: '', processedAt: expect.any(Date) })); expect(event.save).toHaveBeenCalled(); }); it('marks failed events, logs non-Error failures, and still saves attempts', async () => { const event = { id: 'e2', eventType: 'follow_notification', status: 'pending', attempts: 2, payload: {}, save: jest.fn(), }; model.findOne.mockReturnValue({ exec: jest.fn().mockResolvedValue(event) }); (notifications.createFollowNotification as jest.Mock).mockRejectedValue('network'); await expect(service.processEvent('e2')).rejects.toBe('network'); expect(event).toEqual(expect.objectContaining({ status: 'failed', lastError: 'unknown outbox error', attempts: 3 })); expect(logger.warn).toHaveBeenCalledWith(expect.objectContaining({ eventId: 'e2' }), 'OutboxService'); expect(event.save).toHaveBeenCalled(); }); });