diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..a83aec7 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + testEnvironment: 'jsdom', + setupFilesAfterEnv: ['/test-setup.ts'], + transform: { + '^.+\\.(ts|tsx)$': 'ts-jest', + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], +}; diff --git a/package.json b/package.json index b0988bc..15b9378 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/notifications/jest.config.ts b/packages/notifications/jest.config.ts new file mode 100644 index 0000000..7465632 --- /dev/null +++ b/packages/notifications/jest.config.ts @@ -0,0 +1,18 @@ +import type { Config } from 'jest'; + +const config: Config = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + setupFilesAfterEnv: ['/../../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; diff --git a/packages/notifications/package.json b/packages/notifications/package.json index f582ba8..3d82aee 100644 --- a/packages/notifications/package.json +++ b/packages/notifications/package.json @@ -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", diff --git a/packages/notifications/src/core/pubsub.test.ts b/packages/notifications/src/core/pubsub.test.ts new file mode 100644 index 0000000..3627a50 --- /dev/null +++ b/packages/notifications/src/core/pubsub.test.ts @@ -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 = { + 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(); + }); +}); diff --git a/packages/notifications/src/core/pubsub.ts b/packages/notifications/src/core/pubsub.ts index 959b45c..58cd29e 100644 --- a/packages/notifications/src/core/pubsub.ts +++ b/packages/notifications/src/core/pubsub.ts @@ -7,7 +7,7 @@ import { } from './types'; import { getQueue } from '../queue-adapters/adapter'; -const getHandler = async (consumer: Consumer) => { +export const getHandler = async (consumer: Consumer) => { return await match(consumer) .with( P.when((c): c is QueueConfig => typeof c === 'object'), diff --git a/packages/notifications/src/db/connection.test.ts b/packages/notifications/src/db/connection.test.ts new file mode 100644 index 0000000..8d286c7 --- /dev/null +++ b/packages/notifications/src/db/connection.test.ts @@ -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); + }); +}); diff --git a/packages/notifications/src/db/listen.test.ts b/packages/notifications/src/db/listen.test.ts new file mode 100644 index 0000000..71df2c1 --- /dev/null +++ b/packages/notifications/src/db/listen.test.ts @@ -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 { + 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', + }, + }), + ); + }); +}); diff --git a/packages/notifications/src/db/subscribe.test.ts b/packages/notifications/src/db/subscribe.test.ts new file mode 100644 index 0000000..71c97b3 --- /dev/null +++ b/packages/notifications/src/db/subscribe.test.ts @@ -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 { + 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(); + }); +}); diff --git a/packages/notifications/src/index.test.ts b/packages/notifications/src/index.test.ts new file mode 100644 index 0000000..2d22942 --- /dev/null +++ b/packages/notifications/src/index.test.ts @@ -0,0 +1,98 @@ +import { notify, listenHandler, subscribeHandler } from './index'; +import * as corePubSub from './core/pubsub'; +import * as dbListen from './db/listen'; +import * as dbSubscribe from './db/subscribe'; + +jest.mock('./core/pubsub', () => ({ + pubSub: jest.fn(), +})); + +jest.mock('./db/listen', () => ({ + listen: jest.fn(), +})); + +jest.mock('./db/subscribe', () => ({ + subscirbe: jest.fn(), +})); + +const db_url = 'mock-db-url'; +const mockEventTarget = {} as EventTarget; + +beforeEach(() => { + jest.clearAllMocks(); + (corePubSub.pubSub as jest.Mock).mockResolvedValue(mockEventTarget); +}); + +describe('notify', () => { + it('calls listenHandler when pgListenMode is "listen"', async () => { + const subscribers = { + channel1: [jest.fn()], + }; + + await notify(db_url, { pgListenMode: 'listen', subscribers }); + + expect(corePubSub.pubSub).toHaveBeenCalledWith(subscribers); + expect(dbListen.listen).toHaveBeenCalled(); + }); + + it('calls subscribeHandler when pgListenMode is "subscribe"', async () => { + const subscribers = { + channel1: [jest.fn()], + }; + + await notify(db_url, { pgListenMode: 'subscribe', subscribers }); + + expect(corePubSub.pubSub).toHaveBeenCalledWith(subscribers); + expect(dbSubscribe.subscirbe).toHaveBeenCalled(); + }); +}); + +describe('listenHandler', () => { + it('throws error if no subscribers provided', async () => { + await expect( + listenHandler(db_url, { + pgListenMode: 'listen', + subscribers: {}, + }), + ).rejects.toBe( + 'At least one subscriber is mandatory when pgListenMode: listen', + ); + }); + + it('calls pubSub and listen with channels', async () => { + const subscribers = { + channel1: [jest.fn()], + channel2: [jest.fn()], + }; + + await listenHandler(db_url, { + pgListenMode: 'listen', + subscribers, + }); + + expect(corePubSub.pubSub).toHaveBeenCalledWith(subscribers); + expect(dbListen.listen).toHaveBeenCalledWith(db_url, mockEventTarget, { + channels: ['channel1', 'channel2'], + }); + }); +}); + +describe('subscribeHandler', () => { + it('calls pubSub and subscirbe', async () => { + const subscribers = { + channel1: [jest.fn()], + }; + + await subscribeHandler(db_url, { + pgListenMode: 'subscribe', + subscribers, + }); + + expect(corePubSub.pubSub).toHaveBeenCalledWith(subscribers); + expect(dbSubscribe.subscirbe).toHaveBeenCalledWith( + db_url, + mockEventTarget, + {}, + ); + }); +}); diff --git a/packages/notifications/src/index.ts b/packages/notifications/src/index.ts index ad63b66..a1197f8 100644 --- a/packages/notifications/src/index.ts +++ b/packages/notifications/src/index.ts @@ -9,7 +9,7 @@ export interface Options { subscribers: Record[]>; } -const listenHandler = async (db_url: string, opitons: Options) => { +export const listenHandler = async (db_url: string, opitons: Options) => { const { subscribers } = opitons; const channelNames = Object.keys(subscribers); if (!channelNames || channelNames.length < 1) { @@ -19,18 +19,21 @@ const listenHandler = async (db_url: string, opitons: Options) => { listen(db_url, eventTarget, { channels: channelNames }); }; -const subscribeHandler = async (db_url: string, opitons: Options) => { +export const subscribeHandler = async ( + db_url: string, + opitons: Options, +) => { const { subscribers } = opitons; const eventTarget = await pubSub(subscribers); subscirbe(db_url, eventTarget, {}); }; export const notify = async (db_url: string, options: Options) => { - const hanlder = match(options.pgListenMode) + const handler = match(options.pgListenMode) .with('listen', () => listenHandler) .with('subscribe', () => subscribeHandler) .otherwise(() => null); - if (hanlder) { - hanlder(db_url, options); + if (handler) { + handler(db_url, options); } }; diff --git a/packages/notifications/src/queue-adapters/adapter.test.ts b/packages/notifications/src/queue-adapters/adapter.test.ts new file mode 100644 index 0000000..b787761 --- /dev/null +++ b/packages/notifications/src/queue-adapters/adapter.test.ts @@ -0,0 +1,46 @@ +import { getQueue } from './adapter'; +import type { QueueConfig } from '../core/types'; + +jest.mock('./bullmq', () => ({ + bullmqAdapter: jest.fn().mockReturnValue('bullmq-adapter'), +})); + +jest.mock('./sqs', () => ({ + sqlAdapter: jest.fn().mockReturnValue('sqs-adapter'), +})); + +jest.mock('./azure-service-bus', () => ({ + azureServiceBusAdaptor: jest.fn().mockReturnValue('asb-adapter'), +})); + +describe('getQueue', () => { + it('returns bullmq adapter', async () => { + const config: QueueConfig = { + type: 'bullmq', + options: { foo: 'bar' }, + }; + + const adapter = await getQueue(config); + expect(adapter).toBe('bullmq-adapter'); + }); + + it('returns sqs adapter', async () => { + const config: QueueConfig = { + type: 'sqs', + options: { key: 'val' }, + }; + + const adapter = await getQueue(config); + expect(adapter).toBe('sqs-adapter'); + }); + + it('returns asb adapter', async () => { + const config: QueueConfig = { + type: 'asb', + options: { conn: 'string' }, + }; + + const adapter = await getQueue(config); + expect(adapter).toBe('asb-adapter'); + }); +}); diff --git a/packages/notifications/src/queue-adapters/azure-service-bus.test.ts b/packages/notifications/src/queue-adapters/azure-service-bus.test.ts new file mode 100644 index 0000000..37f00ff --- /dev/null +++ b/packages/notifications/src/queue-adapters/azure-service-bus.test.ts @@ -0,0 +1,61 @@ +import { azureServiceBusAdaptor } from './azure-service-bus'; +import { ServiceBusClient } from '@azure/service-bus'; + +jest.mock('@azure/service-bus', () => { + return { + ServiceBusClient: jest.fn().mockImplementation(() => ({ + createSender: jest.fn(() => ({ + sendMessages: jest.fn(), + })), + })), + }; +}); + +describe('azureServiceBusAdaptor', () => { + const mockSendMessages = jest.fn(); + const mockCreateSender = jest.fn(() => ({ + sendMessages: mockSendMessages, + })); + + beforeEach(() => { + (ServiceBusClient as jest.Mock).mockImplementation(() => ({ + createSender: mockCreateSender, + })); + }); + + it('returns adapter with sendMessage and sendMessages', async () => { + const options = { + connectionString: 'mock-connection', + queueName: 'mock-queue', + }; + + const adapter = azureServiceBusAdaptor(options); + + expect(typeof adapter.sendMessage).toBe('function'); + expect(typeof adapter.sendMessages).toBe('function'); + }); + + it('sendMessage calls sender.sendMessages with single message', async () => { + const adapter = azureServiceBusAdaptor({ + connectionString: 'mock', + queueName: 'mock-queue', + }); + + const message = { hello: 'world' }; + await adapter.sendMessage(message); + + expect(mockSendMessages).toHaveBeenCalledWith({ body: message }); + }); + + it('sendMessages calls sender.sendMessages with array', async () => { + const adapter = azureServiceBusAdaptor({ + connectionString: 'mock', + queueName: 'mock-queue', + }); + + const messages = [{ body: 1 }, { body: 2 }]; + await adapter.sendMessages(messages); + + expect(mockSendMessages).toHaveBeenCalledWith(messages); + }); +}); diff --git a/packages/notifications/src/queue-adapters/sqs.test.ts b/packages/notifications/src/queue-adapters/sqs.test.ts new file mode 100644 index 0000000..98ee22d --- /dev/null +++ b/packages/notifications/src/queue-adapters/sqs.test.ts @@ -0,0 +1,58 @@ +import { sqlAdapter } from './sqs'; +import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs'; + +jest.mock('@aws-sdk/client-sqs', () => { + return { + SQSClient: jest.fn().mockImplementation(() => ({ + send: jest.fn(), + })), + SendMessageCommand: jest.fn(), + }; +}); + +describe('sqlAdapter', () => { + let mockSend: jest.Mock; + + beforeEach(() => { + mockSend = jest.fn(); + (SQSClient as jest.Mock).mockImplementation(() => ({ + send: mockSend, + })); + }); + + it('returns adapter with sendMessage and sendMessages', () => { + const options = { queueUrl: 'mock-queue-url' }; + const adapter = sqlAdapter(options); + + expect(typeof adapter.sendMessage).toBe('function'); + expect(typeof adapter.sendMessages).toBe('function'); + }); + + it('sendMessage calls client.send with correct command', async () => { + const options = { queueUrl: 'mock-queue-url' }; + const adapter = sqlAdapter(options); + + const data = { foo: 'bar' }; + await adapter.sendMessage(data); + + expect(SendMessageCommand).toHaveBeenCalledWith({ + QueueUrl: 'mock-queue-url', + MessageBody: JSON.stringify(data), + }); + expect(mockSend).toHaveBeenCalled(); + }); + + it('sendMessages calls client.send with correct command', async () => { + const options = { queueUrl: 'mock-queue-url' }; + const adapter = sqlAdapter(options); + + const data = [{ foo: 'bar' }, { baz: 'qux' }]; + await adapter.sendMessages(data); + + expect(SendMessageCommand).toHaveBeenCalledWith({ + QueueUrl: 'mock-queue-url', + MessageBody: JSON.stringify(data), + }); + expect(mockSend).toHaveBeenCalled(); + }); +}); diff --git a/packages/notifications/tsconfig.json b/packages/notifications/tsconfig.json index ba2c49a..9002fc0 100644 --- a/packages/notifications/tsconfig.json +++ b/packages/notifications/tsconfig.json @@ -8,11 +8,13 @@ "moduleDetection": "force", "jsx": "react-jsx", "allowJs": true, + "types": ["node", "jest"], + "esModuleInterop": true, // Bundler mode "moduleResolution": "bundler", "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, + "verbatimModuleSyntax": false, "noEmit": true, // Best practices @@ -26,5 +28,5 @@ "noPropertyAccessFromIndexSignature": false, "noImplicitAny": true - }, + } } diff --git a/test-setup.ts b/test-setup.ts new file mode 100644 index 0000000..7da4f6d --- /dev/null +++ b/test-setup.ts @@ -0,0 +1,15 @@ +import { jest } from '@jest/globals'; + +if (typeof window !== 'undefined') { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); +} diff --git a/tsconfig.spec.json b/tsconfig.spec.json new file mode 100644 index 0000000..8e0d1a7 --- /dev/null +++ b/tsconfig.spec.json @@ -0,0 +1,31 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "jsx": "react-jsx", + "types": ["jest"] + }, + "include": [ + "jest.config.ts", + "packages/notifications/jest.config.ts", + "packages/notifications/src/**/*.ts", + "packages/notifications/src/**/*.tsx", + "packages/notifications/src/**/*.js", + "packages/notifications/src/**/*.jsx", + "packages/notifications/src/**/*.test.ts", + "packages/notifications/src/**/*.spec.ts", + "packages/notifications/src/**/*.test.tsx", + "packages/notifications/src/**/*.spec.tsx", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +}