UNPKG

@arturwojnar/hermes-postgresql

Version:

Production-Ready TypeScript Outbox Pattern for PostgreSQL

183 lines 7.18 kB
import { Duration } from '@arturwojnar/hermes'; import { afterEach, beforeEach, describe, expect, jest, test } from '@jest/globals'; import { setTimeout } from 'node:timers/promises'; import { AsyncOutboxConsumer } from './AsyncOutboxConsumer.js'; const mockJsonValue = { type: 1, value: '{}', raw: '{}', }; const createMockSql = () => { const sql = jest.fn(); Object.assign(sql, { end: jest.fn().mockResolvedValue(undefined), json: jest.fn().mockReturnValue(mockJsonValue), }); return sql; }; const createPublish = () => { return jest .fn() .mockResolvedValue(undefined); }; describe('AsyncOutboxConsumer', () => { const defaultParams = { getSql: () => createMockSql(), publish: createPublish(), consumerName: 'test-consumer', checkInterval: Duration.ofSeconds(1), }; beforeEach(() => { jest.useFakeTimers(); jest.spyOn(global, 'setInterval'); jest.spyOn(global, 'clearInterval'); }); afterEach(() => { jest.clearAllMocks(); jest.useRealTimers(); }); describe('send', () => { test('should insert message into asyncOutbox table', async () => { const mockSql = createMockSql(); const consumer = new AsyncOutboxConsumer({ ...defaultParams, getSql: () => mockSql, }); const message = { messageId: 'test-id', messageType: 'test-type', message: { data: 'test-data' }, }; await consumer.send(message); expect(mockSql).toHaveBeenCalledWith([ expect.stringContaining('INSERT INTO "asyncOutbox"'), expect.any(String), expect.any(String), expect.any(String), expect.any(String), ], defaultParams.consumerName, message.messageId, message.messageType, mockJsonValue); }); test('should throw error if consumer not started', async () => { const consumer = new AsyncOutboxConsumer({ ...defaultParams, getSql: () => undefined, }); await expect(consumer.send({ messageId: 'test', messageType: 'test', message: {}, })).rejects.toThrow('Database connection not established'); }); }); describe('start/stop', () => { test('should start polling when consumer is started', async () => { const consumer = new AsyncOutboxConsumer(defaultParams); const result = consumer.start(); expect(typeof result).toBe('function'); expect(setInterval).toHaveBeenCalledWith(expect.any(Function), defaultParams.checkInterval.ms); }); test('should not start polling if already started', async () => { const consumer = new AsyncOutboxConsumer(defaultParams); consumer.start(); expect(() => consumer.start()).toThrowError(); expect(setInterval).toHaveBeenCalledTimes(1); }); test('should stop polling and close connection when stopped', async () => { const mockSql = createMockSql(); const consumer = new AsyncOutboxConsumer({ ...defaultParams, getSql: () => mockSql, }); consumer.start(); await consumer.stop(); expect(clearInterval).toHaveBeenCalled(); }); }); describe('message processing', () => { test('should process undelivered messages', async () => { const mockSql = createMockSql(); const mockPublish = jest .fn() .mockResolvedValue(undefined); const pendingMessages = [ { position: 1, messageId: 'msg1', messageType: 'type1', data: { value: 'test1' }, failsCount: 0, }, { position: 2, messageId: 'msg2', messageType: 'type2', data: { value: 'test2' }, failsCount: 0, }, ]; mockSql.mockImplementation(() => pendingMessages); const consumer = new AsyncOutboxConsumer({ ...defaultParams, getSql: () => mockSql, publish: mockPublish, }); consumer.start(); jest.advanceTimersByTime(defaultParams.checkInterval.ms); await setTimeout(Duration.ofSeconds(1).ms); expect(mockPublish).toHaveBeenCalledTimes(2); expect(mockPublish).toHaveBeenCalledWith({ position: pendingMessages[0].position, messageId: pendingMessages[0].messageId, messageType: pendingMessages[0].messageType, message: pendingMessages[0].data, redeliveryCount: 0, }); }); test('should handle failed message delivery', async () => { const mockSql = createMockSql(); const mockPublish = jest.fn().mockRejectedValue(new Error('Publish failed')); const pendingMessage = { position: 1, messageId: 'msg1', messageType: 'type1', data: { value: 'test1' }, failsCount: 0, }; mockSql.mockImplementation(() => [pendingMessage]); const consumer = new AsyncOutboxConsumer({ ...defaultParams, getSql: () => mockSql, publish: mockPublish, }); consumer.start(); jest.advanceTimersByTime(defaultParams.checkInterval.ms); await setTimeout(Duration.ofSeconds(1).ms); expect(mockSql).toHaveBeenCalledWith([expect.stringContaining('UPDATE "asyncOutbox"'), expect.any(String)], pendingMessage.position); }); test('should not process messages if already processing', async () => { const mockSql = createMockSql(); const mockPublish = jest.fn().mockImplementation(async () => { await setTimeout(Duration.ofSeconds(1).ms); }); mockSql.mockImplementation(() => [ { position: 1, messageId: 'msg1', messageType: 'type1', data: { value: 'test1' }, failsCount: 0, }, ]); const consumer = new AsyncOutboxConsumer({ ...defaultParams, getSql: () => mockSql, publish: mockPublish, }); consumer.start(); jest.advanceTimersByTime(defaultParams.checkInterval.ms * 3); await setTimeout(Duration.ofMiliseconds(500).ms); expect(mockPublish).toHaveBeenCalledTimes(1); }); }); }); //# sourceMappingURL=asyncOutbox.unit.test.js.map