From d11392ebb2382e018d96441b0d13adbf5abc5f72 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Sun, 18 Feb 2024 01:55:15 +0000 Subject: [PATCH] chore: tests --- package.json | 1 + src/__mocks__/util.ts | 51 +++ .../__tests__/shared-udp-socket.spec.ts | 340 ++++++++++++++++++ yarn.lock | 12 + 4 files changed, 404 insertions(+) create mode 100644 src/module-api/__tests__/shared-udp-socket.spec.ts diff --git a/package.json b/package.json index e5d7530..2290477 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@types/node": "^18.18.3", "ajv-cli": "^5.0.0", "jest": "^29.7.0", + "jest-mock-extended": "^3.0.5", "json-schema-to-typescript": "^13.1.2", "prettier": "^2.8.8", "ts-jest": "^29.1.2", diff --git a/src/__mocks__/util.ts b/src/__mocks__/util.ts index 452247e..1ff521e 100644 --- a/src/__mocks__/util.ts +++ b/src/__mocks__/util.ts @@ -1,3 +1,6 @@ +import { mock } from 'jest-mock-extended' +import { IpcWrapper } from '../host-api/ipc-wrapper.js' + const orgSetTimeout = setTimeout export async function runAllTimers(): Promise { // Run all timers, and wait, multiple times. @@ -16,3 +19,51 @@ export async function runTimersUntilNow(): Promise { await new Promise((resolve) => orgSetTimeout(resolve, 0)) } } + +const mockOptions = { + fallbackMockImplementation: () => { + throw new Error('not mocked') + }, +} + +export function createIpcWrapperMock( + sendWithCb?: jest.Mock< + ReturnType['sendWithCb']>, + Parameters['sendWithCb']> + > +): IpcWrapper { + return mock>( + { + sendWithCb, + }, + mockOptions + ) +} + +export interface ManualPromise extends Promise { + isResolved: boolean + manualResolve(res: T): void + manualReject(e: Error): void +} +// eslint-disable-next-line @typescript-eslint/promise-function-async +export function createManualPromise(): ManualPromise { + let resolve: (val: T) => void = () => null + let reject: (err: Error) => void = () => null + const promise = new Promise((resolve0, reject0) => { + resolve = resolve0 + reject = reject0 + }) + + const manualPromise: ManualPromise = promise as any + manualPromise.isResolved = false + manualPromise.manualReject = (err) => { + manualPromise.isResolved = true + return reject(err) + } + manualPromise.manualResolve = (val) => { + manualPromise.isResolved = true + return resolve(val) + } + + return manualPromise +} diff --git a/src/module-api/__tests__/shared-udp-socket.spec.ts b/src/module-api/__tests__/shared-udp-socket.spec.ts new file mode 100644 index 0000000..2045770 --- /dev/null +++ b/src/module-api/__tests__/shared-udp-socket.spec.ts @@ -0,0 +1,340 @@ +import { nanoid } from 'nanoid' +import { + ModuleToHostEventsV0SharedSocket, + HostToModuleEventsV0SharedSocket, + SharedUdpSocketMessageJoin, + SharedUdpSocketMessageSend, + SharedUdpSocketMessageLeave, +} from '../../host-api/api.js' +import { IpcWrapper } from '../../host-api/ipc-wrapper.js' +import { SharedUdpSocketImpl } from '../shared-udp-socket.js' +import { ManualPromise, createIpcWrapperMock, createManualPromise } from '../../__mocks__/util.js' + +async function sleepImmediate() { + return new Promise((resolve) => setImmediate(resolve)) +} + +type IpcWrapperExt = IpcWrapper + +describe('Shared UDP', () => { + function createDeps() { + const sendWithCbFn = jest.fn, Parameters>( + () => { + throw new Error('Not implemented') + } + ) + + const mockIpcWrapper = createIpcWrapperMock( + sendWithCbFn + ) + const moduleUdpSockets = new Map() + + return { + mockIpcWrapper, + moduleUdpSockets, + sendWithCbFn, + } + } + + it('call fail before open', () => { + const { mockIpcWrapper, moduleUdpSockets } = createDeps() + const socket = new SharedUdpSocketImpl(mockIpcWrapper, moduleUdpSockets, { type: 'udp4' }) + + expect(() => socket.close()).toThrow(/Socket is not open/) + expect(() => socket.send('', 12, '')).toThrow(/Socket is not open/) + }) + + describe('bind', () => { + it('ok', async () => { + const { mockIpcWrapper, moduleUdpSockets, sendWithCbFn } = createDeps() + const socket = new SharedUdpSocketImpl(mockIpcWrapper, moduleUdpSockets, { type: 'udp4' }) + expect(socket.eventNames()).toHaveLength(0) + + const sendPromises: ManualPromise[] = [] + sendWithCbFn.mockImplementationOnce(async () => { + const sendPromise = createManualPromise() + sendPromises.push(sendPromise) + return sendPromise + }) + + const bindCb = jest.fn() + socket.bind(5678, '1.2.3.4', bindCb) + + // Opening a second time should fail + expect(() => socket.bind(5678, '1.2.3.4', bindCb)).toThrow(/Socket is already/) + + await sleepImmediate() + expect(bindCb).toHaveBeenCalledTimes(0) + + // Check now a listener + expect(socket.eventNames()).toHaveLength(1) + expect(socket.listenerCount('listening')).toBe(1) + + // Check call was made + expect(sendWithCbFn).toHaveBeenCalledTimes(1) + expect(sendPromises).toHaveLength(1) + expect(sendWithCbFn).toHaveBeenCalledWith('sharedUdpSocketJoin', { + family: 'udp4', + portNumber: 5678, + } satisfies SharedUdpSocketMessageJoin) + + // Mock receive a response + const handleId = nanoid() + sendPromises[0].manualResolve(handleId) + + // Verify that opened successfully + await sleepImmediate() + expect(bindCb).toHaveBeenCalledTimes(1) + + // Should be tracked now + expect(moduleUdpSockets.get(handleId)).toBe(socket) + }) + + it('error', async () => { + const { mockIpcWrapper, moduleUdpSockets, sendWithCbFn } = createDeps() + const socket = new SharedUdpSocketImpl(mockIpcWrapper, moduleUdpSockets, { type: 'udp4' }) + expect(socket.eventNames()).toHaveLength(0) + + const sendPromises: ManualPromise[] = [] + sendWithCbFn.mockImplementationOnce(async () => { + const sendPromise = createManualPromise() + sendPromises.push(sendPromise) + return sendPromise + }) + + const bindCb = jest.fn() + socket.bind(5678, '1.2.3.4', bindCb) + + await sleepImmediate() + expect(bindCb).toHaveBeenCalledTimes(0) + + // setup an error listener + const errorCb = jest.fn() + socket.on('error', errorCb) + + // Check call was made + expect(sendWithCbFn).toHaveBeenCalledTimes(1) + expect(sendPromises).toHaveLength(1) + + // Mock receive a response + const err = new Error('Some backend failure') + sendPromises[0].manualReject(err) + + // Verify that opening failed + await sleepImmediate() + expect(bindCb).toHaveBeenCalledTimes(0) + expect(moduleUdpSockets.size).toBe(0) + + // Error should have propogated + expect(errorCb).toHaveBeenCalledTimes(1) + expect(errorCb).toHaveBeenCalledWith(err) + }) + }) + + async function createAndOpenSocket( + mockIpcWrapper: IpcWrapperExt, + moduleUdpSockets: Map, + sendWithCbFn: jest.Mock, Parameters> + ) { + const socket = new SharedUdpSocketImpl(mockIpcWrapper, moduleUdpSockets, { type: 'udp4' }) + expect(socket.eventNames()).toHaveLength(0) + + const sendPromises: ManualPromise[] = [] + sendWithCbFn.mockImplementationOnce(async () => { + const sendPromise = createManualPromise() + sendPromises.push(sendPromise) + return sendPromise + }) + + const bindCb = jest.fn() + socket.bind(5678, '1.2.3.4', bindCb) + + // Mock receive a response + expect(sendWithCbFn).toHaveBeenCalledTimes(1) + expect(sendPromises).toHaveLength(1) + const handleId = nanoid() + sendPromises[0].manualResolve(handleId) + + // Verify that opened successfully + await sleepImmediate() + expect(bindCb).toHaveBeenCalledTimes(1) + + // Should be tracked now + expect(moduleUdpSockets.get(handleId)).toBe(socket) + sendWithCbFn.mockClear() + + return { socket, handleId } + } + + describe('send', () => { + it('ok', async () => { + const { mockIpcWrapper, moduleUdpSockets, sendWithCbFn } = createDeps() + + const { socket, handleId } = await createAndOpenSocket(mockIpcWrapper, moduleUdpSockets, sendWithCbFn) + + const sendPromises: ManualPromise[] = [] + sendWithCbFn.mockImplementationOnce(async () => { + const sendPromise = createManualPromise() + sendPromises.push(sendPromise) + return sendPromise + }) + + // Do send + const sendCb = jest.fn() + const message = Buffer.from('my fake message') + socket.send(message, 4789, '4.5.6.7', sendCb) + + // Check callbacks + await sleepImmediate() + expect(sendCb).toHaveBeenCalledTimes(0) + + // Check call was made + expect(sendWithCbFn).toHaveBeenCalledTimes(1) + expect(sendPromises).toHaveLength(1) + expect(sendWithCbFn).toHaveBeenCalledWith('sharedUdpSocketSend', { + handleId, + message, + address: '4.5.6.7', + port: 4789, + } satisfies SharedUdpSocketMessageSend) + + // Mock receive a response + sendPromises[0].manualResolve(null) + + // Verify the calback + await sleepImmediate() + expect(sendCb).toHaveBeenCalledTimes(1) + + expect(moduleUdpSockets.has(handleId)).toBeTruthy() + }) + + it('error', async () => { + const { mockIpcWrapper, moduleUdpSockets, sendWithCbFn } = createDeps() + + const { socket, handleId } = await createAndOpenSocket(mockIpcWrapper, moduleUdpSockets, sendWithCbFn) + + const sendPromises: ManualPromise[] = [] + sendWithCbFn.mockImplementationOnce(async () => { + const sendPromise = createManualPromise() + sendPromises.push(sendPromise) + return sendPromise + }) + + // Do send + const sendCb = jest.fn() + const message = Buffer.from('my fake message') + socket.send(message, 4789, '4.5.6.7', sendCb) + + const errorCb = jest.fn() + socket.on('error', errorCb) + + // Check callbacks + await sleepImmediate() + expect(sendCb).toHaveBeenCalledTimes(0) + + // Check call was made + expect(sendWithCbFn).toHaveBeenCalledTimes(1) + expect(sendPromises).toHaveLength(1) + expect(sendWithCbFn).toHaveBeenCalledWith('sharedUdpSocketSend', { + handleId, + message, + address: '4.5.6.7', + port: 4789, + } satisfies SharedUdpSocketMessageSend) + + // Mock receive a response + const err = new Error('Some backend failure') + sendPromises[0].manualReject(err) + + // Verify the calback + await sleepImmediate() + expect(sendCb).toHaveBeenCalledTimes(0) + expect(errorCb).toHaveBeenCalledTimes(1) + expect(errorCb).toHaveBeenCalledWith(err) + + expect(moduleUdpSockets.has(handleId)).toBeTruthy() + }) + }) + + describe('close', () => { + it('ok', async () => { + const { mockIpcWrapper, moduleUdpSockets, sendWithCbFn } = createDeps() + + const { socket, handleId } = await createAndOpenSocket(mockIpcWrapper, moduleUdpSockets, sendWithCbFn) + + const sendPromises: ManualPromise[] = [] + sendWithCbFn.mockImplementationOnce(async () => { + const sendPromise = createManualPromise() + sendPromises.push(sendPromise) + return sendPromise + }) + + // Do send + const closeCb = jest.fn() + socket.close(closeCb) + + // Check callbacks + await sleepImmediate() + expect(closeCb).toHaveBeenCalledTimes(0) + + // Check call was made + expect(sendWithCbFn).toHaveBeenCalledTimes(1) + expect(sendPromises).toHaveLength(1) + expect(sendWithCbFn).toHaveBeenCalledWith('sharedUdpSocketLeave', { + handleId, + } satisfies SharedUdpSocketMessageLeave) + + // Mock receive a response + sendPromises[0].manualResolve(null) + + // Verify the calback + await sleepImmediate() + expect(closeCb).toHaveBeenCalledTimes(1) + + expect(moduleUdpSockets.has(handleId)).toBeFalsy() + }) + + it('error', async () => { + const { mockIpcWrapper, moduleUdpSockets, sendWithCbFn } = createDeps() + + const { socket, handleId } = await createAndOpenSocket(mockIpcWrapper, moduleUdpSockets, sendWithCbFn) + + const sendPromises: ManualPromise[] = [] + sendWithCbFn.mockImplementationOnce(async () => { + const sendPromise = createManualPromise() + sendPromises.push(sendPromise) + return sendPromise + }) + + // Do send + const closeCb = jest.fn() + socket.close(closeCb) + + const errorCb = jest.fn() + socket.on('error', errorCb) + + // Check callbacks + await sleepImmediate() + expect(closeCb).toHaveBeenCalledTimes(0) + + // Check call was made + expect(sendWithCbFn).toHaveBeenCalledTimes(1) + expect(sendPromises).toHaveLength(1) + expect(sendWithCbFn).toHaveBeenCalledWith('sharedUdpSocketLeave', { + handleId, + } satisfies SharedUdpSocketMessageLeave) + + // Mock receive a response + const err = new Error('Some backend failure') + sendPromises[0].manualReject(err) + + // Verify the calback + await sleepImmediate() + expect(closeCb).toHaveBeenCalledTimes(0) + expect(errorCb).toHaveBeenCalledTimes(1) + expect(errorCb).toHaveBeenCalledWith(err) + + expect(moduleUdpSockets.has(handleId)).toBeFalsy() + }) + }) +}) diff --git a/yarn.lock b/yarn.lock index 16e561d..42c5a72 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2647,6 +2647,13 @@ jest-message-util@^29.7.0: slash "^3.0.0" stack-utils "^2.0.3" +jest-mock-extended@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/jest-mock-extended/-/jest-mock-extended-3.0.5.tgz#ebf208e363f4f1db603b81fb005c4055b7c1c8b7" + integrity sha512-/eHdaNPUAXe7f65gHH5urc8SbRVWjYxBqmCgax2uqOBJy8UUcCBMN1upj1eZ8y/i+IqpyEm4Kq0VKss/GCCTdw== + dependencies: + ts-essentials "^7.0.3" + jest-mock@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.7.0.tgz#4e836cf60e99c6fcfabe9f99d017f3fdd50a6347" @@ -3853,6 +3860,11 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +ts-essentials@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-7.0.3.tgz#686fd155a02133eedcc5362dc8b5056cde3e5a38" + integrity sha512-8+gr5+lqO3G84KdiTSMRLtuyJ+nTBVRKuCrK4lidMPdVeEp0uqC875uE5NMcaA7YYMN7XsNiFQuMvasF8HT/xQ== + ts-jest@^29.1.2: version "29.1.2" resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.1.2.tgz#7613d8c81c43c8cb312c6904027257e814c40e09"