@arturwojnar/hermes-postgresql
Version:
Production-Ready TypeScript Outbox Pattern for PostgreSQL
183 lines • 7.18 kB
JavaScript
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