feat: add notification microservices and transactional outbox
This commit is contained in:
@@ -4,7 +4,7 @@ import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { OutboxService } from './outbox.service';
|
||||
|
||||
describe('OutboxService', () => {
|
||||
const model = { create: jest.fn(), findById: jest.fn() };
|
||||
const model = { create: jest.fn(), findOne: jest.fn(), updateOne: jest.fn() };
|
||||
const notifications = {
|
||||
createFollowNotification: jest.fn(),
|
||||
createFollowRequestApprovedNotification: jest.fn(),
|
||||
@@ -18,6 +18,10 @@ describe('OutboxService', () => {
|
||||
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));
|
||||
@@ -28,47 +32,63 @@ describe('OutboxService', () => {
|
||||
await processor({});
|
||||
expect(processSpy).toHaveBeenLastCalledWith('');
|
||||
|
||||
model.create.mockResolvedValue({ id: 'event-1' });
|
||||
model.create.mockResolvedValue({ eventId: 'event-1' });
|
||||
await service.enqueueFollowNotification('actor', 'recipient');
|
||||
expect(model.create).toHaveBeenCalledWith({
|
||||
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.create.mockResolvedValue({ id: 'event-2' });
|
||||
model.updateOne.mockReturnValue({ exec: jest.fn().mockResolvedValue({ upsertedCount: 1 }) });
|
||||
await service.enqueueFollowRequestApprovedNotification('actor', 'recipient', 'request');
|
||||
expect(model.create).toHaveBeenCalledWith({
|
||||
eventType: 'follow_request_approved_notification',
|
||||
payload: { actorId: 'actor', recipientId: 'recipient', requestId: 'request' },
|
||||
status: 'pending',
|
||||
});
|
||||
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.findById.mockReturnValue({ exec: jest.fn().mockResolvedValue(event) });
|
||||
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.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null) });
|
||||
model.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue(null) });
|
||||
await service.processEvent('missing');
|
||||
model.findById.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ status: 'processed' }) });
|
||||
model.findOne.mockReturnValueOnce({ exec: jest.fn().mockResolvedValue({ status: 'processed' }) });
|
||||
await service.processEvent('done');
|
||||
expect(notifications.createFollowNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -78,7 +98,7 @@ describe('OutboxService', () => {
|
||||
id: 'e1', eventType: 'follow_notification', status: 'pending', attempts: 0,
|
||||
payload: { actorId: 'a', recipientId: 'r', referenceId: 'post' }, save: jest.fn(),
|
||||
};
|
||||
model.findById.mockReturnValue({ exec: jest.fn().mockResolvedValue(event) });
|
||||
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) }));
|
||||
@@ -90,9 +110,9 @@ describe('OutboxService', () => {
|
||||
id: 'e2', eventType: 'follow_notification', status: 'pending', attempts: 2,
|
||||
payload: {}, save: jest.fn(),
|
||||
};
|
||||
model.findById.mockReturnValue({ exec: jest.fn().mockResolvedValue(event) });
|
||||
model.findOne.mockReturnValue({ exec: jest.fn().mockResolvedValue(event) });
|
||||
(notifications.createFollowNotification as jest.Mock).mockRejectedValue('network');
|
||||
await service.processEvent('e2');
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user