Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/test-setup.ts'],
transform: {
'^.+\\.(ts|tsx)$': 'ts-jest',
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
};
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,25 @@
],
"scripts": {
"eslint": "eslint",
"prettier": "prettier"
"prettier": "prettier",
"test": "jest"
},
"devDependencies": {
"@eslint/js": "^9.20.0",
"@types/bun": "latest",
"@types/eslint-config-prettier": "^6.11.3",
"@types/jest": "^29.5.14",
"@typescript-eslint/eslint-plugin": "^8.24.0",
"@typescript-eslint/parser": "^8.24.0",
"eslint": "^9.20.1",
"globals": "^15.15.0",
"jest": "^29.7.0",
"prettier": "^3.5.1",
"ts-node": "^10.9.2",
"typescript-eslint": "^8.24.0"
},
"peerDependencies": {
"typescript": "^5.0.0"
"typescript": "^5.8.3"
},
"dependencies": {
"eslint-config-prettier": "^10.0.1",
Expand Down
18 changes: 18 additions & 0 deletions packages/notifications/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Config } from 'jest';

const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
setupFilesAfterEnv: ['<rootDir>/../../test-setup.ts'],
moduleFileExtensions: ['ts', 'tsx', 'js'],
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
transform: {
'^.+\\.(ts|tsx)$': 'ts-jest',
},
collectCoverage: true,
coverageDirectory: 'coverage',
verbose: true,
};

export default config;
12 changes: 10 additions & 2 deletions packages/notifications/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,18 @@
"access": "public"
},
"devDependencies": {
"@types/bun": "latest"
"@testing-library/jest-dom": "^6.6.3",
"@types/bun": "latest",
"@types/jest": "^29.5.14",
"babel-jest": "^29.7.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-environment-node": "^29.7.0",
"ts-jest": "^29.3.4",
"ts-node": "^10.9.2"
},
"peerDependencies": {
"typescript": "^5.0.0"
"typescript": "^5.8.3"
},
"dependencies": {
"@aws-sdk/client-sqs": "^3.758.0",
Expand Down
81 changes: 81 additions & 0 deletions packages/notifications/src/core/pubsub.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { getHandler, pubSub } from './pubsub';
import {
EventName,
type SubscriptionMessage,
type ConsumerFn,
type QueueConfig,
} from './types';
import * as adapterModule from '../queue-adapters/adapter';
import * as pubsubModule from './pubsub';

describe('getHandler', () => {
it('returns sendMessage from QueueConfig', async () => {
const mockSendMessage = jest.fn();
const mockGetQueue = jest
.spyOn(adapterModule, 'getQueue')
.mockResolvedValue({
sendMessage: mockSendMessage,
sendMessages: jest.fn(),
});

const queueConfig: QueueConfig<string> = {
type: 'bullmq',
options: { key: 'value' },
};

const handler = await getHandler(queueConfig);
expect(mockGetQueue).toHaveBeenCalledWith(queueConfig);
expect(handler).toBe(mockSendMessage);
});

it('returns function directly if consumer is a ConsumerFn', async () => {
const mockConsumerFn: ConsumerFn = jest.fn();
const handler = await getHandler(mockConsumerFn);
expect(handler).toBe(mockConsumerFn);
});

it('returns null for unknown type', async () => {
const handler = await getHandler('unknown' as any);
expect(handler).toBeNull();
});
});
describe('pubSub (simplified)', () => {
afterEach(() => {
jest.restoreAllMocks();
});

it('calls getHandler for each consumer', async () => {
const getHandlerMock = jest
.spyOn(pubsubModule, 'getHandler')
.mockResolvedValue(() => {});

const subscribers = {
test_event: [1, 2, 3].map(() => ({}) as any),
};

const ps = await pubSub(subscribers);
const event = new Event(EventName) as any;
event.detail = { identifier: 'test_event' };

ps.dispatchEvent(event);
await new Promise(process.nextTick);
expect(getHandlerMock).toHaveBeenCalledTimes(3);
});

it('does not invoke handler if getHandler returns null', async () => {
const handlerSpy = jest.fn();

jest.spyOn(pubsubModule, 'getHandler').mockResolvedValue(null); // handler will be null

const subscribers = {
test_event: [{} as any],
};

const ps = await pubSub(subscribers);
const event = new Event(EventName) as any;
event.detail = { identifier: 'test_event' };

await new Promise(process.nextTick);
expect(handlerSpy).not.toHaveBeenCalled();
});
});
2 changes: 1 addition & 1 deletion packages/notifications/src/core/pubsub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from './types';
import { getQueue } from '../queue-adapters/adapter';

const getHandler = async <T>(consumer: Consumer<T>) => {
export const getHandler = async <T>(consumer: Consumer<T>) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did we export this ?

return await match(consumer)
.with(
P.when((c): c is QueueConfig<T> => typeof c === 'object'),
Expand Down
22 changes: 22 additions & 0 deletions packages/notifications/src/db/connection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import postgres from 'postgres';
import { createSql } from './connection';

jest.mock('postgres');

describe('createSql', () => {
it('creates the client only once and returns the same instance', () => {
const mockSqlInstance = {};
(postgres as unknown as jest.Mock).mockReturnValue(mockSqlInstance);

const getClient = createSql('postgres://test-url', {
idle_timeout: 1,
channels: ['test_channel'],
});

const firstCall = getClient();
const secondCall = getClient();

expect(postgres).toHaveBeenCalledTimes(1);
expect(firstCall).toBe(secondCall);
});
});
57 changes: 57 additions & 0 deletions packages/notifications/src/db/listen.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { listen } from './listen';
import * as connection from './connection';

describe('listen', () => {
const mockListen = jest.fn();
const mockSql = { listen: mockListen };
const mockDispatchEvent = jest.fn();

beforeEach(() => {
jest.clearAllMocks();

jest.spyOn(connection, 'createSql').mockReturnValue(() => mockSql as any);

global.CustomEvent = class MockCustomEvent<T> {
type: string;
detail: T;
constructor(type: string, props: { detail: T }) {
this.type = type;
this.detail = props.detail;
}
} as any;
});

it('throws error if no channels are provided', async () => {
await expect(
listen('postgres://test-url', new EventTarget(), {}),
).rejects.toThrow('No channels provided');
});

it('calls sql.listen and dispatches a CustomEvent', async () => {
const eventTarget = {
dispatchEvent: mockDispatchEvent,
} as unknown as EventTarget;

await listen('postgres://fake-url', eventTarget, {
channels: ['test_channel'],
});

expect(mockListen).toHaveBeenCalledWith(
'test_channel',
expect.any(Function),
);

const listenCallback = mockListen.mock.calls[0][1];
listenCallback('some-value');

expect(mockDispatchEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: 'notification',
detail: {
identifier: 'test_channel',
data: 'some-value',
},
}),
);
});
});
79 changes: 79 additions & 0 deletions packages/notifications/src/db/subscribe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { subscirbe } from './subscribe';
import * as connection from './connection';
import { EventName } from '../core/types';

describe('subscirbe', () => {
const mockSubscribe = jest.fn();
const mockSql = { subscribe: mockSubscribe };
const mockDispatchEvent = jest.fn();

beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(connection, 'createSql').mockReturnValue(() => mockSql as any);

global.CustomEvent = class MockCustomEvent<T> {
type: string;
detail: T;
constructor(type: string, props: { detail: T }) {
this.type = type;
this.detail = props.detail;
}
} as any;
});

it('calls sql.subscribe with "*:inbound"', async () => {
await subscirbe('postgres://url', new EventTarget(), {});

expect(mockSubscribe).toHaveBeenCalledWith(
'*:inbound',
expect.any(Function),
);
});

it('dispatches event on insert', async () => {
const eventTarget = {
dispatchEvent: mockDispatchEvent,
} as unknown as EventTarget;

await subscirbe('postgres://url', eventTarget, {});

const callback = mockSubscribe.mock.calls[0][1];

const row = { id: 1 };
const data = {
command: 'insert',
relation: { table: 'inbound' },
};

callback(row, data);

expect(mockDispatchEvent).toHaveBeenCalledWith(
expect.objectContaining({
type: EventName,
detail: {
identifier: 'inbound',
data: {
tableName: 'inbound',
action: 'insert',
new: row,
old: null,
},
},
}),
);
});

it('does not dispatch event if match returns null', async () => {
const eventTarget = {
dispatchEvent: mockDispatchEvent,
} as unknown as EventTarget;

await subscirbe('postgres://url', eventTarget, {});

const callback = mockSubscribe.mock.calls[0][1];

callback({}, { command: 'unknown' });

expect(mockDispatchEvent).not.toHaveBeenCalled();
});
});
Loading