From c83fda91806f181cadea9925c8b61dc4c772e0c5 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Tue, 1 Jul 2025 13:22:36 -0600 Subject: [PATCH 01/45] record and play test framework outline. --- .../abstract-gateway-test-harness.ts | 120 ++++++++++++ test/record-and-play/api-test-case.ts | 68 +++++++ .../test-dependency-contract.ts | 179 ++++++++++++++++++ 3 files changed, 367 insertions(+) create mode 100644 test/record-and-play/abstract-gateway-test-harness.ts create mode 100644 test/record-and-play/api-test-case.ts create mode 100644 test/record-and-play/test-dependency-contract.ts diff --git a/test/record-and-play/abstract-gateway-test-harness.ts b/test/record-and-play/abstract-gateway-test-harness.ts new file mode 100644 index 0000000000..a484da47dc --- /dev/null +++ b/test/record-and-play/abstract-gateway-test-harness.ts @@ -0,0 +1,120 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { FastifyInstance } from 'fastify'; +import superjson from 'superjson'; + +import { + DependencyFactory, + MockProvider, + TestDependencyContract, +} from './test-dependency-contract'; + +export abstract class AbstractGatewayTestHarness + implements MockProvider +{ + protected _gatewayApp!: FastifyInstance; + protected _mockDir: string; + protected _instance!: TInstance; + protected readonly dependencyFactory = new DependencyFactory(); + protected _spies: Record = {}; + + abstract readonly dependencyContracts: Record< + string, + TestDependencyContract + >; + + constructor(mockDir: string) { + this._mockDir = mockDir; + } + + protected loadMock(fileName: string): any { + const filePath = path.join(this._mockDir, 'mocks', `${fileName}.json`); + if (fs.existsSync(filePath)) { + return superjson.deserialize( + JSON.parse(fs.readFileSync(filePath, 'utf8')), + ); + } + throw new Error(`Mock file not found: ${filePath}`); + } + + protected _saveMock(fileName: string, data: any): void { + const mockDir = path.join(this._mockDir, 'mocks'); + if (!fs.existsSync(mockDir)) { + fs.mkdirSync(mockDir, { recursive: true }); + } + const serialized = superjson.serialize(data); + fs.writeFileSync( + path.join(mockDir, `${fileName}.json`), + JSON.stringify(serialized, null, 2), + ); + } + + protected async initializeGatewayApp() { + this._gatewayApp = (await import('../../src/app')).gatewayApp; + await this._gatewayApp.ready(); + } + + get gatewayApp(): FastifyInstance { + return this._gatewayApp; + } + + get instance(): TInstance { + if (!this._instance) + throw new Error('Instance not initialized. Call setup first.'); + return this._instance; + } + + public getMock(fileName: string): any { + return this.loadMock(fileName); + } + + abstract init(): Promise; + + async setupRecorder() { + await this.init(); + + for (const key in this.dependencyContracts) { + const dep = this.dependencyContracts[key]; + const [spy] = dep.setupRecorder(this.instance); + this._spies[key] = spy; + } + } + + public async saveMocks(mocksToSave: Record) { + for (const [key, filename] of Object.entries(mocksToSave)) { + const spy = this._spies[key]; + if (!spy) { + throw new Error(`Spy for mock key "${key}" not found.`); + } + const data = await spy.mock.results[spy.mock.results.length - 1].value; + this._saveMock(filename, data); + } + } + + public async setupMocksForTest(mocksToSetup: Record) { + for (const [key, mockFileName] of Object.entries(mocksToSetup)) { + const contract = this.dependencyContracts[key]; + if (!contract) { + throw new Error( + `Dependency contract with key '${key}' not found in harness.`, + ); + } + const originalFileName = contract.mockFileName; + contract.mockFileName = mockFileName; + const spy = contract.setupUnitTest(this, this.instance); + contract.mockFileName = originalFileName; // restore it + if (spy) { + this._spies[key] = spy; + } + } + } + + async teardown() { + Object.values(this._spies).forEach((spy) => spy.mockRestore()); + this._spies = {}; + if (this._gatewayApp) { + await this._gatewayApp.close(); + } + } +} diff --git a/test/record-and-play/api-test-case.ts b/test/record-and-play/api-test-case.ts new file mode 100644 index 0000000000..74c40b155b --- /dev/null +++ b/test/record-and-play/api-test-case.ts @@ -0,0 +1,68 @@ +import { + FastifyInstance, + InjectOptions, + LightMyRequestResponse, +} from 'fastify'; + +import { AbstractGatewayTestHarness } from './abstract-gateway-test-harness'; + +export class APITestCase = any> + implements InjectOptions +{ + constructor( + public method: + | 'GET' + | 'POST' + | 'PUT' + | 'DELETE' + | 'PATCH' + | 'OPTIONS' + | 'HEAD', + public url: string, + public expectedStatus: number, + public query: Record, + public payload: Record, + /** + * A map of mock keys to their corresponding mock file basenames. + * The test harness will automatically append the '.json' extension. + * The keys must correspond to the keys in the Test Harness's dependencyContracts object. + * @example { 'getLatestBlock': 'mayanode-getLatestBlock-response' } + */ + public requiredMocks: Partial< + Record + >, + ) {} + + public async processRecorderRequest( + harness: T, + propertyMatchers?: Partial, + ): Promise<{ + response: LightMyRequestResponse; + body: any; + }> { + const response = await harness.gatewayApp.inject(this); + await harness.saveMocks(this.requiredMocks); + if (response.statusCode !== this.expectedStatus) { + console.log('Response body:', response.body); + expect(response.statusCode).toBe(this.expectedStatus); + } + const body = JSON.parse(response.body); + expect(body).toMatchSnapshot(propertyMatchers); + return { response, body }; + } + + public async processPlayRequest( + harness: T, + propertyMatchers?: Partial, + ): Promise<{ + response: LightMyRequestResponse; + body: any; + }> { + await harness.setupMocksForTest(this.requiredMocks); + const response = await harness.gatewayApp.inject(this); + expect(response.statusCode).toBe(this.expectedStatus); + const body = JSON.parse(response.body); + expect(body).toMatchSnapshot(propertyMatchers); + return { response, body }; + } +} diff --git a/test/record-and-play/test-dependency-contract.ts b/test/record-and-play/test-dependency-contract.ts new file mode 100644 index 0000000000..afe40ff40b --- /dev/null +++ b/test/record-and-play/test-dependency-contract.ts @@ -0,0 +1,179 @@ +export interface MockProvider { + getMock(fileName: string): any; +} + +/** + * Defines the contract for a dependency that can be mocked for unit tests + * or spied on for recorder tests. + */ +export abstract class TestDependencyContract { + constructor(public mockFileName: string) {} + + /** + * Sets up a spy on a real instance method to record its live output. + * @returns A tuple containing the spy instance and the generated name for the save function. + */ + abstract setupRecorder(instance: TInstance): [jest.SpyInstance, string]; + + /** + * Replaces a dependency with a mock for isolated unit testing. + * @returns A spy instance if one was created, otherwise void. + */ + abstract setupUnitTest( + harness: MockProvider, + instance: TInstance, + ): jest.SpyInstance | void; +} + +/** + * Handles dependencies that are properties on the main instance. + * - For recorder tests, it spies on a method of the real property instance. + * - For unit tests, it replaces the entire property with a mock object. + */ +export class InstancePropertyDependency< + TInstance, + K extends keyof TInstance, +> extends TestDependencyContract { + constructor( + mockFileName: string, + private instanceKey: K, + private methodName: keyof TInstance[K], + ) { + super(mockFileName); + } + + setupRecorder(instance: TInstance): [jest.SpyInstance, string] { + const dependencyInstance = instance[this.instanceKey]; + const spy = jest.spyOn(dependencyInstance as any, this.methodName as any); + const saverName = this.getSaverName(); + return [spy, saverName]; + } + + setupUnitTest(harness: MockProvider, instance: TInstance): void { + const mockData = harness.getMock(this.mockFileName); + const mockInstance = { + [this.methodName]: jest.fn().mockResolvedValue(mockData), + }; + (instance as any)[this.instanceKey] = mockInstance as TInstance[K]; + } + + private getSaverName(): string { + const methodNameStr = this.methodName as string; + const methodNameTitleCase = + methodNameStr.charAt(0).toUpperCase() + methodNameStr.slice(1); + return `save${methodNameTitleCase}Mock`; + } +} + +/** + * Handles dependencies that exist on a prototype, accessed via a getter method on the instance. + * + * NOTE: This approach is preferred over global prototype mocking. Globally mocking a + * prototype (e.g., via `jest.mock` at the top level) is often fragile and can lead + * to unintended side effects across the entire test suite, as it pollutes the global + * state. + * + * The recommended pattern is to have the class under test provide a getter method + * that returns the dependency instance. This allows `PrototypeDependency` to spy on + * that getter for unit tests, replacing its return value with a mock instance, + * thereby cleanly isolating the mock without affecting other tests. + * + * - For recorder tests, it spies on the real prototype method to capture live data. + * - For unit tests, it spies on the instance's getter method and returns a mock instance. + */ +export class PrototypeDependency< + TInstance, + TPrototype, +> extends TestDependencyContract { + constructor( + mockFileName: string, + private Klass: new (...args: any[]) => TPrototype, + private prototypeMethod: keyof TPrototype, + private method: keyof TInstance, + ) { + super(mockFileName); + } + + setupRecorder(instance: TInstance): [jest.SpyInstance, string] { + const methodSpy = jest.spyOn(instance as any, this.method as any); + + methodSpy.mockImplementation(async (...args: any[]) => { + // Restore the original method to get the real dependency instance + methodSpy.mockRestore(); + const dependencyInstance = await (instance as any)[this.method](...args); + + // Spy on the method of the real dependency instance + jest.spyOn(dependencyInstance, this.prototypeMethod as any); + + // Re-spy on the method to return the now-spied-upon dependency instance + jest + .spyOn(instance as any, this.method as any) + .mockReturnValue(dependencyInstance); + + return dependencyInstance; + }); + + const saverName = this.getSaverName(); + return [methodSpy, saverName]; + } + + setupUnitTest(harness: MockProvider, instance: TInstance): jest.SpyInstance { + const mockData = harness.getMock(this.mockFileName); + const mockInstance = { + [this.prototypeMethod]: jest.fn().mockResolvedValue(mockData), + }; + return jest + .spyOn(instance as any, this.method as any) + .mockResolvedValue(mockInstance); + } + + private getSaverName(): string { + const methodNameStr = this.prototypeMethod as string; + const methodNameTitleCase = + methodNameStr.charAt(0).toUpperCase() + methodNameStr.slice(1); + return `save${methodNameTitleCase}Mock`; + } +} + +export class DependencyFactory { + instanceProperty( + instanceKey: K, + methodName: keyof TInstance[K], + mockFileName?: string, + ): InstancePropertyDependency { + const finalMockFileName = + mockFileName || + this.generateMockFileName(String(instanceKey), String(methodName)); + return new InstancePropertyDependency( + finalMockFileName, + instanceKey, + methodName, + ); + } + + prototype( + Klass: new (...args: any[]) => TPrototype, + prototypeMethod: keyof TPrototype, + instanceMethod: keyof TInstance, + mockFileName?: string, + ): PrototypeDependency { + const finalMockFileName = + mockFileName || + this.generateMockFileName(Klass.name, String(prototypeMethod)); + return new PrototypeDependency( + finalMockFileName, + Klass, + prototypeMethod, + instanceMethod, + ); + } + + private generateMockFileName(key: string, method: string): string { + const keySanitized = key + .replace(/([A-Z])/g, '-$1') + .toLowerCase() + .replace(/^-/, ''); + const keyBase = keySanitized.split('-')[0]; + return `${keyBase}-${method}-response`; + } +} From 26fe4f5aa0a9448a96f6265489eb1e5d24722af1 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Thu, 26 Jun 2025 14:51:09 -0600 Subject: [PATCH 02/45] rnpExample for testing record and play framework --- jest.config.js | 1 + package.json | 12 +-- src/app.ts | 10 ++- test-scripts/jest.config.js | 11 +++ .../rnpExample/rnpExample.recorder.test.ts | 28 ++++++ test-scripts/snapshot-resolver.js | 38 ++++++++ test/record-and-play/api-test-case.ts | 13 ++- .../__snapshots__/rnpExample.test.ts.snap | 15 ++++ test/rnpExample/api/rnpExample.routes.ts | 88 +++++++++++++++++++ test/rnpExample/api/rnpExample.ts | 53 +++++++++++ test/rnpExample/mocks/rnpExample-methodA.json | 3 + test/rnpExample/mocks/rnpExample-methodB.json | 3 + test/rnpExample/rnpExample.api-test-cases.ts | 37 ++++++++ test/rnpExample/rnpExample.test-harness.ts | 58 ++++++++++++ test/rnpExample/rnpExample.test.ts | 27 ++++++ 15 files changed, 388 insertions(+), 9 deletions(-) create mode 100644 test-scripts/jest.config.js create mode 100644 test-scripts/rnpExample/rnpExample.recorder.test.ts create mode 100644 test-scripts/snapshot-resolver.js create mode 100644 test/rnpExample/__snapshots__/rnpExample.test.ts.snap create mode 100644 test/rnpExample/api/rnpExample.routes.ts create mode 100644 test/rnpExample/api/rnpExample.ts create mode 100644 test/rnpExample/mocks/rnpExample-methodA.json create mode 100644 test/rnpExample/mocks/rnpExample-methodB.json create mode 100644 test/rnpExample/rnpExample.api-test-cases.ts create mode 100644 test/rnpExample/rnpExample.test-harness.ts create mode 100644 test/rnpExample/rnpExample.test.ts diff --git a/jest.config.js b/jest.config.js index c95efcdb11..4107dc1053 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,6 @@ const { pathsToModuleNameMapper } = require('ts-jest'); const { compilerOptions } = require('./tsconfig.json'); +process.env.GATEWAY_TEST_MODE = 'test'; module.exports = { preset: 'ts-jest', diff --git a/package.json b/package.json index 8dc65fa6aa..460c934047 100644 --- a/package.json +++ b/package.json @@ -25,12 +25,14 @@ "setup:with-defaults": "bash ./gateway-setup.sh --with-defaults", "start": "START_SERVER=true node dist/index.js", "copy-files": "copyfiles 'src/templates/namespace/*.json' 'src/templates/*.yml' 'src/templates/chains/**/*.yml' 'src/templates/connectors/*.yml' 'src/templates/tokens/**/*.json' 'src/templates/pools/*.json' 'src/templates/rpc/*.yml' dist", - "test": "GATEWAY_TEST_MODE=dev jest --verbose", + "refresh-templates": "printf 'n\\ny\\n' | ./gateway-setup.sh", + "test": "GATEWAY_TEST_MODE=test jest --verbose", "test:clear-cache": "jest --clearCache", - "test:debug": "GATEWAY_TEST_MODE=dev jest --watch --runInBand", - "test:unit": "GATEWAY_TEST_MODE=dev jest --runInBand ./test/", - "test:cov": "GATEWAY_TEST_MODE=dev jest --runInBand --coverage ./test/", - "test:scripts": "GATEWAY_TEST_MODE=dev jest --runInBand ./test-scripts/*.test.ts", + "test:debug": "GATEWAY_TEST_MODE=test jest --watch --runInBand", + "test:unit": "GATEWAY_TEST_MODE=test jest --runInBand ./test/", + "test:cov": "GATEWAY_TEST_MODE=test jest --runInBand --coverage ./test/", + "test:scripts": "GATEWAY_TEST_MODE=test jest --config=test-scripts/jest.config.js --runInBand -u", + "cli": "node dist/index.js", "typecheck": "tsc --noEmit", "generate:openapi": "curl http://localhost:15888/docs/json -o openapi.json && echo 'OpenAPI spec saved to openapi.json'", "rebuild-bigint": "cd node_modules/bigint-buffer && pnpm run rebuild", diff --git a/src/app.ts b/src/app.ts index 0f6c3c27ae..550382a4b4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -37,7 +37,13 @@ import { asciiLogo } from './index'; // When false, runs server in HTTPS mode (secure, default for production) // Use --dev flag to enable HTTP mode, e.g.: pnpm start --dev // Tests automatically run in dev mode via GATEWAY_TEST_MODE=dev -const devMode = process.argv.includes('--dev') || process.env.GATEWAY_TEST_MODE === 'dev'; +const testMode = + process.argv.includes('--test') || process.env.GATEWAY_TEST_MODE === 'test'; + +const devMode = + process.argv.includes('--dev') || + process.env.GATEWAY_TEST_MODE === 'dev' || + testMode; // Promisify exec for async/await usage const execPromise = promisify(exec); @@ -120,7 +126,7 @@ const swaggerOptions = { let docsServer: FastifyInstance | null = null; // Create gateway app configuration function -const configureGatewayServer = () => { +export const configureGatewayServer = () => { const server = Fastify({ logger: ConfigManagerV2.getInstance().get('server.fastifyLogs') ? { diff --git a/test-scripts/jest.config.js b/test-scripts/jest.config.js new file mode 100644 index 0000000000..2d0fc64b23 --- /dev/null +++ b/test-scripts/jest.config.js @@ -0,0 +1,11 @@ +const sharedConfig = require('../jest.config.js'); +// Placed in this directory to allow jest runner to discover this config file + +module.exports = { + ...sharedConfig, + rootDir: '..', + displayName: 'test-scripts', + testMatch: ['/test-scripts/**/*.test.ts'], + testPathIgnorePatterns: ['/node_modules/', '/test/'], + snapshotResolver: '/test-scripts/snapshot-resolver.js', +}; \ No newline at end of file diff --git a/test-scripts/rnpExample/rnpExample.recorder.test.ts b/test-scripts/rnpExample/rnpExample.recorder.test.ts new file mode 100644 index 0000000000..43bfbffc62 --- /dev/null +++ b/test-scripts/rnpExample/rnpExample.recorder.test.ts @@ -0,0 +1,28 @@ +import { RnpExampleTestHarness } from '#test/rnpExample/rnpExample.test-harness'; +import { useABC, useUnmappedDep } from '#test/rnpExample/rnpExample.api-test-cases'; + +describe('RnpExample', () => { + let harness: RnpExampleTestHarness; + jest.setTimeout(1200000); + + beforeAll(async () => { + harness = new RnpExampleTestHarness(); + await harness.setupRecorder(); + }); + + afterAll(async () => { + await harness.teardown(); + }); + + it('useABC', async () => { + await useABC.processRecorderRequest(harness); + }); + + // it('useDTwice', async () => { + // await useDTwice.processRecorderRequest(harness); + // }); + + it('useUnmappedDep', async () => { + await useUnmappedDep.processRecorderRequest(harness); + }); +}); \ No newline at end of file diff --git a/test-scripts/snapshot-resolver.js b/test-scripts/snapshot-resolver.js new file mode 100644 index 0000000000..6b283b1b48 --- /dev/null +++ b/test-scripts/snapshot-resolver.js @@ -0,0 +1,38 @@ +const path = require('path'); + +module.exports = { + // resolves from test to snapshot path + resolveSnapshotPath: (testPath, snapshotExtension) => { + // This resolver makes a snapshot path from a recorder test path to a + // corresponding unit test snapshot path. + // e.g., test-scripts/rnpExample/rnpExample.recorder.test.ts + // -> test/rnpExample/__snapshots__/rnpExample.test.ts.snap + return path.join( + path.dirname(testPath).replace('test-scripts', 'test'), + '__snapshots__', + path.basename(testPath).replace('.recorder', '') + snapshotExtension, + ); + }, + + // resolves from snapshot to test path + resolveTestPath: (snapshotFilePath, snapshotExtension) => { + // This resolver finds the recorder test path from a unit test snapshot path. + // e.g., test/rnpExample/__snapshots__/rnpExample.test.ts.snap + // -> test-scripts/rnpExample/rnpExample.recorder.test.ts + const testPath = path + .dirname(snapshotFilePath) + .replace('__snapshots__', '') + .replace('test', 'test-scripts'); + + return path.join( + testPath, + path + .basename(snapshotFilePath, snapshotExtension) + .replace('.test.ts', '.recorder.test.ts'), + ); + }, + + // Example test path, used for preflight consistency check of the implementation above + testPathForConsistencyCheck: + 'test-scripts/rnpExample/rnpExample.recorder.test.ts', +}; diff --git a/test/record-and-play/api-test-case.ts b/test/record-and-play/api-test-case.ts index 74c40b155b..bf24ba0a34 100644 --- a/test/record-and-play/api-test-case.ts +++ b/test/record-and-play/api-test-case.ts @@ -31,6 +31,7 @@ export class APITestCase = any> public requiredMocks: Partial< Record >, + public propertyMatchers?: Partial, ) {} public async processRecorderRequest( @@ -47,7 +48,7 @@ export class APITestCase = any> expect(response.statusCode).toBe(this.expectedStatus); } const body = JSON.parse(response.body); - expect(body).toMatchSnapshot(propertyMatchers); + this.assertSnapshot(body, propertyMatchers); return { response, body }; } @@ -62,7 +63,15 @@ export class APITestCase = any> const response = await harness.gatewayApp.inject(this); expect(response.statusCode).toBe(this.expectedStatus); const body = JSON.parse(response.body); - expect(body).toMatchSnapshot(propertyMatchers); + this.assertSnapshot(body, propertyMatchers); return { response, body }; } + + public assertSnapshot(body: any, propertyMatchers?: Partial) { + if (propertyMatchers || this.propertyMatchers) { + expect(body).toMatchSnapshot(propertyMatchers || this.propertyMatchers); + } else { + expect(body).toMatchSnapshot(); + } + } } diff --git a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap new file mode 100644 index 0000000000..c00617c27a --- /dev/null +++ b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RnpExample useABC 1`] = ` +{ + "a": "real methodA0.010457451655017325", + "b": "real methodB0.4991488291709618", + "c": "real methodC0.21659401529162925", +} +`; + +exports[`RnpExample useUnmappedDep 1`] = ` +{ + "z": "real methodZ0.34224475880908223", +} +`; diff --git a/test/rnpExample/api/rnpExample.routes.ts b/test/rnpExample/api/rnpExample.routes.ts new file mode 100644 index 0000000000..1d018258cb --- /dev/null +++ b/test/rnpExample/api/rnpExample.routes.ts @@ -0,0 +1,88 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { logger } from '#src/services/logger'; + +import { RnpExample } from './rnpExample'; + +const useABCRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: { network: string }; + Reply: { a: string; b: string; c: string }; + }>( + '/useABC', + { + schema: { + summary: 'A RnpExample route for testing', + }, + }, + async (request) => { + try { + const rnpExample = await RnpExample.getInstance(request.query.network); + return await rnpExample.useABC(); + } catch (error) { + logger.error(`Error getting useABC status: ${error.message}`); + throw fastify.httpErrors.internalServerError( + `Failed to useABC: ${error.message}`, + ); + } + }, + ); +}; + +const useDTwiceRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: { network: string }; + Reply: { d1: string; d2: string }; + }>( + '/useDTwice', + { + schema: { + summary: 'A RnpExample route for testing', + }, + }, + async (request) => { + try { + const rnpExample = await RnpExample.getInstance(request.query.network); + return await rnpExample.useDTwice(); + } catch (error) { + logger.error(`Error getting useDTwice status: ${error.message}`); + throw fastify.httpErrors.internalServerError( + `Failed to useDTwice: ${error.message}`, + ); + } + }, + ); +}; + +const useUnmappedDepRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: { network: string }; + Reply: { z: string }; + }>( + '/useUnmappedDep', + { + schema: { + summary: 'A RnpExample route for testing', + }, + }, + async (request) => { + try { + const rnpExample = await RnpExample.getInstance(request.query.network); + return await rnpExample.useUnmappedDep(); + } catch (error) { + logger.error(`Error getting useUnmappedDep status: ${error.message}`); + throw fastify.httpErrors.internalServerError( + `Failed to useUnmappedDep: ${error.message}`, + ); + } + }, + ); +}; + +export const rnpExampleRoutes: FastifyPluginAsync = async (fastify) => { + await fastify.register(useABCRoute); + await fastify.register(useDTwiceRoute); + await fastify.register(useUnmappedDepRoute); +}; + +export default rnpExampleRoutes; diff --git a/test/rnpExample/api/rnpExample.ts b/test/rnpExample/api/rnpExample.ts new file mode 100644 index 0000000000..abd3114792 --- /dev/null +++ b/test/rnpExample/api/rnpExample.ts @@ -0,0 +1,53 @@ +export class Dep1 { + methodA = () => 'real methodA-' + Math.random(); + + methodB = () => 'real methodB-' + Math.random(); + + methodC = () => 'real methodC-' + Math.random(); + + methodD = () => 'real methodD-' + Math.random(); +} + +export class Dep2 { + methodZ = () => 'real methodZ-' + Math.random(); +} + +export class RnpExample { + private static _instances: { [name: string]: RnpExample }; + public dep1: Dep1; + public dep2: Dep2; + + constructor() { + this.dep1 = new Dep1(); + this.dep2 = new Dep2(); + } + + public static async getInstance(network: string): Promise { + if (RnpExample._instances === undefined) { + RnpExample._instances = {}; + } + if (!(network in RnpExample._instances)) { + const instance = new RnpExample(); + RnpExample._instances[network] = instance; + } + return RnpExample._instances[network]; + } + + async useABC() { + const a = this.dep1.methodA(); + const b = this.dep1.methodB(); + const c = this.dep1.methodC(); + return { a, b, c }; + } + + async useDTwice() { + const d1 = this.dep1.methodD(); + const d2 = this.dep1.methodD(); + return { d1, d2 }; + } + + async useUnmappedDep() { + const z = this.dep2.methodZ(); + return { z }; + } +} diff --git a/test/rnpExample/mocks/rnpExample-methodA.json b/test/rnpExample/mocks/rnpExample-methodA.json new file mode 100644 index 0000000000..eded09cf27 --- /dev/null +++ b/test/rnpExample/mocks/rnpExample-methodA.json @@ -0,0 +1,3 @@ +{ + "json": "real methodA0.010457451655017325" +} \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodB.json b/test/rnpExample/mocks/rnpExample-methodB.json new file mode 100644 index 0000000000..957c492ab6 --- /dev/null +++ b/test/rnpExample/mocks/rnpExample-methodB.json @@ -0,0 +1,3 @@ +{ + "json": "real methodB0.4991488291709618" +} \ No newline at end of file diff --git a/test/rnpExample/rnpExample.api-test-cases.ts b/test/rnpExample/rnpExample.api-test-cases.ts new file mode 100644 index 0000000000..4a0fa2b8e2 --- /dev/null +++ b/test/rnpExample/rnpExample.api-test-cases.ts @@ -0,0 +1,37 @@ +import { APITestCase } from '#test/record-and-play/api-test-case'; + +import { RnpExampleTestHarness } from './rnpExample.test-harness'; + +class TestCase extends APITestCase {} + +export const useABC = new TestCase( + 'GET', + '/rnpExample/useABC', + 200, + { network: 'TEST' }, + {}, + { + dep1_A: 'rnpExample-methodA', + dep1_B: 'rnpExample-methodB', + }, +); + +// export const useDTwice = new TestCase( +// 'GET', +// '/rnpExample/useDTwice', +// 200, +// { network: 'TEST' }, +// {}, +// { +// dep1_D: [ 'rnpExample-methodD1', 'rnpExample-methodD2' ], +// }, +// ); + +export const useUnmappedDep = new TestCase( + 'GET', + '/rnpExample/useUnmappedDep', + 200, + { network: 'TEST' }, + {}, + {}, +); diff --git a/test/rnpExample/rnpExample.test-harness.ts b/test/rnpExample/rnpExample.test-harness.ts new file mode 100644 index 0000000000..55f205f9ed --- /dev/null +++ b/test/rnpExample/rnpExample.test-harness.ts @@ -0,0 +1,58 @@ +import { AbstractGatewayTestHarness } from '#test/record-and-play/abstract-gateway-test-harness'; + +import { RnpExample } from './api/rnpExample'; + +export class RnpExampleTestHarness extends AbstractGatewayTestHarness { + readonly dependencyContracts = { + dep1_A: this.dependencyFactory.instanceProperty( + 'dep1', + 'methodA', + 'rnpExample-methodA', + ), + dep1_B: this.dependencyFactory.instanceProperty( + 'dep1', + 'methodB', + 'rnpExample-methodB', + ), + // TODO: C should be a passthrough + // dep1_C: this.dependencyFactory.instanceProperty( + // 'dep1', + // 'methodC', + // ), + dep1_D: this.dependencyFactory.instanceProperty( + 'dep1', + 'methodD', + 'rnpExample-methodD1', + ), + }; + + constructor() { + super(__dirname); + } + + protected async initializeGatewayApp() { + // This is different from the base class. It creates a new server + // instance and injects test routes before making it ready. + const { configureGatewayServer } = await import('../../src/app'); + this._gatewayApp = configureGatewayServer(); + + const { rnpExampleRoutes } = await import( + '#test/rnpExample/api/rnpExample.routes' + ); + await this._gatewayApp.register(rnpExampleRoutes, { + prefix: '/rnpExample', + }); + + await this._gatewayApp.ready(); + } + + async init() { + await this.initializeGatewayApp(); + this._instance = await RnpExample.getInstance('TEST'); + // this.setupMocksForTest(); + } + + async teardown() { + await super.teardown(); + } +} diff --git a/test/rnpExample/rnpExample.test.ts b/test/rnpExample/rnpExample.test.ts new file mode 100644 index 0000000000..39ead87b4c --- /dev/null +++ b/test/rnpExample/rnpExample.test.ts @@ -0,0 +1,27 @@ +import { useABC, useUnmappedDep } from './rnpExample.api-test-cases'; +import { RnpExampleTestHarness } from './rnpExample.test-harness'; + +describe('RnpExample', () => { + let harness: RnpExampleTestHarness; + + beforeAll(async () => { + harness = new RnpExampleTestHarness(); + await harness.init(); + }); + + afterAll(async () => { + await harness.teardown(); + }); + + it('useABC', async () => { + await useABC.processPlayRequest(harness); + }); + + // it('useDTwice', async () => { + // await useDTwice.processRecorderRequest(harness); + // }); + + it('useUnmappedDep', async () => { + await useUnmappedDep.processPlayRequest(harness); + }); +}); From 015c8d272e9072cf647713245176b7743c4a6328 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Thu, 26 Jun 2025 16:08:26 -0600 Subject: [PATCH 03/45] Get 2 method mapping on single dependency working as well as mulitple responses on same dependency. --- .../rnpExample/rnpExample.recorder.test.ts | 8 +- .../abstract-gateway-test-harness.ts | 73 +++++++---- test/record-and-play/api-test-case.ts | 4 +- .../test-dependency-contract.ts | 122 ++++++------------ .../__snapshots__/rnpExample.test.ts.snap | 15 ++- test/rnpExample/api/rnpExample.routes.ts | 36 +++++- test/rnpExample/api/rnpExample.ts | 33 +++-- test/rnpExample/mocks/rnpExample-methodA.json | 2 +- test/rnpExample/mocks/rnpExample-methodB.json | 2 +- .../rnpExample/mocks/rnpExample-methodD1.json | 3 + .../rnpExample/mocks/rnpExample-methodD2.json | 3 + test/rnpExample/rnpExample.api-test-cases.ts | 20 +-- test/rnpExample/rnpExample.test-harness.ts | 18 +-- test/rnpExample/rnpExample.test.ts | 9 +- 14 files changed, 180 insertions(+), 168 deletions(-) create mode 100644 test/rnpExample/mocks/rnpExample-methodD1.json create mode 100644 test/rnpExample/mocks/rnpExample-methodD2.json diff --git a/test-scripts/rnpExample/rnpExample.recorder.test.ts b/test-scripts/rnpExample/rnpExample.recorder.test.ts index 43bfbffc62..6e94be0bc1 100644 --- a/test-scripts/rnpExample/rnpExample.recorder.test.ts +++ b/test-scripts/rnpExample/rnpExample.recorder.test.ts @@ -1,5 +1,5 @@ import { RnpExampleTestHarness } from '#test/rnpExample/rnpExample.test-harness'; -import { useABC, useUnmappedDep } from '#test/rnpExample/rnpExample.api-test-cases'; +import { useABC, useDTwice, useUnmappedDep } from '#test/rnpExample/rnpExample.api-test-cases'; describe('RnpExample', () => { let harness: RnpExampleTestHarness; @@ -18,9 +18,9 @@ describe('RnpExample', () => { await useABC.processRecorderRequest(harness); }); - // it('useDTwice', async () => { - // await useDTwice.processRecorderRequest(harness); - // }); + it('useDTwice', async () => { + await useDTwice.processRecorderRequest(harness); + }); it('useUnmappedDep', async () => { await useUnmappedDep.processRecorderRequest(harness); diff --git a/test/record-and-play/abstract-gateway-test-harness.ts b/test/record-and-play/abstract-gateway-test-harness.ts index a484da47dc..3a2997d3db 100644 --- a/test/record-and-play/abstract-gateway-test-harness.ts +++ b/test/record-and-play/abstract-gateway-test-harness.ts @@ -10,18 +10,21 @@ import { TestDependencyContract, } from './test-dependency-contract'; +interface ContractWithSpy extends TestDependencyContract { + spy?: jest.SpyInstance; +} + export abstract class AbstractGatewayTestHarness - implements MockProvider + implements MockProvider { protected _gatewayApp!: FastifyInstance; protected _mockDir: string; protected _instance!: TInstance; protected readonly dependencyFactory = new DependencyFactory(); - protected _spies: Record = {}; abstract readonly dependencyContracts: Record< string, - TestDependencyContract + ContractWithSpy >; constructor(mockDir: string) { @@ -71,48 +74,66 @@ export abstract class AbstractGatewayTestHarness abstract init(): Promise; - async setupRecorder() { - await this.init(); - + private async setupSpies() { for (const key in this.dependencyContracts) { const dep = this.dependencyContracts[key]; - const [spy] = dep.setupRecorder(this.instance); - this._spies[key] = spy; + const spy = dep.setupSpy(this); + dep.spy = spy; } } - public async saveMocks(mocksToSave: Record) { - for (const [key, filename] of Object.entries(mocksToSave)) { - const spy = this._spies[key]; - if (!spy) { + async setupRecorder() { + await this.init(); + await this.setupSpies(); + } + + public async saveMocks(requiredMocks: Record) { + for (const [key, filenames] of Object.entries(requiredMocks)) { + const dep = this.dependencyContracts[key]; + if (!dep.spy) { throw new Error(`Spy for mock key "${key}" not found.`); } - const data = await spy.mock.results[spy.mock.results.length - 1].value; - this._saveMock(filename, data); + for (const [i, filename] of (Array.isArray(filenames) + ? filenames + : [filenames] + ).entries()) { + const data = await dep.spy.mock.results[i].value; + this._saveMock(filename, data); + } } } - public async setupMocksForTest(mocksToSetup: Record) { - for (const [key, mockFileName] of Object.entries(mocksToSetup)) { - const contract = this.dependencyContracts[key]; - if (!contract) { + async setupMockedTests() { + await this.init(); + await this.setupSpies(); + // TODO: make unmocked methods throw errors + // for (const key in this.dependencyContracts) { + // const dep = this.dependencyContracts[key]; + // const spyOrSpies = dep.setupSpy(this.instance); + // dep.spy = spyOrSpies + // } + } + + public async loadMocks(requiredMocks: Record) { + for (const [key, filenames] of Object.entries(requiredMocks)) { + const dep = this.dependencyContracts[key]; + if (!dep.spy) { throw new Error( `Dependency contract with key '${key}' not found in harness.`, ); } - const originalFileName = contract.mockFileName; - contract.mockFileName = mockFileName; - const spy = contract.setupUnitTest(this, this.instance); - contract.mockFileName = originalFileName; // restore it - if (spy) { - this._spies[key] = spy; + for (const fileName of Array.isArray(filenames) + ? filenames + : [filenames]) { + dep.setupMock(dep.spy, this, fileName); } } } async teardown() { - Object.values(this._spies).forEach((spy) => spy.mockRestore()); - this._spies = {}; + Object.values(this.dependencyContracts).forEach((dep) => { + dep.spy.mockRestore(); + }); if (this._gatewayApp) { await this._gatewayApp.close(); } diff --git a/test/record-and-play/api-test-case.ts b/test/record-and-play/api-test-case.ts index bf24ba0a34..7ad1f04efe 100644 --- a/test/record-and-play/api-test-case.ts +++ b/test/record-and-play/api-test-case.ts @@ -29,7 +29,7 @@ export class APITestCase = any> * @example { 'getLatestBlock': 'mayanode-getLatestBlock-response' } */ public requiredMocks: Partial< - Record + Record >, public propertyMatchers?: Partial, ) {} @@ -59,7 +59,7 @@ export class APITestCase = any> response: LightMyRequestResponse; body: any; }> { - await harness.setupMocksForTest(this.requiredMocks); + await harness.loadMocks(this.requiredMocks); const response = await harness.gatewayApp.inject(this); expect(response.statusCode).toBe(this.expectedStatus); const body = JSON.parse(response.body); diff --git a/test/record-and-play/test-dependency-contract.ts b/test/record-and-play/test-dependency-contract.ts index afe40ff40b..0b0df98dbf 100644 --- a/test/record-and-play/test-dependency-contract.ts +++ b/test/record-and-play/test-dependency-contract.ts @@ -1,5 +1,6 @@ -export interface MockProvider { +export interface MockProvider { getMock(fileName: string): any; + instance: TInstance; } /** @@ -7,22 +8,27 @@ export interface MockProvider { * or spied on for recorder tests. */ export abstract class TestDependencyContract { - constructor(public mockFileName: string) {} + // constructor(public readonly mockFileName: string) {} + constructor() {} /** * Sets up a spy on a real instance method to record its live output. * @returns A tuple containing the spy instance and the generated name for the save function. */ - abstract setupRecorder(instance: TInstance): [jest.SpyInstance, string]; + abstract setupSpy(harness: MockProvider): jest.SpyInstance; /** * Replaces a dependency with a mock for isolated unit testing. * @returns A spy instance if one was created, otherwise void. */ - abstract setupUnitTest( - harness: MockProvider, - instance: TInstance, - ): jest.SpyInstance | void; + setupMock( + spy: jest.SpyInstance, + harness: MockProvider, + mockFileName: string, + ): void { + const mockData = harness.getMock(mockFileName); + spy.mockResolvedValueOnce(mockData); + } } /** @@ -35,33 +41,18 @@ export class InstancePropertyDependency< K extends keyof TInstance, > extends TestDependencyContract { constructor( - mockFileName: string, - private instanceKey: K, + // mockFileName: string, + private instancePropertyKey: K, private methodName: keyof TInstance[K], ) { - super(mockFileName); + super(); } - setupRecorder(instance: TInstance): [jest.SpyInstance, string] { - const dependencyInstance = instance[this.instanceKey]; + setupSpy(harness: MockProvider): jest.SpyInstance { + const dependencyInstance = harness.instance[this.instancePropertyKey]; + // TODO: check if dependencyInstance is already a spy?? const spy = jest.spyOn(dependencyInstance as any, this.methodName as any); - const saverName = this.getSaverName(); - return [spy, saverName]; - } - - setupUnitTest(harness: MockProvider, instance: TInstance): void { - const mockData = harness.getMock(this.mockFileName); - const mockInstance = { - [this.methodName]: jest.fn().mockResolvedValue(mockData), - }; - (instance as any)[this.instanceKey] = mockInstance as TInstance[K]; - } - - private getSaverName(): string { - const methodNameStr = this.methodName as string; - const methodNameTitleCase = - methodNameStr.charAt(0).toUpperCase() + methodNameStr.slice(1); - return `save${methodNameTitleCase}Mock`; + return spy; } } @@ -86,52 +77,17 @@ export class PrototypeDependency< TPrototype, > extends TestDependencyContract { constructor( - mockFileName: string, + // mockFileName: string, private Klass: new (...args: any[]) => TPrototype, private prototypeMethod: keyof TPrototype, - private method: keyof TInstance, + // private method: keyof TInstance, ) { - super(mockFileName); - } - - setupRecorder(instance: TInstance): [jest.SpyInstance, string] { - const methodSpy = jest.spyOn(instance as any, this.method as any); - - methodSpy.mockImplementation(async (...args: any[]) => { - // Restore the original method to get the real dependency instance - methodSpy.mockRestore(); - const dependencyInstance = await (instance as any)[this.method](...args); - - // Spy on the method of the real dependency instance - jest.spyOn(dependencyInstance, this.prototypeMethod as any); - - // Re-spy on the method to return the now-spied-upon dependency instance - jest - .spyOn(instance as any, this.method as any) - .mockReturnValue(dependencyInstance); - - return dependencyInstance; - }); - - const saverName = this.getSaverName(); - return [methodSpy, saverName]; - } - - setupUnitTest(harness: MockProvider, instance: TInstance): jest.SpyInstance { - const mockData = harness.getMock(this.mockFileName); - const mockInstance = { - [this.prototypeMethod]: jest.fn().mockResolvedValue(mockData), - }; - return jest - .spyOn(instance as any, this.method as any) - .mockResolvedValue(mockInstance); + super(); } - private getSaverName(): string { - const methodNameStr = this.prototypeMethod as string; - const methodNameTitleCase = - methodNameStr.charAt(0).toUpperCase() + methodNameStr.slice(1); - return `save${methodNameTitleCase}Mock`; + setupSpy(_harness: MockProvider): jest.SpyInstance { + const spy = jest.spyOn(this.Klass.prototype, this.prototypeMethod as any); + return spy; } } @@ -139,13 +95,11 @@ export class DependencyFactory { instanceProperty( instanceKey: K, methodName: keyof TInstance[K], - mockFileName?: string, ): InstancePropertyDependency { - const finalMockFileName = - mockFileName || - this.generateMockFileName(String(instanceKey), String(methodName)); - return new InstancePropertyDependency( - finalMockFileName, + // const finalMockFileName = + // mockFileName || + // this.generateMockFileName(String(instanceKey), String(methodName)); + return new InstancePropertyDependency( instanceKey, methodName, ); @@ -154,17 +108,17 @@ export class DependencyFactory { prototype( Klass: new (...args: any[]) => TPrototype, prototypeMethod: keyof TPrototype, - instanceMethod: keyof TInstance, - mockFileName?: string, + // instanceMethod: keyof TInstance, + // mockFileName?: string, ): PrototypeDependency { - const finalMockFileName = - mockFileName || - this.generateMockFileName(Klass.name, String(prototypeMethod)); - return new PrototypeDependency( - finalMockFileName, + // const finalMockFileName = + // mockFileName || + // this.generateMockFileName(Klass.name, String(prototypeMethod)); + return new PrototypeDependency( + // finalMockFileName, Klass, prototypeMethod, - instanceMethod, + // instanceMethod, ); } diff --git a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap index c00617c27a..32c0b9acd4 100644 --- a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap +++ b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap @@ -2,14 +2,21 @@ exports[`RnpExample useABC 1`] = ` { - "a": "real methodA0.010457451655017325", - "b": "real methodB0.4991488291709618", - "c": "real methodC0.21659401529162925", + "a": "real methodA-0.4019890230670282", + "b": "real methodB-0.5932643445442789", + "c": "real methodC-0.9555927541390334", +} +`; + +exports[`RnpExample useDTwice 1`] = ` +{ + "d1": "real methodD-0.546786535872487", + "d2": "real methodD-0.9218730426590118", } `; exports[`RnpExample useUnmappedDep 1`] = ` { - "z": "real methodZ0.34224475880908223", + "z": "real methodZ-0.42323554137867947", } `; diff --git a/test/rnpExample/api/rnpExample.routes.ts b/test/rnpExample/api/rnpExample.routes.ts index 1d018258cb..457a585c49 100644 --- a/test/rnpExample/api/rnpExample.routes.ts +++ b/test/rnpExample/api/rnpExample.routes.ts @@ -54,12 +54,12 @@ const useDTwiceRoute: FastifyPluginAsync = async (fastify) => { ); }; -const useUnmappedDepRoute: FastifyPluginAsync = async (fastify) => { +const useUnmappedMethodRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ Querystring: { network: string }; - Reply: { z: string }; + Reply: { unmapped: string }; }>( - '/useUnmappedDep', + '/useUnmappedMethod', { schema: { summary: 'A RnpExample route for testing', @@ -68,7 +68,7 @@ const useUnmappedDepRoute: FastifyPluginAsync = async (fastify) => { async (request) => { try { const rnpExample = await RnpExample.getInstance(request.query.network); - return await rnpExample.useUnmappedDep(); + return await rnpExample.useUnmappedMethod(); } catch (error) { logger.error(`Error getting useUnmappedDep status: ${error.message}`); throw fastify.httpErrors.internalServerError( @@ -79,10 +79,36 @@ const useUnmappedDepRoute: FastifyPluginAsync = async (fastify) => { ); }; +const useDep2Route: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: { network: string }; + Reply: { z: string }; + }>( + '/useDep2', + { + schema: { + summary: 'A RnpExample route for testing', + }, + }, + async (request) => { + try { + const rnpExample = await RnpExample.getInstance(request.query.network); + return await rnpExample.useDep2(); + } catch (error) { + logger.error(`Error getting useDep2 status: ${error.message}`); + throw fastify.httpErrors.internalServerError( + `Failed to useDep2: ${error.message}`, + ); + } + }, + ); +}; + export const rnpExampleRoutes: FastifyPluginAsync = async (fastify) => { await fastify.register(useABCRoute); await fastify.register(useDTwiceRoute); - await fastify.register(useUnmappedDepRoute); + await fastify.register(useUnmappedMethodRoute); + await fastify.register(useDep2Route); }; export default rnpExampleRoutes; diff --git a/test/rnpExample/api/rnpExample.ts b/test/rnpExample/api/rnpExample.ts index abd3114792..e6850a6e65 100644 --- a/test/rnpExample/api/rnpExample.ts +++ b/test/rnpExample/api/rnpExample.ts @@ -1,15 +1,19 @@ export class Dep1 { - methodA = () => 'real methodA-' + Math.random(); + methodA = async () => 'real methodA-' + Math.random(); - methodB = () => 'real methodB-' + Math.random(); + // TODO: always mocked + methodB = async () => 'real methodB-' + Math.random(); - methodC = () => 'real methodC-' + Math.random(); + // TODO: passthrough during playback + methodC = async () => 'real methodC-' + Math.random(); - methodD = () => 'real methodD-' + Math.random(); + methodD = async () => 'real methodD-' + Math.random(); + + methodUnmapped = async () => 'real methodUnmapped-' + Math.random(); } export class Dep2 { - methodZ = () => 'real methodZ-' + Math.random(); + methodZ = async () => 'real methodZ-' + Math.random(); } export class RnpExample { @@ -34,20 +38,25 @@ export class RnpExample { } async useABC() { - const a = this.dep1.methodA(); - const b = this.dep1.methodB(); - const c = this.dep1.methodC(); + const a = await this.dep1.methodA(); + const b = await this.dep1.methodB(); + const c = await this.dep1.methodC(); return { a, b, c }; } async useDTwice() { - const d1 = this.dep1.methodD(); - const d2 = this.dep1.methodD(); + const d1 = await this.dep1.methodD(); + const d2 = await this.dep1.methodD(); return { d1, d2 }; } - async useUnmappedDep() { - const z = this.dep2.methodZ(); + async useUnmappedMethod() { + const unmapped = await this.dep1.methodUnmapped(); + return { unmapped }; + } + + async useDep2() { + const z = await this.dep2.methodZ(); return { z }; } } diff --git a/test/rnpExample/mocks/rnpExample-methodA.json b/test/rnpExample/mocks/rnpExample-methodA.json index eded09cf27..2a38d87033 100644 --- a/test/rnpExample/mocks/rnpExample-methodA.json +++ b/test/rnpExample/mocks/rnpExample-methodA.json @@ -1,3 +1,3 @@ { - "json": "real methodA0.010457451655017325" + "json": "real methodA-0.4019890230670282" } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodB.json b/test/rnpExample/mocks/rnpExample-methodB.json index 957c492ab6..49dbb3b978 100644 --- a/test/rnpExample/mocks/rnpExample-methodB.json +++ b/test/rnpExample/mocks/rnpExample-methodB.json @@ -1,3 +1,3 @@ { - "json": "real methodB0.4991488291709618" + "json": "real methodB-0.5932643445442789" } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodD1.json b/test/rnpExample/mocks/rnpExample-methodD1.json new file mode 100644 index 0000000000..54b1556659 --- /dev/null +++ b/test/rnpExample/mocks/rnpExample-methodD1.json @@ -0,0 +1,3 @@ +{ + "json": "real methodD-0.546786535872487" +} \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodD2.json b/test/rnpExample/mocks/rnpExample-methodD2.json new file mode 100644 index 0000000000..6285381778 --- /dev/null +++ b/test/rnpExample/mocks/rnpExample-methodD2.json @@ -0,0 +1,3 @@ +{ + "json": "real methodD-0.9218730426590118" +} \ No newline at end of file diff --git a/test/rnpExample/rnpExample.api-test-cases.ts b/test/rnpExample/rnpExample.api-test-cases.ts index 4a0fa2b8e2..1a168d7fb6 100644 --- a/test/rnpExample/rnpExample.api-test-cases.ts +++ b/test/rnpExample/rnpExample.api-test-cases.ts @@ -16,16 +16,16 @@ export const useABC = new TestCase( }, ); -// export const useDTwice = new TestCase( -// 'GET', -// '/rnpExample/useDTwice', -// 200, -// { network: 'TEST' }, -// {}, -// { -// dep1_D: [ 'rnpExample-methodD1', 'rnpExample-methodD2' ], -// }, -// ); +export const useDTwice = new TestCase( + 'GET', + '/rnpExample/useDTwice', + 200, + { network: 'TEST' }, + {}, + { + dep1_D: ['rnpExample-methodD1', 'rnpExample-methodD2'], + }, +); export const useUnmappedDep = new TestCase( 'GET', diff --git a/test/rnpExample/rnpExample.test-harness.ts b/test/rnpExample/rnpExample.test-harness.ts index 55f205f9ed..f56e105eda 100644 --- a/test/rnpExample/rnpExample.test-harness.ts +++ b/test/rnpExample/rnpExample.test-harness.ts @@ -4,26 +4,14 @@ import { RnpExample } from './api/rnpExample'; export class RnpExampleTestHarness extends AbstractGatewayTestHarness { readonly dependencyContracts = { - dep1_A: this.dependencyFactory.instanceProperty( - 'dep1', - 'methodA', - 'rnpExample-methodA', - ), - dep1_B: this.dependencyFactory.instanceProperty( - 'dep1', - 'methodB', - 'rnpExample-methodB', - ), + dep1_A: this.dependencyFactory.instanceProperty('dep1', 'methodA'), + dep1_B: this.dependencyFactory.instanceProperty('dep1', 'methodB'), // TODO: C should be a passthrough // dep1_C: this.dependencyFactory.instanceProperty( // 'dep1', // 'methodC', // ), - dep1_D: this.dependencyFactory.instanceProperty( - 'dep1', - 'methodD', - 'rnpExample-methodD1', - ), + dep1_D: this.dependencyFactory.instanceProperty('dep1', 'methodD'), }; constructor() { diff --git a/test/rnpExample/rnpExample.test.ts b/test/rnpExample/rnpExample.test.ts index 39ead87b4c..be5556881d 100644 --- a/test/rnpExample/rnpExample.test.ts +++ b/test/rnpExample/rnpExample.test.ts @@ -1,4 +1,4 @@ -import { useABC, useUnmappedDep } from './rnpExample.api-test-cases'; +import { useABC, useDTwice, useUnmappedDep } from './rnpExample.api-test-cases'; import { RnpExampleTestHarness } from './rnpExample.test-harness'; describe('RnpExample', () => { @@ -7,6 +7,7 @@ describe('RnpExample', () => { beforeAll(async () => { harness = new RnpExampleTestHarness(); await harness.init(); + await harness.setupMockedTests(); }); afterAll(async () => { @@ -17,9 +18,9 @@ describe('RnpExample', () => { await useABC.processPlayRequest(harness); }); - // it('useDTwice', async () => { - // await useDTwice.processRecorderRequest(harness); - // }); + it('useDTwice', async () => { + await useDTwice.processPlayRequest(harness); + }); it('useUnmappedDep', async () => { await useUnmappedDep.processPlayRequest(harness); From dc93fe275a99d32052f693303e6d7390a06b006c Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Thu, 26 Jun 2025 17:30:11 -0600 Subject: [PATCH 04/45] Add support for having unmocked methods fail. --- src/app.ts | 22 ++++-- .../rnpExample/rnpExample.recorder.test.ts | 10 ++- .../abstract-gateway-test-harness.ts | 22 +++--- test/record-and-play/api-test-case.ts | 15 +++-- .../test-dependency-contract.ts | 67 ++++++------------- .../__snapshots__/rnpExample.test.ts.snap | 28 ++++++-- test/rnpExample/api/rnpExample.routes.ts | 8 ++- test/rnpExample/mocks/rnpExample-methodA.json | 2 +- test/rnpExample/mocks/rnpExample-methodB.json | 2 +- .../rnpExample/mocks/rnpExample-methodD1.json | 2 +- .../rnpExample/mocks/rnpExample-methodD2.json | 2 +- test/rnpExample/rnpExample.api-test-cases.ts | 25 ++++++- test/rnpExample/rnpExample.test.ts | 16 +++-- 13 files changed, 133 insertions(+), 88 deletions(-) diff --git a/src/app.ts b/src/app.ts index 550382a4b4..2d9f33e813 100644 --- a/src/app.ts +++ b/src/app.ts @@ -208,6 +208,8 @@ export const configureGatewayServer = () => { // Register routes on both servers const registerRoutes = async (app: FastifyInstance) => { + app.register(require('@fastify/sensible')); + // Register system routes app.register(configRoutes, { prefix: '/config' }); @@ -290,11 +292,21 @@ export const configureGatewayServer = () => { params: request.params, }); - reply.status(500).send({ - statusCode: 500, - error: 'Internal Server Error', - message: 'An unexpected error occurred', - }); + if (testMode) { + // When in test mode, we want to see the full error stack always + reply.status(500).send({ + statusCode: 500, + error: 'Internal Server Error', + stack: error.stack, + message: error.message, + }); + } else { + reply.status(500).send({ + statusCode: 500, + error: 'Internal Server Error', + message: 'An unexpected error occurred', + }); + } }); // Health check route (outside registerRoutes, only on main server) diff --git a/test-scripts/rnpExample/rnpExample.recorder.test.ts b/test-scripts/rnpExample/rnpExample.recorder.test.ts index 6e94be0bc1..2878ff513e 100644 --- a/test-scripts/rnpExample/rnpExample.recorder.test.ts +++ b/test-scripts/rnpExample/rnpExample.recorder.test.ts @@ -1,5 +1,5 @@ import { RnpExampleTestHarness } from '#test/rnpExample/rnpExample.test-harness'; -import { useABC, useDTwice, useUnmappedDep } from '#test/rnpExample/rnpExample.api-test-cases'; +import { useABC, useDep2, useDTwice, useUnmappedMethodMocked, useUnmappedMethodRecorder } from '#test/rnpExample/rnpExample.api-test-cases'; describe('RnpExample', () => { let harness: RnpExampleTestHarness; @@ -22,7 +22,11 @@ describe('RnpExample', () => { await useDTwice.processRecorderRequest(harness); }); - it('useUnmappedDep', async () => { - await useUnmappedDep.processRecorderRequest(harness); + it('useUnmappedMethodRecorder', async () => { + await useUnmappedMethodRecorder.processRecorderRequest(harness); + }); + + it('useDep2', async () => { + await useDep2.processRecorderRequest(harness); }); }); \ No newline at end of file diff --git a/test/record-and-play/abstract-gateway-test-harness.ts b/test/record-and-play/abstract-gateway-test-harness.ts index 3a2997d3db..addd201dd5 100644 --- a/test/record-and-play/abstract-gateway-test-harness.ts +++ b/test/record-and-play/abstract-gateway-test-harness.ts @@ -75,8 +75,7 @@ export abstract class AbstractGatewayTestHarness abstract init(): Promise; private async setupSpies() { - for (const key in this.dependencyContracts) { - const dep = this.dependencyContracts[key]; + for (const [_key, dep] of Object.entries(this.dependencyContracts)) { const spy = dep.setupSpy(this); dep.spy = spy; } @@ -106,12 +105,19 @@ export abstract class AbstractGatewayTestHarness async setupMockedTests() { await this.init(); await this.setupSpies(); - // TODO: make unmocked methods throw errors - // for (const key in this.dependencyContracts) { - // const dep = this.dependencyContracts[key]; - // const spyOrSpies = dep.setupSpy(this.instance); - // dep.spy = spyOrSpies - // } + for (const [instanceKey, dep] of Object.entries(this.dependencyContracts)) { + const object = dep.getObject(this); + for (const [methodName, method] of Object.entries(object)) { + if (!(method as any).mock) { + const spy = jest.spyOn(object, methodName); + spy.mockImplementation(() => { + throw new Error( + `Unmocked method was called: ${instanceKey}.${methodName}`, + ); + }); + } + } + } } public async loadMocks(requiredMocks: Record) { diff --git a/test/record-and-play/api-test-case.ts b/test/record-and-play/api-test-case.ts index 7ad1f04efe..4ee591c87d 100644 --- a/test/record-and-play/api-test-case.ts +++ b/test/record-and-play/api-test-case.ts @@ -43,10 +43,7 @@ export class APITestCase = any> }> { const response = await harness.gatewayApp.inject(this); await harness.saveMocks(this.requiredMocks); - if (response.statusCode !== this.expectedStatus) { - console.log('Response body:', response.body); - expect(response.statusCode).toBe(this.expectedStatus); - } + this.assertStatusCode(response); const body = JSON.parse(response.body); this.assertSnapshot(body, propertyMatchers); return { response, body }; @@ -61,7 +58,7 @@ export class APITestCase = any> }> { await harness.loadMocks(this.requiredMocks); const response = await harness.gatewayApp.inject(this); - expect(response.statusCode).toBe(this.expectedStatus); + this.assertStatusCode(response); const body = JSON.parse(response.body); this.assertSnapshot(body, propertyMatchers); return { response, body }; @@ -74,4 +71,12 @@ export class APITestCase = any> expect(body).toMatchSnapshot(); } } + + public assertStatusCode(response: LightMyRequestResponse) { + if (response.statusCode !== this.expectedStatus) { + console.log('Response body:', response.body); + expect(response.statusCode).toBe(this.expectedStatus); + // TODO: check if it has a stack property to log + } + } } diff --git a/test/record-and-play/test-dependency-contract.ts b/test/record-and-play/test-dependency-contract.ts index 0b0df98dbf..4e15305ded 100644 --- a/test/record-and-play/test-dependency-contract.ts +++ b/test/record-and-play/test-dependency-contract.ts @@ -4,22 +4,24 @@ export interface MockProvider { } /** - * Defines the contract for a dependency that can be mocked for unit tests - * or spied on for recorder tests. + * Defines the contract for a dependency that can be utilized for record and play testing. */ export abstract class TestDependencyContract { - // constructor(public readonly mockFileName: string) {} - constructor() {} - /** - * Sets up a spy on a real instance method to record its live output. - * @returns A tuple containing the spy instance and the generated name for the save function. + * Sets up a spy on a real method to record or modify its output. + * @returns The spy instance. */ abstract setupSpy(harness: MockProvider): jest.SpyInstance; /** - * Replaces a dependency with a mock for isolated unit testing. - * @returns A spy instance if one was created, otherwise void. + * @returns The object that has the property that is being spied on. + */ + abstract getObject(harness: MockProvider): any; + + /** + * Replaces a dependency call with a mock for record and play testing. + * Can be called multiple times to mock subsequent calls to the same dependency. + * @returns void. */ setupMock( spy: jest.SpyInstance, @@ -33,15 +35,12 @@ export abstract class TestDependencyContract { /** * Handles dependencies that are properties on the main instance. - * - For recorder tests, it spies on a method of the real property instance. - * - For unit tests, it replaces the entire property with a mock object. */ export class InstancePropertyDependency< TInstance, K extends keyof TInstance, > extends TestDependencyContract { constructor( - // mockFileName: string, private instancePropertyKey: K, private methodName: keyof TInstance[K], ) { @@ -50,37 +49,26 @@ export class InstancePropertyDependency< setupSpy(harness: MockProvider): jest.SpyInstance { const dependencyInstance = harness.instance[this.instancePropertyKey]; - // TODO: check if dependencyInstance is already a spy?? const spy = jest.spyOn(dependencyInstance as any, this.methodName as any); return spy; } + + getObject(harness: MockProvider): any { + return harness.instance[this.instancePropertyKey]; + } } /** - * Handles dependencies that exist on a prototype, accessed via a getter method on the instance. + * Handles dependencies that exist on a prototype, e.g. a class which is initialized inside a method. * - * NOTE: This approach is preferred over global prototype mocking. Globally mocking a - * prototype (e.g., via `jest.mock` at the top level) is often fragile and can lead - * to unintended side effects across the entire test suite, as it pollutes the global - * state. - * - * The recommended pattern is to have the class under test provide a getter method - * that returns the dependency instance. This allows `PrototypeDependency` to spy on - * that getter for unit tests, replacing its return value with a mock instance, - * thereby cleanly isolating the mock without affecting other tests. - * - * - For recorder tests, it spies on the real prototype method to capture live data. - * - For unit tests, it spies on the instance's getter method and returns a mock instance. */ export class PrototypeDependency< TInstance, TPrototype, > extends TestDependencyContract { constructor( - // mockFileName: string, private Klass: new (...args: any[]) => TPrototype, private prototypeMethod: keyof TPrototype, - // private method: keyof TInstance, ) { super(); } @@ -89,6 +77,10 @@ export class PrototypeDependency< const spy = jest.spyOn(this.Klass.prototype, this.prototypeMethod as any); return spy; } + + getObject(_harness: MockProvider): any { + return this.Klass.prototype; + } } export class DependencyFactory { @@ -96,9 +88,6 @@ export class DependencyFactory { instanceKey: K, methodName: keyof TInstance[K], ): InstancePropertyDependency { - // const finalMockFileName = - // mockFileName || - // this.generateMockFileName(String(instanceKey), String(methodName)); return new InstancePropertyDependency( instanceKey, methodName, @@ -108,26 +97,10 @@ export class DependencyFactory { prototype( Klass: new (...args: any[]) => TPrototype, prototypeMethod: keyof TPrototype, - // instanceMethod: keyof TInstance, - // mockFileName?: string, ): PrototypeDependency { - // const finalMockFileName = - // mockFileName || - // this.generateMockFileName(Klass.name, String(prototypeMethod)); return new PrototypeDependency( - // finalMockFileName, Klass, prototypeMethod, - // instanceMethod, ); } - - private generateMockFileName(key: string, method: string): string { - const keySanitized = key - .replace(/([A-Z])/g, '-$1') - .toLowerCase() - .replace(/^-/, ''); - const keyBase = keySanitized.split('-')[0]; - return `${keyBase}-${method}-response`; - } } diff --git a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap index 32c0b9acd4..b183107c4b 100644 --- a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap +++ b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap @@ -2,21 +2,35 @@ exports[`RnpExample useABC 1`] = ` { - "a": "real methodA-0.4019890230670282", - "b": "real methodB-0.5932643445442789", - "c": "real methodC-0.9555927541390334", + "a": "real methodA-0.04965624398717727", + "b": "real methodB-0.29469062977016547", + "c": "real methodC-0.07593266140415622", } `; exports[`RnpExample useDTwice 1`] = ` { - "d1": "real methodD-0.546786535872487", - "d2": "real methodD-0.9218730426590118", + "d1": "real methodD-0.6444137082306514", + "d2": "real methodD-0.1376059747752234", } `; -exports[`RnpExample useUnmappedDep 1`] = ` +exports[`RnpExample useDep2 1`] = ` { - "z": "real methodZ-0.42323554137867947", + "z": Any, +} +`; + +exports[`RnpExample useUnmappedMethodMocked 1`] = ` +{ + "error": "FailedDependencyError", + "message": "Failed to useUnmappedMethod: Unmocked method was called: dep1_A.methodUnmapped", + "statusCode": 424, +} +`; + +exports[`RnpExample useUnmappedMethodRecorder 1`] = ` +{ + "unmapped": "real methodUnmapped-0.17405589451073245", } `; diff --git a/test/rnpExample/api/rnpExample.routes.ts b/test/rnpExample/api/rnpExample.routes.ts index 457a585c49..8398d42c26 100644 --- a/test/rnpExample/api/rnpExample.routes.ts +++ b/test/rnpExample/api/rnpExample.routes.ts @@ -70,9 +70,11 @@ const useUnmappedMethodRoute: FastifyPluginAsync = async (fastify) => { const rnpExample = await RnpExample.getInstance(request.query.network); return await rnpExample.useUnmappedMethod(); } catch (error) { - logger.error(`Error getting useUnmappedDep status: ${error.message}`); - throw fastify.httpErrors.internalServerError( - `Failed to useUnmappedDep: ${error.message}`, + logger.error( + `Error getting useUnmappedMethod status: ${error.message}`, + ); + throw fastify.httpErrors.failedDependency( + `Failed to useUnmappedMethod: ${error.message}`, ); } }, diff --git a/test/rnpExample/mocks/rnpExample-methodA.json b/test/rnpExample/mocks/rnpExample-methodA.json index 2a38d87033..902c4a926c 100644 --- a/test/rnpExample/mocks/rnpExample-methodA.json +++ b/test/rnpExample/mocks/rnpExample-methodA.json @@ -1,3 +1,3 @@ { - "json": "real methodA-0.4019890230670282" + "json": "real methodA-0.04965624398717727" } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodB.json b/test/rnpExample/mocks/rnpExample-methodB.json index 49dbb3b978..a435aa381c 100644 --- a/test/rnpExample/mocks/rnpExample-methodB.json +++ b/test/rnpExample/mocks/rnpExample-methodB.json @@ -1,3 +1,3 @@ { - "json": "real methodB-0.5932643445442789" + "json": "real methodB-0.29469062977016547" } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodD1.json b/test/rnpExample/mocks/rnpExample-methodD1.json index 54b1556659..547e5bab74 100644 --- a/test/rnpExample/mocks/rnpExample-methodD1.json +++ b/test/rnpExample/mocks/rnpExample-methodD1.json @@ -1,3 +1,3 @@ { - "json": "real methodD-0.546786535872487" + "json": "real methodD-0.6444137082306514" } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodD2.json b/test/rnpExample/mocks/rnpExample-methodD2.json index 6285381778..4424a7ce41 100644 --- a/test/rnpExample/mocks/rnpExample-methodD2.json +++ b/test/rnpExample/mocks/rnpExample-methodD2.json @@ -1,3 +1,3 @@ { - "json": "real methodD-0.9218730426590118" + "json": "real methodD-0.1376059747752234" } \ No newline at end of file diff --git a/test/rnpExample/rnpExample.api-test-cases.ts b/test/rnpExample/rnpExample.api-test-cases.ts index 1a168d7fb6..e42d16af19 100644 --- a/test/rnpExample/rnpExample.api-test-cases.ts +++ b/test/rnpExample/rnpExample.api-test-cases.ts @@ -27,11 +27,32 @@ export const useDTwice = new TestCase( }, ); -export const useUnmappedDep = new TestCase( +export const useUnmappedMethodRecorder = new TestCase( 'GET', - '/rnpExample/useUnmappedDep', + '/rnpExample/useUnmappedMethod', 200, { network: 'TEST' }, {}, {}, ); + +export const useUnmappedMethodMocked = new TestCase( + 'GET', + '/rnpExample/useUnmappedMethod', + 424, + { network: 'TEST' }, + {}, + {}, +); + +export const useDep2 = new TestCase( + 'GET', + '/rnpExample/useDep2', + 200, + { network: 'TEST' }, + {}, + {}, + { + z: expect.any(String), + }, +); diff --git a/test/rnpExample/rnpExample.test.ts b/test/rnpExample/rnpExample.test.ts index be5556881d..23aa8a5d29 100644 --- a/test/rnpExample/rnpExample.test.ts +++ b/test/rnpExample/rnpExample.test.ts @@ -1,4 +1,9 @@ -import { useABC, useDTwice, useUnmappedDep } from './rnpExample.api-test-cases'; +import { + useABC, + useDep2, + useDTwice, + useUnmappedMethodMocked, +} from './rnpExample.api-test-cases'; import { RnpExampleTestHarness } from './rnpExample.test-harness'; describe('RnpExample', () => { @@ -6,7 +11,6 @@ describe('RnpExample', () => { beforeAll(async () => { harness = new RnpExampleTestHarness(); - await harness.init(); await harness.setupMockedTests(); }); @@ -22,7 +26,11 @@ describe('RnpExample', () => { await useDTwice.processPlayRequest(harness); }); - it('useUnmappedDep', async () => { - await useUnmappedDep.processPlayRequest(harness); + it('useUnmappedMethodMocked', async () => { + await useUnmappedMethodMocked.processPlayRequest(harness); + }); + + it('useDep2', async () => { + await useDep2.processPlayRequest(harness); }); }); From dd8e9078ac2c245ee90d91d9474f1459997e5048 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Tue, 1 Jul 2025 14:55:22 -0600 Subject: [PATCH 05/45] fix wallet failure tests which fail when sensible is registered globally. 400 makes more sense for invalid input anyway. --- test/chains/ethereum/wallet.test.ts | 6 +++--- test/chains/solana/wallet.test.ts | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/test/chains/ethereum/wallet.test.ts b/test/chains/ethereum/wallet.test.ts index 4ef1436cb1..a73299213f 100644 --- a/test/chains/ethereum/wallet.test.ts +++ b/test/chains/ethereum/wallet.test.ts @@ -168,7 +168,7 @@ describe('Ethereum Wallet Operations', () => { }, }); - expect(response.statusCode).toBe(500); + expect(response.statusCode).toBe(400); }); it('should fail with missing parameters', async () => { @@ -271,8 +271,8 @@ describe('Ethereum Wallet Operations', () => { }, }); - // Address validation happens and throws 500 on invalid format - expect(response.statusCode).toBe(500); + // Address validation happens and throws 400 on invalid format + expect(response.statusCode).toBe(400); }); }); diff --git a/test/chains/solana/wallet.test.ts b/test/chains/solana/wallet.test.ts index 615025c1d9..1b4e2093a3 100644 --- a/test/chains/solana/wallet.test.ts +++ b/test/chains/solana/wallet.test.ts @@ -171,7 +171,7 @@ describe('Solana Wallet Operations', () => { }, }); - expect(response.statusCode).toBe(500); + expect(response.statusCode).toBe(400); }); it('should fail with missing parameters', async () => { @@ -274,8 +274,8 @@ describe('Solana Wallet Operations', () => { }, }); - // Address validation happens and throws 500 on invalid format - expect(response.statusCode).toBe(500); + // Address validation happens and throws 400 on invalid format + expect(response.statusCode).toBe(400); }); }); @@ -356,7 +356,7 @@ describe('Solana Wallet Operations', () => { }, }); - expect(response.statusCode).toBe(500); + expect(response.statusCode).toBe(400); }); }); }); From 89c295612b9e83166a2ac51e761c5523ab774688 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Tue, 1 Jul 2025 15:01:43 -0600 Subject: [PATCH 06/45] don't print the logo for unit tests. clarify that setup file is run per suite rather than once globally. --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 459d1506ec..d8343a4edc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,7 @@ if (process.env.START_SERVER === 'true') { console.error('Failed to start server:', error); process.exit(1); }); -} else { +} else if (process.env.GATEWAY_TEST_MODE !== 'test') { console.log(asciiLogo); console.log('Use "pnpm start" to start the Gateway server'); } From cd94a33aad12907332c1ebd6ad68598771358f74 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Thu, 26 Jun 2025 18:06:43 -0600 Subject: [PATCH 07/45] Add allowPassThrough option to enable methods on dependencies which are okay to ignore during playback. --- .../record-and-play/abstract-gateway-test-harness.ts | 2 +- test/record-and-play/test-dependency-contract.ts | 11 +++++++++++ .../rnpExample/__snapshots__/rnpExample.test.ts.snap | 12 ++++++------ test/rnpExample/api/rnpExample.ts | 1 - test/rnpExample/mocks/rnpExample-methodA.json | 2 +- test/rnpExample/mocks/rnpExample-methodB.json | 2 +- test/rnpExample/mocks/rnpExample-methodD1.json | 2 +- test/rnpExample/mocks/rnpExample-methodD2.json | 2 +- test/rnpExample/rnpExample.api-test-cases.ts | 3 +++ test/rnpExample/rnpExample.test-harness.ts | 11 +++++------ 10 files changed, 30 insertions(+), 18 deletions(-) diff --git a/test/record-and-play/abstract-gateway-test-harness.ts b/test/record-and-play/abstract-gateway-test-harness.ts index addd201dd5..512493608d 100644 --- a/test/record-and-play/abstract-gateway-test-harness.ts +++ b/test/record-and-play/abstract-gateway-test-harness.ts @@ -108,7 +108,7 @@ export abstract class AbstractGatewayTestHarness for (const [instanceKey, dep] of Object.entries(this.dependencyContracts)) { const object = dep.getObject(this); for (const [methodName, method] of Object.entries(object)) { - if (!(method as any).mock) { + if (!(method as any).mock && !dep.allowPassThrough) { const spy = jest.spyOn(object, methodName); spy.mockImplementation(() => { throw new Error( diff --git a/test/record-and-play/test-dependency-contract.ts b/test/record-and-play/test-dependency-contract.ts index 4e15305ded..c118eafbca 100644 --- a/test/record-and-play/test-dependency-contract.ts +++ b/test/record-and-play/test-dependency-contract.ts @@ -18,6 +18,11 @@ export abstract class TestDependencyContract { */ abstract getObject(harness: MockProvider): any; + /** + * Whether to allow the dependency to pass through to the real implementation. + */ + abstract allowPassThrough: boolean; + /** * Replaces a dependency call with a mock for record and play testing. * Can be called multiple times to mock subsequent calls to the same dependency. @@ -43,6 +48,7 @@ export class InstancePropertyDependency< constructor( private instancePropertyKey: K, private methodName: keyof TInstance[K], + public allowPassThrough: boolean = false, ) { super(); } @@ -69,6 +75,7 @@ export class PrototypeDependency< constructor( private Klass: new (...args: any[]) => TPrototype, private prototypeMethod: keyof TPrototype, + public allowPassThrough: boolean = false, ) { super(); } @@ -87,20 +94,24 @@ export class DependencyFactory { instanceProperty( instanceKey: K, methodName: keyof TInstance[K], + allowPassThrough: boolean = false, ): InstancePropertyDependency { return new InstancePropertyDependency( instanceKey, methodName, + allowPassThrough, ); } prototype( Klass: new (...args: any[]) => TPrototype, prototypeMethod: keyof TPrototype, + allowPassThrough: boolean = false, ): PrototypeDependency { return new PrototypeDependency( Klass, prototypeMethod, + allowPassThrough, ); } } diff --git a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap index b183107c4b..7c051e0ee1 100644 --- a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap +++ b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap @@ -2,16 +2,16 @@ exports[`RnpExample useABC 1`] = ` { - "a": "real methodA-0.04965624398717727", - "b": "real methodB-0.29469062977016547", - "c": "real methodC-0.07593266140415622", + "a": "real methodA-0.3167918897895934", + "b": "real methodB-0.5959480847973675", + "c": Any, } `; exports[`RnpExample useDTwice 1`] = ` { - "d1": "real methodD-0.6444137082306514", - "d2": "real methodD-0.1376059747752234", + "d1": "real methodD-0.9822143426913119", + "d2": "real methodD-0.3523185054710709", } `; @@ -31,6 +31,6 @@ exports[`RnpExample useUnmappedMethodMocked 1`] = ` exports[`RnpExample useUnmappedMethodRecorder 1`] = ` { - "unmapped": "real methodUnmapped-0.17405589451073245", + "unmapped": "real methodUnmapped-0.14017814520237115", } `; diff --git a/test/rnpExample/api/rnpExample.ts b/test/rnpExample/api/rnpExample.ts index e6850a6e65..ed5b1d92b6 100644 --- a/test/rnpExample/api/rnpExample.ts +++ b/test/rnpExample/api/rnpExample.ts @@ -4,7 +4,6 @@ export class Dep1 { // TODO: always mocked methodB = async () => 'real methodB-' + Math.random(); - // TODO: passthrough during playback methodC = async () => 'real methodC-' + Math.random(); methodD = async () => 'real methodD-' + Math.random(); diff --git a/test/rnpExample/mocks/rnpExample-methodA.json b/test/rnpExample/mocks/rnpExample-methodA.json index 902c4a926c..c603c2c9b7 100644 --- a/test/rnpExample/mocks/rnpExample-methodA.json +++ b/test/rnpExample/mocks/rnpExample-methodA.json @@ -1,3 +1,3 @@ { - "json": "real methodA-0.04965624398717727" + "json": "real methodA-0.3167918897895934" } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodB.json b/test/rnpExample/mocks/rnpExample-methodB.json index a435aa381c..80a9fa8714 100644 --- a/test/rnpExample/mocks/rnpExample-methodB.json +++ b/test/rnpExample/mocks/rnpExample-methodB.json @@ -1,3 +1,3 @@ { - "json": "real methodB-0.29469062977016547" + "json": "real methodB-0.5959480847973675" } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodD1.json b/test/rnpExample/mocks/rnpExample-methodD1.json index 547e5bab74..8795672427 100644 --- a/test/rnpExample/mocks/rnpExample-methodD1.json +++ b/test/rnpExample/mocks/rnpExample-methodD1.json @@ -1,3 +1,3 @@ { - "json": "real methodD-0.6444137082306514" + "json": "real methodD-0.9822143426913119" } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodD2.json b/test/rnpExample/mocks/rnpExample-methodD2.json index 4424a7ce41..fba980a410 100644 --- a/test/rnpExample/mocks/rnpExample-methodD2.json +++ b/test/rnpExample/mocks/rnpExample-methodD2.json @@ -1,3 +1,3 @@ { - "json": "real methodD-0.1376059747752234" + "json": "real methodD-0.3523185054710709" } \ No newline at end of file diff --git a/test/rnpExample/rnpExample.api-test-cases.ts b/test/rnpExample/rnpExample.api-test-cases.ts index e42d16af19..f318fda12b 100644 --- a/test/rnpExample/rnpExample.api-test-cases.ts +++ b/test/rnpExample/rnpExample.api-test-cases.ts @@ -14,6 +14,9 @@ export const useABC = new TestCase( dep1_A: 'rnpExample-methodA', dep1_B: 'rnpExample-methodB', }, + { + c: expect.any(String), + }, ); export const useDTwice = new TestCase( diff --git a/test/rnpExample/rnpExample.test-harness.ts b/test/rnpExample/rnpExample.test-harness.ts index f56e105eda..8a07539a51 100644 --- a/test/rnpExample/rnpExample.test-harness.ts +++ b/test/rnpExample/rnpExample.test-harness.ts @@ -5,13 +5,13 @@ import { RnpExample } from './api/rnpExample'; export class RnpExampleTestHarness extends AbstractGatewayTestHarness { readonly dependencyContracts = { dep1_A: this.dependencyFactory.instanceProperty('dep1', 'methodA'), + // TODO: Alwayed mocked depdendency dep1_B: this.dependencyFactory.instanceProperty('dep1', 'methodB'), - // TODO: C should be a passthrough - // dep1_C: this.dependencyFactory.instanceProperty( - // 'dep1', - // 'methodC', - // ), + dep1_C: this.dependencyFactory.instanceProperty('dep1', 'methodC', true), dep1_D: this.dependencyFactory.instanceProperty('dep1', 'methodD'), + + // TODO: prototype dependency examples + // TODO: explain that unlisted deps will be ignored }; constructor() { @@ -37,7 +37,6 @@ export class RnpExampleTestHarness extends AbstractGatewayTestHarness Date: Fri, 27 Jun 2025 12:50:59 -0600 Subject: [PATCH 08/45] reset mocks after each test. --- .../rnpExample/rnpExample.recorder.test.ts | 4 ++++ .../abstract-gateway-test-harness.ts | 18 +++++++++++++++--- test/rnpExample/rnpExample.test.ts | 4 ++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/test-scripts/rnpExample/rnpExample.recorder.test.ts b/test-scripts/rnpExample/rnpExample.recorder.test.ts index 2878ff513e..e7981dd62c 100644 --- a/test-scripts/rnpExample/rnpExample.recorder.test.ts +++ b/test-scripts/rnpExample/rnpExample.recorder.test.ts @@ -10,6 +10,10 @@ describe('RnpExample', () => { await harness.setupRecorder(); }); + afterEach(async () => { + await harness.reset(); + }); + afterAll(async () => { await harness.teardown(); }); diff --git a/test/record-and-play/abstract-gateway-test-harness.ts b/test/record-and-play/abstract-gateway-test-harness.ts index 512493608d..e47cdd2ed3 100644 --- a/test/record-and-play/abstract-gateway-test-harness.ts +++ b/test/record-and-play/abstract-gateway-test-harness.ts @@ -76,8 +76,10 @@ export abstract class AbstractGatewayTestHarness private async setupSpies() { for (const [_key, dep] of Object.entries(this.dependencyContracts)) { - const spy = dep.setupSpy(this); - dep.spy = spy; + if (!dep.spy) { + const spy = dep.setupSpy(this); + dep.spy = spy; + } } } @@ -136,9 +138,19 @@ export abstract class AbstractGatewayTestHarness } } + async reset() { + Object.values(this.dependencyContracts).forEach((dep) => { + if (dep.spy) { + dep.spy.mockReset(); + } + }); + } + async teardown() { Object.values(this.dependencyContracts).forEach((dep) => { - dep.spy.mockRestore(); + if (dep.spy) { + dep.spy.mockRestore(); + } }); if (this._gatewayApp) { await this._gatewayApp.close(); diff --git a/test/rnpExample/rnpExample.test.ts b/test/rnpExample/rnpExample.test.ts index 23aa8a5d29..4e68b13dd2 100644 --- a/test/rnpExample/rnpExample.test.ts +++ b/test/rnpExample/rnpExample.test.ts @@ -14,6 +14,10 @@ describe('RnpExample', () => { await harness.setupMockedTests(); }); + afterEach(async () => { + await harness.reset(); + }); + afterAll(async () => { await harness.teardown(); }); From ad6236aa9ef4b59e890d3a62fee664deda34e997 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Fri, 27 Jun 2025 13:27:21 -0600 Subject: [PATCH 09/45] separate out each route into a separate file to match connectors and chains. rename useDep2 to useUnmappedDep. Also made complimentary useUnmappedMethodMocked and useUnmappedMethodRecorder to produce clean test pass output. --- .../rnpExample/rnpExample.recorder.test.ts | 14 ++- .../__snapshots__/rnpExample.test.ts.snap | 4 +- test/rnpExample/api/rnpExample.routes.ts | 111 +----------------- test/rnpExample/api/rnpExample.ts | 2 +- test/rnpExample/api/routes/useABC.ts | 30 +++++ test/rnpExample/api/routes/useDTwice.ts | 30 +++++ test/rnpExample/api/routes/useUnmappedDep.ts | 30 +++++ .../api/routes/useUnmappedMethod.ts | 33 ++++++ test/rnpExample/rnpExample.api-test-cases.ts | 5 +- test/rnpExample/rnpExample.test-harness.ts | 1 - test/rnpExample/rnpExample.test.ts | 13 +- 11 files changed, 156 insertions(+), 117 deletions(-) create mode 100644 test/rnpExample/api/routes/useABC.ts create mode 100644 test/rnpExample/api/routes/useDTwice.ts create mode 100644 test/rnpExample/api/routes/useUnmappedDep.ts create mode 100644 test/rnpExample/api/routes/useUnmappedMethod.ts diff --git a/test-scripts/rnpExample/rnpExample.recorder.test.ts b/test-scripts/rnpExample/rnpExample.recorder.test.ts index e7981dd62c..2a67e3d31e 100644 --- a/test-scripts/rnpExample/rnpExample.recorder.test.ts +++ b/test-scripts/rnpExample/rnpExample.recorder.test.ts @@ -1,4 +1,5 @@ import { RnpExampleTestHarness } from '#test/rnpExample/rnpExample.test-harness'; +import { useABC, useUnmappedDep, useDTwice, useUnmappedMethodRecorder } from '#test/rnpExample/rnpExample.api-test-cases'; import { useABC, useDep2, useDTwice, useUnmappedMethodMocked, useUnmappedMethodRecorder } from '#test/rnpExample/rnpExample.api-test-cases'; describe('RnpExample', () => { @@ -30,7 +31,16 @@ describe('RnpExample', () => { await useUnmappedMethodRecorder.processRecorderRequest(harness); }); - it('useDep2', async () => { - await useDep2.processRecorderRequest(harness); + it('useUnmappedMethodMocked', async () => { + // Create to force snapshot file to match exactly + expect({ + "error": "FailedDependencyError", + "message": "Failed to useUnmappedMethod: Unmocked method was called: dep1_A.methodUnmapped", + "statusCode": 424, + }).toMatchSnapshot(); + }); + + it('useUnmappedDep', async () => { + await useUnmappedDep.processRecorderRequest(harness); }); }); \ No newline at end of file diff --git a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap index 7c051e0ee1..311b87653c 100644 --- a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap +++ b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap @@ -15,7 +15,7 @@ exports[`RnpExample useDTwice 1`] = ` } `; -exports[`RnpExample useDep2 1`] = ` +exports[`RnpExample useUnmappedDep 1`] = ` { "z": Any, } @@ -31,6 +31,6 @@ exports[`RnpExample useUnmappedMethodMocked 1`] = ` exports[`RnpExample useUnmappedMethodRecorder 1`] = ` { - "unmapped": "real methodUnmapped-0.14017814520237115", + "unmapped": Any, } `; diff --git a/test/rnpExample/api/rnpExample.routes.ts b/test/rnpExample/api/rnpExample.routes.ts index 8398d42c26..cde861acfa 100644 --- a/test/rnpExample/api/rnpExample.routes.ts +++ b/test/rnpExample/api/rnpExample.routes.ts @@ -1,116 +1,15 @@ import { FastifyPluginAsync } from 'fastify'; -import { logger } from '#src/services/logger'; - -import { RnpExample } from './rnpExample'; - -const useABCRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: { network: string }; - Reply: { a: string; b: string; c: string }; - }>( - '/useABC', - { - schema: { - summary: 'A RnpExample route for testing', - }, - }, - async (request) => { - try { - const rnpExample = await RnpExample.getInstance(request.query.network); - return await rnpExample.useABC(); - } catch (error) { - logger.error(`Error getting useABC status: ${error.message}`); - throw fastify.httpErrors.internalServerError( - `Failed to useABC: ${error.message}`, - ); - } - }, - ); -}; - -const useDTwiceRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: { network: string }; - Reply: { d1: string; d2: string }; - }>( - '/useDTwice', - { - schema: { - summary: 'A RnpExample route for testing', - }, - }, - async (request) => { - try { - const rnpExample = await RnpExample.getInstance(request.query.network); - return await rnpExample.useDTwice(); - } catch (error) { - logger.error(`Error getting useDTwice status: ${error.message}`); - throw fastify.httpErrors.internalServerError( - `Failed to useDTwice: ${error.message}`, - ); - } - }, - ); -}; - -const useUnmappedMethodRoute: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: { network: string }; - Reply: { unmapped: string }; - }>( - '/useUnmappedMethod', - { - schema: { - summary: 'A RnpExample route for testing', - }, - }, - async (request) => { - try { - const rnpExample = await RnpExample.getInstance(request.query.network); - return await rnpExample.useUnmappedMethod(); - } catch (error) { - logger.error( - `Error getting useUnmappedMethod status: ${error.message}`, - ); - throw fastify.httpErrors.failedDependency( - `Failed to useUnmappedMethod: ${error.message}`, - ); - } - }, - ); -}; - -const useDep2Route: FastifyPluginAsync = async (fastify) => { - fastify.get<{ - Querystring: { network: string }; - Reply: { z: string }; - }>( - '/useDep2', - { - schema: { - summary: 'A RnpExample route for testing', - }, - }, - async (request) => { - try { - const rnpExample = await RnpExample.getInstance(request.query.network); - return await rnpExample.useDep2(); - } catch (error) { - logger.error(`Error getting useDep2 status: ${error.message}`); - throw fastify.httpErrors.internalServerError( - `Failed to useDep2: ${error.message}`, - ); - } - }, - ); -}; +import { useABCRoute } from './routes/useABC'; +import { useDTwiceRoute } from './routes/useDTwice'; +import { useUnmappedDepRoute } from './routes/useUnmappedDep'; +import { useUnmappedMethodRoute } from './routes/useUnmappedMethod'; export const rnpExampleRoutes: FastifyPluginAsync = async (fastify) => { await fastify.register(useABCRoute); await fastify.register(useDTwiceRoute); await fastify.register(useUnmappedMethodRoute); - await fastify.register(useDep2Route); + await fastify.register(useUnmappedDepRoute); }; export default rnpExampleRoutes; diff --git a/test/rnpExample/api/rnpExample.ts b/test/rnpExample/api/rnpExample.ts index ed5b1d92b6..89c2bf2865 100644 --- a/test/rnpExample/api/rnpExample.ts +++ b/test/rnpExample/api/rnpExample.ts @@ -54,7 +54,7 @@ export class RnpExample { return { unmapped }; } - async useDep2() { + async useUnmappedDep() { const z = await this.dep2.methodZ(); return { z }; } diff --git a/test/rnpExample/api/routes/useABC.ts b/test/rnpExample/api/routes/useABC.ts new file mode 100644 index 0000000000..7d2cc64548 --- /dev/null +++ b/test/rnpExample/api/routes/useABC.ts @@ -0,0 +1,30 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { logger } from '#src/services/logger'; + +import { RnpExample } from '../rnpExample'; + +export const useABCRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: { network: string }; + Reply: { a: string; b: string; c: string }; + }>( + '/useABC', + { + schema: { + summary: 'A RnpExample route for testing', + }, + }, + async (request) => { + try { + const rnpExample = await RnpExample.getInstance(request.query.network); + return await rnpExample.useABC(); + } catch (error) { + logger.error(`Error getting useABC status: ${error.message}`); + throw fastify.httpErrors.internalServerError( + `Failed to useABC: ${error.message}`, + ); + } + }, + ); +}; diff --git a/test/rnpExample/api/routes/useDTwice.ts b/test/rnpExample/api/routes/useDTwice.ts new file mode 100644 index 0000000000..902aeac9b7 --- /dev/null +++ b/test/rnpExample/api/routes/useDTwice.ts @@ -0,0 +1,30 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { logger } from '#src/services/logger'; + +import { RnpExample } from '../rnpExample'; + +export const useDTwiceRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: { network: string }; + Reply: { d1: string; d2: string }; + }>( + '/useDTwice', + { + schema: { + summary: 'A RnpExample route for testing', + }, + }, + async (request) => { + try { + const rnpExample = await RnpExample.getInstance(request.query.network); + return await rnpExample.useDTwice(); + } catch (error) { + logger.error(`Error getting useDTwice status: ${error.message}`); + throw fastify.httpErrors.internalServerError( + `Failed to useDTwice: ${error.message}`, + ); + } + }, + ); +}; diff --git a/test/rnpExample/api/routes/useUnmappedDep.ts b/test/rnpExample/api/routes/useUnmappedDep.ts new file mode 100644 index 0000000000..b47c36ca16 --- /dev/null +++ b/test/rnpExample/api/routes/useUnmappedDep.ts @@ -0,0 +1,30 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { logger } from '#src/services/logger'; + +import { RnpExample } from '../rnpExample'; + +export const useUnmappedDepRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: { network: string }; + Reply: { z: string }; + }>( + '/useUnmappedDep', + { + schema: { + summary: 'A RnpExample route for testing', + }, + }, + async (request) => { + try { + const rnpExample = await RnpExample.getInstance(request.query.network); + return await rnpExample.useUnmappedDep(); + } catch (error) { + logger.error(`Error getting useUnmappedDep status: ${error.message}`); + throw fastify.httpErrors.internalServerError( + `Failed to useUnmappedDep: ${error.message}`, + ); + } + }, + ); +}; diff --git a/test/rnpExample/api/routes/useUnmappedMethod.ts b/test/rnpExample/api/routes/useUnmappedMethod.ts new file mode 100644 index 0000000000..b9e81d77ab --- /dev/null +++ b/test/rnpExample/api/routes/useUnmappedMethod.ts @@ -0,0 +1,33 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { logger } from '#src/services/logger'; + +import { RnpExample } from '../rnpExample'; + +export const useUnmappedMethodRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: { network: string }; + Reply: { unmapped: string }; + }>( + '/useUnmappedMethod', + { + schema: { + summary: 'A RnpExample route for testing', + }, + }, + async (request) => { + try { + const rnpExample = await RnpExample.getInstance(request.query.network); + return await rnpExample.useUnmappedMethod(); + } catch (error) { + logger.error( + `Error getting useUnmappedMethod status: ${error.message}`, + ); + // Throw specific error to verify a 424 is returned for snapshot + throw fastify.httpErrors.failedDependency( + `Failed to useUnmappedMethod: ${error.message}`, + ); + } + }, + ); +}; diff --git a/test/rnpExample/rnpExample.api-test-cases.ts b/test/rnpExample/rnpExample.api-test-cases.ts index f318fda12b..57e6c3628d 100644 --- a/test/rnpExample/rnpExample.api-test-cases.ts +++ b/test/rnpExample/rnpExample.api-test-cases.ts @@ -37,6 +37,7 @@ export const useUnmappedMethodRecorder = new TestCase( { network: 'TEST' }, {}, {}, + { unmapped: expect.any(String) }, ); export const useUnmappedMethodMocked = new TestCase( @@ -48,9 +49,9 @@ export const useUnmappedMethodMocked = new TestCase( {}, ); -export const useDep2 = new TestCase( +export const useUnmappedDep = new TestCase( 'GET', - '/rnpExample/useDep2', + '/rnpExample/useUnmappedDep', 200, { network: 'TEST' }, {}, diff --git a/test/rnpExample/rnpExample.test-harness.ts b/test/rnpExample/rnpExample.test-harness.ts index 8a07539a51..a22a6487c4 100644 --- a/test/rnpExample/rnpExample.test-harness.ts +++ b/test/rnpExample/rnpExample.test-harness.ts @@ -5,7 +5,6 @@ import { RnpExample } from './api/rnpExample'; export class RnpExampleTestHarness extends AbstractGatewayTestHarness { readonly dependencyContracts = { dep1_A: this.dependencyFactory.instanceProperty('dep1', 'methodA'), - // TODO: Alwayed mocked depdendency dep1_B: this.dependencyFactory.instanceProperty('dep1', 'methodB'), dep1_C: this.dependencyFactory.instanceProperty('dep1', 'methodC', true), dep1_D: this.dependencyFactory.instanceProperty('dep1', 'methodD'), diff --git a/test/rnpExample/rnpExample.test.ts b/test/rnpExample/rnpExample.test.ts index 4e68b13dd2..0101111862 100644 --- a/test/rnpExample/rnpExample.test.ts +++ b/test/rnpExample/rnpExample.test.ts @@ -1,6 +1,6 @@ import { useABC, - useDep2, + useUnmappedDep, useDTwice, useUnmappedMethodMocked, } from './rnpExample.api-test-cases'; @@ -30,11 +30,18 @@ describe('RnpExample', () => { await useDTwice.processPlayRequest(harness); }); + it('useUnmappedMethodRecorder', async () => { + // Used to force snapshot file to match exactly + expect({ + unmapped: expect.any(String), + }).toMatchSnapshot(); + }); + it('useUnmappedMethodMocked', async () => { await useUnmappedMethodMocked.processPlayRequest(harness); }); - it('useDep2', async () => { - await useDep2.processPlayRequest(harness); + it('useUnmappedDep', async () => { + await useUnmappedDep.processPlayRequest(harness); }); }); From dad3044022fcbad1b62b7c65eae11c5f8fa4f87d Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Fri, 27 Jun 2025 13:34:01 -0600 Subject: [PATCH 10/45] Add prototype dependency support with useProtoDep route and corresponding test case --- .../rnpExample/rnpExample.recorder.test.ts | 12 +++++--- .../__snapshots__/rnpExample.test.ts.snap | 14 ++++++--- test/rnpExample/api/rnpExample.routes.ts | 2 ++ test/rnpExample/api/rnpExample.ts | 12 +++++++- test/rnpExample/api/routes/useProtoDep.ts | 30 +++++++++++++++++++ test/rnpExample/mocks/rnpExample-methodA.json | 2 +- test/rnpExample/mocks/rnpExample-methodB.json | 2 +- .../rnpExample/mocks/rnpExample-methodD1.json | 2 +- .../rnpExample/mocks/rnpExample-methodD2.json | 2 +- test/rnpExample/mocks/rnpExample-methodX.json | 3 ++ test/rnpExample/rnpExample.api-test-cases.ts | 11 +++++++ test/rnpExample/rnpExample.test-harness.ts | 3 +- test/rnpExample/rnpExample.test.ts | 5 ++++ 13 files changed, 86 insertions(+), 14 deletions(-) create mode 100644 test/rnpExample/api/routes/useProtoDep.ts create mode 100644 test/rnpExample/mocks/rnpExample-methodX.json diff --git a/test-scripts/rnpExample/rnpExample.recorder.test.ts b/test-scripts/rnpExample/rnpExample.recorder.test.ts index 2a67e3d31e..21f239639e 100644 --- a/test-scripts/rnpExample/rnpExample.recorder.test.ts +++ b/test-scripts/rnpExample/rnpExample.recorder.test.ts @@ -1,6 +1,5 @@ import { RnpExampleTestHarness } from '#test/rnpExample/rnpExample.test-harness'; -import { useABC, useUnmappedDep, useDTwice, useUnmappedMethodRecorder } from '#test/rnpExample/rnpExample.api-test-cases'; -import { useABC, useDep2, useDTwice, useUnmappedMethodMocked, useUnmappedMethodRecorder } from '#test/rnpExample/rnpExample.api-test-cases'; +import { useABC, useUnmappedDep, useDTwice, useProtoDep, useUnmappedMethodRecorder } from '#test/rnpExample/rnpExample.api-test-cases'; describe('RnpExample', () => { let harness: RnpExampleTestHarness; @@ -12,7 +11,8 @@ describe('RnpExample', () => { }); afterEach(async () => { - await harness.reset(); + // Do NOT call reset() it will break the spies + // TODO: allow calling reset as currently multiple tests using the same method will break }); afterAll(async () => { @@ -22,11 +22,15 @@ describe('RnpExample', () => { it('useABC', async () => { await useABC.processRecorderRequest(harness); }); - + it('useDTwice', async () => { await useDTwice.processRecorderRequest(harness); }); + it('useProtoDep', async () => { + await useProtoDep.processRecorderRequest(harness); + }); + it('useUnmappedMethodRecorder', async () => { await useUnmappedMethodRecorder.processRecorderRequest(harness); }); diff --git a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap index 311b87653c..df8c68fdf5 100644 --- a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap +++ b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap @@ -2,16 +2,22 @@ exports[`RnpExample useABC 1`] = ` { - "a": "real methodA-0.3167918897895934", - "b": "real methodB-0.5959480847973675", + "a": "real methodA-0.8603623557983471", + "b": "real methodB-0.6325482401357516", "c": Any, } `; exports[`RnpExample useDTwice 1`] = ` { - "d1": "real methodD-0.9822143426913119", - "d2": "real methodD-0.3523185054710709", + "d1": "real methodD-0.44441784174219356", + "d2": "real methodD-0.9093530606362545", +} +`; + +exports[`RnpExample useProtoDep 1`] = ` +{ + "x": "real methodX-0.4117859175944383", } `; diff --git a/test/rnpExample/api/rnpExample.routes.ts b/test/rnpExample/api/rnpExample.routes.ts index cde861acfa..dfc113159c 100644 --- a/test/rnpExample/api/rnpExample.routes.ts +++ b/test/rnpExample/api/rnpExample.routes.ts @@ -2,6 +2,7 @@ import { FastifyPluginAsync } from 'fastify'; import { useABCRoute } from './routes/useABC'; import { useDTwiceRoute } from './routes/useDTwice'; +import { useProtoDepRoute } from './routes/useProtoDep'; import { useUnmappedDepRoute } from './routes/useUnmappedDep'; import { useUnmappedMethodRoute } from './routes/useUnmappedMethod'; @@ -9,6 +10,7 @@ export const rnpExampleRoutes: FastifyPluginAsync = async (fastify) => { await fastify.register(useABCRoute); await fastify.register(useDTwiceRoute); await fastify.register(useUnmappedMethodRoute); + await fastify.register(useProtoDepRoute); await fastify.register(useUnmappedDepRoute); }; diff --git a/test/rnpExample/api/rnpExample.ts b/test/rnpExample/api/rnpExample.ts index 89c2bf2865..1e14a937cb 100644 --- a/test/rnpExample/api/rnpExample.ts +++ b/test/rnpExample/api/rnpExample.ts @@ -1,7 +1,6 @@ export class Dep1 { methodA = async () => 'real methodA-' + Math.random(); - // TODO: always mocked methodB = async () => 'real methodB-' + Math.random(); methodC = async () => 'real methodC-' + Math.random(); @@ -15,6 +14,12 @@ export class Dep2 { methodZ = async () => 'real methodZ-' + Math.random(); } +export class DepProto { + async methodX() { + return 'real methodX-' + Math.random(); + } +} + export class RnpExample { private static _instances: { [name: string]: RnpExample }; public dep1: Dep1; @@ -54,6 +59,11 @@ export class RnpExample { return { unmapped }; } + async useProtoDep() { + const dep3 = new DepProto(); + const x = await dep3.methodX(); + return { x }; + } async useUnmappedDep() { const z = await this.dep2.methodZ(); return { z }; diff --git a/test/rnpExample/api/routes/useProtoDep.ts b/test/rnpExample/api/routes/useProtoDep.ts new file mode 100644 index 0000000000..cdde030017 --- /dev/null +++ b/test/rnpExample/api/routes/useProtoDep.ts @@ -0,0 +1,30 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { logger } from '#src/services/logger'; + +import { RnpExample } from '../rnpExample'; + +export const useProtoDepRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: { network: string }; + Reply: { x: string }; + }>( + '/useProtoDep', + { + schema: { + summary: 'A RnpExample route for testing prototype dependencies', + }, + }, + async (request) => { + try { + const rnpExample = await RnpExample.getInstance(request.query.network); + return await rnpExample.useProtoDep(); + } catch (error) { + logger.error(`Error getting useProtoDep status: ${error.message}`); + throw fastify.httpErrors.internalServerError( + `Failed to useProtoDep: ${error.message}`, + ); + } + }, + ); +}; diff --git a/test/rnpExample/mocks/rnpExample-methodA.json b/test/rnpExample/mocks/rnpExample-methodA.json index c603c2c9b7..ee44b5d6b1 100644 --- a/test/rnpExample/mocks/rnpExample-methodA.json +++ b/test/rnpExample/mocks/rnpExample-methodA.json @@ -1,3 +1,3 @@ { - "json": "real methodA-0.3167918897895934" + "json": "real methodA-0.8603623557983471" } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodB.json b/test/rnpExample/mocks/rnpExample-methodB.json index 80a9fa8714..d34fbd0c61 100644 --- a/test/rnpExample/mocks/rnpExample-methodB.json +++ b/test/rnpExample/mocks/rnpExample-methodB.json @@ -1,3 +1,3 @@ { - "json": "real methodB-0.5959480847973675" + "json": "real methodB-0.6325482401357516" } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodD1.json b/test/rnpExample/mocks/rnpExample-methodD1.json index 8795672427..74fb7cfb9d 100644 --- a/test/rnpExample/mocks/rnpExample-methodD1.json +++ b/test/rnpExample/mocks/rnpExample-methodD1.json @@ -1,3 +1,3 @@ { - "json": "real methodD-0.9822143426913119" + "json": "real methodD-0.44441784174219356" } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodD2.json b/test/rnpExample/mocks/rnpExample-methodD2.json index fba980a410..8f304e4009 100644 --- a/test/rnpExample/mocks/rnpExample-methodD2.json +++ b/test/rnpExample/mocks/rnpExample-methodD2.json @@ -1,3 +1,3 @@ { - "json": "real methodD-0.3523185054710709" + "json": "real methodD-0.9093530606362545" } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodX.json b/test/rnpExample/mocks/rnpExample-methodX.json new file mode 100644 index 0000000000..01395b4dac --- /dev/null +++ b/test/rnpExample/mocks/rnpExample-methodX.json @@ -0,0 +1,3 @@ +{ + "json": "real methodX-0.4117859175944383" +} \ No newline at end of file diff --git a/test/rnpExample/rnpExample.api-test-cases.ts b/test/rnpExample/rnpExample.api-test-cases.ts index 57e6c3628d..397acb43bc 100644 --- a/test/rnpExample/rnpExample.api-test-cases.ts +++ b/test/rnpExample/rnpExample.api-test-cases.ts @@ -60,3 +60,14 @@ export const useUnmappedDep = new TestCase( z: expect.any(String), }, ); + +export const useProtoDep = new TestCase( + 'GET', + '/rnpExample/useProtoDep', + 200, + { network: 'TEST' }, + {}, + { + dep3_X: 'rnpExample-methodX', + }, +); diff --git a/test/rnpExample/rnpExample.test-harness.ts b/test/rnpExample/rnpExample.test-harness.ts index a22a6487c4..1ee241613b 100644 --- a/test/rnpExample/rnpExample.test-harness.ts +++ b/test/rnpExample/rnpExample.test-harness.ts @@ -1,6 +1,6 @@ import { AbstractGatewayTestHarness } from '#test/record-and-play/abstract-gateway-test-harness'; -import { RnpExample } from './api/rnpExample'; +import { DepProto, RnpExample } from './api/rnpExample'; export class RnpExampleTestHarness extends AbstractGatewayTestHarness { readonly dependencyContracts = { @@ -8,6 +8,7 @@ export class RnpExampleTestHarness extends AbstractGatewayTestHarness { await useUnmappedMethodMocked.processPlayRequest(harness); }); + it('useProtoDep', async () => { + await useProtoDep.processPlayRequest(harness); + }); + it('useUnmappedDep', async () => { await useUnmappedDep.processPlayRequest(harness); }); From eec08c1e539c098c00e0b6a951520a66a9373a35 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Fri, 27 Jun 2025 13:42:02 -0600 Subject: [PATCH 11/45] introduce a parameter object for api-test-case to help deal with the large number of parameters. --- test/record-and-play/api-test-case.ts | 91 ++++++++++------- test/rnpExample/rnpExample.api-test-cases.ts | 102 +++++++++---------- 2 files changed, 105 insertions(+), 88 deletions(-) diff --git a/test/record-and-play/api-test-case.ts b/test/record-and-play/api-test-case.ts index 4ee591c87d..c9d937dab5 100644 --- a/test/record-and-play/api-test-case.ts +++ b/test/record-and-play/api-test-case.ts @@ -6,38 +6,58 @@ import { import { AbstractGatewayTestHarness } from './abstract-gateway-test-harness'; +export interface APITestCaseParams< + T extends AbstractGatewayTestHarness = any, +> { + method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | 'HEAD'; + url: string; + expectedStatus: number; + query?: Record; + payload?: Record; + /** + * A map of mock keys to their corresponding mock file basenames. + * The test harness will automatically append the '.json' extension. + * The keys must correspond to the keys in the Test Harness's dependencyContracts object. + * @example { 'getLatestBlock': 'mayanode-getLatestBlock-response' } + */ + requiredMocks?: Partial< + Record + >; + propertyMatchers?: Partial; +} + export class APITestCase = any> implements InjectOptions { - constructor( - public method: - | 'GET' - | 'POST' - | 'PUT' - | 'DELETE' - | 'PATCH' - | 'OPTIONS' - | 'HEAD', - public url: string, - public expectedStatus: number, - public query: Record, - public payload: Record, - /** - * A map of mock keys to their corresponding mock file basenames. - * The test harness will automatically append the '.json' extension. - * The keys must correspond to the keys in the Test Harness's dependencyContracts object. - * @example { 'getLatestBlock': 'mayanode-getLatestBlock-response' } - */ - public requiredMocks: Partial< - Record - >, - public propertyMatchers?: Partial, - ) {} + method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | 'HEAD'; + url: string; + expectedStatus: number; + query: Record; + payload: Record; + requiredMocks: Partial< + Record + >; + propertyMatchers?: Partial; + + constructor({ + method, + url, + expectedStatus, + query = {}, + payload = {}, + requiredMocks = {}, + propertyMatchers, + }: APITestCaseParams) { + this.method = method; + this.url = url; + this.expectedStatus = expectedStatus; + this.query = query; + this.payload = payload; + this.requiredMocks = requiredMocks; + this.propertyMatchers = propertyMatchers; + } - public async processRecorderRequest( - harness: T, - propertyMatchers?: Partial, - ): Promise<{ + public async processRecorderRequest(harness: T): Promise<{ response: LightMyRequestResponse; body: any; }> { @@ -45,14 +65,11 @@ export class APITestCase = any> await harness.saveMocks(this.requiredMocks); this.assertStatusCode(response); const body = JSON.parse(response.body); - this.assertSnapshot(body, propertyMatchers); + this.assertSnapshot(body); return { response, body }; } - public async processPlayRequest( - harness: T, - propertyMatchers?: Partial, - ): Promise<{ + public async processPlayRequest(harness: T): Promise<{ response: LightMyRequestResponse; body: any; }> { @@ -60,13 +77,13 @@ export class APITestCase = any> const response = await harness.gatewayApp.inject(this); this.assertStatusCode(response); const body = JSON.parse(response.body); - this.assertSnapshot(body, propertyMatchers); + this.assertSnapshot(body); return { response, body }; } - public assertSnapshot(body: any, propertyMatchers?: Partial) { - if (propertyMatchers || this.propertyMatchers) { - expect(body).toMatchSnapshot(propertyMatchers || this.propertyMatchers); + public assertSnapshot(body: any) { + if (this.propertyMatchers) { + expect(body).toMatchSnapshot(this.propertyMatchers); } else { expect(body).toMatchSnapshot(); } diff --git a/test/rnpExample/rnpExample.api-test-cases.ts b/test/rnpExample/rnpExample.api-test-cases.ts index 397acb43bc..b028134d20 100644 --- a/test/rnpExample/rnpExample.api-test-cases.ts +++ b/test/rnpExample/rnpExample.api-test-cases.ts @@ -4,70 +4,70 @@ import { RnpExampleTestHarness } from './rnpExample.test-harness'; class TestCase extends APITestCase {} -export const useABC = new TestCase( - 'GET', - '/rnpExample/useABC', - 200, - { network: 'TEST' }, - {}, - { +export const useABC = new TestCase({ + method: 'GET', + url: '/rnpExample/useABC', + expectedStatus: 200, + query: { network: 'TEST' }, + payload: {}, + requiredMocks: { dep1_A: 'rnpExample-methodA', dep1_B: 'rnpExample-methodB', }, - { + propertyMatchers: { c: expect.any(String), }, -); +}); -export const useDTwice = new TestCase( - 'GET', - '/rnpExample/useDTwice', - 200, - { network: 'TEST' }, - {}, - { +export const useDTwice = new TestCase({ + method: 'GET', + url: '/rnpExample/useDTwice', + expectedStatus: 200, + query: { network: 'TEST' }, + payload: {}, + requiredMocks: { dep1_D: ['rnpExample-methodD1', 'rnpExample-methodD2'], }, -); +}); -export const useUnmappedMethodRecorder = new TestCase( - 'GET', - '/rnpExample/useUnmappedMethod', - 200, - { network: 'TEST' }, - {}, - {}, - { unmapped: expect.any(String) }, -); +export const useUnmappedMethodRecorder = new TestCase({ + method: 'GET', + url: '/rnpExample/useUnmappedMethod', + expectedStatus: 200, + query: { network: 'TEST' }, + payload: {}, + propertyMatchers: { unmapped: expect.any(String) }, +}); -export const useUnmappedMethodMocked = new TestCase( - 'GET', - '/rnpExample/useUnmappedMethod', - 424, - { network: 'TEST' }, - {}, - {}, -); +export const useUnmappedMethodMocked = new TestCase({ + method: 'GET', + url: '/rnpExample/useUnmappedMethod', + expectedStatus: 424, + query: { network: 'TEST' }, + payload: {}, + requiredMocks: {}, + propertyMatchers: {}, +}); -export const useUnmappedDep = new TestCase( - 'GET', - '/rnpExample/useUnmappedDep', - 200, - { network: 'TEST' }, - {}, - {}, - { +export const useUnmappedDep = new TestCase({ + method: 'GET', + url: '/rnpExample/useUnmappedDep', + expectedStatus: 200, + query: { network: 'TEST' }, + payload: {}, + requiredMocks: {}, + propertyMatchers: { z: expect.any(String), }, -); +}); -export const useProtoDep = new TestCase( - 'GET', - '/rnpExample/useProtoDep', - 200, - { network: 'TEST' }, - {}, - { +export const useProtoDep = new TestCase({ + method: 'GET', + url: '/rnpExample/useProtoDep', + expectedStatus: 200, + query: { network: 'TEST' }, + payload: {}, + requiredMocks: { dep3_X: 'rnpExample-methodX', }, -); +}); From eb5c5c7af6430bc22fef412c14b27909af8d6205 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Fri, 27 Jun 2025 13:46:50 -0600 Subject: [PATCH 12/45] rename init methods to contain init. --- test-scripts/rnpExample/rnpExample.recorder.test.ts | 2 +- test/record-and-play/abstract-gateway-test-harness.ts | 6 +++--- test/rnpExample/rnpExample.test.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/test-scripts/rnpExample/rnpExample.recorder.test.ts b/test-scripts/rnpExample/rnpExample.recorder.test.ts index 21f239639e..5f3b057f3c 100644 --- a/test-scripts/rnpExample/rnpExample.recorder.test.ts +++ b/test-scripts/rnpExample/rnpExample.recorder.test.ts @@ -7,7 +7,7 @@ describe('RnpExample', () => { beforeAll(async () => { harness = new RnpExampleTestHarness(); - await harness.setupRecorder(); + await harness.initRecorderTests(); }); afterEach(async () => { diff --git a/test/record-and-play/abstract-gateway-test-harness.ts b/test/record-and-play/abstract-gateway-test-harness.ts index e47cdd2ed3..9362d541a2 100644 --- a/test/record-and-play/abstract-gateway-test-harness.ts +++ b/test/record-and-play/abstract-gateway-test-harness.ts @@ -72,7 +72,7 @@ export abstract class AbstractGatewayTestHarness return this.loadMock(fileName); } - abstract init(): Promise; + protected abstract init(): Promise; private async setupSpies() { for (const [_key, dep] of Object.entries(this.dependencyContracts)) { @@ -83,7 +83,7 @@ export abstract class AbstractGatewayTestHarness } } - async setupRecorder() { + async initRecorderTests() { await this.init(); await this.setupSpies(); } @@ -104,7 +104,7 @@ export abstract class AbstractGatewayTestHarness } } - async setupMockedTests() { + async initMockedTests() { await this.init(); await this.setupSpies(); for (const [instanceKey, dep] of Object.entries(this.dependencyContracts)) { diff --git a/test/rnpExample/rnpExample.test.ts b/test/rnpExample/rnpExample.test.ts index fde46ad9ec..3dd1b44e5e 100644 --- a/test/rnpExample/rnpExample.test.ts +++ b/test/rnpExample/rnpExample.test.ts @@ -12,7 +12,7 @@ describe('RnpExample', () => { beforeAll(async () => { harness = new RnpExampleTestHarness(); - await harness.setupMockedTests(); + await harness.initMockedTests(); }); afterEach(async () => { From a3165cf188659a3e0ebacd744552d89073e7081a Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Fri, 27 Jun 2025 14:00:52 -0600 Subject: [PATCH 13/45] add useB example to recorder . --- .../rnpExample/rnpExample.recorder.test.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/test-scripts/rnpExample/rnpExample.recorder.test.ts b/test-scripts/rnpExample/rnpExample.recorder.test.ts index 5f3b057f3c..e9e7245af7 100644 --- a/test-scripts/rnpExample/rnpExample.recorder.test.ts +++ b/test-scripts/rnpExample/rnpExample.recorder.test.ts @@ -1,5 +1,12 @@ import { RnpExampleTestHarness } from '#test/rnpExample/rnpExample.test-harness'; -import { useABC, useUnmappedDep, useDTwice, useProtoDep, useUnmappedMethodRecorder } from '#test/rnpExample/rnpExample.api-test-cases'; +import { + useABC, + useUnmappedDep, + useDTwice, + useProtoDep, + useUnmappedMethodRecorder, + useB, + } from '#test/rnpExample/rnpExample.api-test-cases'; describe('RnpExample', () => { let harness: RnpExampleTestHarness; @@ -22,6 +29,10 @@ describe('RnpExample', () => { it('useABC', async () => { await useABC.processRecorderRequest(harness); }); + + it('useB', async () => { + await useB.processRecorderRequest(harness); + }); it('useDTwice', async () => { await useDTwice.processRecorderRequest(harness); @@ -41,10 +52,12 @@ describe('RnpExample', () => { "error": "FailedDependencyError", "message": "Failed to useUnmappedMethod: Unmocked method was called: dep1_A.methodUnmapped", "statusCode": 424, - }).toMatchSnapshot(); + }).toMatchSnapshot( { + + }); }); it('useUnmappedDep', async () => { await useUnmappedDep.processRecorderRequest(harness); }); -}); \ No newline at end of file +}); \ No newline at end of file From e0235d611dc9946fc16f51e13fe16d4c2caa6ece Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Fri, 27 Jun 2025 14:23:13 -0600 Subject: [PATCH 14/45] fix harness reset for recorder tests --- .../rnpExample/rnpExample.recorder.test.ts | 3 +- .../abstract-gateway-test-harness.ts | 2 +- .../__snapshots__/rnpExample.test.ts.snap | 16 ++++++---- test/rnpExample/api/rnpExample.routes.ts | 2 ++ test/rnpExample/api/rnpExample.ts | 5 ++++ test/rnpExample/api/routes/useB.ts | 30 +++++++++++++++++++ test/rnpExample/mocks/rnpExample-methodA.json | 2 +- .../mocks/rnpExample-methodB-useB.json | 3 ++ test/rnpExample/mocks/rnpExample-methodB.json | 2 +- .../rnpExample/mocks/rnpExample-methodD1.json | 2 +- .../rnpExample/mocks/rnpExample-methodD2.json | 2 +- test/rnpExample/mocks/rnpExample-methodX.json | 2 +- test/rnpExample/rnpExample.api-test-cases.ts | 11 +++++++ test/rnpExample/rnpExample.test-harness.ts | 4 +-- test/rnpExample/rnpExample.test.ts | 6 ++++ 15 files changed, 77 insertions(+), 15 deletions(-) create mode 100644 test/rnpExample/api/routes/useB.ts create mode 100644 test/rnpExample/mocks/rnpExample-methodB-useB.json diff --git a/test-scripts/rnpExample/rnpExample.recorder.test.ts b/test-scripts/rnpExample/rnpExample.recorder.test.ts index e9e7245af7..be326dd9b9 100644 --- a/test-scripts/rnpExample/rnpExample.recorder.test.ts +++ b/test-scripts/rnpExample/rnpExample.recorder.test.ts @@ -18,8 +18,7 @@ describe('RnpExample', () => { }); afterEach(async () => { - // Do NOT call reset() it will break the spies - // TODO: allow calling reset as currently multiple tests using the same method will break + await harness.reset(); }); afterAll(async () => { diff --git a/test/record-and-play/abstract-gateway-test-harness.ts b/test/record-and-play/abstract-gateway-test-harness.ts index 9362d541a2..8a88db3aee 100644 --- a/test/record-and-play/abstract-gateway-test-harness.ts +++ b/test/record-and-play/abstract-gateway-test-harness.ts @@ -141,7 +141,7 @@ export abstract class AbstractGatewayTestHarness async reset() { Object.values(this.dependencyContracts).forEach((dep) => { if (dep.spy) { - dep.spy.mockReset(); + dep.spy.mockClear(); } }); } diff --git a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap index df8c68fdf5..121fefc508 100644 --- a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap +++ b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap @@ -2,22 +2,28 @@ exports[`RnpExample useABC 1`] = ` { - "a": "real methodA-0.8603623557983471", - "b": "real methodB-0.6325482401357516", + "a": "real methodA-0.034080353463923796", + "b": "real methodB-0.39351069588009646", "c": Any, } `; +exports[`RnpExample useB 1`] = ` +{ + "b": "real methodB-0.2923468171146517", +} +`; + exports[`RnpExample useDTwice 1`] = ` { - "d1": "real methodD-0.44441784174219356", - "d2": "real methodD-0.9093530606362545", + "d1": "real methodD-0.1248116055553734", + "d2": "real methodD-0.6261969755482932", } `; exports[`RnpExample useProtoDep 1`] = ` { - "x": "real methodX-0.4117859175944383", + "x": "real methodX-0.36103557228007754", } `; diff --git a/test/rnpExample/api/rnpExample.routes.ts b/test/rnpExample/api/rnpExample.routes.ts index dfc113159c..f83ef6368d 100644 --- a/test/rnpExample/api/rnpExample.routes.ts +++ b/test/rnpExample/api/rnpExample.routes.ts @@ -1,6 +1,7 @@ import { FastifyPluginAsync } from 'fastify'; import { useABCRoute } from './routes/useABC'; +import { useBRoute } from './routes/useB'; import { useDTwiceRoute } from './routes/useDTwice'; import { useProtoDepRoute } from './routes/useProtoDep'; import { useUnmappedDepRoute } from './routes/useUnmappedDep'; @@ -8,6 +9,7 @@ import { useUnmappedMethodRoute } from './routes/useUnmappedMethod'; export const rnpExampleRoutes: FastifyPluginAsync = async (fastify) => { await fastify.register(useABCRoute); + await fastify.register(useBRoute); await fastify.register(useDTwiceRoute); await fastify.register(useUnmappedMethodRoute); await fastify.register(useProtoDepRoute); diff --git a/test/rnpExample/api/rnpExample.ts b/test/rnpExample/api/rnpExample.ts index 1e14a937cb..d6cdde14f2 100644 --- a/test/rnpExample/api/rnpExample.ts +++ b/test/rnpExample/api/rnpExample.ts @@ -48,6 +48,11 @@ export class RnpExample { return { a, b, c }; } + async useB() { + const b = await this.dep1.methodB(); + return { b }; + } + async useDTwice() { const d1 = await this.dep1.methodD(); const d2 = await this.dep1.methodD(); diff --git a/test/rnpExample/api/routes/useB.ts b/test/rnpExample/api/routes/useB.ts new file mode 100644 index 0000000000..93289c99f6 --- /dev/null +++ b/test/rnpExample/api/routes/useB.ts @@ -0,0 +1,30 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { logger } from '#src/services/logger'; + +import { RnpExample } from '../rnpExample'; + +export const useBRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: { network: string }; + Reply: { b: string }; + }>( + '/useB', + { + schema: { + summary: 'A RnpExample route for testing', + }, + }, + async (request) => { + try { + const rnpExample = await RnpExample.getInstance(request.query.network); + return await rnpExample.useB(); + } catch (error) { + logger.error(`Error getting useB status: ${error.message}`); + throw fastify.httpErrors.internalServerError( + `Failed to useB: ${error.message}`, + ); + } + }, + ); +}; diff --git a/test/rnpExample/mocks/rnpExample-methodA.json b/test/rnpExample/mocks/rnpExample-methodA.json index ee44b5d6b1..85b6682c4b 100644 --- a/test/rnpExample/mocks/rnpExample-methodA.json +++ b/test/rnpExample/mocks/rnpExample-methodA.json @@ -1,3 +1,3 @@ { - "json": "real methodA-0.8603623557983471" + "json": "real methodA-0.034080353463923796" } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodB-useB.json b/test/rnpExample/mocks/rnpExample-methodB-useB.json new file mode 100644 index 0000000000..7c8819a28a --- /dev/null +++ b/test/rnpExample/mocks/rnpExample-methodB-useB.json @@ -0,0 +1,3 @@ +{ + "json": "real methodB-0.2923468171146517" +} \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodB.json b/test/rnpExample/mocks/rnpExample-methodB.json index d34fbd0c61..130fec8174 100644 --- a/test/rnpExample/mocks/rnpExample-methodB.json +++ b/test/rnpExample/mocks/rnpExample-methodB.json @@ -1,3 +1,3 @@ { - "json": "real methodB-0.6325482401357516" + "json": "real methodB-0.39351069588009646" } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodD1.json b/test/rnpExample/mocks/rnpExample-methodD1.json index 74fb7cfb9d..53a367f5b2 100644 --- a/test/rnpExample/mocks/rnpExample-methodD1.json +++ b/test/rnpExample/mocks/rnpExample-methodD1.json @@ -1,3 +1,3 @@ { - "json": "real methodD-0.44441784174219356" + "json": "real methodD-0.1248116055553734" } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodD2.json b/test/rnpExample/mocks/rnpExample-methodD2.json index 8f304e4009..c3e48cdc3b 100644 --- a/test/rnpExample/mocks/rnpExample-methodD2.json +++ b/test/rnpExample/mocks/rnpExample-methodD2.json @@ -1,3 +1,3 @@ { - "json": "real methodD-0.9093530606362545" + "json": "real methodD-0.6261969755482932" } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodX.json b/test/rnpExample/mocks/rnpExample-methodX.json index 01395b4dac..e5d6cb0a98 100644 --- a/test/rnpExample/mocks/rnpExample-methodX.json +++ b/test/rnpExample/mocks/rnpExample-methodX.json @@ -1,3 +1,3 @@ { - "json": "real methodX-0.4117859175944383" + "json": "real methodX-0.36103557228007754" } \ No newline at end of file diff --git a/test/rnpExample/rnpExample.api-test-cases.ts b/test/rnpExample/rnpExample.api-test-cases.ts index b028134d20..2392f4fb0d 100644 --- a/test/rnpExample/rnpExample.api-test-cases.ts +++ b/test/rnpExample/rnpExample.api-test-cases.ts @@ -19,6 +19,17 @@ export const useABC = new TestCase({ }, }); +export const useB = new TestCase({ + method: 'GET', + url: '/rnpExample/useB', + expectedStatus: 200, + query: { network: 'TEST' }, + payload: {}, + requiredMocks: { + dep1_B: 'rnpExample-methodB-useB', + }, +}); + export const useDTwice = new TestCase({ method: 'GET', url: '/rnpExample/useDTwice', diff --git a/test/rnpExample/rnpExample.test-harness.ts b/test/rnpExample/rnpExample.test-harness.ts index 1ee241613b..95f93bb79e 100644 --- a/test/rnpExample/rnpExample.test-harness.ts +++ b/test/rnpExample/rnpExample.test-harness.ts @@ -10,8 +10,8 @@ export class RnpExampleTestHarness extends AbstractGatewayTestHarness { await harness.teardown(); }); + it('useB', async () => { + // In a different order than recorder test to verify that the mock order is correct + await useB.processPlayRequest(harness); + }); + it('useABC', async () => { await useABC.processPlayRequest(harness); }); From a00aa35b451bed0155d6f8b22191f88304c5febe Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Fri, 27 Jun 2025 14:38:21 -0600 Subject: [PATCH 15/45] rename unmappedDep to unlistedDep for clarity --- .../rnpExample/rnpExample.recorder.test.ts | 17 ++++++++--------- .../__snapshots__/rnpExample.test.ts.snap | 2 +- test/rnpExample/api/rnpExample.routes.ts | 6 +++--- test/rnpExample/api/rnpExample.ts | 3 ++- .../{useUnmappedDep.ts => useUnlistedDep.ts} | 10 +++++----- test/rnpExample/rnpExample.api-test-cases.ts | 4 ++-- test/rnpExample/rnpExample.test-harness.ts | 3 +-- test/rnpExample/rnpExample.test.ts | 6 +++--- 8 files changed, 25 insertions(+), 26 deletions(-) rename test/rnpExample/api/routes/{useUnmappedDep.ts => useUnlistedDep.ts} (68%) diff --git a/test-scripts/rnpExample/rnpExample.recorder.test.ts b/test-scripts/rnpExample/rnpExample.recorder.test.ts index be326dd9b9..e4ed92db77 100644 --- a/test-scripts/rnpExample/rnpExample.recorder.test.ts +++ b/test-scripts/rnpExample/rnpExample.recorder.test.ts @@ -1,7 +1,7 @@ import { RnpExampleTestHarness } from '#test/rnpExample/rnpExample.test-harness'; import { useABC, - useUnmappedDep, + useUnlistedDep, useDTwice, useProtoDep, useUnmappedMethodRecorder, @@ -48,15 +48,14 @@ describe('RnpExample', () => { it('useUnmappedMethodMocked', async () => { // Create to force snapshot file to match exactly expect({ - "error": "FailedDependencyError", - "message": "Failed to useUnmappedMethod: Unmocked method was called: dep1_A.methodUnmapped", - "statusCode": 424, - }).toMatchSnapshot( { - - }); + error: 'FailedDependencyError', + message: + 'Failed to useUnmappedMethod: Unmocked method was called: dep1_A.methodUnmapped', + statusCode: 424, + }).toMatchSnapshot({}); }); - it('useUnmappedDep', async () => { - await useUnmappedDep.processRecorderRequest(harness); + it('useUnlistedDep', async () => { + await useUnlistedDep.processRecorderRequest(harness); }); }); \ No newline at end of file diff --git a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap index 121fefc508..a360062b88 100644 --- a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap +++ b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap @@ -27,7 +27,7 @@ exports[`RnpExample useProtoDep 1`] = ` } `; -exports[`RnpExample useUnmappedDep 1`] = ` +exports[`RnpExample useUnlistedDep 1`] = ` { "z": Any, } diff --git a/test/rnpExample/api/rnpExample.routes.ts b/test/rnpExample/api/rnpExample.routes.ts index f83ef6368d..8558527bab 100644 --- a/test/rnpExample/api/rnpExample.routes.ts +++ b/test/rnpExample/api/rnpExample.routes.ts @@ -4,16 +4,16 @@ import { useABCRoute } from './routes/useABC'; import { useBRoute } from './routes/useB'; import { useDTwiceRoute } from './routes/useDTwice'; import { useProtoDepRoute } from './routes/useProtoDep'; -import { useUnmappedDepRoute } from './routes/useUnmappedDep'; +import { useUnlistedDepRoute } from './routes/useUnlistedDep'; import { useUnmappedMethodRoute } from './routes/useUnmappedMethod'; export const rnpExampleRoutes: FastifyPluginAsync = async (fastify) => { await fastify.register(useABCRoute); await fastify.register(useBRoute); await fastify.register(useDTwiceRoute); - await fastify.register(useUnmappedMethodRoute); await fastify.register(useProtoDepRoute); - await fastify.register(useUnmappedDepRoute); + await fastify.register(useUnlistedDepRoute); + await fastify.register(useUnmappedMethodRoute); }; export default rnpExampleRoutes; diff --git a/test/rnpExample/api/rnpExample.ts b/test/rnpExample/api/rnpExample.ts index d6cdde14f2..481f8d6b95 100644 --- a/test/rnpExample/api/rnpExample.ts +++ b/test/rnpExample/api/rnpExample.ts @@ -69,7 +69,8 @@ export class RnpExample { const x = await dep3.methodX(); return { x }; } - async useUnmappedDep() { + + async useUnlistedDep() { const z = await this.dep2.methodZ(); return { z }; } diff --git a/test/rnpExample/api/routes/useUnmappedDep.ts b/test/rnpExample/api/routes/useUnlistedDep.ts similarity index 68% rename from test/rnpExample/api/routes/useUnmappedDep.ts rename to test/rnpExample/api/routes/useUnlistedDep.ts index b47c36ca16..bdc4b6ced3 100644 --- a/test/rnpExample/api/routes/useUnmappedDep.ts +++ b/test/rnpExample/api/routes/useUnlistedDep.ts @@ -4,12 +4,12 @@ import { logger } from '#src/services/logger'; import { RnpExample } from '../rnpExample'; -export const useUnmappedDepRoute: FastifyPluginAsync = async (fastify) => { +export const useUnlistedDepRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ Querystring: { network: string }; Reply: { z: string }; }>( - '/useUnmappedDep', + '/useUnlistedDep', { schema: { summary: 'A RnpExample route for testing', @@ -18,11 +18,11 @@ export const useUnmappedDepRoute: FastifyPluginAsync = async (fastify) => { async (request) => { try { const rnpExample = await RnpExample.getInstance(request.query.network); - return await rnpExample.useUnmappedDep(); + return await rnpExample.useUnlistedDep(); } catch (error) { - logger.error(`Error getting useUnmappedDep status: ${error.message}`); + logger.error(`Error getting useUnlistedDep status: ${error.message}`); throw fastify.httpErrors.internalServerError( - `Failed to useUnmappedDep: ${error.message}`, + `Failed to useUnlistedDep: ${error.message}`, ); } }, diff --git a/test/rnpExample/rnpExample.api-test-cases.ts b/test/rnpExample/rnpExample.api-test-cases.ts index 2392f4fb0d..73de154257 100644 --- a/test/rnpExample/rnpExample.api-test-cases.ts +++ b/test/rnpExample/rnpExample.api-test-cases.ts @@ -60,9 +60,9 @@ export const useUnmappedMethodMocked = new TestCase({ propertyMatchers: {}, }); -export const useUnmappedDep = new TestCase({ +export const useUnlistedDep = new TestCase({ method: 'GET', - url: '/rnpExample/useUnmappedDep', + url: '/rnpExample/useUnlistedDep', expectedStatus: 200, query: { network: 'TEST' }, payload: {}, diff --git a/test/rnpExample/rnpExample.test-harness.ts b/test/rnpExample/rnpExample.test-harness.ts index 95f93bb79e..1889177ebc 100644 --- a/test/rnpExample/rnpExample.test-harness.ts +++ b/test/rnpExample/rnpExample.test-harness.ts @@ -10,8 +10,7 @@ export class RnpExampleTestHarness extends AbstractGatewayTestHarness { await useProtoDep.processPlayRequest(harness); }); - it('useUnmappedDep', async () => { - await useUnmappedDep.processPlayRequest(harness); + it('useUnlistedDep', async () => { + await useUnlistedDep.processPlayRequest(harness); }); }); From 8216523d05e21a355b709073c124798fbef459ea Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Fri, 27 Jun 2025 15:14:45 -0600 Subject: [PATCH 16/45] Throw error when mocked method doesn't have a mock loaded --- .../rnpExample/rnpExample.recorder.test.ts | 12 +++++++++- .../abstract-gateway-test-harness.ts | 12 +++++++--- .../__snapshots__/rnpExample.test.ts.snap | 22 +++++++++++++------ test/rnpExample/mocks/rnpExample-methodA.json | 2 +- .../mocks/rnpExample-methodB-useB.json | 2 +- test/rnpExample/mocks/rnpExample-methodB.json | 2 +- .../rnpExample/mocks/rnpExample-methodD1.json | 2 +- .../rnpExample/mocks/rnpExample-methodD2.json | 2 +- test/rnpExample/mocks/rnpExample-methodX.json | 2 +- test/rnpExample/rnpExample.api-test-cases.ts | 9 ++++++++ test/rnpExample/rnpExample.test.ts | 5 +++++ 11 files changed, 55 insertions(+), 17 deletions(-) diff --git a/test-scripts/rnpExample/rnpExample.recorder.test.ts b/test-scripts/rnpExample/rnpExample.recorder.test.ts index e4ed92db77..7e67bad9a7 100644 --- a/test-scripts/rnpExample/rnpExample.recorder.test.ts +++ b/test-scripts/rnpExample/rnpExample.recorder.test.ts @@ -50,11 +50,21 @@ describe('RnpExample', () => { expect({ error: 'FailedDependencyError', message: - 'Failed to useUnmappedMethod: Unmocked method was called: dep1_A.methodUnmapped', + 'Failed to useUnmappedMethod: Unmapped method was called: dep1_A.methodUnmapped. Method must be listed and either mocked or specify allowPassThrough.', statusCode: 424, }).toMatchSnapshot({}); }); + it('useBUnloaded', async () => { + // Create to force snapshot file to match exactly + expect({ + error: 'InternalServerError', + message: + 'Failed to useB: Mocked dependency was called without a mock loaded: dep1_B. Either load a mock or allowPassThrough.', + statusCode: 500, + }).toMatchSnapshot({}); + }); + it('useUnlistedDep', async () => { await useUnlistedDep.processRecorderRequest(harness); }); diff --git a/test/record-and-play/abstract-gateway-test-harness.ts b/test/record-and-play/abstract-gateway-test-harness.ts index 8a88db3aee..17b204d407 100644 --- a/test/record-and-play/abstract-gateway-test-harness.ts +++ b/test/record-and-play/abstract-gateway-test-harness.ts @@ -107,14 +107,20 @@ export abstract class AbstractGatewayTestHarness async initMockedTests() { await this.init(); await this.setupSpies(); - for (const [instanceKey, dep] of Object.entries(this.dependencyContracts)) { + for (const [depKey, dep] of Object.entries(this.dependencyContracts)) { const object = dep.getObject(this); for (const [methodName, method] of Object.entries(object)) { - if (!(method as any).mock && !dep.allowPassThrough) { + if (!(method as any).mock) { const spy = jest.spyOn(object, methodName); spy.mockImplementation(() => { throw new Error( - `Unmocked method was called: ${instanceKey}.${methodName}`, + `Unmapped method was called: ${depKey}.${methodName}. Method must be listed and either mocked or specify allowPassThrough.`, + ); + }); + } else if (!dep.allowPassThrough) { + dep.spy.mockImplementation(() => { + throw new Error( + `Mocked dependency was called without a mock loaded: ${depKey}. Either load a mock or allowPassThrough.`, ); }); } diff --git a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap index a360062b88..5e7216600f 100644 --- a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap +++ b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap @@ -2,28 +2,36 @@ exports[`RnpExample useABC 1`] = ` { - "a": "real methodA-0.034080353463923796", - "b": "real methodB-0.39351069588009646", + "a": "real methodA-0.2479827843513731", + "b": "real methodB-0.00816658738172471", "c": Any, } `; exports[`RnpExample useB 1`] = ` { - "b": "real methodB-0.2923468171146517", + "b": "real methodB-0.16196714271689694", +} +`; + +exports[`RnpExample useBUnloaded 1`] = ` +{ + "error": "InternalServerError", + "message": "Failed to useB: Mocked dependency was called without a mock loaded: dep1_B. Either load a mock or allowPassThrough.", + "statusCode": 500, } `; exports[`RnpExample useDTwice 1`] = ` { - "d1": "real methodD-0.1248116055553734", - "d2": "real methodD-0.6261969755482932", + "d1": "real methodD-0.2688678022183604", + "d2": "real methodD-0.7199207348433552", } `; exports[`RnpExample useProtoDep 1`] = ` { - "x": "real methodX-0.36103557228007754", + "x": "real methodX-0.41410977701045093", } `; @@ -36,7 +44,7 @@ exports[`RnpExample useUnlistedDep 1`] = ` exports[`RnpExample useUnmappedMethodMocked 1`] = ` { "error": "FailedDependencyError", - "message": "Failed to useUnmappedMethod: Unmocked method was called: dep1_A.methodUnmapped", + "message": "Failed to useUnmappedMethod: Unmapped method was called: dep1_A.methodUnmapped. Method must be listed and either mocked or specify allowPassThrough.", "statusCode": 424, } `; diff --git a/test/rnpExample/mocks/rnpExample-methodA.json b/test/rnpExample/mocks/rnpExample-methodA.json index 85b6682c4b..c723ffe3b8 100644 --- a/test/rnpExample/mocks/rnpExample-methodA.json +++ b/test/rnpExample/mocks/rnpExample-methodA.json @@ -1,3 +1,3 @@ { - "json": "real methodA-0.034080353463923796" + "json": "real methodA-0.2479827843513731" } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodB-useB.json b/test/rnpExample/mocks/rnpExample-methodB-useB.json index 7c8819a28a..44b2b17692 100644 --- a/test/rnpExample/mocks/rnpExample-methodB-useB.json +++ b/test/rnpExample/mocks/rnpExample-methodB-useB.json @@ -1,3 +1,3 @@ { - "json": "real methodB-0.2923468171146517" + "json": "real methodB-0.16196714271689694" } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodB.json b/test/rnpExample/mocks/rnpExample-methodB.json index 130fec8174..d63804dfb1 100644 --- a/test/rnpExample/mocks/rnpExample-methodB.json +++ b/test/rnpExample/mocks/rnpExample-methodB.json @@ -1,3 +1,3 @@ { - "json": "real methodB-0.39351069588009646" + "json": "real methodB-0.00816658738172471" } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodD1.json b/test/rnpExample/mocks/rnpExample-methodD1.json index 53a367f5b2..a0dc338d4c 100644 --- a/test/rnpExample/mocks/rnpExample-methodD1.json +++ b/test/rnpExample/mocks/rnpExample-methodD1.json @@ -1,3 +1,3 @@ { - "json": "real methodD-0.1248116055553734" + "json": "real methodD-0.2688678022183604" } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodD2.json b/test/rnpExample/mocks/rnpExample-methodD2.json index c3e48cdc3b..40289a014c 100644 --- a/test/rnpExample/mocks/rnpExample-methodD2.json +++ b/test/rnpExample/mocks/rnpExample-methodD2.json @@ -1,3 +1,3 @@ { - "json": "real methodD-0.6261969755482932" + "json": "real methodD-0.7199207348433552" } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodX.json b/test/rnpExample/mocks/rnpExample-methodX.json index e5d6cb0a98..2cf37cf148 100644 --- a/test/rnpExample/mocks/rnpExample-methodX.json +++ b/test/rnpExample/mocks/rnpExample-methodX.json @@ -1,3 +1,3 @@ { - "json": "real methodX-0.36103557228007754" + "json": "real methodX-0.41410977701045093" } \ No newline at end of file diff --git a/test/rnpExample/rnpExample.api-test-cases.ts b/test/rnpExample/rnpExample.api-test-cases.ts index 73de154257..ef642669fa 100644 --- a/test/rnpExample/rnpExample.api-test-cases.ts +++ b/test/rnpExample/rnpExample.api-test-cases.ts @@ -30,6 +30,15 @@ export const useB = new TestCase({ }, }); +export const useBUnloaded = new TestCase({ + method: 'GET', + url: '/rnpExample/useB', + expectedStatus: 500, + query: { network: 'TEST' }, + payload: {}, + requiredMocks: {}, +}); + export const useDTwice = new TestCase({ method: 'GET', url: '/rnpExample/useDTwice', diff --git a/test/rnpExample/rnpExample.test.ts b/test/rnpExample/rnpExample.test.ts index 85cb25185a..07b63e2bd4 100644 --- a/test/rnpExample/rnpExample.test.ts +++ b/test/rnpExample/rnpExample.test.ts @@ -5,6 +5,7 @@ import { useProtoDep, useUnmappedMethodMocked, useB, + useBUnloaded, } from './rnpExample.api-test-cases'; import { RnpExampleTestHarness } from './rnpExample.test-harness'; @@ -33,6 +34,10 @@ describe('RnpExample', () => { await useABC.processPlayRequest(harness); }); + it('useBUnloaded', async () => { + await useBUnloaded.processPlayRequest(harness); + }); + it('useDTwice', async () => { await useDTwice.processPlayRequest(harness); }); From 8d48f73418690b88841a7f2d75fb048bcbc64a1d Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Fri, 27 Jun 2025 16:03:10 -0600 Subject: [PATCH 17/45] testing guide --- .../rules/record-and-play-testing-guide.mdc | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 .cursor/rules/record-and-play-testing-guide.mdc diff --git a/.cursor/rules/record-and-play-testing-guide.mdc b/.cursor/rules/record-and-play-testing-guide.mdc new file mode 100644 index 0000000000..273b269867 --- /dev/null +++ b/.cursor/rules/record-and-play-testing-guide.mdc @@ -0,0 +1,113 @@ +--- +description: +globs: +alwaysApply: false +--- +This guide provides the complete instructions for creating tests using the project's "Record and Play" (RnP) framework. It is intended to be used by LLM agents to autonomously create, run, and maintain tests for new and existing features. Adherence to these rules is not optional; it is critical for the stability and predictability of the testing suite. + +This framework applies to any major feature area, including `chains` (e.g., `solana`) and `connectors` (e.g., `raydium`). The `rnpExample` directory is the canonical source for all examples referenced below. + +## 1. Philosophy: Why Record and Play? + +The RnP framework is designed to create high-fidelity tests that are both robust and easy to maintain. The core philosophy is: +* **Record against reality:** Tests are initially run against live, real-world services to record their actual responses. This captures the true behavior of our dependencies. +* **Play in isolation:** Once recorded, tests are run in a "play" mode that uses the saved recordings (mocks) instead of making live network calls. This makes the tests fast, deterministic, and isolated from external failures. +* **Confidence:** This approach ensures that our application logic is tested against realistic data, which gives us high confidence that it will work correctly in production. It prevents "mock drift," where mocks no longer reflect the reality of the API they are simulating. + +## 2. Core Components + +The RnP framework consists of four key types of files that work together for a given feature. + +### `*.test-harness.ts` - The Engine +* **Location:** `test/{chains|connectors}/{feature-name}/{feature-name}.test-harness.ts` +* **Example:** `test/rnpExample/rnpExample.test-harness.ts` +* **Purpose:** The harness is the central engine of a test suite. It is responsible for initializing the application instance under test and, most importantly, defining the `dependencyContracts`. +* **The `dependencyContracts` Object:** This object is a manifest of all external dependencies. As seen in `rnpExample.test-harness.ts`, it maps a human-readable name (e.g., `dep1_A`) to a dependency contract. This mapping is what allows the framework to intercept calls for recording or mocking. + +### `*.api-test-cases.ts` - The Single Source of Truth +* **Location:** `test/{chains|connectors}/{feature-name}/{feature-name}.api-test-cases.ts` +* **Example:** `test/rnpExample/rnpExample.api-test-cases.ts` +* **Purpose:** This file defines the specific API requests that will be used for both recording and testing. By defining test cases in one place (e.g., `export const useABC = new TestCase(...)`), we prevent code duplication and ensure the recorder and the unit test are perfectly aligned. + +### `*.recorder.test.ts` - The Recorder +* **Location:** `test-scripts/{chains|connectors}/{feature-name}/{feature-name}.recorder.test.ts` +* **Example:** `test-scripts/rnpExample/rnpExample.recorder.test.ts` +* **Purpose:** The sole responsibility of this test suite is to generate mock files. It imports a `TestCase` and calls `processRecorderRequest(harness)` on it to execute against live services and save the results. +* **Execution:** Recorder tests are slow, make real network calls, and are run individually. + +### `*.test.ts` - The Unit Test +* **Location:** `test/{chains|connectors}/{feature-name}/{feature-name}.test.ts` +* **Example:** `test/rnpExample/rnpExample.test.ts` +* **Purpose:** This is the fast, isolated unit test suite. It imports a `TestCase` and calls `processPlayRequest(harness)` on it. This uses the generated mocks to validate application logic without making network calls. +* **Execution:** These tests are fast and run as part of the main `pnpm test` suite. + +## 3. Directory and Naming Conventions + +The framework relies on a strict set of conventions. These are functional requirements, not style suggestions. The `rnpExample` has a simplified API source structure and should not be followed for new development; use the `solana` or `raydium` structure as your template. + +### Directory Structure for Chains +* **Source Code:** `src/chains/solana/` +* **Unit Tests & Harness:** `test/chains/solana/` +* **Recorder Tests:** `test-scripts/chains/solana/` + +### Directory Structure for Connectors +* **Source Code:** `src/connectors/raydium/` +* **Unit Tests & Harness:** `test/connectors/raydium/` +* **Recorder Tests:** `test-scripts/connectors/raydium/` + +### Test Naming +The `describe()` and `it()` block names in the recorder and the unit test **MUST MATCH EXACTLY**. Compare the `it('useABC', ...)` blocks in `rnpExample.recorder.test.ts` and `rnpExample.test.ts` to see this in practice. This naming convention is how Jest associates API response snapshots with the correct test. + +### Command Segregation +* **To run fast unit tests:** + ```bash + pnpm test {feature-name}.test.ts + ``` +* **To run a slow recorder test and generate mocks:** + ```bash + pnpm test:scripts path/to/your/{feature-name}.recorder.test.ts -t "test name" + ``` + +## 4. The Critical Rule of Dependency Management + +To ensure unit tests are fast and never make accidental live network calls, the framework enforces a strict safety policy. Understanding this is not optional. +* When you add even one method from a dependency object (e.g., `dep1.methodA`) to the `dependencyContracts` in `rnpExample.test-harness.ts`, the *entire object* (`dep1`) is now considered "managed." +* **In Recorder Mode:** Managed dependencies behave as expected. The listed methods are spied on, and unlisted methods on the same object (like `dep1.methodUnmapped`) call their real implementation. +* **In Play Mode:** This is where the safety rule applies. **Every method** on a managed object must be explicitly mocked. If a method (like `dep1.methodUnmapped`) is called without a mock, the test will fail. The `useUnmappedMethodMocked` test case in `rnpExample.api-test-cases.ts` is designed specifically to validate this failure. +* **Why?** This strictness forces the agent developer to be fully aware of every interaction with an external service. It makes it impossible for a dependency to add a new network call that would slow down unit tests. +* **Truly Unmanaged Dependencies:** In contrast, `dep2` from `rnpExample` is never mentioned in the `dependencyContracts`. It is "unmanaged," so its methods can be called freely in either mode. The `useUnlistedDep` test case demonstrates this. + +## 5. Workflow: Adding a New Endpoint Test + +Follow this step-by-step process. In these paths, `{feature-type}` is either `chains` or `connectors`, and `{feature-name}` is the name of your chain or connector (e.g., `solana`, `raydium`). + +**Step 1: Define the Test Case** +* Open or create `test/{feature-type}/{feature-name}/{feature-name}.api-test-cases.ts`. +* Create and export a new `TestCase` instance (e.g., `export const useNewFeature = new TestCase(...)`). + +**Step 2: Update the Test Harness** +* Open `test/{feature-type}/{feature-name}/{feature-name}.test-harness.ts`. +* If your endpoint uses any new dependencies, add them to `dependencyContracts`, as seen in `rnpExample.test-harness.ts`. + +**Step 3: Create the Recorder Test** +* Open `test-scripts/{feature-type}/{feature-name}/{feature-name}.recorder.test.ts`. +* Add a new `it()` block with a name that will be identical to the unit test's. +* Inside, call `processRecorderRequest(harness)` on your new test case. + +**Step 4: Run the Recorder** +* Execute the recorder test from your terminal to generate mock and snapshot files. + ```bash + pnpm test:scripts test-scripts/{feature-type}/{feature-name}/{feature-name}.recorder.test.ts -t "your exact test name" + ``` + +**Step 5: Create the Unit Test** +* Open `test/{feature-type}/{feature-name}/{feature-name}.test.ts`. +* Add a new `it()` block with a name that **exactly matches** the recorder's. +* Inside, call `processPlayRequest(harness)` on the same test case. + +**Step 6: Run the Unit Test** +* Execute the main test suite to verify your logic against the generated mocks. + ```bash + pnpm test {feature-name}.test.ts + ``` +* The test will run, using the mocks you generated. It will pass if the application logic correctly processes the mocked dependency responses to produce the expected final API response. From 435b81438087e94ffc4bdb8961f9da8bdffcc567 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Fri, 27 Jun 2025 16:27:09 -0600 Subject: [PATCH 18/45] refactor: Comments and LLM refactor for clarity. --- .../abstract-gateway-test-harness.ts | 148 +++++++++++++++--- test/record-and-play/api-test-case.ts | 87 ++++++---- .../test-dependency-contract.ts | 136 ++++++++-------- test/rnpExample/rnpExample.api-test-cases.ts | 41 +++++ test/rnpExample/rnpExample.test-harness.ts | 18 ++- 5 files changed, 314 insertions(+), 116 deletions(-) diff --git a/test/record-and-play/abstract-gateway-test-harness.ts b/test/record-and-play/abstract-gateway-test-harness.ts index 17b204d407..b1ca234688 100644 --- a/test/record-and-play/abstract-gateway-test-harness.ts +++ b/test/record-and-play/abstract-gateway-test-harness.ts @@ -14,6 +14,16 @@ interface ContractWithSpy extends TestDependencyContract { spy?: jest.SpyInstance; } +/** + * The abstract base class for a Record and Play (RnP) test harness. + * + * A test harness is the central engine for a test suite. It is responsible for: + * 1. Initializing the application instance under test. + * 2. Defining the `dependencyContracts`, which declare all external dependencies. + * 3. Orchestrating the setup of spies for recording and mocks for playing. + * + * @template TInstance The type of the application class being tested. + */ export abstract class AbstractGatewayTestHarness implements MockProvider { @@ -22,25 +32,43 @@ export abstract class AbstractGatewayTestHarness protected _instance!: TInstance; protected readonly dependencyFactory = new DependencyFactory(); + /** + * A map of dependency contracts for the service under test. + * This is the core of the RnP framework. Each key is a human-readable alias + * for a dependency, and the value is a contract defining how to spy on or mock it. + */ abstract readonly dependencyContracts: Record< string, ContractWithSpy >; + /** + * @param mockDir The directory where mock files are stored, typically `__dirname`. + */ constructor(mockDir: string) { this._mockDir = mockDir; } - protected loadMock(fileName: string): any { + /** + * Loads a serialized mock file from the `/mocks` subdirectory. + * @param fileName The name of the mock file (without the .json extension). + * @returns The deserialized mock data. + */ + protected loadMock(fileName: string): TMock { const filePath = path.join(this._mockDir, 'mocks', `${fileName}.json`); if (fs.existsSync(filePath)) { - return superjson.deserialize( + return superjson.deserialize( JSON.parse(fs.readFileSync(filePath, 'utf8')), ); } throw new Error(`Mock file not found: ${filePath}`); } + /** + * Saves data as a serialized mock file in the `/mocks` subdirectory. + * @param fileName The name of the mock file to create. + * @param data The data to serialize and save. + */ protected _saveMock(fileName: string, data: any): void { const mockDir = path.join(this._mockDir, 'mocks'); if (!fs.existsSync(mockDir)) { @@ -53,27 +81,45 @@ export abstract class AbstractGatewayTestHarness ); } + /** + * Initializes the Fastify gateway application instance. + */ protected async initializeGatewayApp() { this._gatewayApp = (await import('../../src/app')).gatewayApp; await this._gatewayApp.ready(); } + /** The initialized Fastify application. */ get gatewayApp(): FastifyInstance { return this._gatewayApp; } + /** The initialized instance of the service being tested. */ get instance(): TInstance { if (!this._instance) throw new Error('Instance not initialized. Call setup first.'); return this._instance; } - public getMock(fileName: string): any { - return this.loadMock(fileName); + /** + * Public accessor to load a mock file. + * Required by the MockProvider interface. + * @param fileName The name of the mock file. + */ + public getMock(fileName: string): TMock { + return this.loadMock(fileName); } + /** + * Initializes the instance of the service being tested. + * Must be implemented by the concrete harness class. + */ protected abstract init(): Promise; + /** + * Iterates through the dependencyContracts and creates a Jest spy for each one. + * This is a prerequisite for both recording and mocking. + */ private async setupSpies() { for (const [_key, dep] of Object.entries(this.dependencyContracts)) { if (!dep.spy) { @@ -83,11 +129,20 @@ export abstract class AbstractGatewayTestHarness } } + /** + * Prepares the harness for a "Recorder" test run. + * It initializes the service instance and sets up spies on all declared dependencies. + */ async initRecorderTests() { await this.init(); await this.setupSpies(); } + /** + * Saves the results of spied dependency calls to mock files. + * @param requiredMocks A map where keys are dependency contract aliases and + * values are the filenames for the mocks to be saved. + */ public async saveMocks(requiredMocks: Record) { for (const [key, filenames] of Object.entries(requiredMocks)) { const dep = this.dependencyContracts[key]; @@ -104,30 +159,77 @@ export abstract class AbstractGatewayTestHarness } } + /** + * Prepares the harness for a "Play" unit test run. + * It initializes the service, sets up spies, and then implements a crucial + * safety feature: any method on a "managed" dependency that is NOT explicitly + * mocked will throw an error if called. This prevents accidental live network calls. + */ async initMockedTests() { await this.init(); await this.setupSpies(); - for (const [depKey, dep] of Object.entries(this.dependencyContracts)) { - const object = dep.getObject(this); - for (const [methodName, method] of Object.entries(object)) { - if (!(method as any).mock) { - const spy = jest.spyOn(object, methodName); - spy.mockImplementation(() => { - throw new Error( - `Unmapped method was called: ${depKey}.${methodName}. Method must be listed and either mocked or specify allowPassThrough.`, - ); - }); - } else if (!dep.allowPassThrough) { - dep.spy.mockImplementation(() => { - throw new Error( - `Mocked dependency was called without a mock loaded: ${depKey}. Either load a mock or allowPassThrough.`, + + // Get a set of all unique objects that are "managed" by at least one contract. + const managedObjects = new Set(); + Object.values(this.dependencyContracts).forEach((dep) => { + managedObjects.add(dep.getObject(this)); + }); + + // For every method on each managed object, ensure it's either explicitly + // mocked or configured to throw an error. + for (const object of managedObjects) { + for (const methodName of Object.keys(object)) { + // Find if a contract exists for this specific method. + const contract = Object.values(this.dependencyContracts).find( + (c) => + c.getObject(this) === object && + (c as any).methodName === methodName, + ); + + if (contract) { + // This method IS listed in dependencyContracts. + // If it's not allowed to pass through, set a default error for when + // a test case forgets to load a specific mock for it. + if (contract.spy && !contract.allowPassThrough) { + const depKey = Object.keys(this.dependencyContracts).find( + (k) => this.dependencyContracts[k] === contract, ); - }); + contract.spy.mockImplementation(() => { + throw new Error( + `Mocked dependency was called without a mock loaded: ${depKey}. Either load a mock or allowPassThrough.`, + ); + }); + } + } else { + // This method is NOT in dependencyContracts, but it's on a managed object. + // It's an "unmapped" method and must be spied on to throw an error. + if (typeof object[methodName] === 'function') { + const spy = jest.spyOn(object, methodName as any); + spy.mockImplementation(() => { + // Find a representative key for this object from the dependency contracts. + const representativeKey = Object.keys( + this.dependencyContracts, + ).find( + (key) => + this.dependencyContracts[key].getObject(this) === object, + ); + + const depKey = representativeKey || object.constructor.name; + throw new Error( + `Unmapped method was called: ${depKey}.${methodName}. Method must be listed and either mocked or specify allowPassThrough.`, + ); + }); + } } } } } + /** + * Loads mock files and attaches them to the corresponding dependency spies. + * @param requiredMocks A map where keys are dependency contract aliases and + * values are the filenames of the mocks to load. + */ public async loadMocks(requiredMocks: Record) { for (const [key, filenames] of Object.entries(requiredMocks)) { const dep = this.dependencyContracts[key]; @@ -144,6 +246,10 @@ export abstract class AbstractGatewayTestHarness } } + /** + * Clears the call history of all spies. + * This should be run between tests to ensure isolation. + */ async reset() { Object.values(this.dependencyContracts).forEach((dep) => { if (dep.spy) { @@ -152,6 +258,10 @@ export abstract class AbstractGatewayTestHarness }); } + /** + * Restores all spies to their original implementations and closes the gateway app. + * This should be run after the entire test suite has completed. + */ async teardown() { Object.values(this.dependencyContracts).forEach((dep) => { if (dep.spy) { diff --git a/test/record-and-play/api-test-case.ts b/test/record-and-play/api-test-case.ts index c9d937dab5..30e4a51831 100644 --- a/test/record-and-play/api-test-case.ts +++ b/test/record-and-play/api-test-case.ts @@ -1,32 +1,45 @@ -import { - FastifyInstance, - InjectOptions, - LightMyRequestResponse, -} from 'fastify'; +import { InjectOptions, LightMyRequestResponse } from 'fastify'; import { AbstractGatewayTestHarness } from './abstract-gateway-test-harness'; -export interface APITestCaseParams< - T extends AbstractGatewayTestHarness = any, -> { +/** + * Defines the parameters for a single API test case. + */ +interface APITestCaseParams> { + /** The HTTP method for the request. */ method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | 'HEAD'; + /** The URL for the API endpoint. */ url: string; + /** The expected HTTP status code of the response. */ expectedStatus: number; + /** An object representing the query string parameters. */ query?: Record; + /** The payload/body for POST or PUT requests. */ payload?: Record; /** - * A map of mock keys to their corresponding mock file basenames. - * The test harness will automatically append the '.json' extension. - * The keys must correspond to the keys in the Test Harness's dependencyContracts object. - * @example { 'getLatestBlock': 'mayanode-getLatestBlock-response' } + * A map of dependency contracts to the mock files they should use. + * The key must match a key in the harness's `dependencyContracts`. + * The value is the name of the mock file (or an array of names for sequential calls). */ requiredMocks?: Partial< - Record + Record >; - propertyMatchers?: Partial; + /** An object of Jest property matchers (e.g., `{ a: expect.any(String) }`) + * to allow for non-deterministic values in snapshot testing. */ + propertyMatchers?: Record; } -export class APITestCase = any> +/** + * Represents a single, reusable API test case. + * + * This class encapsulates the entire lifecycle of an API test, from making the + * request to handling mocks and validating the response against a snapshot. + * It is the "single source of truth" that is used by both the "Recorder" + * and "Play" test suites, ensuring they are always synchronized. + * + * @template Harness The type of the test harness this case will be run with. + */ +export class APITestCase> implements InjectOptions { method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | 'HEAD'; @@ -35,29 +48,29 @@ export class APITestCase = any> query: Record; payload: Record; requiredMocks: Partial< - Record + Record >; propertyMatchers?: Partial; + private params: APITestCaseParams; - constructor({ - method, - url, - expectedStatus, - query = {}, - payload = {}, - requiredMocks = {}, - propertyMatchers, - }: APITestCaseParams) { - this.method = method; - this.url = url; - this.expectedStatus = expectedStatus; - this.query = query; - this.payload = payload; - this.requiredMocks = requiredMocks; - this.propertyMatchers = propertyMatchers; + constructor(params: APITestCaseParams) { + this.params = params; + this.method = params.method; + this.url = params.url; + this.expectedStatus = params.expectedStatus; + this.query = params.query || {}; + this.payload = params.payload || {}; + this.requiredMocks = params.requiredMocks || {}; + this.propertyMatchers = params.propertyMatchers; } - public async processRecorderRequest(harness: T): Promise<{ + /** + * Executes the test case in "Recorder" mode. + * It makes a live API call and then saves the results of all dependent calls + * (as defined in `requiredMocks`) to mock files. + * @param harness The test harness instance. + */ + public async processRecorderRequest(harness: Harness): Promise<{ response: LightMyRequestResponse; body: any; }> { @@ -69,7 +82,13 @@ export class APITestCase = any> return { response, body }; } - public async processPlayRequest(harness: T): Promise<{ + /** + * Executes the test case in "Play" mode. + * It loads all `requiredMocks` to intercept dependency calls, makes the API + * request against the in-memory application, and validates the response. + * @param harness The test harness instance. + */ + public async processPlayRequest(harness: Harness): Promise<{ response: LightMyRequestResponse; body: any; }> { diff --git a/test/record-and-play/test-dependency-contract.ts b/test/record-and-play/test-dependency-contract.ts index c118eafbca..76238139dd 100644 --- a/test/record-and-play/test-dependency-contract.ts +++ b/test/record-and-play/test-dependency-contract.ts @@ -1,117 +1,129 @@ +/** + * Provides access to the harness's core mocking capabilities. + * This interface is used by dependency contracts to get mocks and the service instance. + */ export interface MockProvider { - getMock(fileName: string): any; + getMock(fileName: string): TMock; instance: TInstance; } /** - * Defines the contract for a dependency that can be utilized for record and play testing. + * Defines the contract for how a dependency should be spied upon and mocked. */ export abstract class TestDependencyContract { + /** If true, the real method will be called even during "Play" mode tests. */ + public allowPassThrough: boolean; + /** Returns the object or prototype that contains the method to be spied on. */ + abstract getObject(provider: MockProvider): any; + /** Creates and returns a Jest spy on the dependency's method. */ + abstract setupSpy(provider: MockProvider): jest.SpyInstance; /** - * Sets up a spy on a real method to record or modify its output. - * @returns The spy instance. - */ - abstract setupSpy(harness: MockProvider): jest.SpyInstance; - - /** - * @returns The object that has the property that is being spied on. - */ - abstract getObject(harness: MockProvider): any; - - /** - * Whether to allow the dependency to pass through to the real implementation. - */ - abstract allowPassThrough: boolean; - - /** - * Replaces a dependency call with a mock for record and play testing. - * Can be called multiple times to mock subsequent calls to the same dependency. - * @returns void. + * Attaches a mock implementation to the spy. + * Can be called multiple times to mock subsequent calls. */ - setupMock( + setupMock( spy: jest.SpyInstance, - harness: MockProvider, - mockFileName: string, + provider: MockProvider, + fileName: string, ): void { - const mockData = harness.getMock(mockFileName); - spy.mockResolvedValueOnce(mockData); + const mock = provider.getMock(fileName); + spy.mockResolvedValueOnce(mock); } } /** - * Handles dependencies that are properties on the main instance. + * A dependency contract for a method on a class *instance*. + * This is the most common type of dependency. It should be used for any + * dependency that is a property of the main service instance being tested. + * It is required for methods defined with arrow functions. */ export class InstancePropertyDependency< TInstance, - K extends keyof TInstance, + TObject, > extends TestDependencyContract { constructor( - private instancePropertyKey: K, - private methodName: keyof TInstance[K], - public allowPassThrough: boolean = false, + private getObjectFn: (provider: MockProvider) => TObject, + public methodName: keyof TObject, + public allowPassThrough = false, ) { super(); } - setupSpy(harness: MockProvider): jest.SpyInstance { - const dependencyInstance = harness.instance[this.instancePropertyKey]; - const spy = jest.spyOn(dependencyInstance as any, this.methodName as any); - return spy; + getObject(provider: MockProvider) { + return this.getObjectFn(provider); } - getObject(harness: MockProvider): any { - return harness.instance[this.instancePropertyKey]; + setupSpy(provider: MockProvider): jest.SpyInstance { + const object = this.getObject(provider); + return jest.spyOn(object, this.methodName as any); } } /** - * Handles dependencies that exist on a prototype, e.g. a class which is initialized inside a method. + * A dependency contract for a method on a class *prototype*. + * This should be used when the dependency is not an instance property, but a method + * on the prototype of a class that is instantiated within the code under test. * + * IMPORTANT: This will NOT work for methods defined with arrow functions, as they + * do not exist on the prototype. */ export class PrototypeDependency< TInstance, - TPrototype, + TObject, > extends TestDependencyContract { constructor( - private Klass: new (...args: any[]) => TPrototype, - private prototypeMethod: keyof TPrototype, - public allowPassThrough: boolean = false, + private proto: { new (...args: any[]): TObject }, + public methodName: keyof TObject, + public allowPassThrough = false, ) { super(); } - setupSpy(_harness: MockProvider): jest.SpyInstance { - const spy = jest.spyOn(this.Klass.prototype, this.prototypeMethod as any); - return spy; + getObject(_provider: MockProvider) { + return this.proto.prototype; } - getObject(_harness: MockProvider): any { - return this.Klass.prototype; + setupSpy(_provider: MockProvider): jest.SpyInstance { + return jest.spyOn(this.proto.prototype, this.methodName as any); } } +/** + * Provides factory methods for creating dependency contracts. + * This should be used within a concrete TestHarness class. + */ export class DependencyFactory { + /** + * Creates a contract for a method on a class instance property. + * @param instancePropertyName The name of the property on the main service instance that holds the dependency object. + * @param methodName The name of the method on the dependency object to spy on. + * @param allowPassThrough If true, the real method is called during "Play" mode. + */ instanceProperty( - instanceKey: K, + instancePropertyName: K, methodName: keyof TInstance[K], - allowPassThrough: boolean = false, - ): InstancePropertyDependency { - return new InstancePropertyDependency( - instanceKey, + allowPassThrough = false, + ) { + return new InstancePropertyDependency( + (p: MockProvider): TInstance[K] => + p.instance[instancePropertyName], methodName, allowPassThrough, ); } - prototype( - Klass: new (...args: any[]) => TPrototype, - prototypeMethod: keyof TPrototype, - allowPassThrough: boolean = false, - ): PrototypeDependency { - return new PrototypeDependency( - Klass, - prototypeMethod, - allowPassThrough, - ); + /** + * Creates a contract for a method on a class prototype. + * WARNING: This will not work for methods defined as arrow functions. + * @param proto The class (not an instance) whose prototype contains the method. + * @param methodName The name of the method on the prototype to spy on. + * @param allowPassThrough If true, the real method is called during "Play" mode. + */ + prototype( + proto: { new (...args: any[]): TObject }, + methodName: keyof TObject, + allowPassThrough = false, + ) { + return new PrototypeDependency(proto, methodName, allowPassThrough); } } diff --git a/test/rnpExample/rnpExample.api-test-cases.ts b/test/rnpExample/rnpExample.api-test-cases.ts index ef642669fa..2a7e032656 100644 --- a/test/rnpExample/rnpExample.api-test-cases.ts +++ b/test/rnpExample/rnpExample.api-test-cases.ts @@ -4,6 +4,12 @@ import { RnpExampleTestHarness } from './rnpExample.test-harness'; class TestCase extends APITestCase {} +/** + * A standard test case that calls a method with multiple mocked dependencies. + * Note that `dep1_C` is not listed in `requiredMocks`. Because it is marked + * with `allowPassThrough` in the harness, it will call its real implementation + * without throwing an error. + */ export const useABC = new TestCase({ method: 'GET', url: '/rnpExample/useABC', @@ -19,6 +25,9 @@ export const useABC = new TestCase({ }, }); +/** + * A simple test case that calls one mocked dependency. + */ export const useB = new TestCase({ method: 'GET', url: '/rnpExample/useB', @@ -30,6 +39,12 @@ export const useB = new TestCase({ }, }); +/** + * A test case for an unloaded dependency. + * This test calls a method that requires the 'dep1_B' mock, but does not load it + * via `requiredMocks`. This is designed to fail, demonstrating the safety + * feature that prevents calls to managed dependencies that haven't been loaded. + */ export const useBUnloaded = new TestCase({ method: 'GET', url: '/rnpExample/useB', @@ -39,6 +54,11 @@ export const useBUnloaded = new TestCase({ requiredMocks: {}, }); +/** + * A test case that calls the same dependency twice. + * The `requiredMocks` array provides two different mock files, which will be + * returned in order for the two sequential calls to `dep1.methodD()`. + */ export const useDTwice = new TestCase({ method: 'GET', url: '/rnpExample/useDTwice', @@ -50,6 +70,11 @@ export const useDTwice = new TestCase({ }, }); +/** + * A recorder-only test case for a method that calls an unmapped dependency. + * This test is only used in the recorder to generate a snapshot. + * It is not used in "Play" mode. + */ export const useUnmappedMethodRecorder = new TestCase({ method: 'GET', url: '/rnpExample/useUnmappedMethod', @@ -59,6 +84,13 @@ export const useUnmappedMethodRecorder = new TestCase({ propertyMatchers: { unmapped: expect.any(String) }, }); +/** + * A "Play" mode test case that calls a method with an unmapped dependency. + * The `useUnmappedMethod` endpoint calls `dep1.methodUnmapped`. Because `dep1` + * is a "managed" object in the harness, but `methodUnmapped` is not explicitly + * mocked or set to `allowPassThrough`, this test is expected to fail. + * This demonstrates the key safety feature of the RnP framework. + */ export const useUnmappedMethodMocked = new TestCase({ method: 'GET', url: '/rnpExample/useUnmappedMethod', @@ -69,6 +101,12 @@ export const useUnmappedMethodMocked = new TestCase({ propertyMatchers: {}, }); +/** + * A test case for a method that calls a truly "unmanaged" dependency. + * The `useUnlistedDep` endpoint calls `dep2.methodZ`. Because `dep2` is not + * mentioned anywhere in the harness's `dependencyContracts`, it is "unmanaged" + * and will always call its real implementation in both Record and Play modes. + */ export const useUnlistedDep = new TestCase({ method: 'GET', url: '/rnpExample/useUnlistedDep', @@ -81,6 +119,9 @@ export const useUnlistedDep = new TestCase({ }, }); +/** + * A test case for a dependency defined on a class prototype. + */ export const useProtoDep = new TestCase({ method: 'GET', url: '/rnpExample/useProtoDep', diff --git a/test/rnpExample/rnpExample.test-harness.ts b/test/rnpExample/rnpExample.test-harness.ts index 1889177ebc..d6915e38da 100644 --- a/test/rnpExample/rnpExample.test-harness.ts +++ b/test/rnpExample/rnpExample.test-harness.ts @@ -4,13 +4,29 @@ import { DepProto, RnpExample } from './api/rnpExample'; export class RnpExampleTestHarness extends AbstractGatewayTestHarness { readonly dependencyContracts = { + // Defines a contract for `this.dep1.methodA()`. Since it's listed here, + // it's "managed" and must be mocked in "Play" tests. dep1_A: this.dependencyFactory.instanceProperty('dep1', 'methodA'), dep1_B: this.dependencyFactory.instanceProperty('dep1', 'methodB'), + + // The `allowPassThrough: true` flag creates an exception. `dep1.methodC` + // is still "managed", but it will call its real implementation during + // "Play" tests instead of throwing an error if it isn't mocked. dep1_C: this.dependencyFactory.instanceProperty('dep1', 'methodC', true), dep1_D: this.dependencyFactory.instanceProperty('dep1', 'methodD'), + + // This defines a contract for a method on a class prototype. This is + // necessary for dependencies that are instantiated inside other methods. dep3_X: this.dependencyFactory.prototype(DepProto, 'methodX'), - // Unlisted deps like unlistedDep will be ignored and used as is + // CRITICAL NOTE: Because other `dep1` methods are listed above, the entire + // `dep1` object is now "managed". This means `dep1.methodUnmapped()` + // will throw an error if called during a "Play" test because it's not + // explicitly mocked or allowed to pass through. + // + // In contrast, `dep2` is not mentioned in `dependencyContracts` at all. + // It is "unmanaged", so `dep2.methodZ()` will always call its real + // implementation in both "Record" and "Play" modes. }; constructor() { From e9b7026b20adb39d151e0d080cf378f3b28cee98 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Mon, 30 Jun 2025 11:12:24 -0600 Subject: [PATCH 19/45] throw more detailed error about missing value for spy. --- test/record-and-play/abstract-gateway-test-harness.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/record-and-play/abstract-gateway-test-harness.ts b/test/record-and-play/abstract-gateway-test-harness.ts index b1ca234688..dac505be8f 100644 --- a/test/record-and-play/abstract-gateway-test-harness.ts +++ b/test/record-and-play/abstract-gateway-test-harness.ts @@ -153,7 +153,13 @@ export abstract class AbstractGatewayTestHarness ? filenames : [filenames] ).entries()) { - const data = await dep.spy.mock.results[i].value; + const result = dep.spy.mock.results[i]; + if (!result) { + throw new Error( + `Spy for dependency "${key}" was only called ${dep.spy.mock.calls.length} time(s), but a mock was required for call number ${i + 1}.`, + ); + } + const data = await result.value; this._saveMock(filename, data); } } From cee6c96f36a49a0aaacdd0db9898bfb71ed3c668 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Mon, 30 Jun 2025 11:42:20 -0600 Subject: [PATCH 20/45] move save mock after assertStatusCode for consistency with unit tests. --- test/record-and-play/api-test-case.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/record-and-play/api-test-case.ts b/test/record-and-play/api-test-case.ts index 30e4a51831..61a780d31d 100644 --- a/test/record-and-play/api-test-case.ts +++ b/test/record-and-play/api-test-case.ts @@ -75,8 +75,8 @@ export class APITestCase> body: any; }> { const response = await harness.gatewayApp.inject(this); - await harness.saveMocks(this.requiredMocks); this.assertStatusCode(response); + await harness.saveMocks(this.requiredMocks); const body = JSON.parse(response.body); this.assertSnapshot(body); return { response, body }; From 39d20c0c3a62cf8d9eb89bc66f6e2f41f27bc694 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Mon, 30 Jun 2025 12:14:25 -0600 Subject: [PATCH 21/45] construct a custom error message to make stack trace come from internals --- test/record-and-play/api-test-case.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/test/record-and-play/api-test-case.ts b/test/record-and-play/api-test-case.ts index 61a780d31d..4bd3dfa83f 100644 --- a/test/record-and-play/api-test-case.ts +++ b/test/record-and-play/api-test-case.ts @@ -110,9 +110,23 @@ export class APITestCase> public assertStatusCode(response: LightMyRequestResponse) { if (response.statusCode !== this.expectedStatus) { - console.log('Response body:', response.body); - expect(response.statusCode).toBe(this.expectedStatus); - // TODO: check if it has a stack property to log + let body: any; + try { + body = JSON.parse(response.body); + } catch (e) { + // If parsing fails, body remains undefined and we'll use response.body directly + } + + const reason = body?.message || response.body || 'Unexpected status code'; + const message = `Test failed with status ${response.statusCode} (expected ${this.expectedStatus}).\nReason: ${reason}`; + + const error = new Error(message); + + if (body?.stack) { + error.stack = `Error: ${message}\n --\n${body.stack}`; + } + + throw error; } } } From 979d1f88aa09f597b51094e20103ee2e292de6f7 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Mon, 30 Jun 2025 12:59:39 -0600 Subject: [PATCH 22/45] move save mock before assertStatusCode to allow moving to unit tests ASAP. --- .../record-and-play/abstract-gateway-test-harness.ts | 12 +++++++++--- test/record-and-play/api-test-case.ts | 6 +++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/test/record-and-play/abstract-gateway-test-harness.ts b/test/record-and-play/abstract-gateway-test-harness.ts index dac505be8f..90614fbba7 100644 --- a/test/record-and-play/abstract-gateway-test-harness.ts +++ b/test/record-and-play/abstract-gateway-test-harness.ts @@ -143,11 +143,15 @@ export abstract class AbstractGatewayTestHarness * @param requiredMocks A map where keys are dependency contract aliases and * values are the filenames for the mocks to be saved. */ - public async saveMocks(requiredMocks: Record) { + public async saveMocks( + requiredMocks: Record, + ): Promise> { + const errors: Record = {}; for (const [key, filenames] of Object.entries(requiredMocks)) { const dep = this.dependencyContracts[key]; if (!dep.spy) { - throw new Error(`Spy for mock key "${key}" not found.`); + errors[key] = new Error(`Spy for mock key "${key}" not found.`); + continue; } for (const [i, filename] of (Array.isArray(filenames) ? filenames @@ -155,14 +159,16 @@ export abstract class AbstractGatewayTestHarness ).entries()) { const result = dep.spy.mock.results[i]; if (!result) { - throw new Error( + errors[key] = new Error( `Spy for dependency "${key}" was only called ${dep.spy.mock.calls.length} time(s), but a mock was required for call number ${i + 1}.`, ); + continue; } const data = await result.value; this._saveMock(filename, data); } } + return errors; } /** diff --git a/test/record-and-play/api-test-case.ts b/test/record-and-play/api-test-case.ts index 4bd3dfa83f..06ef4b136a 100644 --- a/test/record-and-play/api-test-case.ts +++ b/test/record-and-play/api-test-case.ts @@ -75,8 +75,12 @@ export class APITestCase> body: any; }> { const response = await harness.gatewayApp.inject(this); + // save mocks first so that we can move to replay for quicker testing if we got all the data we needed. + const saveMockErrors = await harness.saveMocks(this.requiredMocks); this.assertStatusCode(response); - await harness.saveMocks(this.requiredMocks); + for (const [_key, error] of Object.entries(saveMockErrors)) { + throw error; + } const body = JSON.parse(response.body); this.assertSnapshot(body); return { response, body }; From 68d1973fc4aab96f59ad19bb2d013ec83ecd9164 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Mon, 30 Jun 2025 13:27:16 -0600 Subject: [PATCH 23/45] handle cases of a dependency being retried. --- .../abstract-gateway-test-harness.ts | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/test/record-and-play/abstract-gateway-test-harness.ts b/test/record-and-play/abstract-gateway-test-harness.ts index 90614fbba7..db5344fcbe 100644 --- a/test/record-and-play/abstract-gateway-test-harness.ts +++ b/test/record-and-play/abstract-gateway-test-harness.ts @@ -153,14 +153,28 @@ export abstract class AbstractGatewayTestHarness errors[key] = new Error(`Spy for mock key "${key}" not found.`); continue; } - for (const [i, filename] of (Array.isArray(filenames) - ? filenames - : [filenames] - ).entries()) { - const result = dep.spy.mock.results[i]; + + const mockFilenames = Array.isArray(filenames) ? filenames : [filenames]; + const numMocks = mockFilenames.length; + const numCalls = dep.spy.mock.calls.length; + + let callIndexStart = 0; + if (numCalls > numMocks) { + // When there are more calls than mocks, we assume the dependency experienced retries + // before succeeding. In retry scenarios, the first calls typically fail (e.g., network + // timeouts, temporary errors) and only the final successful calls should be recorded + // as mocks. Therefore, we set callIndexStart to numCalls - numMocks to capture only + // the last numMocks calls, which represent the successful sequence that should be + // replayed during testing. + callIndexStart = numCalls - numMocks; + } + + for (const [i, filename] of mockFilenames.entries()) { + const callIndex = callIndexStart + i; + const result = dep.spy.mock.results[callIndex]; if (!result) { errors[key] = new Error( - `Spy for dependency "${key}" was only called ${dep.spy.mock.calls.length} time(s), but a mock was required for call number ${i + 1}.`, + `Spy for dependency "${key}" was only called ${numCalls} time(s), but a mock was required for call number ${callIndex + 1}.`, ); continue; } From b71da5cb57eafff34ee9b9e883639511e9b4d59c Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Tue, 1 Jul 2025 14:25:46 -0600 Subject: [PATCH 24/45] add superjson support for custom object deserialization during rnp. --- jest.config.js | 6 +++++- package.json | 1 + .../rnpExample/__snapshots__/rnpExample.test.ts.snap | 12 ++++++------ test/rnpExample/api/rnpExample.ts | 11 ++++++++--- test/rnpExample/mocks/rnpExample-methodA.json | 2 +- test/rnpExample/mocks/rnpExample-methodB-useB.json | 10 +++++++++- test/rnpExample/mocks/rnpExample-methodB.json | 10 +++++++++- test/rnpExample/mocks/rnpExample-methodD1.json | 2 +- test/rnpExample/mocks/rnpExample-methodD2.json | 2 +- test/rnpExample/mocks/rnpExample-methodX.json | 2 +- test/superjson-setup.ts | 11 +++++++++++ 11 files changed, 53 insertions(+), 16 deletions(-) create mode 100644 test/superjson-setup.ts diff --git a/jest.config.js b/jest.config.js index 4107dc1053..0d3084cbd9 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,6 +7,7 @@ module.exports = { testEnvironment: 'node', forceExit: true, detectOpenHandles: false, + coveragePathIgnorePatterns: [ 'src/app.ts', 'src/https.ts', @@ -20,7 +21,10 @@ module.exports = { 'test/*', ], modulePathIgnorePatterns: ['/dist/'], - setupFilesAfterEnv: ['/test/jest-setup.js'], + setupFilesAfterEnv: [ + '/test/jest-setup.js', + '/test/superjson-setup.ts', + ], testPathIgnorePatterns: [ '/node_modules/', 'test-helpers', diff --git a/package.json b/package.json index 460c934047..e07104e494 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "pino-pretty": "^11.3.0", "pnpm": "^10.10.0", "snake-case": "^4.0.0", + "superjson": "^1.12.2", "triple-beam": "^1.4.1", "tslib": "^2.8.1", "uuid": "^8.3.2", diff --git a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap index 5e7216600f..c2cd373783 100644 --- a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap +++ b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap @@ -2,15 +2,15 @@ exports[`RnpExample useABC 1`] = ` { - "a": "real methodA-0.2479827843513731", - "b": "real methodB-0.00816658738172471", + "a": "real methodA-0.20636781091534218", + "b": "937538", "c": Any, } `; exports[`RnpExample useB 1`] = ` { - "b": "real methodB-0.16196714271689694", + "b": "467539", } `; @@ -24,14 +24,14 @@ exports[`RnpExample useBUnloaded 1`] = ` exports[`RnpExample useDTwice 1`] = ` { - "d1": "real methodD-0.2688678022183604", - "d2": "real methodD-0.7199207348433552", + "d1": "real methodD-0.7450202811379858", + "d2": "real methodD-0.22170614534273492", } `; exports[`RnpExample useProtoDep 1`] = ` { - "x": "real methodX-0.41410977701045093", + "x": "real methodX-0.40755652551480015", } `; diff --git a/test/rnpExample/api/rnpExample.ts b/test/rnpExample/api/rnpExample.ts index 481f8d6b95..fb9d5912c6 100644 --- a/test/rnpExample/api/rnpExample.ts +++ b/test/rnpExample/api/rnpExample.ts @@ -1,7 +1,9 @@ +import { BigNumber } from 'ethers'; + export class Dep1 { methodA = async () => 'real methodA-' + Math.random(); - methodB = async () => 'real methodB-' + Math.random(); + methodB = async () => BigNumber.from(Math.floor(Math.random() * 1000000)); methodC = async () => 'real methodC-' + Math.random(); @@ -45,12 +47,15 @@ export class RnpExample { const a = await this.dep1.methodA(); const b = await this.dep1.methodB(); const c = await this.dep1.methodC(); - return { a, b, c }; + return { a, b: b.toString(), c }; } async useB() { const b = await this.dep1.methodB(); - return { b }; + if (!BigNumber.isBigNumber(b)) { + throw new Error('b is not a BigNumber'); + } + return { b: b.toString() }; } async useDTwice() { diff --git a/test/rnpExample/mocks/rnpExample-methodA.json b/test/rnpExample/mocks/rnpExample-methodA.json index c723ffe3b8..0459d8095e 100644 --- a/test/rnpExample/mocks/rnpExample-methodA.json +++ b/test/rnpExample/mocks/rnpExample-methodA.json @@ -1,3 +1,3 @@ { - "json": "real methodA-0.2479827843513731" + "json": "real methodA-0.20636781091534218" } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodB-useB.json b/test/rnpExample/mocks/rnpExample-methodB-useB.json index 44b2b17692..e95b1f591a 100644 --- a/test/rnpExample/mocks/rnpExample-methodB-useB.json +++ b/test/rnpExample/mocks/rnpExample-methodB-useB.json @@ -1,3 +1,11 @@ { - "json": "real methodB-0.16196714271689694" + "json": "0x072253", + "meta": { + "values": [ + [ + "custom", + "ethers.BigNumber" + ] + ] + } } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodB.json b/test/rnpExample/mocks/rnpExample-methodB.json index d63804dfb1..c7d10aba92 100644 --- a/test/rnpExample/mocks/rnpExample-methodB.json +++ b/test/rnpExample/mocks/rnpExample-methodB.json @@ -1,3 +1,11 @@ { - "json": "real methodB-0.00816658738172471" + "json": "0x0e4e42", + "meta": { + "values": [ + [ + "custom", + "ethers.BigNumber" + ] + ] + } } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodD1.json b/test/rnpExample/mocks/rnpExample-methodD1.json index a0dc338d4c..3ad32d6ad0 100644 --- a/test/rnpExample/mocks/rnpExample-methodD1.json +++ b/test/rnpExample/mocks/rnpExample-methodD1.json @@ -1,3 +1,3 @@ { - "json": "real methodD-0.2688678022183604" + "json": "real methodD-0.7450202811379858" } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodD2.json b/test/rnpExample/mocks/rnpExample-methodD2.json index 40289a014c..7b72ce135e 100644 --- a/test/rnpExample/mocks/rnpExample-methodD2.json +++ b/test/rnpExample/mocks/rnpExample-methodD2.json @@ -1,3 +1,3 @@ { - "json": "real methodD-0.7199207348433552" + "json": "real methodD-0.22170614534273492" } \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-methodX.json b/test/rnpExample/mocks/rnpExample-methodX.json index 2cf37cf148..aba5127e80 100644 --- a/test/rnpExample/mocks/rnpExample-methodX.json +++ b/test/rnpExample/mocks/rnpExample-methodX.json @@ -1,3 +1,3 @@ { - "json": "real methodX-0.41410977701045093" + "json": "real methodX-0.40755652551480015" } \ No newline at end of file diff --git a/test/superjson-setup.ts b/test/superjson-setup.ts new file mode 100644 index 0000000000..79a767e608 --- /dev/null +++ b/test/superjson-setup.ts @@ -0,0 +1,11 @@ +import { BigNumber } from 'ethers'; +import superjson from 'superjson'; + +superjson.registerCustom( + { + isApplicable: (v): v is BigNumber => BigNumber.isBigNumber(v), + serialize: (v) => v.toHexString(), + deserialize: (v) => BigNumber.from(v), + }, + 'ethers.BigNumber', +); From c7faa60d2d217c95813a806d3b3d40a02f81b35f Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Wed, 2 Jul 2025 12:47:26 -0600 Subject: [PATCH 25/45] join error thrown when saving mocks into one error. --- test/record-and-play/api-test-case.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/record-and-play/api-test-case.ts b/test/record-and-play/api-test-case.ts index 06ef4b136a..4ff6df9677 100644 --- a/test/record-and-play/api-test-case.ts +++ b/test/record-and-play/api-test-case.ts @@ -78,8 +78,12 @@ export class APITestCase> // save mocks first so that we can move to replay for quicker testing if we got all the data we needed. const saveMockErrors = await harness.saveMocks(this.requiredMocks); this.assertStatusCode(response); - for (const [_key, error] of Object.entries(saveMockErrors)) { - throw error; + const errorEntries = Object.entries(saveMockErrors); + if (errorEntries.length > 0) { + const errorMessages = errorEntries + .map(([key, error]) => `${key}: ${error.message}`) + .join('\n'); + throw new Error(`Failed to save mocks:\n${errorMessages}`); } const body = JSON.parse(response.body); this.assertSnapshot(body); From 646f1c9448c9c76a587554d79a33dc6e983fe1b3 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Wed, 2 Jul 2025 15:41:23 -0600 Subject: [PATCH 26/45] PR comment about clarity with proto property --- test/record-and-play/test-dependency-contract.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/record-and-play/test-dependency-contract.ts b/test/record-and-play/test-dependency-contract.ts index 76238139dd..bade4d05a6 100644 --- a/test/record-and-play/test-dependency-contract.ts +++ b/test/record-and-play/test-dependency-contract.ts @@ -72,7 +72,7 @@ export class PrototypeDependency< TObject, > extends TestDependencyContract { constructor( - private proto: { new (...args: any[]): TObject }, + private ClassConstructor: { new (...args: any[]): TObject }, public methodName: keyof TObject, public allowPassThrough = false, ) { @@ -80,11 +80,11 @@ export class PrototypeDependency< } getObject(_provider: MockProvider) { - return this.proto.prototype; + return this.ClassConstructor.prototype; } setupSpy(_provider: MockProvider): jest.SpyInstance { - return jest.spyOn(this.proto.prototype, this.methodName as any); + return jest.spyOn(this.ClassConstructor.prototype, this.methodName as any); } } From f9a104ca788787a8ad77024f186dab915bea28f0 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Wed, 2 Jul 2025 15:41:47 -0600 Subject: [PATCH 27/45] switch to templates instead of string concatenation. --- test/rnpExample/api/rnpExample.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/rnpExample/api/rnpExample.ts b/test/rnpExample/api/rnpExample.ts index fb9d5912c6..df9d67ec63 100644 --- a/test/rnpExample/api/rnpExample.ts +++ b/test/rnpExample/api/rnpExample.ts @@ -1,24 +1,24 @@ import { BigNumber } from 'ethers'; export class Dep1 { - methodA = async () => 'real methodA-' + Math.random(); + methodA = async () => `real methodA-${Math.random()}`; methodB = async () => BigNumber.from(Math.floor(Math.random() * 1000000)); - methodC = async () => 'real methodC-' + Math.random(); + methodC = async () => `real methodC-${Math.random()}`; - methodD = async () => 'real methodD-' + Math.random(); + methodD = async () => `real methodD-${Math.random()}`; - methodUnmapped = async () => 'real methodUnmapped-' + Math.random(); + methodUnmapped = async () => `real methodUnmapped-${Math.random()}`; } export class Dep2 { - methodZ = async () => 'real methodZ-' + Math.random(); + methodZ = async () => `real methodZ-${Math.random()}`; } export class DepProto { async methodX() { - return 'real methodX-' + Math.random(); + return `real methodX-${Math.random()}`; } } From 38a15a83dfc590121aa3769455f3c09784b83b31 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Wed, 2 Jul 2025 15:44:49 -0600 Subject: [PATCH 28/45] update rnp snapshots --- .../rnpExample/__snapshots__/rnpExample.test.ts.snap | 12 ++++++------ test/rnpExample/mocks/rnpExample-methodA.json | 4 ++-- test/rnpExample/mocks/rnpExample-methodB-useB.json | 11 +++-------- test/rnpExample/mocks/rnpExample-methodB.json | 11 +++-------- test/rnpExample/mocks/rnpExample-methodD1.json | 4 ++-- test/rnpExample/mocks/rnpExample-methodD2.json | 4 ++-- test/rnpExample/mocks/rnpExample-methodX.json | 4 ++-- 7 files changed, 20 insertions(+), 30 deletions(-) diff --git a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap index c2cd373783..bb03dfc0c7 100644 --- a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap +++ b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap @@ -2,15 +2,15 @@ exports[`RnpExample useABC 1`] = ` { - "a": "real methodA-0.20636781091534218", - "b": "937538", + "a": "real methodA-0.623159602411435", + "b": "624554", "c": Any, } `; exports[`RnpExample useB 1`] = ` { - "b": "467539", + "b": "735292", } `; @@ -24,14 +24,14 @@ exports[`RnpExample useBUnloaded 1`] = ` exports[`RnpExample useDTwice 1`] = ` { - "d1": "real methodD-0.7450202811379858", - "d2": "real methodD-0.22170614534273492", + "d1": "real methodD-0.9962881775546952", + "d2": "real methodD-0.38055493047559086", } `; exports[`RnpExample useProtoDep 1`] = ` { - "x": "real methodX-0.40755652551480015", + "x": "real methodX-0.10033756858188925", } `; diff --git a/test/rnpExample/mocks/rnpExample-methodA.json b/test/rnpExample/mocks/rnpExample-methodA.json index 0459d8095e..56e6eb8a61 100644 --- a/test/rnpExample/mocks/rnpExample-methodA.json +++ b/test/rnpExample/mocks/rnpExample-methodA.json @@ -1,3 +1,3 @@ { - "json": "real methodA-0.20636781091534218" -} \ No newline at end of file + "json": "real methodA-0.623159602411435" +} diff --git a/test/rnpExample/mocks/rnpExample-methodB-useB.json b/test/rnpExample/mocks/rnpExample-methodB-useB.json index e95b1f591a..0d1eb9013c 100644 --- a/test/rnpExample/mocks/rnpExample-methodB-useB.json +++ b/test/rnpExample/mocks/rnpExample-methodB-useB.json @@ -1,11 +1,6 @@ { - "json": "0x072253", + "json": "0x0b383c", "meta": { - "values": [ - [ - "custom", - "ethers.BigNumber" - ] - ] + "values": [["custom", "ethers.BigNumber"]] } -} \ No newline at end of file +} diff --git a/test/rnpExample/mocks/rnpExample-methodB.json b/test/rnpExample/mocks/rnpExample-methodB.json index c7d10aba92..227f10a57c 100644 --- a/test/rnpExample/mocks/rnpExample-methodB.json +++ b/test/rnpExample/mocks/rnpExample-methodB.json @@ -1,11 +1,6 @@ { - "json": "0x0e4e42", + "json": "0x0987aa", "meta": { - "values": [ - [ - "custom", - "ethers.BigNumber" - ] - ] + "values": [["custom", "ethers.BigNumber"]] } -} \ No newline at end of file +} diff --git a/test/rnpExample/mocks/rnpExample-methodD1.json b/test/rnpExample/mocks/rnpExample-methodD1.json index 3ad32d6ad0..70fe45ca91 100644 --- a/test/rnpExample/mocks/rnpExample-methodD1.json +++ b/test/rnpExample/mocks/rnpExample-methodD1.json @@ -1,3 +1,3 @@ { - "json": "real methodD-0.7450202811379858" -} \ No newline at end of file + "json": "real methodD-0.9962881775546952" +} diff --git a/test/rnpExample/mocks/rnpExample-methodD2.json b/test/rnpExample/mocks/rnpExample-methodD2.json index 7b72ce135e..678d50b41f 100644 --- a/test/rnpExample/mocks/rnpExample-methodD2.json +++ b/test/rnpExample/mocks/rnpExample-methodD2.json @@ -1,3 +1,3 @@ { - "json": "real methodD-0.22170614534273492" -} \ No newline at end of file + "json": "real methodD-0.38055493047559086" +} diff --git a/test/rnpExample/mocks/rnpExample-methodX.json b/test/rnpExample/mocks/rnpExample-methodX.json index aba5127e80..dc4df12cd8 100644 --- a/test/rnpExample/mocks/rnpExample-methodX.json +++ b/test/rnpExample/mocks/rnpExample-methodX.json @@ -1,3 +1,3 @@ { - "json": "real methodX-0.40755652551480015" -} \ No newline at end of file + "json": "real methodX-0.10033756858188925" +} From e39fa26329dae255332708ef23546f823a3fb412 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Wed, 2 Jul 2025 15:52:09 -0600 Subject: [PATCH 29/45] make possible to mock non async method in the future. --- test/record-and-play/test-dependency-contract.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/record-and-play/test-dependency-contract.ts b/test/record-and-play/test-dependency-contract.ts index bade4d05a6..bcb439beb6 100644 --- a/test/record-and-play/test-dependency-contract.ts +++ b/test/record-and-play/test-dependency-contract.ts @@ -25,9 +25,14 @@ export abstract class TestDependencyContract { spy: jest.SpyInstance, provider: MockProvider, fileName: string, + isAsync = true, ): void { const mock = provider.getMock(fileName); - spy.mockResolvedValueOnce(mock); + if (isAsync) { + spy.mockResolvedValueOnce(mock); + } else { + spy.mockReturnValueOnce(mock); + } } } From c7a78af823fca26ac7a96918fe28115c87ab7d13 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Wed, 9 Jul 2025 11:39:41 -0600 Subject: [PATCH 30/45] create a guide for setting up the project testing and debugging in vscode. --- CURSOR_VSCODE_SETUP.md | 130 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 CURSOR_VSCODE_SETUP.md diff --git a/CURSOR_VSCODE_SETUP.md b/CURSOR_VSCODE_SETUP.md new file mode 100644 index 0000000000..2a6e4a7ba0 --- /dev/null +++ b/CURSOR_VSCODE_SETUP.md @@ -0,0 +1,130 @@ +# VS Code Setup Guide + +This guide provides the minimal VS Code configuration needed to work with the Gateway project's dual test suite setup (main tests + test scripts) and debug the server. + +## Jest extension for test discovery and debugging + +- **Jest** (`orta.vscode-jest`) + +### Settings (`.vscode/settings.json`) + +Configure Jest virtual folders for multiple test suites + +```json +{ + "jest.virtualFolders": [ + { + "name": "unit-tests", + "jestCommandLine": "pnpm test", + "runMode": "watch" + }, + { + "name": "test-scripts", + "jestCommandLine": "pnpm test:scripts", + "runMode": "on-demand" + } + ] +} +``` + +## Debugging the server and unit-tests + +### launch.json (`.vscode/launch.json`) + +Launch configuration for debugging the Gateway server and unit tests. + +```json +{ + "configurations": [ + { + "name": "Debug Gateway Server", + "type": "node", + "request": "launch", + "runtimeExecutable": "pnpm", + "runtimeArgs": [ + "start" + ], + "env": { + "GATEWAY_PASSPHRASE": "${input:password}", + }, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "skipFiles": [ + "/**", + "${workspaceFolder}/node_modules/**" + ], + "preLaunchTask": "build" + }, + { + "name": "vscode-jest-tests.v2.unit-tests", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/node_modules/jest/bin/jest.js", + "cwd": "${workspaceFolder}", + "env": { + "GATEWAY_TEST_MODE": "test" + }, + "args": [ + // Make sure to keep aligned with package.json config + "--verbose", + "--testNamePattern", + "${jest.testNamePattern}", + "--runTestsByPath", + "${jest.testFile}" + ], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "skipFiles": [ + "/**", + "${workspaceFolder}/node_modules/**" + ], + }, + { + "name": "vscode-jest-tests.v2.test-scripts", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/node_modules/jest/bin/jest.js", + "cwd": "${workspaceFolder}", + "env": { + "GATEWAY_TEST_MODE": "test" + }, + "args": [ + "--config", + "${workspaceFolder}/test-scripts/jest.config.js", + "--testNamePattern", + "${jest.testNamePattern}", + "--runTestsByPath", + "${jest.testFile}", + "--runInBand", + "-u" + ], + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + } + ], + "inputs": [ + { + "id": "password", + "type": "promptString", + "description": "Specify a password to use for gateway passphrase.", + }, + ] +} +``` + +### Tasks (`.vscode/tasks.json`) + +Build task for pre-launch compilation + +```json +{ + "tasks": [ + { + "type": "npm", + "script": "build", + "group": "build", + "label": "build" + } + ] +} +``` From a5e797cf5219ce595f4899383cad04573297211f Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Fri, 18 Jul 2025 10:37:34 -0600 Subject: [PATCH 31/45] make method selector a lambda instead of a string. --- .../abstract-gateway-test-harness.ts | 8 ++- .../test-dependency-contract.ts | 65 +++++++++++++++---- test/rnpExample/rnpExample.test-harness.ts | 14 ++-- 3 files changed, 66 insertions(+), 21 deletions(-) diff --git a/test/record-and-play/abstract-gateway-test-harness.ts b/test/record-and-play/abstract-gateway-test-harness.ts index db5344fcbe..896386a2f1 100644 --- a/test/record-and-play/abstract-gateway-test-harness.ts +++ b/test/record-and-play/abstract-gateway-test-harness.ts @@ -10,7 +10,8 @@ import { TestDependencyContract, } from './test-dependency-contract'; -interface ContractWithSpy extends TestDependencyContract { +interface ContractWithSpy + extends TestDependencyContract { spy?: jest.SpyInstance; } @@ -69,7 +70,7 @@ export abstract class AbstractGatewayTestHarness * @param fileName The name of the mock file to create. * @param data The data to serialize and save. */ - protected _saveMock(fileName: string, data: any): void { + protected _saveMock(fileName: string, data: TMock): void { const mockDir = path.join(this._mockDir, 'mocks'); if (!fs.existsSync(mockDir)) { fs.mkdirSync(mockDir, { recursive: true }); @@ -166,6 +167,9 @@ export abstract class AbstractGatewayTestHarness // as mocks. Therefore, we set callIndexStart to numCalls - numMocks to capture only // the last numMocks calls, which represent the successful sequence that should be // replayed during testing. + + // Example: If a method is called 5 times but only 2 mocks are requested, we'll + // record calls 4 and 5, assuming calls 1-3 were retries that failed. callIndexStart = numCalls - numMocks; } diff --git a/test/record-and-play/test-dependency-contract.ts b/test/record-and-play/test-dependency-contract.ts index bcb439beb6..0d6c26b33c 100644 --- a/test/record-and-play/test-dependency-contract.ts +++ b/test/record-and-play/test-dependency-contract.ts @@ -10,7 +10,7 @@ export interface MockProvider { /** * Defines the contract for how a dependency should be spied upon and mocked. */ -export abstract class TestDependencyContract { +export abstract class TestDependencyContract { /** If true, the real method will be called even during "Play" mode tests. */ public allowPassThrough: boolean; /** Returns the object or prototype that contains the method to be spied on. */ @@ -21,7 +21,7 @@ export abstract class TestDependencyContract { * Attaches a mock implementation to the spy. * Can be called multiple times to mock subsequent calls. */ - setupMock( + setupMock( spy: jest.SpyInstance, provider: MockProvider, fileName: string, @@ -45,7 +45,8 @@ export abstract class TestDependencyContract { export class InstancePropertyDependency< TInstance, TObject, -> extends TestDependencyContract { + TMock, +> extends TestDependencyContract { constructor( private getObjectFn: (provider: MockProvider) => TObject, public methodName: keyof TObject, @@ -75,7 +76,8 @@ export class InstancePropertyDependency< export class PrototypeDependency< TInstance, TObject, -> extends TestDependencyContract { + TMock, +> extends TestDependencyContract { constructor( private ClassConstructor: { new (...args: any[]): TObject }, public methodName: keyof TObject, @@ -98,18 +100,47 @@ export class PrototypeDependency< * This should be used within a concrete TestHarness class. */ export class DependencyFactory { + private _extractMethodName any>( + selector: (obj: T) => TMethod, + ): keyof T { + // This is a hack: create a Proxy to intercept the property access + let prop: string | symbol | undefined; + const proxy = new Proxy( + {}, + { + get(_target, p) { + prop = p; + // eslint-disable-next-line @typescript-eslint/no-empty-function + return () => {}; + }, + }, + ) as T; + selector(proxy); + if (!prop) { + throw new Error('Could not extract method name from selector'); + } + return prop as keyof T; + } + /** * Creates a contract for a method on a class instance property. * @param instancePropertyName The name of the property on the main service instance that holds the dependency object. - * @param methodName The name of the method on the dependency object to spy on. + * @param methodSelector A lambda function that selects the method on the dependency object (e.g., `x => x.myMethod`). * @param allowPassThrough If true, the real method is called during "Play" mode. */ - instanceProperty( + instanceProperty< + K extends keyof TInstance, + TMethod extends (...args: any[]) => any = any, + >( instancePropertyName: K, - methodName: keyof TInstance[K], + methodSelector: (dep: TInstance[K]) => TMethod, allowPassThrough = false, ) { - return new InstancePropertyDependency( + const methodName = this._extractMethodName(methodSelector); + + type TMock = Awaited>; + + return new InstancePropertyDependency( (p: MockProvider): TInstance[K] => p.instance[instancePropertyName], methodName, @@ -120,15 +151,21 @@ export class DependencyFactory { /** * Creates a contract for a method on a class prototype. * WARNING: This will not work for methods defined as arrow functions. - * @param proto The class (not an instance) whose prototype contains the method. - * @param methodName The name of the method on the prototype to spy on. + * @param ClassConstructor The class (not an instance) whose prototype contains the method. + * @param methodSelector A lambda function that selects the method on the prototype (e.g., `x => x.myMethod`). * @param allowPassThrough If true, the real method is called during "Play" mode. */ - prototype( - proto: { new (...args: any[]): TObject }, - methodName: keyof TObject, + prototype any = any>( + ClassConstructor: { new (...args: any[]): TObject }, + methodSelector: (obj: TObject) => TMethod, allowPassThrough = false, ) { - return new PrototypeDependency(proto, methodName, allowPassThrough); + const methodName = this._extractMethodName(methodSelector); + type TMock = Awaited>; + return new PrototypeDependency( + ClassConstructor, + methodName, + allowPassThrough, + ); } } diff --git a/test/rnpExample/rnpExample.test-harness.ts b/test/rnpExample/rnpExample.test-harness.ts index d6915e38da..64644055cf 100644 --- a/test/rnpExample/rnpExample.test-harness.ts +++ b/test/rnpExample/rnpExample.test-harness.ts @@ -6,18 +6,22 @@ export class RnpExampleTestHarness extends AbstractGatewayTestHarness x.methodA), + dep1_B: this.dependencyFactory.instanceProperty('dep1', (x) => x.methodB), // The `allowPassThrough: true` flag creates an exception. `dep1.methodC` // is still "managed", but it will call its real implementation during // "Play" tests instead of throwing an error if it isn't mocked. - dep1_C: this.dependencyFactory.instanceProperty('dep1', 'methodC', true), - dep1_D: this.dependencyFactory.instanceProperty('dep1', 'methodD'), + dep1_C: this.dependencyFactory.instanceProperty( + 'dep1', + (x) => x.methodC, + true, + ), + dep1_D: this.dependencyFactory.instanceProperty('dep1', (x) => x.methodD), // This defines a contract for a method on a class prototype. This is // necessary for dependencies that are instantiated inside other methods. - dep3_X: this.dependencyFactory.prototype(DepProto, 'methodX'), + dep3_X: this.dependencyFactory.prototype(DepProto, (x) => x.methodX), // CRITICAL NOTE: Because other `dep1` methods are listed above, the entire // `dep1` object is now "managed". This means `dep1.methodUnmapped()` From f797d0a2b44f2eb3293a45b1a059540dbdd7eac4 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Fri, 18 Jul 2025 11:36:58 -0600 Subject: [PATCH 32/45] Change rnpExample method names to be more descriptive --- .../rnpExample/rnpExample.recorder.test.ts | 38 +++++----- .../__snapshots__/rnpExample.test.ts.snap | 30 ++++---- test/rnpExample/api/rnpExample.routes.ts | 8 +-- test/rnpExample/api/rnpExample.ts | 53 +++++++------- .../{useProtoDep.ts => usePrototypeDep.ts} | 10 +-- .../routes/{useB.ts => useSuperJsonMethod.ts} | 12 ++-- test/rnpExample/mocks/rnpExample-methodA.json | 3 - .../rnpExample/mocks/rnpExample-methodD1.json | 3 - .../rnpExample/mocks/rnpExample-methodD2.json | 3 - test/rnpExample/mocks/rnpExample-methodX.json | 3 - .../rnpExample/mocks/rnpExample-useABC_A.json | 3 + ...-methodB.json => rnpExample-useABC_B.json} | 2 +- ...n => rnpExample-useB_superJsonMethod.json} | 2 +- .../mocks/rnpExample-useDTwice_1.json | 3 + .../mocks/rnpExample-useDTwice_2.json | 3 + .../mocks/rnpExample-usePrototypeDep.json | 3 + test/rnpExample/rnpExample.api-test-cases.ts | 70 +++++++++---------- test/rnpExample/rnpExample.test-harness.ts | 22 +++--- test/rnpExample/rnpExample.test.ts | 35 +++++----- 19 files changed, 151 insertions(+), 155 deletions(-) rename test/rnpExample/api/routes/{useProtoDep.ts => usePrototypeDep.ts} (66%) rename test/rnpExample/api/routes/{useB.ts => useSuperJsonMethod.ts} (62%) delete mode 100644 test/rnpExample/mocks/rnpExample-methodA.json delete mode 100644 test/rnpExample/mocks/rnpExample-methodD1.json delete mode 100644 test/rnpExample/mocks/rnpExample-methodD2.json delete mode 100644 test/rnpExample/mocks/rnpExample-methodX.json create mode 100644 test/rnpExample/mocks/rnpExample-useABC_A.json rename test/rnpExample/mocks/{rnpExample-methodB.json => rnpExample-useABC_B.json} (75%) rename test/rnpExample/mocks/{rnpExample-methodB-useB.json => rnpExample-useB_superJsonMethod.json} (75%) create mode 100644 test/rnpExample/mocks/rnpExample-useDTwice_1.json create mode 100644 test/rnpExample/mocks/rnpExample-useDTwice_2.json create mode 100644 test/rnpExample/mocks/rnpExample-usePrototypeDep.json diff --git a/test-scripts/rnpExample/rnpExample.recorder.test.ts b/test-scripts/rnpExample/rnpExample.recorder.test.ts index 7e67bad9a7..1f425b47ba 100644 --- a/test-scripts/rnpExample/rnpExample.recorder.test.ts +++ b/test-scripts/rnpExample/rnpExample.recorder.test.ts @@ -3,9 +3,9 @@ import { useABC, useUnlistedDep, useDTwice, - useProtoDep, - useUnmappedMethodRecorder, - useB, + usePrototypeDep, + useUnmappedMethod_Recorder, + useB_superJsonMethod, } from '#test/rnpExample/rnpExample.api-test-cases'; describe('RnpExample', () => { @@ -29,39 +29,39 @@ describe('RnpExample', () => { await useABC.processRecorderRequest(harness); }); - it('useB', async () => { - await useB.processRecorderRequest(harness); + it('useB_superJsonMethod', async () => { + await useB_superJsonMethod.processRecorderRequest(harness); }); it('useDTwice', async () => { await useDTwice.processRecorderRequest(harness); }); - it('useProtoDep', async () => { - await useProtoDep.processRecorderRequest(harness); + it('usePrototypeDep', async () => { + await usePrototypeDep.processRecorderRequest(harness); }); - it('useUnmappedMethodRecorder', async () => { - await useUnmappedMethodRecorder.processRecorderRequest(harness); - }); - - it('useUnmappedMethodMocked', async () => { + it('useABCUnloaded', async () => { // Create to force snapshot file to match exactly expect({ - error: 'FailedDependencyError', + error: 'InternalServerError', message: - 'Failed to useUnmappedMethod: Unmapped method was called: dep1_A.methodUnmapped. Method must be listed and either mocked or specify allowPassThrough.', - statusCode: 424, + 'Failed to useABC: Mocked dependency was called without a mock loaded: dep1_A. Either load a mock or allowPassThrough.', + statusCode: 500, }).toMatchSnapshot({}); }); - it('useBUnloaded', async () => { + it('useUnmappedMethod_Recorder', async () => { + await useUnmappedMethod_Recorder.processRecorderRequest(harness); + }); + + it('useUnmappedMethod_Mocked', async () => { // Create to force snapshot file to match exactly expect({ - error: 'InternalServerError', + error: 'FailedDependencyError', message: - 'Failed to useB: Mocked dependency was called without a mock loaded: dep1_B. Either load a mock or allowPassThrough.', - statusCode: 500, + 'Failed to useUnmappedMethod: Unmapped method was called: dep1_A.unmappedMethod. Method must be listed and either mocked or specify allowPassThrough.', + statusCode: 424, }).toMatchSnapshot({}); }); diff --git a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap index bb03dfc0c7..4786ea4f6f 100644 --- a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap +++ b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap @@ -2,36 +2,36 @@ exports[`RnpExample useABC 1`] = ` { - "a": "real methodA-0.623159602411435", - "b": "624554", + "a": "real methodA-0.9780876880931055", + "b": "798931", "c": Any, } `; -exports[`RnpExample useB 1`] = ` +exports[`RnpExample useABCUnloaded 1`] = ` { - "b": "735292", + "error": "InternalServerError", + "message": "Failed to useABC: Mocked dependency was called without a mock loaded: dep1_A. Either load a mock or allowPassThrough.", + "statusCode": 500, } `; -exports[`RnpExample useBUnloaded 1`] = ` +exports[`RnpExample useB_superJsonMethod 1`] = ` { - "error": "InternalServerError", - "message": "Failed to useB: Mocked dependency was called without a mock loaded: dep1_B. Either load a mock or allowPassThrough.", - "statusCode": 500, + "b": "634042", } `; exports[`RnpExample useDTwice 1`] = ` { - "d1": "real methodD-0.9962881775546952", - "d2": "real methodD-0.38055493047559086", + "d1": "real methodD-0.38345424792538996", + "d2": "real methodD-0.7831965722241947", } `; -exports[`RnpExample useProtoDep 1`] = ` +exports[`RnpExample usePrototypeDep 1`] = ` { - "x": "real methodX-0.10033756858188925", + "x": "real methodX-0.3895412431687655", } `; @@ -41,15 +41,15 @@ exports[`RnpExample useUnlistedDep 1`] = ` } `; -exports[`RnpExample useUnmappedMethodMocked 1`] = ` +exports[`RnpExample useUnmappedMethod_Mocked 1`] = ` { "error": "FailedDependencyError", - "message": "Failed to useUnmappedMethod: Unmapped method was called: dep1_A.methodUnmapped. Method must be listed and either mocked or specify allowPassThrough.", + "message": "Failed to useUnmappedMethod: Unmapped method was called: dep1_A.unmappedMethod. Method must be listed and either mocked or specify allowPassThrough.", "statusCode": 424, } `; -exports[`RnpExample useUnmappedMethodRecorder 1`] = ` +exports[`RnpExample useUnmappedMethod_Recorder 1`] = ` { "unmapped": Any, } diff --git a/test/rnpExample/api/rnpExample.routes.ts b/test/rnpExample/api/rnpExample.routes.ts index 8558527bab..e5d7985b2e 100644 --- a/test/rnpExample/api/rnpExample.routes.ts +++ b/test/rnpExample/api/rnpExample.routes.ts @@ -1,17 +1,17 @@ import { FastifyPluginAsync } from 'fastify'; import { useABCRoute } from './routes/useABC'; -import { useBRoute } from './routes/useB'; import { useDTwiceRoute } from './routes/useDTwice'; -import { useProtoDepRoute } from './routes/useProtoDep'; +import { usePrototypeDepRoute } from './routes/usePrototypeDep'; +import { useSuperJsonMethodRoute } from './routes/useSuperJsonMethod'; import { useUnlistedDepRoute } from './routes/useUnlistedDep'; import { useUnmappedMethodRoute } from './routes/useUnmappedMethod'; export const rnpExampleRoutes: FastifyPluginAsync = async (fastify) => { await fastify.register(useABCRoute); - await fastify.register(useBRoute); + await fastify.register(useSuperJsonMethodRoute); await fastify.register(useDTwiceRoute); - await fastify.register(useProtoDepRoute); + await fastify.register(usePrototypeDepRoute); await fastify.register(useUnlistedDepRoute); await fastify.register(useUnmappedMethodRoute); }; diff --git a/test/rnpExample/api/rnpExample.ts b/test/rnpExample/api/rnpExample.ts index df9d67ec63..6e39eaeb66 100644 --- a/test/rnpExample/api/rnpExample.ts +++ b/test/rnpExample/api/rnpExample.ts @@ -1,35 +1,36 @@ import { BigNumber } from 'ethers'; -export class Dep1 { - methodA = async () => `real methodA-${Math.random()}`; +export class Dependency1 { + A_basicMethod = async () => `real methodA-${Math.random()}`; - methodB = async () => BigNumber.from(Math.floor(Math.random() * 1000000)); + B_superJsonMethod = async () => + BigNumber.from(Math.floor(Math.random() * 1000000)); - methodC = async () => `real methodC-${Math.random()}`; + C_passthroughMethod = async () => `real C_passthroughMethod-${Math.random()}`; - methodD = async () => `real methodD-${Math.random()}`; + D_usedTwiceInOneCallMethod = async () => `real methodD-${Math.random()}`; - methodUnmapped = async () => `real methodUnmapped-${Math.random()}`; + unmappedMethod = async () => `real unmappedMethod-${Math.random()}`; } -export class Dep2 { - methodZ = async () => `real methodZ-${Math.random()}`; +export class UnlistedDependency { + unlistedMethod = async () => `real methodZ-${Math.random()}`; } -export class DepProto { - async methodX() { +export class LocallyInitializedDependency { + async prototypeMethod() { return `real methodX-${Math.random()}`; } } export class RnpExample { private static _instances: { [name: string]: RnpExample }; - public dep1: Dep1; - public dep2: Dep2; + public dep1: Dependency1; + public dep2: UnlistedDependency; constructor() { - this.dep1 = new Dep1(); - this.dep2 = new Dep2(); + this.dep1 = new Dependency1(); + this.dep2 = new UnlistedDependency(); } public static async getInstance(network: string): Promise { @@ -44,14 +45,14 @@ export class RnpExample { } async useABC() { - const a = await this.dep1.methodA(); - const b = await this.dep1.methodB(); - const c = await this.dep1.methodC(); + const a = await this.dep1.A_basicMethod(); + const b = await this.dep1.B_superJsonMethod(); + const c = await this.dep1.C_passthroughMethod(); return { a, b: b.toString(), c }; } - async useB() { - const b = await this.dep1.methodB(); + async useSuperJsonMethod() { + const b = await this.dep1.B_superJsonMethod(); if (!BigNumber.isBigNumber(b)) { throw new Error('b is not a BigNumber'); } @@ -59,24 +60,24 @@ export class RnpExample { } async useDTwice() { - const d1 = await this.dep1.methodD(); - const d2 = await this.dep1.methodD(); + const d1 = await this.dep1.D_usedTwiceInOneCallMethod(); + const d2 = await this.dep1.D_usedTwiceInOneCallMethod(); return { d1, d2 }; } async useUnmappedMethod() { - const unmapped = await this.dep1.methodUnmapped(); + const unmapped = await this.dep1.unmappedMethod(); return { unmapped }; } - async useProtoDep() { - const dep3 = new DepProto(); - const x = await dep3.methodX(); + async usePrototypeDep() { + const localDep = new LocallyInitializedDependency(); + const x = await localDep.prototypeMethod(); return { x }; } async useUnlistedDep() { - const z = await this.dep2.methodZ(); + const z = await this.dep2.unlistedMethod(); return { z }; } } diff --git a/test/rnpExample/api/routes/useProtoDep.ts b/test/rnpExample/api/routes/usePrototypeDep.ts similarity index 66% rename from test/rnpExample/api/routes/useProtoDep.ts rename to test/rnpExample/api/routes/usePrototypeDep.ts index cdde030017..ea0aef090a 100644 --- a/test/rnpExample/api/routes/useProtoDep.ts +++ b/test/rnpExample/api/routes/usePrototypeDep.ts @@ -4,12 +4,12 @@ import { logger } from '#src/services/logger'; import { RnpExample } from '../rnpExample'; -export const useProtoDepRoute: FastifyPluginAsync = async (fastify) => { +export const usePrototypeDepRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ Querystring: { network: string }; Reply: { x: string }; }>( - '/useProtoDep', + '/usePrototypeDep', { schema: { summary: 'A RnpExample route for testing prototype dependencies', @@ -18,11 +18,11 @@ export const useProtoDepRoute: FastifyPluginAsync = async (fastify) => { async (request) => { try { const rnpExample = await RnpExample.getInstance(request.query.network); - return await rnpExample.useProtoDep(); + return await rnpExample.usePrototypeDep(); } catch (error) { - logger.error(`Error getting useProtoDep status: ${error.message}`); + logger.error(`Error getting usePrototypeDep status: ${error.message}`); throw fastify.httpErrors.internalServerError( - `Failed to useProtoDep: ${error.message}`, + `Failed to usePrototypeDep: ${error.message}`, ); } }, diff --git a/test/rnpExample/api/routes/useB.ts b/test/rnpExample/api/routes/useSuperJsonMethod.ts similarity index 62% rename from test/rnpExample/api/routes/useB.ts rename to test/rnpExample/api/routes/useSuperJsonMethod.ts index 93289c99f6..c74ad77224 100644 --- a/test/rnpExample/api/routes/useB.ts +++ b/test/rnpExample/api/routes/useSuperJsonMethod.ts @@ -4,12 +4,12 @@ import { logger } from '#src/services/logger'; import { RnpExample } from '../rnpExample'; -export const useBRoute: FastifyPluginAsync = async (fastify) => { +export const useSuperJsonMethodRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ Querystring: { network: string }; Reply: { b: string }; }>( - '/useB', + '/useSuperJsonMethod', { schema: { summary: 'A RnpExample route for testing', @@ -18,11 +18,13 @@ export const useBRoute: FastifyPluginAsync = async (fastify) => { async (request) => { try { const rnpExample = await RnpExample.getInstance(request.query.network); - return await rnpExample.useB(); + return await rnpExample.useSuperJsonMethod(); } catch (error) { - logger.error(`Error getting useB status: ${error.message}`); + logger.error( + `Error getting useSuperJsonMethod status: ${error.message}`, + ); throw fastify.httpErrors.internalServerError( - `Failed to useB: ${error.message}`, + `Failed to useSuperJsonMethod: ${error.message}`, ); } }, diff --git a/test/rnpExample/mocks/rnpExample-methodA.json b/test/rnpExample/mocks/rnpExample-methodA.json deleted file mode 100644 index 56e6eb8a61..0000000000 --- a/test/rnpExample/mocks/rnpExample-methodA.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "json": "real methodA-0.623159602411435" -} diff --git a/test/rnpExample/mocks/rnpExample-methodD1.json b/test/rnpExample/mocks/rnpExample-methodD1.json deleted file mode 100644 index 70fe45ca91..0000000000 --- a/test/rnpExample/mocks/rnpExample-methodD1.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "json": "real methodD-0.9962881775546952" -} diff --git a/test/rnpExample/mocks/rnpExample-methodD2.json b/test/rnpExample/mocks/rnpExample-methodD2.json deleted file mode 100644 index 678d50b41f..0000000000 --- a/test/rnpExample/mocks/rnpExample-methodD2.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "json": "real methodD-0.38055493047559086" -} diff --git a/test/rnpExample/mocks/rnpExample-methodX.json b/test/rnpExample/mocks/rnpExample-methodX.json deleted file mode 100644 index dc4df12cd8..0000000000 --- a/test/rnpExample/mocks/rnpExample-methodX.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "json": "real methodX-0.10033756858188925" -} diff --git a/test/rnpExample/mocks/rnpExample-useABC_A.json b/test/rnpExample/mocks/rnpExample-useABC_A.json new file mode 100644 index 0000000000..d226207ecf --- /dev/null +++ b/test/rnpExample/mocks/rnpExample-useABC_A.json @@ -0,0 +1,3 @@ +{ + "json": "real methodA-0.9780876880931055" +} diff --git a/test/rnpExample/mocks/rnpExample-methodB.json b/test/rnpExample/mocks/rnpExample-useABC_B.json similarity index 75% rename from test/rnpExample/mocks/rnpExample-methodB.json rename to test/rnpExample/mocks/rnpExample-useABC_B.json index 227f10a57c..7fb55b6106 100644 --- a/test/rnpExample/mocks/rnpExample-methodB.json +++ b/test/rnpExample/mocks/rnpExample-useABC_B.json @@ -1,5 +1,5 @@ { - "json": "0x0987aa", + "json": "0x0c30d3", "meta": { "values": [["custom", "ethers.BigNumber"]] } diff --git a/test/rnpExample/mocks/rnpExample-methodB-useB.json b/test/rnpExample/mocks/rnpExample-useB_superJsonMethod.json similarity index 75% rename from test/rnpExample/mocks/rnpExample-methodB-useB.json rename to test/rnpExample/mocks/rnpExample-useB_superJsonMethod.json index 0d1eb9013c..7642c0a4ca 100644 --- a/test/rnpExample/mocks/rnpExample-methodB-useB.json +++ b/test/rnpExample/mocks/rnpExample-useB_superJsonMethod.json @@ -1,5 +1,5 @@ { - "json": "0x0b383c", + "json": "0x09acba", "meta": { "values": [["custom", "ethers.BigNumber"]] } diff --git a/test/rnpExample/mocks/rnpExample-useDTwice_1.json b/test/rnpExample/mocks/rnpExample-useDTwice_1.json new file mode 100644 index 0000000000..9174a0de66 --- /dev/null +++ b/test/rnpExample/mocks/rnpExample-useDTwice_1.json @@ -0,0 +1,3 @@ +{ + "json": "real methodD-0.38345424792538996" +} diff --git a/test/rnpExample/mocks/rnpExample-useDTwice_2.json b/test/rnpExample/mocks/rnpExample-useDTwice_2.json new file mode 100644 index 0000000000..fa51f5f52d --- /dev/null +++ b/test/rnpExample/mocks/rnpExample-useDTwice_2.json @@ -0,0 +1,3 @@ +{ + "json": "real methodD-0.7831965722241947" +} diff --git a/test/rnpExample/mocks/rnpExample-usePrototypeDep.json b/test/rnpExample/mocks/rnpExample-usePrototypeDep.json new file mode 100644 index 0000000000..241f2efcda --- /dev/null +++ b/test/rnpExample/mocks/rnpExample-usePrototypeDep.json @@ -0,0 +1,3 @@ +{ + "json": "real methodX-0.3895412431687655" +} diff --git a/test/rnpExample/rnpExample.api-test-cases.ts b/test/rnpExample/rnpExample.api-test-cases.ts index 2a7e032656..b3a4033e9b 100644 --- a/test/rnpExample/rnpExample.api-test-cases.ts +++ b/test/rnpExample/rnpExample.api-test-cases.ts @@ -17,8 +17,8 @@ export const useABC = new TestCase({ query: { network: 'TEST' }, payload: {}, requiredMocks: { - dep1_A: 'rnpExample-methodA', - dep1_B: 'rnpExample-methodB', + dep1_A: 'rnpExample-useABC_A', + dep1_B: 'rnpExample-useABC_B', }, propertyMatchers: { c: expect.any(String), @@ -28,54 +28,68 @@ export const useABC = new TestCase({ /** * A simple test case that calls one mocked dependency. */ -export const useB = new TestCase({ +export const useB_superJsonMethod = new TestCase({ method: 'GET', - url: '/rnpExample/useB', + url: '/rnpExample/useSuperJsonMethod', expectedStatus: 200, query: { network: 'TEST' }, payload: {}, requiredMocks: { - dep1_B: 'rnpExample-methodB-useB', + dep1_B: 'rnpExample-useB_superJsonMethod', }, }); /** - * A test case for an unloaded dependency. - * This test calls a method that requires the 'dep1_B' mock, but does not load it - * via `requiredMocks`. This is designed to fail, demonstrating the safety - * feature that prevents calls to managed dependencies that haven't been loaded. + * A test case that calls the same dependency twice. + * The `requiredMocks` array provides two different mock files, which will be + * returned in order for the two sequential calls to `dep1.methodD()`. */ -export const useBUnloaded = new TestCase({ +export const useDTwice = new TestCase({ method: 'GET', - url: '/rnpExample/useB', - expectedStatus: 500, + url: '/rnpExample/useDTwice', + expectedStatus: 200, query: { network: 'TEST' }, payload: {}, - requiredMocks: {}, + requiredMocks: { + dep1_D: ['rnpExample-useDTwice_1', 'rnpExample-useDTwice_2'], + }, }); /** - * A test case that calls the same dependency twice. - * The `requiredMocks` array provides two different mock files, which will be - * returned in order for the two sequential calls to `dep1.methodD()`. + * A test case for a dependency defined on a class prototype. */ -export const useDTwice = new TestCase({ +export const usePrototypeDep = new TestCase({ method: 'GET', - url: '/rnpExample/useDTwice', + url: '/rnpExample/usePrototypeDep', expectedStatus: 200, query: { network: 'TEST' }, payload: {}, requiredMocks: { - dep1_D: ['rnpExample-methodD1', 'rnpExample-methodD2'], + localDep: 'rnpExample-usePrototypeDep', }, }); +/** + * A test case for an unloaded dependency. + * This test calls a method that requires the 'dep1_A' mock, but does not load it + * via `requiredMocks`. This is designed to fail, demonstrating the safety + * feature that prevents calls to managed dependencies that haven't been loaded. + */ +export const useABCUnloaded = new TestCase({ + method: 'GET', + url: '/rnpExample/useABC', + expectedStatus: 500, + query: { network: 'TEST' }, + payload: {}, + requiredMocks: {}, +}); + /** * A recorder-only test case for a method that calls an unmapped dependency. * This test is only used in the recorder to generate a snapshot. * It is not used in "Play" mode. */ -export const useUnmappedMethodRecorder = new TestCase({ +export const useUnmappedMethod_Recorder = new TestCase({ method: 'GET', url: '/rnpExample/useUnmappedMethod', expectedStatus: 200, @@ -91,7 +105,7 @@ export const useUnmappedMethodRecorder = new TestCase({ * mocked or set to `allowPassThrough`, this test is expected to fail. * This demonstrates the key safety feature of the RnP framework. */ -export const useUnmappedMethodMocked = new TestCase({ +export const useUnmappedMethod_Mocked = new TestCase({ method: 'GET', url: '/rnpExample/useUnmappedMethod', expectedStatus: 424, @@ -118,17 +132,3 @@ export const useUnlistedDep = new TestCase({ z: expect.any(String), }, }); - -/** - * A test case for a dependency defined on a class prototype. - */ -export const useProtoDep = new TestCase({ - method: 'GET', - url: '/rnpExample/useProtoDep', - expectedStatus: 200, - query: { network: 'TEST' }, - payload: {}, - requiredMocks: { - dep3_X: 'rnpExample-methodX', - }, -}); diff --git a/test/rnpExample/rnpExample.test-harness.ts b/test/rnpExample/rnpExample.test-harness.ts index 64644055cf..a032db98e4 100644 --- a/test/rnpExample/rnpExample.test-harness.ts +++ b/test/rnpExample/rnpExample.test-harness.ts @@ -1,27 +1,23 @@ import { AbstractGatewayTestHarness } from '#test/record-and-play/abstract-gateway-test-harness'; -import { DepProto, RnpExample } from './api/rnpExample'; +import { LocallyInitializedDependency, RnpExample } from './api/rnpExample'; export class RnpExampleTestHarness extends AbstractGatewayTestHarness { readonly dependencyContracts = { // Defines a contract for `this.dep1.methodA()`. Since it's listed here, // it's "managed" and must be mocked in "Play" tests. - dep1_A: this.dependencyFactory.instanceProperty('dep1', (x) => x.methodA), - dep1_B: this.dependencyFactory.instanceProperty('dep1', (x) => x.methodB), + dep1_A: this.dependencyFactory.instanceProperty('dep1', (x) => x.A_basicMethod), + dep1_B: this.dependencyFactory.instanceProperty('dep1', (x) => x.B_superJsonMethod), - // The `allowPassThrough: true` flag creates an exception. `dep1.methodC` + // The `allowPassThrough: true` flag creates an exception. `dep1.C_passthroughMethod` // is still "managed", but it will call its real implementation during // "Play" tests instead of throwing an error if it isn't mocked. - dep1_C: this.dependencyFactory.instanceProperty( - 'dep1', - (x) => x.methodC, - true, - ), - dep1_D: this.dependencyFactory.instanceProperty('dep1', (x) => x.methodD), + dep1_C: this.dependencyFactory.instanceProperty('dep1', (x) => x.C_passthroughMethod, true), + dep1_D: this.dependencyFactory.instanceProperty('dep1', (x) => x.D_usedTwiceInOneCallMethod), // This defines a contract for a method on a class prototype. This is // necessary for dependencies that are instantiated inside other methods. - dep3_X: this.dependencyFactory.prototype(DepProto, (x) => x.methodX), + localDep: this.dependencyFactory.prototype(LocallyInitializedDependency, (x) => x.prototypeMethod), // CRITICAL NOTE: Because other `dep1` methods are listed above, the entire // `dep1` object is now "managed". This means `dep1.methodUnmapped()` @@ -43,9 +39,7 @@ export class RnpExampleTestHarness extends AbstractGatewayTestHarness { await harness.teardown(); }); - it('useB', async () => { - // In a different order than recorder test to verify that the mock order is correct - await useB.processPlayRequest(harness); - }); - it('useABC', async () => { await useABC.processPlayRequest(harness); }); - it('useBUnloaded', async () => { - await useBUnloaded.processPlayRequest(harness); + it('useB_superJsonMethod', async () => { + await useB_superJsonMethod.processPlayRequest(harness); }); it('useDTwice', async () => { await useDTwice.processPlayRequest(harness); }); - it('useUnmappedMethodRecorder', async () => { + it('usePrototypeDep', async () => { + await usePrototypeDep.processPlayRequest(harness); + }); + + it('useABCUnloaded', async () => { + await useABCUnloaded.processPlayRequest(harness); + }); + + it('useUnmappedMethod_Recorder', async () => { // Used to force snapshot file to match exactly expect({ unmapped: expect.any(String), }).toMatchSnapshot(); }); - it('useUnmappedMethodMocked', async () => { - await useUnmappedMethodMocked.processPlayRequest(harness); - }); - - it('useProtoDep', async () => { - await useProtoDep.processPlayRequest(harness); + it('useUnmappedMethod_Mocked', async () => { + await useUnmappedMethod_Mocked.processPlayRequest(harness); }); it('useUnlistedDep', async () => { From 579caf176858cd6cfbc2aa2c04c0d901c0901c28 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Fri, 18 Jul 2025 12:04:03 -0600 Subject: [PATCH 33/45] split use unloaded method test case into Recorder and Mocked cases to clarify explicitly that the output varies between the two. --- .../rnpExample/rnpExample.recorder.test.ts | 13 ++++++---- .../__snapshots__/rnpExample.test.ts.snap | 24 ++++++++++++------- .../rnpExample/mocks/rnpExample-useABC_A.json | 2 +- .../rnpExample/mocks/rnpExample-useABC_B.json | 2 +- .../rnpExample-useB_superJsonMethod.json | 2 +- .../mocks/rnpExample-useDTwice_1.json | 2 +- .../mocks/rnpExample-useDTwice_2.json | 2 +- .../mocks/rnpExample-usePrototypeDep.json | 2 +- test/rnpExample/rnpExample.api-test-cases.ts | 20 ++++++++++++++-- test/rnpExample/rnpExample.test.ts | 21 ++++++++++------ 10 files changed, 62 insertions(+), 28 deletions(-) diff --git a/test-scripts/rnpExample/rnpExample.recorder.test.ts b/test-scripts/rnpExample/rnpExample.recorder.test.ts index 1f425b47ba..98528a769c 100644 --- a/test-scripts/rnpExample/rnpExample.recorder.test.ts +++ b/test-scripts/rnpExample/rnpExample.recorder.test.ts @@ -6,6 +6,7 @@ import { usePrototypeDep, useUnmappedMethod_Recorder, useB_superJsonMethod, + useBUnloaded_Recorder, } from '#test/rnpExample/rnpExample.api-test-cases'; describe('RnpExample', () => { @@ -41,12 +42,16 @@ describe('RnpExample', () => { await usePrototypeDep.processRecorderRequest(harness); }); - it('useABCUnloaded', async () => { - // Create to force snapshot file to match exactly + it('useBUnloaded_Recorder', async () => { + await useBUnloaded_Recorder.processRecorderRequest(harness); + }); + + it('useBUnloaded_Mocked', async () => { + // Create expected snapshot for running test in "Play" mode expect({ error: 'InternalServerError', message: - 'Failed to useABC: Mocked dependency was called without a mock loaded: dep1_A. Either load a mock or allowPassThrough.', + 'Failed to useSuperJsonMethod: Mocked dependency was called without a mock loaded: dep1_B. Either load a mock or allowPassThrough.', statusCode: 500, }).toMatchSnapshot({}); }); @@ -56,7 +61,7 @@ describe('RnpExample', () => { }); it('useUnmappedMethod_Mocked', async () => { - // Create to force snapshot file to match exactly + // Create expected snapshot for running test in "Play" mode expect({ error: 'FailedDependencyError', message: diff --git a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap index 4786ea4f6f..abdf1daa4d 100644 --- a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap +++ b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap @@ -2,36 +2,42 @@ exports[`RnpExample useABC 1`] = ` { - "a": "real methodA-0.9780876880931055", - "b": "798931", + "a": "real methodA-0.7963534618359678", + "b": "830950", "c": Any, } `; -exports[`RnpExample useABCUnloaded 1`] = ` +exports[`RnpExample useB_superJsonMethod 1`] = ` +{ + "b": "234078", +} +`; + +exports[`RnpExample useBUnloaded_Mocked 1`] = ` { "error": "InternalServerError", - "message": "Failed to useABC: Mocked dependency was called without a mock loaded: dep1_A. Either load a mock or allowPassThrough.", + "message": "Failed to useSuperJsonMethod: Mocked dependency was called without a mock loaded: dep1_B. Either load a mock or allowPassThrough.", "statusCode": 500, } `; -exports[`RnpExample useB_superJsonMethod 1`] = ` +exports[`RnpExample useBUnloaded_Recorder 1`] = ` { - "b": "634042", + "b": Any, } `; exports[`RnpExample useDTwice 1`] = ` { - "d1": "real methodD-0.38345424792538996", - "d2": "real methodD-0.7831965722241947", + "d1": "real methodD-0.7081604449390264", + "d2": "real methodD-0.6030192472856837", } `; exports[`RnpExample usePrototypeDep 1`] = ` { - "x": "real methodX-0.3895412431687655", + "x": "real methodX-0.7299019795895281", } `; diff --git a/test/rnpExample/mocks/rnpExample-useABC_A.json b/test/rnpExample/mocks/rnpExample-useABC_A.json index d226207ecf..fab675c39c 100644 --- a/test/rnpExample/mocks/rnpExample-useABC_A.json +++ b/test/rnpExample/mocks/rnpExample-useABC_A.json @@ -1,3 +1,3 @@ { - "json": "real methodA-0.9780876880931055" + "json": "real methodA-0.7963534618359678" } diff --git a/test/rnpExample/mocks/rnpExample-useABC_B.json b/test/rnpExample/mocks/rnpExample-useABC_B.json index 7fb55b6106..ab48785439 100644 --- a/test/rnpExample/mocks/rnpExample-useABC_B.json +++ b/test/rnpExample/mocks/rnpExample-useABC_B.json @@ -1,5 +1,5 @@ { - "json": "0x0c30d3", + "json": "0x0cade6", "meta": { "values": [["custom", "ethers.BigNumber"]] } diff --git a/test/rnpExample/mocks/rnpExample-useB_superJsonMethod.json b/test/rnpExample/mocks/rnpExample-useB_superJsonMethod.json index 7642c0a4ca..3bd1abc966 100644 --- a/test/rnpExample/mocks/rnpExample-useB_superJsonMethod.json +++ b/test/rnpExample/mocks/rnpExample-useB_superJsonMethod.json @@ -1,5 +1,5 @@ { - "json": "0x09acba", + "json": "0x03925e", "meta": { "values": [["custom", "ethers.BigNumber"]] } diff --git a/test/rnpExample/mocks/rnpExample-useDTwice_1.json b/test/rnpExample/mocks/rnpExample-useDTwice_1.json index 9174a0de66..6baea43c55 100644 --- a/test/rnpExample/mocks/rnpExample-useDTwice_1.json +++ b/test/rnpExample/mocks/rnpExample-useDTwice_1.json @@ -1,3 +1,3 @@ { - "json": "real methodD-0.38345424792538996" + "json": "real methodD-0.7081604449390264" } diff --git a/test/rnpExample/mocks/rnpExample-useDTwice_2.json b/test/rnpExample/mocks/rnpExample-useDTwice_2.json index fa51f5f52d..2c707d195d 100644 --- a/test/rnpExample/mocks/rnpExample-useDTwice_2.json +++ b/test/rnpExample/mocks/rnpExample-useDTwice_2.json @@ -1,3 +1,3 @@ { - "json": "real methodD-0.7831965722241947" + "json": "real methodD-0.6030192472856837" } diff --git a/test/rnpExample/mocks/rnpExample-usePrototypeDep.json b/test/rnpExample/mocks/rnpExample-usePrototypeDep.json index 241f2efcda..de0394f297 100644 --- a/test/rnpExample/mocks/rnpExample-usePrototypeDep.json +++ b/test/rnpExample/mocks/rnpExample-usePrototypeDep.json @@ -1,3 +1,3 @@ { - "json": "real methodX-0.3895412431687655" + "json": "real methodX-0.7299019795895281" } diff --git a/test/rnpExample/rnpExample.api-test-cases.ts b/test/rnpExample/rnpExample.api-test-cases.ts index b3a4033e9b..5bb84a1759 100644 --- a/test/rnpExample/rnpExample.api-test-cases.ts +++ b/test/rnpExample/rnpExample.api-test-cases.ts @@ -75,9 +75,25 @@ export const usePrototypeDep = new TestCase({ * via `requiredMocks`. This is designed to fail, demonstrating the safety * feature that prevents calls to managed dependencies that haven't been loaded. */ -export const useABCUnloaded = new TestCase({ +export const useBUnloaded_Recorder = new TestCase({ method: 'GET', - url: '/rnpExample/useABC', + url: '/rnpExample/useSuperJsonMethod', + expectedStatus: 200, + query: { network: 'TEST' }, + payload: {}, + requiredMocks: {}, + propertyMatchers: { b: expect.any(String) }, +}); + +/** + * A test case for an unloaded dependency. + * This test calls a method that requires the 'dep1_A' mock, but does not load it + * via `requiredMocks`. This is designed to fail, demonstrating the safety + * feature that prevents calls to managed dependencies that haven't been loaded. + */ +export const useBUnloaded_Mocked = new TestCase({ + method: 'GET', + url: '/rnpExample/useSuperJsonMethod', expectedStatus: 500, query: { network: 'TEST' }, payload: {}, diff --git a/test/rnpExample/rnpExample.test.ts b/test/rnpExample/rnpExample.test.ts index da189c9ccb..8ea493c759 100644 --- a/test/rnpExample/rnpExample.test.ts +++ b/test/rnpExample/rnpExample.test.ts @@ -5,7 +5,9 @@ import { usePrototypeDep, useUnmappedMethod_Mocked, useB_superJsonMethod, - useABCUnloaded, + useBUnloaded_Recorder, + useBUnloaded_Mocked, + useUnmappedMethod_Recorder, } from './rnpExample.api-test-cases'; import { RnpExampleTestHarness } from './rnpExample.test-harness'; @@ -41,15 +43,20 @@ describe('RnpExample', () => { await usePrototypeDep.processPlayRequest(harness); }); - it('useABCUnloaded', async () => { - await useABCUnloaded.processPlayRequest(harness); + it('useBUnloaded_Recorder', async () => { + // Snapshots must match the recorded output and this call fails in "play" mode + // so we force a predictable result by just using the recorder test's propertyMatchers. + expect(useBUnloaded_Recorder.propertyMatchers).toMatchSnapshot(); + }); + + it('useBUnloaded_Mocked', async () => { + await useBUnloaded_Mocked.processPlayRequest(harness); }); it('useUnmappedMethod_Recorder', async () => { - // Used to force snapshot file to match exactly - expect({ - unmapped: expect.any(String), - }).toMatchSnapshot(); + // Snapshots must match the recorded output and this call fails in "play" mode + // so we force a predictable result by just using the recorder test's propertyMatchers. + expect(useUnmappedMethod_Recorder.propertyMatchers).toMatchSnapshot(); }); it('useUnmappedMethod_Mocked', async () => { From 5d13ecb68d2c4c838d714410a97cc41239a0b838 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Fri, 18 Jul 2025 12:52:35 -0600 Subject: [PATCH 34/45] Move unlistedDep test, delete Recorder tests and redo some comments + names with gemini-2.5-pro. --- .../rules/record-and-play-testing-guide.mdc | 6 +- .../rnpExample/rnpExample.recorder.test.ts | 8 +-- .../__snapshots__/rnpExample.test.ts.snap | 12 ++-- test/rnpExample/api/rnpExample.ts | 9 +-- .../rnpExample/mocks/rnpExample-useABC_A.json | 4 +- .../rnpExample/mocks/rnpExample-useABC_B.json | 11 +++- .../rnpExample-useB_superJsonMethod.json | 11 +++- .../mocks/rnpExample-useDTwice_1.json | 4 +- .../mocks/rnpExample-useDTwice_2.json | 4 +- .../mocks/rnpExample-usePrototypeDep.json | 4 +- test/rnpExample/rnpExample.api-test-cases.ts | 64 ++++++++++--------- test/rnpExample/rnpExample.test-harness.ts | 6 +- test/rnpExample/rnpExample.test.ts | 7 +- 13 files changed, 82 insertions(+), 68 deletions(-) diff --git a/.cursor/rules/record-and-play-testing-guide.mdc b/.cursor/rules/record-and-play-testing-guide.mdc index 273b269867..2c7afd39b1 100644 --- a/.cursor/rules/record-and-play-testing-guide.mdc +++ b/.cursor/rules/record-and-play-testing-guide.mdc @@ -71,9 +71,9 @@ The `describe()` and `it()` block names in the recorder and the unit test **MUST ## 4. The Critical Rule of Dependency Management To ensure unit tests are fast and never make accidental live network calls, the framework enforces a strict safety policy. Understanding this is not optional. -* When you add even one method from a dependency object (e.g., `dep1.methodA`) to the `dependencyContracts` in `rnpExample.test-harness.ts`, the *entire object* (`dep1`) is now considered "managed." -* **In Recorder Mode:** Managed dependencies behave as expected. The listed methods are spied on, and unlisted methods on the same object (like `dep1.methodUnmapped`) call their real implementation. -* **In Play Mode:** This is where the safety rule applies. **Every method** on a managed object must be explicitly mocked. If a method (like `dep1.methodUnmapped`) is called without a mock, the test will fail. The `useUnmappedMethodMocked` test case in `rnpExample.api-test-cases.ts` is designed specifically to validate this failure. +* When you add even one method from a dependency object (e.g., `Dependency1.A_basicMethod`) to the `dependencyContracts` in `rnpExample.test-harness.ts`, the *entire object* (`dep1`) is now considered "managed." +* **In Recorder Mode:** Managed dependencies behave as expected. The listed methods are spied on, and unlisted methods on the same object (like `Dependency1.unmappedMethod`) call their real implementation. +* **In Play Mode:** This is where the safety rule applies. **Every method** on a managed object must be explicitly mocked. If a method (like `Dependency1.unmappedMethod`) is called without a mock, the test will fail. The `useUnmappedMethodMocked` test case in `rnpExample.api-test-cases.ts` is designed specifically to validate this failure. * **Why?** This strictness forces the agent developer to be fully aware of every interaction with an external service. It makes it impossible for a dependency to add a new network call that would slow down unit tests. * **Truly Unmanaged Dependencies:** In contrast, `dep2` from `rnpExample` is never mentioned in the `dependencyContracts`. It is "unmanaged," so its methods can be called freely in either mode. The `useUnlistedDep` test case demonstrates this. diff --git a/test-scripts/rnpExample/rnpExample.recorder.test.ts b/test-scripts/rnpExample/rnpExample.recorder.test.ts index 98528a769c..9246bf8aa9 100644 --- a/test-scripts/rnpExample/rnpExample.recorder.test.ts +++ b/test-scripts/rnpExample/rnpExample.recorder.test.ts @@ -41,6 +41,10 @@ describe('RnpExample', () => { it('usePrototypeDep', async () => { await usePrototypeDep.processRecorderRequest(harness); }); + + it('useUnlistedDep', async () => { + await useUnlistedDep.processRecorderRequest(harness); + }); it('useBUnloaded_Recorder', async () => { await useBUnloaded_Recorder.processRecorderRequest(harness); @@ -69,8 +73,4 @@ describe('RnpExample', () => { statusCode: 424, }).toMatchSnapshot({}); }); - - it('useUnlistedDep', async () => { - await useUnlistedDep.processRecorderRequest(harness); - }); }); \ No newline at end of file diff --git a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap index abdf1daa4d..1f122ee11e 100644 --- a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap +++ b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap @@ -2,15 +2,15 @@ exports[`RnpExample useABC 1`] = ` { - "a": "real methodA-0.7963534618359678", - "b": "830950", + "a": "real A_basicMethod-0.9261297200194791", + "b": "559573", "c": Any, } `; exports[`RnpExample useB_superJsonMethod 1`] = ` { - "b": "234078", + "b": "984683", } `; @@ -30,14 +30,14 @@ exports[`RnpExample useBUnloaded_Recorder 1`] = ` exports[`RnpExample useDTwice 1`] = ` { - "d1": "real methodD-0.7081604449390264", - "d2": "real methodD-0.6030192472856837", + "d1": "real D_usedTwiceInOneCallMethod-0.1108886413926522", + "d2": "real D_usedTwiceInOneCallMethod-0.7985412087769337", } `; exports[`RnpExample usePrototypeDep 1`] = ` { - "x": "real methodX-0.7299019795895281", + "x": "real prototypeMethod-0.9680478758520263", } `; diff --git a/test/rnpExample/api/rnpExample.ts b/test/rnpExample/api/rnpExample.ts index 6e39eaeb66..beea6aa7ba 100644 --- a/test/rnpExample/api/rnpExample.ts +++ b/test/rnpExample/api/rnpExample.ts @@ -1,25 +1,26 @@ import { BigNumber } from 'ethers'; export class Dependency1 { - A_basicMethod = async () => `real methodA-${Math.random()}`; + A_basicMethod = async () => `real A_basicMethod-${Math.random()}`; B_superJsonMethod = async () => BigNumber.from(Math.floor(Math.random() * 1000000)); C_passthroughMethod = async () => `real C_passthroughMethod-${Math.random()}`; - D_usedTwiceInOneCallMethod = async () => `real methodD-${Math.random()}`; + D_usedTwiceInOneCallMethod = async () => + `real D_usedTwiceInOneCallMethod-${Math.random()}`; unmappedMethod = async () => `real unmappedMethod-${Math.random()}`; } export class UnlistedDependency { - unlistedMethod = async () => `real methodZ-${Math.random()}`; + unlistedMethod = async () => `real unlistedMethod-${Math.random()}`; } export class LocallyInitializedDependency { async prototypeMethod() { - return `real methodX-${Math.random()}`; + return `real prototypeMethod-${Math.random()}`; } } diff --git a/test/rnpExample/mocks/rnpExample-useABC_A.json b/test/rnpExample/mocks/rnpExample-useABC_A.json index fab675c39c..8ca454b103 100644 --- a/test/rnpExample/mocks/rnpExample-useABC_A.json +++ b/test/rnpExample/mocks/rnpExample-useABC_A.json @@ -1,3 +1,3 @@ { - "json": "real methodA-0.7963534618359678" -} + "json": "real A_basicMethod-0.9261297200194791" +} \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-useABC_B.json b/test/rnpExample/mocks/rnpExample-useABC_B.json index ab48785439..d83e012d39 100644 --- a/test/rnpExample/mocks/rnpExample-useABC_B.json +++ b/test/rnpExample/mocks/rnpExample-useABC_B.json @@ -1,6 +1,11 @@ { - "json": "0x0cade6", + "json": "0x0889d5", "meta": { - "values": [["custom", "ethers.BigNumber"]] + "values": [ + [ + "custom", + "ethers.BigNumber" + ] + ] } -} +} \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-useB_superJsonMethod.json b/test/rnpExample/mocks/rnpExample-useB_superJsonMethod.json index 3bd1abc966..50a3502bc9 100644 --- a/test/rnpExample/mocks/rnpExample-useB_superJsonMethod.json +++ b/test/rnpExample/mocks/rnpExample-useB_superJsonMethod.json @@ -1,6 +1,11 @@ { - "json": "0x03925e", + "json": "0x0f066b", "meta": { - "values": [["custom", "ethers.BigNumber"]] + "values": [ + [ + "custom", + "ethers.BigNumber" + ] + ] } -} +} \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-useDTwice_1.json b/test/rnpExample/mocks/rnpExample-useDTwice_1.json index 6baea43c55..d3fd04b3af 100644 --- a/test/rnpExample/mocks/rnpExample-useDTwice_1.json +++ b/test/rnpExample/mocks/rnpExample-useDTwice_1.json @@ -1,3 +1,3 @@ { - "json": "real methodD-0.7081604449390264" -} + "json": "real D_usedTwiceInOneCallMethod-0.1108886413926522" +} \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-useDTwice_2.json b/test/rnpExample/mocks/rnpExample-useDTwice_2.json index 2c707d195d..6b943a3dca 100644 --- a/test/rnpExample/mocks/rnpExample-useDTwice_2.json +++ b/test/rnpExample/mocks/rnpExample-useDTwice_2.json @@ -1,3 +1,3 @@ { - "json": "real methodD-0.6030192472856837" -} + "json": "real D_usedTwiceInOneCallMethod-0.7985412087769337" +} \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-usePrototypeDep.json b/test/rnpExample/mocks/rnpExample-usePrototypeDep.json index de0394f297..fb1a0354e6 100644 --- a/test/rnpExample/mocks/rnpExample-usePrototypeDep.json +++ b/test/rnpExample/mocks/rnpExample-usePrototypeDep.json @@ -1,3 +1,3 @@ { - "json": "real methodX-0.7299019795895281" -} + "json": "real prototypeMethod-0.9680478758520263" +} \ No newline at end of file diff --git a/test/rnpExample/rnpExample.api-test-cases.ts b/test/rnpExample/rnpExample.api-test-cases.ts index 5bb84a1759..b9065e89bc 100644 --- a/test/rnpExample/rnpExample.api-test-cases.ts +++ b/test/rnpExample/rnpExample.api-test-cases.ts @@ -42,7 +42,7 @@ export const useB_superJsonMethod = new TestCase({ /** * A test case that calls the same dependency twice. * The `requiredMocks` array provides two different mock files, which will be - * returned in order for the two sequential calls to `dep1.methodD()`. + * returned in order for the two sequential calls to `dep1.D_usedTwiceInOneCallMethod()`. */ export const useDTwice = new TestCase({ method: 'GET', @@ -69,11 +69,29 @@ export const usePrototypeDep = new TestCase({ }, }); + /** - * A test case for an unloaded dependency. - * This test calls a method that requires the 'dep1_A' mock, but does not load it - * via `requiredMocks`. This is designed to fail, demonstrating the safety - * feature that prevents calls to managed dependencies that haven't been loaded. + * A test case for a method that calls a truly "unmanaged" dependency. + * The `useUnlistedDep` endpoint calls `dep2.unlistedMethod`. Because `dep2` is not + * mentioned anywhere in the harness's `dependencyContracts`, it is "unmanaged" + * and will always call its real implementation in both Record and Play modes. + */ +export const useUnlistedDep = new TestCase({ + method: 'GET', + url: '/rnpExample/useUnlistedDep', + expectedStatus: 200, + query: { network: 'TEST' }, + payload: {}, + requiredMocks: {}, + propertyMatchers: { + z: expect.any(String), + }, +}); + +/** + * A recorder-only test case for an unloaded dependency. + * This exists to demonstrate that the recorder and "Play" mode test output + * varies in this scenario as the mock test will fail but recorder test will pass. */ export const useBUnloaded_Recorder = new TestCase({ method: 'GET', @@ -86,7 +104,7 @@ export const useBUnloaded_Recorder = new TestCase({ }); /** - * A test case for an unloaded dependency. + * A failure test case for a mock/"Play" mode test with an unloaded dependency. * This test calls a method that requires the 'dep1_A' mock, but does not load it * via `requiredMocks`. This is designed to fail, demonstrating the safety * feature that prevents calls to managed dependencies that haven't been loaded. @@ -101,9 +119,9 @@ export const useBUnloaded_Mocked = new TestCase({ }); /** - * A recorder-only test case for a method that calls an unmapped dependency. - * This test is only used in the recorder to generate a snapshot. - * It is not used in "Play" mode. + * A recorder-only test case for a method that calls an unmapped method on a managed dependency. + * This exists to demonstrate that the recorder and "Play" mode test output + * varies in this scenario as the mock test will fail but recorder test will pass. */ export const useUnmappedMethod_Recorder = new TestCase({ method: 'GET', @@ -115,11 +133,12 @@ export const useUnmappedMethod_Recorder = new TestCase({ }); /** - * A "Play" mode test case that calls a method with an unmapped dependency. - * The `useUnmappedMethod` endpoint calls `dep1.methodUnmapped`. Because `dep1` - * is a "managed" object in the harness, but `methodUnmapped` is not explicitly - * mocked or set to `allowPassThrough`, this test is expected to fail. - * This demonstrates the key safety feature of the RnP framework. + * A failure test case for a "Play" mode test case that calls a method with an unmapped dependency. + * The `useUnmappedMethod` endpoint calls `dep1.unmappedMethod`. + * But because `dep1` * is a "managed" object in the harness and `unmappedMethod` + * is not explicitly mocked or set to `allowPassThrough`, this test gets a error return object. + * This demonstrates the key safety feature of the RnP framework + * that prevents calls to unmapped methods. . */ export const useUnmappedMethod_Mocked = new TestCase({ method: 'GET', @@ -131,20 +150,3 @@ export const useUnmappedMethod_Mocked = new TestCase({ propertyMatchers: {}, }); -/** - * A test case for a method that calls a truly "unmanaged" dependency. - * The `useUnlistedDep` endpoint calls `dep2.methodZ`. Because `dep2` is not - * mentioned anywhere in the harness's `dependencyContracts`, it is "unmanaged" - * and will always call its real implementation in both Record and Play modes. - */ -export const useUnlistedDep = new TestCase({ - method: 'GET', - url: '/rnpExample/useUnlistedDep', - expectedStatus: 200, - query: { network: 'TEST' }, - payload: {}, - requiredMocks: {}, - propertyMatchers: { - z: expect.any(String), - }, -}); diff --git a/test/rnpExample/rnpExample.test-harness.ts b/test/rnpExample/rnpExample.test-harness.ts index a032db98e4..d13909db59 100644 --- a/test/rnpExample/rnpExample.test-harness.ts +++ b/test/rnpExample/rnpExample.test-harness.ts @@ -4,7 +4,7 @@ import { LocallyInitializedDependency, RnpExample } from './api/rnpExample'; export class RnpExampleTestHarness extends AbstractGatewayTestHarness { readonly dependencyContracts = { - // Defines a contract for `this.dep1.methodA()`. Since it's listed here, + // Defines a contract for `this.dep1.A_basicMethod()`. Since it's listed here, // it's "managed" and must be mocked in "Play" tests. dep1_A: this.dependencyFactory.instanceProperty('dep1', (x) => x.A_basicMethod), dep1_B: this.dependencyFactory.instanceProperty('dep1', (x) => x.B_superJsonMethod), @@ -20,12 +20,12 @@ export class RnpExampleTestHarness extends AbstractGatewayTestHarness x.prototypeMethod), // CRITICAL NOTE: Because other `dep1` methods are listed above, the entire - // `dep1` object is now "managed". This means `dep1.methodUnmapped()` + // `dep1` object is now "managed". This means `dep1.unmappedMethod()` // will throw an error if called during a "Play" test because it's not // explicitly mocked or allowed to pass through. // // In contrast, `dep2` is not mentioned in `dependencyContracts` at all. - // It is "unmanaged", so `dep2.methodZ()` will always call its real + // It is "unmanaged", so `dep2.unlistedMethod()` will always call its real // implementation in both "Record" and "Play" modes. }; diff --git a/test/rnpExample/rnpExample.test.ts b/test/rnpExample/rnpExample.test.ts index 8ea493c759..477096390e 100644 --- a/test/rnpExample/rnpExample.test.ts +++ b/test/rnpExample/rnpExample.test.ts @@ -43,6 +43,10 @@ describe('RnpExample', () => { await usePrototypeDep.processPlayRequest(harness); }); + it('useUnlistedDep', async () => { + await useUnlistedDep.processPlayRequest(harness); + }); + it('useBUnloaded_Recorder', async () => { // Snapshots must match the recorded output and this call fails in "play" mode // so we force a predictable result by just using the recorder test's propertyMatchers. @@ -63,7 +67,4 @@ describe('RnpExample', () => { await useUnmappedMethod_Mocked.processPlayRequest(harness); }); - it('useUnlistedDep', async () => { - await useUnlistedDep.processPlayRequest(harness); - }); }); From 4e9525a2099272aca561184c514964ae91c9268c Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Fri, 18 Jul 2025 13:17:00 -0600 Subject: [PATCH 35/45] create helper to streamline test creation. --- .../rules/record-and-play-testing-guide.mdc | 18 +++-- .../rnpExample/rnpExample.recorder.test.ts | 71 ++++++++----------- test/record-and-play/api-test-case.ts | 14 ++++ test/rnpExample/rnpExample.test.ts | 55 ++++++-------- 4 files changed, 76 insertions(+), 82 deletions(-) diff --git a/.cursor/rules/record-and-play-testing-guide.mdc b/.cursor/rules/record-and-play-testing-guide.mdc index 2c7afd39b1..529a4694d5 100644 --- a/.cursor/rules/record-and-play-testing-guide.mdc +++ b/.cursor/rules/record-and-play-testing-guide.mdc @@ -32,13 +32,13 @@ The RnP framework consists of four key types of files that work together for a g ### `*.recorder.test.ts` - The Recorder * **Location:** `test-scripts/{chains|connectors}/{feature-name}/{feature-name}.recorder.test.ts` * **Example:** `test-scripts/rnpExample/rnpExample.recorder.test.ts` -* **Purpose:** The sole responsibility of this test suite is to generate mock files. It imports a `TestCase` and calls `processRecorderRequest(harness)` on it to execute against live services and save the results. +* **Purpose:** The sole responsibility of this test suite is to generate mock files. It imports a `TestCase` and uses the `createRecordTest(harness)` method to generate a one-line `it` block that executes the test against live services. * **Execution:** Recorder tests are slow, make real network calls, and are run individually. ### `*.test.ts` - The Unit Test * **Location:** `test/{chains|connectors}/{feature-name}/{feature-name}.test.ts` * **Example:** `test/rnpExample/rnpExample.test.ts` -* **Purpose:** This is the fast, isolated unit test suite. It imports a `TestCase` and calls `processPlayRequest(harness)` on it. This uses the generated mocks to validate application logic without making network calls. +* **Purpose:** This is the fast, isolated unit test suite. It imports a `TestCase` and uses the `createPlayTest(harness)` method to generate a one-line `it` block that validates application logic against generated mocks. * **Execution:** These tests are fast and run as part of the main `pnpm test` suite. ## 3. Directory and Naming Conventions @@ -56,7 +56,7 @@ The framework relies on a strict set of conventions. These are functional requir * **Recorder Tests:** `test-scripts/connectors/raydium/` ### Test Naming -The `describe()` and `it()` block names in the recorder and the unit test **MUST MATCH EXACTLY**. Compare the `it('useABC', ...)` blocks in `rnpExample.recorder.test.ts` and `rnpExample.test.ts` to see this in practice. This naming convention is how Jest associates API response snapshots with the correct test. +The `describe()` and `it()` block names in the recorder and the unit test **MUST MATCH EXACTLY**. The test case's variable name is used for the `it` block's name to ensure consistency. Compare the `it('useABC', ...)` blocks in `rnpExample.recorder.test.ts` and `rnpExample.test.ts` to see this in practice. This naming convention is how Jest associates API response snapshots with the correct test. ### Command Segregation * **To run fast unit tests:** @@ -91,8 +91,10 @@ Follow this step-by-step process. In these paths, `{feature-type}` is either `ch **Step 3: Create the Recorder Test** * Open `test-scripts/{feature-type}/{feature-name}/{feature-name}.recorder.test.ts`. -* Add a new `it()` block with a name that will be identical to the unit test's. -* Inside, call `processRecorderRequest(harness)` on your new test case. +* Add a new one-line `it()` block using the `createRecordTest` helper: + ```typescript + it('your_test_case_name', yourTestCase.createRecordTest(harness)); + ``` **Step 4: Run the Recorder** * Execute the recorder test from your terminal to generate mock and snapshot files. @@ -102,8 +104,10 @@ Follow this step-by-step process. In these paths, `{feature-type}` is either `ch **Step 5: Create the Unit Test** * Open `test/{feature-type}/{feature-name}/{feature-name}.test.ts`. -* Add a new `it()` block with a name that **exactly matches** the recorder's. -* Inside, call `processPlayRequest(harness)` on the same test case. +* Add a new one-line `it()` block that **exactly matches** the recorder's: + ```typescript + it('your_test_case_name', yourTestCase.createPlayTest(harness)); + ``` **Step 6: Run the Unit Test** * Execute the main test suite to verify your logic against the generated mocks. diff --git a/test-scripts/rnpExample/rnpExample.recorder.test.ts b/test-scripts/rnpExample/rnpExample.recorder.test.ts index 9246bf8aa9..a20f8b0a1a 100644 --- a/test-scripts/rnpExample/rnpExample.recorder.test.ts +++ b/test-scripts/rnpExample/rnpExample.recorder.test.ts @@ -1,54 +1,45 @@ +import { + useABC, + useB_superJsonMethod, + useBUnloaded_Recorder, + useDTwice, + usePrototypeDep, + useUnlistedDep, + useUnmappedMethod_Recorder, +} from '#test/rnpExample/rnpExample.api-test-cases'; import { RnpExampleTestHarness } from '#test/rnpExample/rnpExample.test-harness'; -import { - useABC, - useUnlistedDep, - useDTwice, - usePrototypeDep, - useUnmappedMethod_Recorder, - useB_superJsonMethod, - useBUnloaded_Recorder, - } from '#test/rnpExample/rnpExample.api-test-cases'; describe('RnpExample', () => { - let harness: RnpExampleTestHarness; - jest.setTimeout(1200000); - + let _harness: RnpExampleTestHarness; + const harness = () => _harness; + jest.setTimeout(10000); + beforeAll(async () => { - harness = new RnpExampleTestHarness(); - await harness.initRecorderTests(); + _harness = new RnpExampleTestHarness(); + await _harness.initRecorderTests(); }); afterEach(async () => { - await harness.reset(); + await _harness.reset(); }); afterAll(async () => { - await harness.teardown(); + await _harness.teardown(); }); - it('useABC', async () => { - await useABC.processRecorderRequest(harness); - }); - - it('useB_superJsonMethod', async () => { - await useB_superJsonMethod.processRecorderRequest(harness); - }); - - it('useDTwice', async () => { - await useDTwice.processRecorderRequest(harness); - }); - - it('usePrototypeDep', async () => { - await usePrototypeDep.processRecorderRequest(harness); - }); + it('useABC', useABC.createRecordTest(harness)); - it('useUnlistedDep', async () => { - await useUnlistedDep.processRecorderRequest(harness); - }); - - it('useBUnloaded_Recorder', async () => { - await useBUnloaded_Recorder.processRecorderRequest(harness); - }); + it('useB_superJsonMethod', useB_superJsonMethod.createRecordTest(harness)); + + it('useDTwice', useDTwice.createRecordTest(harness)); + + it('usePrototypeDep', usePrototypeDep.createRecordTest(harness)); + + it('useUnlistedDep', useUnlistedDep.createRecordTest(harness)); + + it('useBUnloaded_Recorder', useBUnloaded_Recorder.createRecordTest(harness)); + + it('useUnmappedMethod_Recorder', useUnmappedMethod_Recorder.createRecordTest(harness)); it('useBUnloaded_Mocked', async () => { // Create expected snapshot for running test in "Play" mode @@ -60,10 +51,6 @@ describe('RnpExample', () => { }).toMatchSnapshot({}); }); - it('useUnmappedMethod_Recorder', async () => { - await useUnmappedMethod_Recorder.processRecorderRequest(harness); - }); - it('useUnmappedMethod_Mocked', async () => { // Create expected snapshot for running test in "Play" mode expect({ diff --git a/test/record-and-play/api-test-case.ts b/test/record-and-play/api-test-case.ts index 4ff6df9677..4baef2dc33 100644 --- a/test/record-and-play/api-test-case.ts +++ b/test/record-and-play/api-test-case.ts @@ -137,4 +137,18 @@ export class APITestCase> throw error; } } + + public createPlayTest(getHarness: () => Harness): () => Promise { + return async () => { + const harness = getHarness(); + await this.processPlayRequest(harness); + }; + } + + public createRecordTest(getHarness: () => Harness): () => Promise { + return async () => { + const harness = getHarness(); + await this.processRecorderRequest(harness); + }; + } } diff --git a/test/rnpExample/rnpExample.test.ts b/test/rnpExample/rnpExample.test.ts index 477096390e..3ebc4f42b8 100644 --- a/test/rnpExample/rnpExample.test.ts +++ b/test/rnpExample/rnpExample.test.ts @@ -1,51 +1,49 @@ import { useABC, - useUnlistedDep, + useB_superJsonMethod, + useBUnloaded_Mocked, + useBUnloaded_Recorder, useDTwice, usePrototypeDep, + useUnlistedDep, useUnmappedMethod_Mocked, - useB_superJsonMethod, - useBUnloaded_Recorder, - useBUnloaded_Mocked, useUnmappedMethod_Recorder, } from './rnpExample.api-test-cases'; import { RnpExampleTestHarness } from './rnpExample.test-harness'; describe('RnpExample', () => { - let harness: RnpExampleTestHarness; + let _harness: RnpExampleTestHarness; + const harness = () => _harness; beforeAll(async () => { - harness = new RnpExampleTestHarness(); - await harness.initMockedTests(); + _harness = new RnpExampleTestHarness(); + await _harness.initMockedTests(); }); afterEach(async () => { - await harness.reset(); + await _harness.reset(); }); afterAll(async () => { - await harness.teardown(); + await _harness.teardown(); }); - it('useABC', async () => { - await useABC.processPlayRequest(harness); - }); + it('useABC', useABC.createPlayTest(harness)); - it('useB_superJsonMethod', async () => { - await useB_superJsonMethod.processPlayRequest(harness); - }); + it('useB_superJsonMethod', useB_superJsonMethod.createPlayTest(harness)); - it('useDTwice', async () => { - await useDTwice.processPlayRequest(harness); - }); + it('useDTwice', useDTwice.createPlayTest(harness)); - it('usePrototypeDep', async () => { - await usePrototypeDep.processPlayRequest(harness); - }); + it('usePrototypeDep', usePrototypeDep.createPlayTest(harness)); - it('useUnlistedDep', async () => { - await useUnlistedDep.processPlayRequest(harness); - }); + it('useUnlistedDep', useUnlistedDep.createPlayTest(harness)); + + it('useBUnloaded_Mocked', useBUnloaded_Mocked.createPlayTest(harness)); + + it( + 'useUnmappedMethod_Mocked', + useUnmappedMethod_Mocked.createPlayTest(harness), + ); it('useBUnloaded_Recorder', async () => { // Snapshots must match the recorded output and this call fails in "play" mode @@ -53,18 +51,9 @@ describe('RnpExample', () => { expect(useBUnloaded_Recorder.propertyMatchers).toMatchSnapshot(); }); - it('useBUnloaded_Mocked', async () => { - await useBUnloaded_Mocked.processPlayRequest(harness); - }); - it('useUnmappedMethod_Recorder', async () => { // Snapshots must match the recorded output and this call fails in "play" mode // so we force a predictable result by just using the recorder test's propertyMatchers. expect(useUnmappedMethod_Recorder.propertyMatchers).toMatchSnapshot(); }); - - it('useUnmappedMethod_Mocked', async () => { - await useUnmappedMethod_Mocked.processPlayRequest(harness); - }); - }); From 4f7b5e27880ec31d388489ef1445c54d5fc05788 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Fri, 18 Jul 2025 13:55:46 -0600 Subject: [PATCH 36/45] make TestDependencyContract more type safe by adding TObject to signature --- .../abstract-gateway-test-harness.ts | 6 ++---- test/record-and-play/test-dependency-contract.ts | 16 +++++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/test/record-and-play/abstract-gateway-test-harness.ts b/test/record-and-play/abstract-gateway-test-harness.ts index 896386a2f1..8593433b29 100644 --- a/test/record-and-play/abstract-gateway-test-harness.ts +++ b/test/record-and-play/abstract-gateway-test-harness.ts @@ -11,7 +11,7 @@ import { } from './test-dependency-contract'; interface ContractWithSpy - extends TestDependencyContract { + extends TestDependencyContract { spy?: jest.SpyInstance; } @@ -211,9 +211,7 @@ export abstract class AbstractGatewayTestHarness for (const methodName of Object.keys(object)) { // Find if a contract exists for this specific method. const contract = Object.values(this.dependencyContracts).find( - (c) => - c.getObject(this) === object && - (c as any).methodName === methodName, + (c) => c.getObject(this) === object && c.methodName === methodName, ); if (contract) { diff --git a/test/record-and-play/test-dependency-contract.ts b/test/record-and-play/test-dependency-contract.ts index 0d6c26b33c..e036874be5 100644 --- a/test/record-and-play/test-dependency-contract.ts +++ b/test/record-and-play/test-dependency-contract.ts @@ -10,11 +10,13 @@ export interface MockProvider { /** * Defines the contract for how a dependency should be spied upon and mocked. */ -export abstract class TestDependencyContract { +export abstract class TestDependencyContract { /** If true, the real method will be called even during "Play" mode tests. */ public allowPassThrough: boolean; + /** The name of the method to be spied on. */ + abstract readonly methodName: keyof TObject; /** Returns the object or prototype that contains the method to be spied on. */ - abstract getObject(provider: MockProvider): any; + abstract getObject(provider: MockProvider): TObject; /** Creates and returns a Jest spy on the dependency's method. */ abstract setupSpy(provider: MockProvider): jest.SpyInstance; /** @@ -46,10 +48,10 @@ export class InstancePropertyDependency< TInstance, TObject, TMock, -> extends TestDependencyContract { +> extends TestDependencyContract { constructor( private getObjectFn: (provider: MockProvider) => TObject, - public methodName: keyof TObject, + public readonly methodName: keyof TObject, public allowPassThrough = false, ) { super(); @@ -77,16 +79,16 @@ export class PrototypeDependency< TInstance, TObject, TMock, -> extends TestDependencyContract { +> extends TestDependencyContract { constructor( private ClassConstructor: { new (...args: any[]): TObject }, - public methodName: keyof TObject, + public readonly methodName: keyof TObject, public allowPassThrough = false, ) { super(); } - getObject(_provider: MockProvider) { + getObject(_provider: MockProvider): TObject { return this.ClassConstructor.prototype; } From 87b0dc2a7a9aa41aceae25d9b55f7767038118bb Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Fri, 18 Jul 2025 14:16:03 -0600 Subject: [PATCH 37/45] use `call` instead of `use` when referring to usage of dependency methods. --- .../rules/record-and-play-testing-guide.mdc | 10 ++-- .../rnpExample/rnpExample.recorder.test.ts | 36 ++++++------ .../__snapshots__/rnpExample.test.ts.snap | 34 +++++------ test/rnpExample/api/rnpExample.routes.ts | 24 ++++---- test/rnpExample/api/rnpExample.ts | 12 ++-- .../api/routes/{useABC.ts => callABC.ts} | 10 ++-- .../routes/{useDTwice.ts => callDTwice.ts} | 10 ++-- ...usePrototypeDep.ts => callPrototypeDep.ts} | 10 ++-- ...erJsonMethod.ts => callSuperJsonMethod.ts} | 10 ++-- .../{useUnlistedDep.ts => callUnlistedDep.ts} | 10 ++-- ...nmappedMethod.ts => callUnmappedMethod.ts} | 10 ++-- .../mocks/rnpExample-callABC_A.json | 3 + .../mocks/rnpExample-callABC_B.json | 6 ++ .../rnpExample-callB_superJsonMethod.json | 6 ++ .../mocks/rnpExample-callDTwice_1.json | 3 + .../mocks/rnpExample-callDTwice_2.json | 3 + .../mocks/rnpExample-callPrototypeDep.json | 3 + .../rnpExample/mocks/rnpExample-useABC_A.json | 3 - .../rnpExample/mocks/rnpExample-useABC_B.json | 11 ---- .../rnpExample-useB_superJsonMethod.json | 11 ---- .../mocks/rnpExample-useDTwice_1.json | 3 - .../mocks/rnpExample-useDTwice_2.json | 3 - .../mocks/rnpExample-usePrototypeDep.json | 3 - test/rnpExample/rnpExample.api-test-cases.ts | 58 +++++++++---------- test/rnpExample/rnpExample.test.ts | 42 +++++++------- 25 files changed, 161 insertions(+), 173 deletions(-) rename test/rnpExample/api/routes/{useABC.ts => callABC.ts} (69%) rename test/rnpExample/api/routes/{useDTwice.ts => callDTwice.ts} (67%) rename test/rnpExample/api/routes/{usePrototypeDep.ts => callPrototypeDep.ts} (65%) rename test/rnpExample/api/routes/{useSuperJsonMethod.ts => callSuperJsonMethod.ts} (66%) rename test/rnpExample/api/routes/{useUnlistedDep.ts => callUnlistedDep.ts} (65%) rename test/rnpExample/api/routes/{useUnmappedMethod.ts => callUnmappedMethod.ts} (69%) create mode 100644 test/rnpExample/mocks/rnpExample-callABC_A.json create mode 100644 test/rnpExample/mocks/rnpExample-callABC_B.json create mode 100644 test/rnpExample/mocks/rnpExample-callB_superJsonMethod.json create mode 100644 test/rnpExample/mocks/rnpExample-callDTwice_1.json create mode 100644 test/rnpExample/mocks/rnpExample-callDTwice_2.json create mode 100644 test/rnpExample/mocks/rnpExample-callPrototypeDep.json delete mode 100644 test/rnpExample/mocks/rnpExample-useABC_A.json delete mode 100644 test/rnpExample/mocks/rnpExample-useABC_B.json delete mode 100644 test/rnpExample/mocks/rnpExample-useB_superJsonMethod.json delete mode 100644 test/rnpExample/mocks/rnpExample-useDTwice_1.json delete mode 100644 test/rnpExample/mocks/rnpExample-useDTwice_2.json delete mode 100644 test/rnpExample/mocks/rnpExample-usePrototypeDep.json diff --git a/.cursor/rules/record-and-play-testing-guide.mdc b/.cursor/rules/record-and-play-testing-guide.mdc index 529a4694d5..a9050fd340 100644 --- a/.cursor/rules/record-and-play-testing-guide.mdc +++ b/.cursor/rules/record-and-play-testing-guide.mdc @@ -27,7 +27,7 @@ The RnP framework consists of four key types of files that work together for a g ### `*.api-test-cases.ts` - The Single Source of Truth * **Location:** `test/{chains|connectors}/{feature-name}/{feature-name}.api-test-cases.ts` * **Example:** `test/rnpExample/rnpExample.api-test-cases.ts` -* **Purpose:** This file defines the specific API requests that will be used for both recording and testing. By defining test cases in one place (e.g., `export const useABC = new TestCase(...)`), we prevent code duplication and ensure the recorder and the unit test are perfectly aligned. +* **Purpose:** This file defines the specific API requests that will be used for both recording and testing. By defining test cases in one place (e.g., `export const callABC = new TestCase(...)`), we prevent code duplication and ensure the recorder and the unit test are perfectly aligned. ### `*.recorder.test.ts` - The Recorder * **Location:** `test-scripts/{chains|connectors}/{feature-name}/{feature-name}.recorder.test.ts` @@ -56,7 +56,7 @@ The framework relies on a strict set of conventions. These are functional requir * **Recorder Tests:** `test-scripts/connectors/raydium/` ### Test Naming -The `describe()` and `it()` block names in the recorder and the unit test **MUST MATCH EXACTLY**. The test case's variable name is used for the `it` block's name to ensure consistency. Compare the `it('useABC', ...)` blocks in `rnpExample.recorder.test.ts` and `rnpExample.test.ts` to see this in practice. This naming convention is how Jest associates API response snapshots with the correct test. +The `describe()` and `it()` block names in the recorder and the unit test **MUST MATCH EXACTLY**. The test case's variable name is used for the `it` block's name to ensure consistency. Compare the `it('callABC', ...)` blocks in `rnpExample.recorder.test.ts` and `rnpExample.test.ts` to see this in practice. This naming convention is how Jest associates API response snapshots with the correct test. ### Command Segregation * **To run fast unit tests:** @@ -73,9 +73,9 @@ The `describe()` and `it()` block names in the recorder and the unit test **MUST To ensure unit tests are fast and never make accidental live network calls, the framework enforces a strict safety policy. Understanding this is not optional. * When you add even one method from a dependency object (e.g., `Dependency1.A_basicMethod`) to the `dependencyContracts` in `rnpExample.test-harness.ts`, the *entire object* (`dep1`) is now considered "managed." * **In Recorder Mode:** Managed dependencies behave as expected. The listed methods are spied on, and unlisted methods on the same object (like `Dependency1.unmappedMethod`) call their real implementation. -* **In Play Mode:** This is where the safety rule applies. **Every method** on a managed object must be explicitly mocked. If a method (like `Dependency1.unmappedMethod`) is called without a mock, the test will fail. The `useUnmappedMethodMocked` test case in `rnpExample.api-test-cases.ts` is designed specifically to validate this failure. +* **In Play Mode:** This is where the safety rule applies. **Every method** on a managed object must be explicitly mocked. If a method (like `Dependency1.unmappedMethod`) is called without a mock, the test will fail. The `callUnmappedMethodMocked` test case in `rnpExample.api-test-cases.ts` is designed specifically to validate this failure. * **Why?** This strictness forces the agent developer to be fully aware of every interaction with an external service. It makes it impossible for a dependency to add a new network call that would slow down unit tests. -* **Truly Unmanaged Dependencies:** In contrast, `dep2` from `rnpExample` is never mentioned in the `dependencyContracts`. It is "unmanaged," so its methods can be called freely in either mode. The `useUnlistedDep` test case demonstrates this. +* **Truly Unmanaged Dependencies:** In contrast, `dep2` from `rnpExample` is never mentioned in the `dependencyContracts`. It is "unmanaged," so its methods can be called freely in either mode. The `callUnlistedDep` test case demonstrates this. ## 5. Workflow: Adding a New Endpoint Test @@ -83,7 +83,7 @@ Follow this step-by-step process. In these paths, `{feature-type}` is either `ch **Step 1: Define the Test Case** * Open or create `test/{feature-type}/{feature-name}/{feature-name}.api-test-cases.ts`. -* Create and export a new `TestCase` instance (e.g., `export const useNewFeature = new TestCase(...)`). +* Create and export a new `TestCase` instance (e.g., `export const callNewFeature = new TestCase(...)`). **Step 2: Update the Test Harness** * Open `test/{feature-type}/{feature-name}/{feature-name}.test-harness.ts`. diff --git a/test-scripts/rnpExample/rnpExample.recorder.test.ts b/test-scripts/rnpExample/rnpExample.recorder.test.ts index a20f8b0a1a..5e2dc8f473 100644 --- a/test-scripts/rnpExample/rnpExample.recorder.test.ts +++ b/test-scripts/rnpExample/rnpExample.recorder.test.ts @@ -1,11 +1,11 @@ import { - useABC, - useB_superJsonMethod, - useBUnloaded_Recorder, - useDTwice, - usePrototypeDep, - useUnlistedDep, - useUnmappedMethod_Recorder, + callABC, + callB_superJsonMethod, + callBUnloaded_Recorder, + callDTwice, + callPrototypeDep, + callUnlistedDep, + callUnmappedMethod_Recorder, } from '#test/rnpExample/rnpExample.api-test-cases'; import { RnpExampleTestHarness } from '#test/rnpExample/rnpExample.test-harness'; @@ -27,36 +27,36 @@ describe('RnpExample', () => { await _harness.teardown(); }); - it('useABC', useABC.createRecordTest(harness)); + it('callABC', callABC.createRecordTest(harness)); - it('useB_superJsonMethod', useB_superJsonMethod.createRecordTest(harness)); + it('callB_superJsonMethod', callB_superJsonMethod.createRecordTest(harness)); - it('useDTwice', useDTwice.createRecordTest(harness)); + it('callDTwice', callDTwice.createRecordTest(harness)); - it('usePrototypeDep', usePrototypeDep.createRecordTest(harness)); + it('callPrototypeDep', callPrototypeDep.createRecordTest(harness)); - it('useUnlistedDep', useUnlistedDep.createRecordTest(harness)); + it('callUnlistedDep', callUnlistedDep.createRecordTest(harness)); - it('useBUnloaded_Recorder', useBUnloaded_Recorder.createRecordTest(harness)); + it('callBUnloaded_Recorder', callBUnloaded_Recorder.createRecordTest(harness)); - it('useUnmappedMethod_Recorder', useUnmappedMethod_Recorder.createRecordTest(harness)); + it('callUnmappedMethod_Recorder', callUnmappedMethod_Recorder.createRecordTest(harness)); - it('useBUnloaded_Mocked', async () => { + it('callBUnloaded_Mocked', async () => { // Create expected snapshot for running test in "Play" mode expect({ error: 'InternalServerError', message: - 'Failed to useSuperJsonMethod: Mocked dependency was called without a mock loaded: dep1_B. Either load a mock or allowPassThrough.', + 'Failed to callSuperJsonMethod: Mocked dependency was called without a mock loaded: dep1_B. Either load a mock or allowPassThrough.', statusCode: 500, }).toMatchSnapshot({}); }); - it('useUnmappedMethod_Mocked', async () => { + it('callUnmappedMethod_Mocked', async () => { // Create expected snapshot for running test in "Play" mode expect({ error: 'FailedDependencyError', message: - 'Failed to useUnmappedMethod: Unmapped method was called: dep1_A.unmappedMethod. Method must be listed and either mocked or specify allowPassThrough.', + 'Failed to callUnmappedMethod: Unmapped method was called: dep1_A.unmappedMethod. Method must be listed and either mocked or specify allowPassThrough.', statusCode: 424, }).toMatchSnapshot({}); }); diff --git a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap index 1f122ee11e..a630859413 100644 --- a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap +++ b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap @@ -1,61 +1,61 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`RnpExample useABC 1`] = ` +exports[`RnpExample callABC 1`] = ` { - "a": "real A_basicMethod-0.9261297200194791", - "b": "559573", + "a": "real A_basicMethod-0.8194074871792008", + "b": "648847", "c": Any, } `; -exports[`RnpExample useB_superJsonMethod 1`] = ` +exports[`RnpExample callB_superJsonMethod 1`] = ` { - "b": "984683", + "b": "993671", } `; -exports[`RnpExample useBUnloaded_Mocked 1`] = ` +exports[`RnpExample callBUnloaded_Mocked 1`] = ` { "error": "InternalServerError", - "message": "Failed to useSuperJsonMethod: Mocked dependency was called without a mock loaded: dep1_B. Either load a mock or allowPassThrough.", + "message": "Failed to callSuperJsonMethod: Mocked dependency was called without a mock loaded: dep1_B. Either load a mock or allowPassThrough.", "statusCode": 500, } `; -exports[`RnpExample useBUnloaded_Recorder 1`] = ` +exports[`RnpExample callBUnloaded_Recorder 1`] = ` { "b": Any, } `; -exports[`RnpExample useDTwice 1`] = ` +exports[`RnpExample callDTwice 1`] = ` { - "d1": "real D_usedTwiceInOneCallMethod-0.1108886413926522", - "d2": "real D_usedTwiceInOneCallMethod-0.7985412087769337", + "d1": "real D_usedTwiceInOneCallMethod-0.5699946728175469", + "d2": "real D_usedTwiceInOneCallMethod-0.926208199266757", } `; -exports[`RnpExample usePrototypeDep 1`] = ` +exports[`RnpExample callPrototypeDep 1`] = ` { - "x": "real prototypeMethod-0.9680478758520263", + "x": "real prototypeMethod-0.6351804576911579", } `; -exports[`RnpExample useUnlistedDep 1`] = ` +exports[`RnpExample callUnlistedDep 1`] = ` { "z": Any, } `; -exports[`RnpExample useUnmappedMethod_Mocked 1`] = ` +exports[`RnpExample callUnmappedMethod_Mocked 1`] = ` { "error": "FailedDependencyError", - "message": "Failed to useUnmappedMethod: Unmapped method was called: dep1_A.unmappedMethod. Method must be listed and either mocked or specify allowPassThrough.", + "message": "Failed to callUnmappedMethod: Unmapped method was called: dep1_A.unmappedMethod. Method must be listed and either mocked or specify allowPassThrough.", "statusCode": 424, } `; -exports[`RnpExample useUnmappedMethod_Recorder 1`] = ` +exports[`RnpExample callUnmappedMethod_Recorder 1`] = ` { "unmapped": Any, } diff --git a/test/rnpExample/api/rnpExample.routes.ts b/test/rnpExample/api/rnpExample.routes.ts index e5d7985b2e..a519cc24b4 100644 --- a/test/rnpExample/api/rnpExample.routes.ts +++ b/test/rnpExample/api/rnpExample.routes.ts @@ -1,19 +1,19 @@ import { FastifyPluginAsync } from 'fastify'; -import { useABCRoute } from './routes/useABC'; -import { useDTwiceRoute } from './routes/useDTwice'; -import { usePrototypeDepRoute } from './routes/usePrototypeDep'; -import { useSuperJsonMethodRoute } from './routes/useSuperJsonMethod'; -import { useUnlistedDepRoute } from './routes/useUnlistedDep'; -import { useUnmappedMethodRoute } from './routes/useUnmappedMethod'; +import { callABCRoute } from './routes/callABC'; +import { callDTwiceRoute } from './routes/callDTwice'; +import { callPrototypeDepRoute } from './routes/callPrototypeDep'; +import { callSuperJsonMethodRoute } from './routes/callSuperJsonMethod'; +import { callUnlistedDepRoute } from './routes/callUnlistedDep'; +import { callUnmappedMethodRoute } from './routes/callUnmappedMethod'; export const rnpExampleRoutes: FastifyPluginAsync = async (fastify) => { - await fastify.register(useABCRoute); - await fastify.register(useSuperJsonMethodRoute); - await fastify.register(useDTwiceRoute); - await fastify.register(usePrototypeDepRoute); - await fastify.register(useUnlistedDepRoute); - await fastify.register(useUnmappedMethodRoute); + await fastify.register(callABCRoute); + await fastify.register(callSuperJsonMethodRoute); + await fastify.register(callDTwiceRoute); + await fastify.register(callPrototypeDepRoute); + await fastify.register(callUnlistedDepRoute); + await fastify.register(callUnmappedMethodRoute); }; export default rnpExampleRoutes; diff --git a/test/rnpExample/api/rnpExample.ts b/test/rnpExample/api/rnpExample.ts index beea6aa7ba..2a89de384a 100644 --- a/test/rnpExample/api/rnpExample.ts +++ b/test/rnpExample/api/rnpExample.ts @@ -45,14 +45,14 @@ export class RnpExample { return RnpExample._instances[network]; } - async useABC() { + async callABC() { const a = await this.dep1.A_basicMethod(); const b = await this.dep1.B_superJsonMethod(); const c = await this.dep1.C_passthroughMethod(); return { a, b: b.toString(), c }; } - async useSuperJsonMethod() { + async callSuperJsonMethod() { const b = await this.dep1.B_superJsonMethod(); if (!BigNumber.isBigNumber(b)) { throw new Error('b is not a BigNumber'); @@ -60,24 +60,24 @@ export class RnpExample { return { b: b.toString() }; } - async useDTwice() { + async callDTwice() { const d1 = await this.dep1.D_usedTwiceInOneCallMethod(); const d2 = await this.dep1.D_usedTwiceInOneCallMethod(); return { d1, d2 }; } - async useUnmappedMethod() { + async callUnmappedMethod() { const unmapped = await this.dep1.unmappedMethod(); return { unmapped }; } - async usePrototypeDep() { + async callPrototypeDep() { const localDep = new LocallyInitializedDependency(); const x = await localDep.prototypeMethod(); return { x }; } - async useUnlistedDep() { + async callUnlistedDep() { const z = await this.dep2.unlistedMethod(); return { z }; } diff --git a/test/rnpExample/api/routes/useABC.ts b/test/rnpExample/api/routes/callABC.ts similarity index 69% rename from test/rnpExample/api/routes/useABC.ts rename to test/rnpExample/api/routes/callABC.ts index 7d2cc64548..91518a5ebb 100644 --- a/test/rnpExample/api/routes/useABC.ts +++ b/test/rnpExample/api/routes/callABC.ts @@ -4,12 +4,12 @@ import { logger } from '#src/services/logger'; import { RnpExample } from '../rnpExample'; -export const useABCRoute: FastifyPluginAsync = async (fastify) => { +export const callABCRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ Querystring: { network: string }; Reply: { a: string; b: string; c: string }; }>( - '/useABC', + '/callABC', { schema: { summary: 'A RnpExample route for testing', @@ -18,11 +18,11 @@ export const useABCRoute: FastifyPluginAsync = async (fastify) => { async (request) => { try { const rnpExample = await RnpExample.getInstance(request.query.network); - return await rnpExample.useABC(); + return await rnpExample.callABC(); } catch (error) { - logger.error(`Error getting useABC status: ${error.message}`); + logger.error(`Error getting callABC status: ${error.message}`); throw fastify.httpErrors.internalServerError( - `Failed to useABC: ${error.message}`, + `Failed to callABC: ${error.message}`, ); } }, diff --git a/test/rnpExample/api/routes/useDTwice.ts b/test/rnpExample/api/routes/callDTwice.ts similarity index 67% rename from test/rnpExample/api/routes/useDTwice.ts rename to test/rnpExample/api/routes/callDTwice.ts index 902aeac9b7..9f0764f476 100644 --- a/test/rnpExample/api/routes/useDTwice.ts +++ b/test/rnpExample/api/routes/callDTwice.ts @@ -4,12 +4,12 @@ import { logger } from '#src/services/logger'; import { RnpExample } from '../rnpExample'; -export const useDTwiceRoute: FastifyPluginAsync = async (fastify) => { +export const callDTwiceRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ Querystring: { network: string }; Reply: { d1: string; d2: string }; }>( - '/useDTwice', + '/callDTwice', { schema: { summary: 'A RnpExample route for testing', @@ -18,11 +18,11 @@ export const useDTwiceRoute: FastifyPluginAsync = async (fastify) => { async (request) => { try { const rnpExample = await RnpExample.getInstance(request.query.network); - return await rnpExample.useDTwice(); + return await rnpExample.callDTwice(); } catch (error) { - logger.error(`Error getting useDTwice status: ${error.message}`); + logger.error(`Error getting callDTwice status: ${error.message}`); throw fastify.httpErrors.internalServerError( - `Failed to useDTwice: ${error.message}`, + `Failed to callDTwice: ${error.message}`, ); } }, diff --git a/test/rnpExample/api/routes/usePrototypeDep.ts b/test/rnpExample/api/routes/callPrototypeDep.ts similarity index 65% rename from test/rnpExample/api/routes/usePrototypeDep.ts rename to test/rnpExample/api/routes/callPrototypeDep.ts index ea0aef090a..e36efdfd96 100644 --- a/test/rnpExample/api/routes/usePrototypeDep.ts +++ b/test/rnpExample/api/routes/callPrototypeDep.ts @@ -4,12 +4,12 @@ import { logger } from '#src/services/logger'; import { RnpExample } from '../rnpExample'; -export const usePrototypeDepRoute: FastifyPluginAsync = async (fastify) => { +export const callPrototypeDepRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ Querystring: { network: string }; Reply: { x: string }; }>( - '/usePrototypeDep', + '/callPrototypeDep', { schema: { summary: 'A RnpExample route for testing prototype dependencies', @@ -18,11 +18,11 @@ export const usePrototypeDepRoute: FastifyPluginAsync = async (fastify) => { async (request) => { try { const rnpExample = await RnpExample.getInstance(request.query.network); - return await rnpExample.usePrototypeDep(); + return await rnpExample.callPrototypeDep(); } catch (error) { - logger.error(`Error getting usePrototypeDep status: ${error.message}`); + logger.error(`Error getting callPrototypeDep status: ${error.message}`); throw fastify.httpErrors.internalServerError( - `Failed to usePrototypeDep: ${error.message}`, + `Failed to callPrototypeDep: ${error.message}`, ); } }, diff --git a/test/rnpExample/api/routes/useSuperJsonMethod.ts b/test/rnpExample/api/routes/callSuperJsonMethod.ts similarity index 66% rename from test/rnpExample/api/routes/useSuperJsonMethod.ts rename to test/rnpExample/api/routes/callSuperJsonMethod.ts index c74ad77224..62ab59213f 100644 --- a/test/rnpExample/api/routes/useSuperJsonMethod.ts +++ b/test/rnpExample/api/routes/callSuperJsonMethod.ts @@ -4,12 +4,12 @@ import { logger } from '#src/services/logger'; import { RnpExample } from '../rnpExample'; -export const useSuperJsonMethodRoute: FastifyPluginAsync = async (fastify) => { +export const callSuperJsonMethodRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ Querystring: { network: string }; Reply: { b: string }; }>( - '/useSuperJsonMethod', + '/callSuperJsonMethod', { schema: { summary: 'A RnpExample route for testing', @@ -18,13 +18,13 @@ export const useSuperJsonMethodRoute: FastifyPluginAsync = async (fastify) => { async (request) => { try { const rnpExample = await RnpExample.getInstance(request.query.network); - return await rnpExample.useSuperJsonMethod(); + return await rnpExample.callSuperJsonMethod(); } catch (error) { logger.error( - `Error getting useSuperJsonMethod status: ${error.message}`, + `Error getting callSuperJsonMethod status: ${error.message}`, ); throw fastify.httpErrors.internalServerError( - `Failed to useSuperJsonMethod: ${error.message}`, + `Failed to callSuperJsonMethod: ${error.message}`, ); } }, diff --git a/test/rnpExample/api/routes/useUnlistedDep.ts b/test/rnpExample/api/routes/callUnlistedDep.ts similarity index 65% rename from test/rnpExample/api/routes/useUnlistedDep.ts rename to test/rnpExample/api/routes/callUnlistedDep.ts index bdc4b6ced3..9d2b2b4752 100644 --- a/test/rnpExample/api/routes/useUnlistedDep.ts +++ b/test/rnpExample/api/routes/callUnlistedDep.ts @@ -4,12 +4,12 @@ import { logger } from '#src/services/logger'; import { RnpExample } from '../rnpExample'; -export const useUnlistedDepRoute: FastifyPluginAsync = async (fastify) => { +export const callUnlistedDepRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ Querystring: { network: string }; Reply: { z: string }; }>( - '/useUnlistedDep', + '/callUnlistedDep', { schema: { summary: 'A RnpExample route for testing', @@ -18,11 +18,11 @@ export const useUnlistedDepRoute: FastifyPluginAsync = async (fastify) => { async (request) => { try { const rnpExample = await RnpExample.getInstance(request.query.network); - return await rnpExample.useUnlistedDep(); + return await rnpExample.callUnlistedDep(); } catch (error) { - logger.error(`Error getting useUnlistedDep status: ${error.message}`); + logger.error(`Error getting callUnlistedDep status: ${error.message}`); throw fastify.httpErrors.internalServerError( - `Failed to useUnlistedDep: ${error.message}`, + `Failed to callUnlistedDep: ${error.message}`, ); } }, diff --git a/test/rnpExample/api/routes/useUnmappedMethod.ts b/test/rnpExample/api/routes/callUnmappedMethod.ts similarity index 69% rename from test/rnpExample/api/routes/useUnmappedMethod.ts rename to test/rnpExample/api/routes/callUnmappedMethod.ts index b9e81d77ab..8459dadedb 100644 --- a/test/rnpExample/api/routes/useUnmappedMethod.ts +++ b/test/rnpExample/api/routes/callUnmappedMethod.ts @@ -4,12 +4,12 @@ import { logger } from '#src/services/logger'; import { RnpExample } from '../rnpExample'; -export const useUnmappedMethodRoute: FastifyPluginAsync = async (fastify) => { +export const callUnmappedMethodRoute: FastifyPluginAsync = async (fastify) => { fastify.get<{ Querystring: { network: string }; Reply: { unmapped: string }; }>( - '/useUnmappedMethod', + '/callUnmappedMethod', { schema: { summary: 'A RnpExample route for testing', @@ -18,14 +18,14 @@ export const useUnmappedMethodRoute: FastifyPluginAsync = async (fastify) => { async (request) => { try { const rnpExample = await RnpExample.getInstance(request.query.network); - return await rnpExample.useUnmappedMethod(); + return await rnpExample.callUnmappedMethod(); } catch (error) { logger.error( - `Error getting useUnmappedMethod status: ${error.message}`, + `Error getting callUnmappedMethod status: ${error.message}`, ); // Throw specific error to verify a 424 is returned for snapshot throw fastify.httpErrors.failedDependency( - `Failed to useUnmappedMethod: ${error.message}`, + `Failed to callUnmappedMethod: ${error.message}`, ); } }, diff --git a/test/rnpExample/mocks/rnpExample-callABC_A.json b/test/rnpExample/mocks/rnpExample-callABC_A.json new file mode 100644 index 0000000000..984b8804e2 --- /dev/null +++ b/test/rnpExample/mocks/rnpExample-callABC_A.json @@ -0,0 +1,3 @@ +{ + "json": "real A_basicMethod-0.8194074871792008" +} diff --git a/test/rnpExample/mocks/rnpExample-callABC_B.json b/test/rnpExample/mocks/rnpExample-callABC_B.json new file mode 100644 index 0000000000..a9276c272d --- /dev/null +++ b/test/rnpExample/mocks/rnpExample-callABC_B.json @@ -0,0 +1,6 @@ +{ + "json": "0x09e68f", + "meta": { + "values": [["custom", "ethers.BigNumber"]] + } +} diff --git a/test/rnpExample/mocks/rnpExample-callB_superJsonMethod.json b/test/rnpExample/mocks/rnpExample-callB_superJsonMethod.json new file mode 100644 index 0000000000..e15184909e --- /dev/null +++ b/test/rnpExample/mocks/rnpExample-callB_superJsonMethod.json @@ -0,0 +1,6 @@ +{ + "json": "0x0f2987", + "meta": { + "values": [["custom", "ethers.BigNumber"]] + } +} diff --git a/test/rnpExample/mocks/rnpExample-callDTwice_1.json b/test/rnpExample/mocks/rnpExample-callDTwice_1.json new file mode 100644 index 0000000000..9e8764f4b6 --- /dev/null +++ b/test/rnpExample/mocks/rnpExample-callDTwice_1.json @@ -0,0 +1,3 @@ +{ + "json": "real D_usedTwiceInOneCallMethod-0.5699946728175469" +} diff --git a/test/rnpExample/mocks/rnpExample-callDTwice_2.json b/test/rnpExample/mocks/rnpExample-callDTwice_2.json new file mode 100644 index 0000000000..0c4fb7a729 --- /dev/null +++ b/test/rnpExample/mocks/rnpExample-callDTwice_2.json @@ -0,0 +1,3 @@ +{ + "json": "real D_usedTwiceInOneCallMethod-0.926208199266757" +} diff --git a/test/rnpExample/mocks/rnpExample-callPrototypeDep.json b/test/rnpExample/mocks/rnpExample-callPrototypeDep.json new file mode 100644 index 0000000000..2624b1cad8 --- /dev/null +++ b/test/rnpExample/mocks/rnpExample-callPrototypeDep.json @@ -0,0 +1,3 @@ +{ + "json": "real prototypeMethod-0.6351804576911579" +} diff --git a/test/rnpExample/mocks/rnpExample-useABC_A.json b/test/rnpExample/mocks/rnpExample-useABC_A.json deleted file mode 100644 index 8ca454b103..0000000000 --- a/test/rnpExample/mocks/rnpExample-useABC_A.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "json": "real A_basicMethod-0.9261297200194791" -} \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-useABC_B.json b/test/rnpExample/mocks/rnpExample-useABC_B.json deleted file mode 100644 index d83e012d39..0000000000 --- a/test/rnpExample/mocks/rnpExample-useABC_B.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "json": "0x0889d5", - "meta": { - "values": [ - [ - "custom", - "ethers.BigNumber" - ] - ] - } -} \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-useB_superJsonMethod.json b/test/rnpExample/mocks/rnpExample-useB_superJsonMethod.json deleted file mode 100644 index 50a3502bc9..0000000000 --- a/test/rnpExample/mocks/rnpExample-useB_superJsonMethod.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "json": "0x0f066b", - "meta": { - "values": [ - [ - "custom", - "ethers.BigNumber" - ] - ] - } -} \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-useDTwice_1.json b/test/rnpExample/mocks/rnpExample-useDTwice_1.json deleted file mode 100644 index d3fd04b3af..0000000000 --- a/test/rnpExample/mocks/rnpExample-useDTwice_1.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "json": "real D_usedTwiceInOneCallMethod-0.1108886413926522" -} \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-useDTwice_2.json b/test/rnpExample/mocks/rnpExample-useDTwice_2.json deleted file mode 100644 index 6b943a3dca..0000000000 --- a/test/rnpExample/mocks/rnpExample-useDTwice_2.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "json": "real D_usedTwiceInOneCallMethod-0.7985412087769337" -} \ No newline at end of file diff --git a/test/rnpExample/mocks/rnpExample-usePrototypeDep.json b/test/rnpExample/mocks/rnpExample-usePrototypeDep.json deleted file mode 100644 index fb1a0354e6..0000000000 --- a/test/rnpExample/mocks/rnpExample-usePrototypeDep.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "json": "real prototypeMethod-0.9680478758520263" -} \ No newline at end of file diff --git a/test/rnpExample/rnpExample.api-test-cases.ts b/test/rnpExample/rnpExample.api-test-cases.ts index b9065e89bc..def7f99402 100644 --- a/test/rnpExample/rnpExample.api-test-cases.ts +++ b/test/rnpExample/rnpExample.api-test-cases.ts @@ -10,15 +10,15 @@ class TestCase extends APITestCase {} * with `allowPassThrough` in the harness, it will call its real implementation * without throwing an error. */ -export const useABC = new TestCase({ +export const callABC = new TestCase({ method: 'GET', - url: '/rnpExample/useABC', + url: '/rnpExample/callABC', expectedStatus: 200, query: { network: 'TEST' }, payload: {}, requiredMocks: { - dep1_A: 'rnpExample-useABC_A', - dep1_B: 'rnpExample-useABC_B', + dep1_A: 'rnpExample-callABC_A', + dep1_B: 'rnpExample-callABC_B', }, propertyMatchers: { c: expect.any(String), @@ -28,14 +28,14 @@ export const useABC = new TestCase({ /** * A simple test case that calls one mocked dependency. */ -export const useB_superJsonMethod = new TestCase({ +export const callB_superJsonMethod = new TestCase({ method: 'GET', - url: '/rnpExample/useSuperJsonMethod', + url: '/rnpExample/callSuperJsonMethod', expectedStatus: 200, query: { network: 'TEST' }, payload: {}, requiredMocks: { - dep1_B: 'rnpExample-useB_superJsonMethod', + dep1_B: 'rnpExample-callB_superJsonMethod', }, }); @@ -44,41 +44,40 @@ export const useB_superJsonMethod = new TestCase({ * The `requiredMocks` array provides two different mock files, which will be * returned in order for the two sequential calls to `dep1.D_usedTwiceInOneCallMethod()`. */ -export const useDTwice = new TestCase({ +export const callDTwice = new TestCase({ method: 'GET', - url: '/rnpExample/useDTwice', + url: '/rnpExample/callDTwice', expectedStatus: 200, query: { network: 'TEST' }, payload: {}, requiredMocks: { - dep1_D: ['rnpExample-useDTwice_1', 'rnpExample-useDTwice_2'], + dep1_D: ['rnpExample-callDTwice_1', 'rnpExample-callDTwice_2'], }, }); /** * A test case for a dependency defined on a class prototype. */ -export const usePrototypeDep = new TestCase({ +export const callPrototypeDep = new TestCase({ method: 'GET', - url: '/rnpExample/usePrototypeDep', + url: '/rnpExample/callPrototypeDep', expectedStatus: 200, query: { network: 'TEST' }, payload: {}, requiredMocks: { - localDep: 'rnpExample-usePrototypeDep', + localDep: 'rnpExample-callPrototypeDep', }, }); - /** * A test case for a method that calls a truly "unmanaged" dependency. - * The `useUnlistedDep` endpoint calls `dep2.unlistedMethod`. Because `dep2` is not + * The `callUnlistedDep` endpoint calls `dep2.unlistedMethod`. Because `dep2` is not * mentioned anywhere in the harness's `dependencyContracts`, it is "unmanaged" * and will always call its real implementation in both Record and Play modes. */ -export const useUnlistedDep = new TestCase({ +export const callUnlistedDep = new TestCase({ method: 'GET', - url: '/rnpExample/useUnlistedDep', + url: '/rnpExample/callUnlistedDep', expectedStatus: 200, query: { network: 'TEST' }, payload: {}, @@ -90,12 +89,12 @@ export const useUnlistedDep = new TestCase({ /** * A recorder-only test case for an unloaded dependency. - * This exists to demonstrate that the recorder and "Play" mode test output + * This exists to demonstrate that the recorder and "Play" mode test output * varies in this scenario as the mock test will fail but recorder test will pass. */ -export const useBUnloaded_Recorder = new TestCase({ +export const callBUnloaded_Recorder = new TestCase({ method: 'GET', - url: '/rnpExample/useSuperJsonMethod', + url: '/rnpExample/callSuperJsonMethod', expectedStatus: 200, query: { network: 'TEST' }, payload: {}, @@ -109,9 +108,9 @@ export const useBUnloaded_Recorder = new TestCase({ * via `requiredMocks`. This is designed to fail, demonstrating the safety * feature that prevents calls to managed dependencies that haven't been loaded. */ -export const useBUnloaded_Mocked = new TestCase({ +export const callBUnloaded_Mocked = new TestCase({ method: 'GET', - url: '/rnpExample/useSuperJsonMethod', + url: '/rnpExample/callSuperJsonMethod', expectedStatus: 500, query: { network: 'TEST' }, payload: {}, @@ -123,9 +122,9 @@ export const useBUnloaded_Mocked = new TestCase({ * This exists to demonstrate that the recorder and "Play" mode test output * varies in this scenario as the mock test will fail but recorder test will pass. */ -export const useUnmappedMethod_Recorder = new TestCase({ +export const callUnmappedMethod_Recorder = new TestCase({ method: 'GET', - url: '/rnpExample/useUnmappedMethod', + url: '/rnpExample/callUnmappedMethod', expectedStatus: 200, query: { network: 'TEST' }, payload: {}, @@ -134,19 +133,18 @@ export const useUnmappedMethod_Recorder = new TestCase({ /** * A failure test case for a "Play" mode test case that calls a method with an unmapped dependency. - * The `useUnmappedMethod` endpoint calls `dep1.unmappedMethod`. - * But because `dep1` * is a "managed" object in the harness and `unmappedMethod` + * The `callUnmappedMethod` endpoint calls `dep1.unmappedMethod`. + * But because `dep1` * is a "managed" object in the harness and `unmappedMethod` * is not explicitly mocked or set to `allowPassThrough`, this test gets a error return object. - * This demonstrates the key safety feature of the RnP framework + * This demonstrates the key safety feature of the RnP framework * that prevents calls to unmapped methods. . */ -export const useUnmappedMethod_Mocked = new TestCase({ +export const callUnmappedMethod_Mocked = new TestCase({ method: 'GET', - url: '/rnpExample/useUnmappedMethod', + url: '/rnpExample/callUnmappedMethod', expectedStatus: 424, query: { network: 'TEST' }, payload: {}, requiredMocks: {}, propertyMatchers: {}, }); - diff --git a/test/rnpExample/rnpExample.test.ts b/test/rnpExample/rnpExample.test.ts index 3ebc4f42b8..d00e376bf6 100644 --- a/test/rnpExample/rnpExample.test.ts +++ b/test/rnpExample/rnpExample.test.ts @@ -1,13 +1,13 @@ import { - useABC, - useB_superJsonMethod, - useBUnloaded_Mocked, - useBUnloaded_Recorder, - useDTwice, - usePrototypeDep, - useUnlistedDep, - useUnmappedMethod_Mocked, - useUnmappedMethod_Recorder, + callABC, + callB_superJsonMethod, + callBUnloaded_Mocked, + callBUnloaded_Recorder, + callDTwice, + callPrototypeDep, + callUnlistedDep, + callUnmappedMethod_Mocked, + callUnmappedMethod_Recorder, } from './rnpExample.api-test-cases'; import { RnpExampleTestHarness } from './rnpExample.test-harness'; @@ -28,32 +28,32 @@ describe('RnpExample', () => { await _harness.teardown(); }); - it('useABC', useABC.createPlayTest(harness)); + it('callABC', callABC.createPlayTest(harness)); - it('useB_superJsonMethod', useB_superJsonMethod.createPlayTest(harness)); + it('callB_superJsonMethod', callB_superJsonMethod.createPlayTest(harness)); - it('useDTwice', useDTwice.createPlayTest(harness)); + it('callDTwice', callDTwice.createPlayTest(harness)); - it('usePrototypeDep', usePrototypeDep.createPlayTest(harness)); + it('callPrototypeDep', callPrototypeDep.createPlayTest(harness)); - it('useUnlistedDep', useUnlistedDep.createPlayTest(harness)); + it('callUnlistedDep', callUnlistedDep.createPlayTest(harness)); - it('useBUnloaded_Mocked', useBUnloaded_Mocked.createPlayTest(harness)); + it('callBUnloaded_Mocked', callBUnloaded_Mocked.createPlayTest(harness)); it( - 'useUnmappedMethod_Mocked', - useUnmappedMethod_Mocked.createPlayTest(harness), + 'callUnmappedMethod_Mocked', + callUnmappedMethod_Mocked.createPlayTest(harness), ); - it('useBUnloaded_Recorder', async () => { + it('callBUnloaded_Recorder', async () => { // Snapshots must match the recorded output and this call fails in "play" mode // so we force a predictable result by just using the recorder test's propertyMatchers. - expect(useBUnloaded_Recorder.propertyMatchers).toMatchSnapshot(); + expect(callBUnloaded_Recorder.propertyMatchers).toMatchSnapshot(); }); - it('useUnmappedMethod_Recorder', async () => { + it('callUnmappedMethod_Recorder', async () => { // Snapshots must match the recorded output and this call fails in "play" mode // so we force a predictable result by just using the recorder test's propertyMatchers. - expect(useUnmappedMethod_Recorder.propertyMatchers).toMatchSnapshot(); + expect(callUnmappedMethod_Recorder.propertyMatchers).toMatchSnapshot(); }); }); From aaab4f2cf336e9cd484012a4c80dea87096a804c Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Fri, 18 Jul 2025 14:21:53 -0600 Subject: [PATCH 38/45] update description on cursor rule --- .cursor/rules/record-and-play-testing-guide.mdc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.cursor/rules/record-and-play-testing-guide.mdc b/.cursor/rules/record-and-play-testing-guide.mdc index a9050fd340..fc92f44146 100644 --- a/.cursor/rules/record-and-play-testing-guide.mdc +++ b/.cursor/rules/record-and-play-testing-guide.mdc @@ -1,6 +1,6 @@ --- -description: -globs: ++description: Comprehensive guide for creating tests using the Record and Play (RnP) framework ++globs: ["test-scripts/**/*.recorder.test.ts", "test/**/*.test-harness.ts", "test/**/*.api-test-cases.ts"] alwaysApply: false --- This guide provides the complete instructions for creating tests using the project's "Record and Play" (RnP) framework. It is intended to be used by LLM agents to autonomously create, run, and maintain tests for new and existing features. Adherence to these rules is not optional; it is critical for the stability and predictability of the testing suite. From 79b54b110a3d22924974268d24d6b5495d55a3b0 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Fri, 18 Jul 2025 14:27:00 -0600 Subject: [PATCH 39/45] use 501 instead of 424 status code on rnpExample. --- .../rnpExample/rnpExample.recorder.test.ts | 8 +++++--- .../__snapshots__/rnpExample.test.ts.snap | 16 ++++++++-------- test/rnpExample/api/routes/callUnmappedMethod.ts | 10 +++------- test/rnpExample/mocks/rnpExample-callABC_A.json | 2 +- test/rnpExample/mocks/rnpExample-callABC_B.json | 2 +- .../mocks/rnpExample-callB_superJsonMethod.json | 2 +- .../mocks/rnpExample-callDTwice_1.json | 2 +- .../mocks/rnpExample-callDTwice_2.json | 2 +- .../mocks/rnpExample-callPrototypeDep.json | 2 +- test/rnpExample/rnpExample.api-test-cases.ts | 2 +- 10 files changed, 23 insertions(+), 25 deletions(-) diff --git a/test-scripts/rnpExample/rnpExample.recorder.test.ts b/test-scripts/rnpExample/rnpExample.recorder.test.ts index 5e2dc8f473..ced175f875 100644 --- a/test-scripts/rnpExample/rnpExample.recorder.test.ts +++ b/test-scripts/rnpExample/rnpExample.recorder.test.ts @@ -1,10 +1,12 @@ import { callABC, callB_superJsonMethod, + callBUnloaded_Mocked, callBUnloaded_Recorder, callDTwice, callPrototypeDep, callUnlistedDep, + callUnmappedMethod_Mocked, callUnmappedMethod_Recorder, } from '#test/rnpExample/rnpExample.api-test-cases'; import { RnpExampleTestHarness } from '#test/rnpExample/rnpExample.test-harness'; @@ -47,17 +49,17 @@ describe('RnpExample', () => { error: 'InternalServerError', message: 'Failed to callSuperJsonMethod: Mocked dependency was called without a mock loaded: dep1_B. Either load a mock or allowPassThrough.', - statusCode: 500, + statusCode: callBUnloaded_Mocked.expectedStatus, }).toMatchSnapshot({}); }); it('callUnmappedMethod_Mocked', async () => { // Create expected snapshot for running test in "Play" mode expect({ - error: 'FailedDependencyError', + error: 'NotImplementedError', message: 'Failed to callUnmappedMethod: Unmapped method was called: dep1_A.unmappedMethod. Method must be listed and either mocked or specify allowPassThrough.', - statusCode: 424, + statusCode: callUnmappedMethod_Mocked.expectedStatus, }).toMatchSnapshot({}); }); }); \ No newline at end of file diff --git a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap index a630859413..dcc98631f3 100644 --- a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap +++ b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap @@ -2,15 +2,15 @@ exports[`RnpExample callABC 1`] = ` { - "a": "real A_basicMethod-0.8194074871792008", - "b": "648847", + "a": "real A_basicMethod-0.613904698364715", + "b": "337111", "c": Any, } `; exports[`RnpExample callB_superJsonMethod 1`] = ` { - "b": "993671", + "b": "824836", } `; @@ -30,14 +30,14 @@ exports[`RnpExample callBUnloaded_Recorder 1`] = ` exports[`RnpExample callDTwice 1`] = ` { - "d1": "real D_usedTwiceInOneCallMethod-0.5699946728175469", - "d2": "real D_usedTwiceInOneCallMethod-0.926208199266757", + "d1": "real D_usedTwiceInOneCallMethod-0.1646371768088688", + "d2": "real D_usedTwiceInOneCallMethod-0.5056626165727529", } `; exports[`RnpExample callPrototypeDep 1`] = ` { - "x": "real prototypeMethod-0.6351804576911579", + "x": "real prototypeMethod-0.3625906666354404", } `; @@ -49,9 +49,9 @@ exports[`RnpExample callUnlistedDep 1`] = ` exports[`RnpExample callUnmappedMethod_Mocked 1`] = ` { - "error": "FailedDependencyError", + "error": "NotImplementedError", "message": "Failed to callUnmappedMethod: Unmapped method was called: dep1_A.unmappedMethod. Method must be listed and either mocked or specify allowPassThrough.", - "statusCode": 424, + "statusCode": 501, } `; diff --git a/test/rnpExample/api/routes/callUnmappedMethod.ts b/test/rnpExample/api/routes/callUnmappedMethod.ts index 8459dadedb..bf1ea93dc3 100644 --- a/test/rnpExample/api/routes/callUnmappedMethod.ts +++ b/test/rnpExample/api/routes/callUnmappedMethod.ts @@ -20,13 +20,9 @@ export const callUnmappedMethodRoute: FastifyPluginAsync = async (fastify) => { const rnpExample = await RnpExample.getInstance(request.query.network); return await rnpExample.callUnmappedMethod(); } catch (error) { - logger.error( - `Error getting callUnmappedMethod status: ${error.message}`, - ); - // Throw specific error to verify a 424 is returned for snapshot - throw fastify.httpErrors.failedDependency( - `Failed to callUnmappedMethod: ${error.message}`, - ); + logger.error(`Error getting callUnmappedMethod status: ${error.message}`); + // Throw specific error to verify a 501 is returned for snapshot + throw fastify.httpErrors.notImplemented(`Failed to callUnmappedMethod: ${error.message}`); } }, ); diff --git a/test/rnpExample/mocks/rnpExample-callABC_A.json b/test/rnpExample/mocks/rnpExample-callABC_A.json index 984b8804e2..2671874c0c 100644 --- a/test/rnpExample/mocks/rnpExample-callABC_A.json +++ b/test/rnpExample/mocks/rnpExample-callABC_A.json @@ -1,3 +1,3 @@ { - "json": "real A_basicMethod-0.8194074871792008" + "json": "real A_basicMethod-0.613904698364715" } diff --git a/test/rnpExample/mocks/rnpExample-callABC_B.json b/test/rnpExample/mocks/rnpExample-callABC_B.json index a9276c272d..cfb96b638b 100644 --- a/test/rnpExample/mocks/rnpExample-callABC_B.json +++ b/test/rnpExample/mocks/rnpExample-callABC_B.json @@ -1,5 +1,5 @@ { - "json": "0x09e68f", + "json": "0x0524d7", "meta": { "values": [["custom", "ethers.BigNumber"]] } diff --git a/test/rnpExample/mocks/rnpExample-callB_superJsonMethod.json b/test/rnpExample/mocks/rnpExample-callB_superJsonMethod.json index e15184909e..51a0329ef0 100644 --- a/test/rnpExample/mocks/rnpExample-callB_superJsonMethod.json +++ b/test/rnpExample/mocks/rnpExample-callB_superJsonMethod.json @@ -1,5 +1,5 @@ { - "json": "0x0f2987", + "json": "0x0c9604", "meta": { "values": [["custom", "ethers.BigNumber"]] } diff --git a/test/rnpExample/mocks/rnpExample-callDTwice_1.json b/test/rnpExample/mocks/rnpExample-callDTwice_1.json index 9e8764f4b6..b153d5e2bc 100644 --- a/test/rnpExample/mocks/rnpExample-callDTwice_1.json +++ b/test/rnpExample/mocks/rnpExample-callDTwice_1.json @@ -1,3 +1,3 @@ { - "json": "real D_usedTwiceInOneCallMethod-0.5699946728175469" + "json": "real D_usedTwiceInOneCallMethod-0.1646371768088688" } diff --git a/test/rnpExample/mocks/rnpExample-callDTwice_2.json b/test/rnpExample/mocks/rnpExample-callDTwice_2.json index 0c4fb7a729..8965d08832 100644 --- a/test/rnpExample/mocks/rnpExample-callDTwice_2.json +++ b/test/rnpExample/mocks/rnpExample-callDTwice_2.json @@ -1,3 +1,3 @@ { - "json": "real D_usedTwiceInOneCallMethod-0.926208199266757" + "json": "real D_usedTwiceInOneCallMethod-0.5056626165727529" } diff --git a/test/rnpExample/mocks/rnpExample-callPrototypeDep.json b/test/rnpExample/mocks/rnpExample-callPrototypeDep.json index 2624b1cad8..fe0be717de 100644 --- a/test/rnpExample/mocks/rnpExample-callPrototypeDep.json +++ b/test/rnpExample/mocks/rnpExample-callPrototypeDep.json @@ -1,3 +1,3 @@ { - "json": "real prototypeMethod-0.6351804576911579" + "json": "real prototypeMethod-0.3625906666354404" } diff --git a/test/rnpExample/rnpExample.api-test-cases.ts b/test/rnpExample/rnpExample.api-test-cases.ts index def7f99402..48392ced87 100644 --- a/test/rnpExample/rnpExample.api-test-cases.ts +++ b/test/rnpExample/rnpExample.api-test-cases.ts @@ -142,7 +142,7 @@ export const callUnmappedMethod_Recorder = new TestCase({ export const callUnmappedMethod_Mocked = new TestCase({ method: 'GET', url: '/rnpExample/callUnmappedMethod', - expectedStatus: 424, + expectedStatus: 501, query: { network: 'TEST' }, payload: {}, requiredMocks: {}, From c1a125db453590bf0e306c212d031c65b54ffac5 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Fri, 18 Jul 2025 14:44:43 -0600 Subject: [PATCH 40/45] simplify the randomNumber on the snapshot to reduce cognitive load. --- .../__snapshots__/rnpExample.test.ts.snap | 12 +++++------ test/rnpExample/api/rnpExample.ts | 20 ++++++++++--------- .../mocks/rnpExample-callABC_A.json | 2 +- .../mocks/rnpExample-callABC_B.json | 2 +- .../rnpExample-callB_superJsonMethod.json | 2 +- .../mocks/rnpExample-callDTwice_1.json | 2 +- .../mocks/rnpExample-callDTwice_2.json | 2 +- .../mocks/rnpExample-callPrototypeDep.json | 2 +- test/superjson-setup.ts | 2 +- 9 files changed, 24 insertions(+), 22 deletions(-) diff --git a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap index dcc98631f3..b208e2286d 100644 --- a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap +++ b/test/rnpExample/__snapshots__/rnpExample.test.ts.snap @@ -2,15 +2,15 @@ exports[`RnpExample callABC 1`] = ` { - "a": "real A_basicMethod-0.613904698364715", - "b": "337111", + "a": "real A_basicMethod-48", + "b": "54", "c": Any, } `; exports[`RnpExample callB_superJsonMethod 1`] = ` { - "b": "824836", + "b": "56", } `; @@ -30,14 +30,14 @@ exports[`RnpExample callBUnloaded_Recorder 1`] = ` exports[`RnpExample callDTwice 1`] = ` { - "d1": "real D_usedTwiceInOneCallMethod-0.1646371768088688", - "d2": "real D_usedTwiceInOneCallMethod-0.5056626165727529", + "d1": "real D_usedTwiceInOneCallMethod-67", + "d2": "real D_usedTwiceInOneCallMethod-4", } `; exports[`RnpExample callPrototypeDep 1`] = ` { - "x": "real prototypeMethod-0.3625906666354404", + "x": "real prototypeMethod-67", } `; diff --git a/test/rnpExample/api/rnpExample.ts b/test/rnpExample/api/rnpExample.ts index 2a89de384a..9cb5b60e27 100644 --- a/test/rnpExample/api/rnpExample.ts +++ b/test/rnpExample/api/rnpExample.ts @@ -1,26 +1,28 @@ import { BigNumber } from 'ethers'; +const randomInt = () => Math.floor(Math.random() * 100); + export class Dependency1 { - A_basicMethod = async () => `real A_basicMethod-${Math.random()}`; + A_basicMethod = async () => `real A_basicMethod-${randomInt()}`; - B_superJsonMethod = async () => - BigNumber.from(Math.floor(Math.random() * 1000000)); + B_superJsonMethod = async () => BigNumber.from(randomInt()); - C_passthroughMethod = async () => `real C_passthroughMethod-${Math.random()}`; + C_passthroughMethod = async () => `real C_passthroughMethod-${randomInt()}`; - D_usedTwiceInOneCallMethod = async () => - `real D_usedTwiceInOneCallMethod-${Math.random()}`; + D_usedTwiceInOneCallMethod = async () => `real D_usedTwiceInOneCallMethod-${randomInt()}`; - unmappedMethod = async () => `real unmappedMethod-${Math.random()}`; + unmappedMethod = async () => `real unmappedMethod-${randomInt()}`; } export class UnlistedDependency { - unlistedMethod = async () => `real unlistedMethod-${Math.random()}`; + unlistedMethod = async () => `real unlistedMethod-${randomInt()}`; } export class LocallyInitializedDependency { + // Note the lambda function syntax will NOT work for prototype mocking as JS replicates the method for each instance + // If you need to mock a lambda function you can't define then create a mock instance async prototypeMethod() { - return `real prototypeMethod-${Math.random()}`; + return `real prototypeMethod-${randomInt()}`; } } diff --git a/test/rnpExample/mocks/rnpExample-callABC_A.json b/test/rnpExample/mocks/rnpExample-callABC_A.json index 2671874c0c..55d3ad9d79 100644 --- a/test/rnpExample/mocks/rnpExample-callABC_A.json +++ b/test/rnpExample/mocks/rnpExample-callABC_A.json @@ -1,3 +1,3 @@ { - "json": "real A_basicMethod-0.613904698364715" + "json": "real A_basicMethod-48" } diff --git a/test/rnpExample/mocks/rnpExample-callABC_B.json b/test/rnpExample/mocks/rnpExample-callABC_B.json index cfb96b638b..98155d038b 100644 --- a/test/rnpExample/mocks/rnpExample-callABC_B.json +++ b/test/rnpExample/mocks/rnpExample-callABC_B.json @@ -1,5 +1,5 @@ { - "json": "0x0524d7", + "json": "54", "meta": { "values": [["custom", "ethers.BigNumber"]] } diff --git a/test/rnpExample/mocks/rnpExample-callB_superJsonMethod.json b/test/rnpExample/mocks/rnpExample-callB_superJsonMethod.json index 51a0329ef0..668e2d774b 100644 --- a/test/rnpExample/mocks/rnpExample-callB_superJsonMethod.json +++ b/test/rnpExample/mocks/rnpExample-callB_superJsonMethod.json @@ -1,5 +1,5 @@ { - "json": "0x0c9604", + "json": "56", "meta": { "values": [["custom", "ethers.BigNumber"]] } diff --git a/test/rnpExample/mocks/rnpExample-callDTwice_1.json b/test/rnpExample/mocks/rnpExample-callDTwice_1.json index b153d5e2bc..ba8947c229 100644 --- a/test/rnpExample/mocks/rnpExample-callDTwice_1.json +++ b/test/rnpExample/mocks/rnpExample-callDTwice_1.json @@ -1,3 +1,3 @@ { - "json": "real D_usedTwiceInOneCallMethod-0.1646371768088688" + "json": "real D_usedTwiceInOneCallMethod-67" } diff --git a/test/rnpExample/mocks/rnpExample-callDTwice_2.json b/test/rnpExample/mocks/rnpExample-callDTwice_2.json index 8965d08832..0285f60980 100644 --- a/test/rnpExample/mocks/rnpExample-callDTwice_2.json +++ b/test/rnpExample/mocks/rnpExample-callDTwice_2.json @@ -1,3 +1,3 @@ { - "json": "real D_usedTwiceInOneCallMethod-0.5056626165727529" + "json": "real D_usedTwiceInOneCallMethod-4" } diff --git a/test/rnpExample/mocks/rnpExample-callPrototypeDep.json b/test/rnpExample/mocks/rnpExample-callPrototypeDep.json index fe0be717de..0b6a7da4ee 100644 --- a/test/rnpExample/mocks/rnpExample-callPrototypeDep.json +++ b/test/rnpExample/mocks/rnpExample-callPrototypeDep.json @@ -1,3 +1,3 @@ { - "json": "real prototypeMethod-0.3625906666354404" + "json": "real prototypeMethod-67" } diff --git a/test/superjson-setup.ts b/test/superjson-setup.ts index 79a767e608..c0e4a4da0c 100644 --- a/test/superjson-setup.ts +++ b/test/superjson-setup.ts @@ -4,7 +4,7 @@ import superjson from 'superjson'; superjson.registerCustom( { isApplicable: (v): v is BigNumber => BigNumber.isBigNumber(v), - serialize: (v) => v.toHexString(), + serialize: (v) => v.toString(), deserialize: (v) => BigNumber.from(v), }, 'ethers.BigNumber', From 75c1ef7b3a90dbae4706a57474a05f34ac075b29 Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Fri, 18 Jul 2025 14:56:47 -0600 Subject: [PATCH 41/45] minor formatting. --- test/rnpExample/rnpExample.api-test-cases.ts | 2 +- test/rnpExample/rnpExample.test.ts | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/test/rnpExample/rnpExample.api-test-cases.ts b/test/rnpExample/rnpExample.api-test-cases.ts index 48392ced87..11d686f96b 100644 --- a/test/rnpExample/rnpExample.api-test-cases.ts +++ b/test/rnpExample/rnpExample.api-test-cases.ts @@ -137,7 +137,7 @@ export const callUnmappedMethod_Recorder = new TestCase({ * But because `dep1` * is a "managed" object in the harness and `unmappedMethod` * is not explicitly mocked or set to `allowPassThrough`, this test gets a error return object. * This demonstrates the key safety feature of the RnP framework - * that prevents calls to unmapped methods. . + * that prevents calls to unmapped methods. */ export const callUnmappedMethod_Mocked = new TestCase({ method: 'GET', diff --git a/test/rnpExample/rnpExample.test.ts b/test/rnpExample/rnpExample.test.ts index d00e376bf6..93d90c6e30 100644 --- a/test/rnpExample/rnpExample.test.ts +++ b/test/rnpExample/rnpExample.test.ts @@ -40,10 +40,7 @@ describe('RnpExample', () => { it('callBUnloaded_Mocked', callBUnloaded_Mocked.createPlayTest(harness)); - it( - 'callUnmappedMethod_Mocked', - callUnmappedMethod_Mocked.createPlayTest(harness), - ); + it('callUnmappedMethod_Mocked', callUnmappedMethod_Mocked.createPlayTest(harness)); it('callBUnloaded_Recorder', async () => { // Snapshots must match the recorded output and this call fails in "play" mode From 97bc1e6f8793009aacf8f20eb128ccde67d5e36f Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Thu, 24 Jul 2025 18:41:32 -0600 Subject: [PATCH 42/45] separate "Play" tests from the existing test suite. leave shared record-and-play files under test/ . Prevent snapshot updates during "Play" tests. --- .../rules/record-and-play-testing-guide.mdc | 84 ++++++++++--------- CURSOR_VSCODE_SETUP.md | 18 ++-- jest.config.js | 8 +- package.json | 4 +- test-play/jest.config.js | 13 +++ .../rnpExample.play.test.ts.snap | 0 .../mocks/rnpExample-callABC_A.json | 0 .../mocks/rnpExample-callABC_B.json | 0 .../rnpExample-callB_superJsonMethod.json | 0 .../mocks/rnpExample-callDTwice_1.json | 0 .../mocks/rnpExample-callDTwice_2.json | 0 .../mocks/rnpExample-callPrototypeDep.json | 0 .../rnpExample/rnpExample.play.test.ts | 8 +- test-record/jest.config.js | 10 +++ .../rnpExample/rnpExample.record.test.ts | 0 .../snapshot-resolver.js | 22 ++--- test-scripts/jest.config.js | 11 --- .../abstract-gateway-test-harness.ts | 73 +++++++--------- test/rnpExample/rnpExample.api-test-cases.ts | 14 ++-- tsconfig.build.json | 2 +- tsconfig.json | 2 +- 21 files changed, 140 insertions(+), 129 deletions(-) create mode 100644 test-play/jest.config.js rename test/rnpExample/__snapshots__/rnpExample.test.ts.snap => test-play/rnpExample/__snapshots__/rnpExample.play.test.ts.snap (100%) rename {test => test-play}/rnpExample/mocks/rnpExample-callABC_A.json (100%) rename {test => test-play}/rnpExample/mocks/rnpExample-callABC_B.json (100%) rename {test => test-play}/rnpExample/mocks/rnpExample-callB_superJsonMethod.json (100%) rename {test => test-play}/rnpExample/mocks/rnpExample-callDTwice_1.json (100%) rename {test => test-play}/rnpExample/mocks/rnpExample-callDTwice_2.json (100%) rename {test => test-play}/rnpExample/mocks/rnpExample-callPrototypeDep.json (100%) rename test/rnpExample/rnpExample.test.ts => test-play/rnpExample/rnpExample.play.test.ts (82%) create mode 100644 test-record/jest.config.js rename test-scripts/rnpExample/rnpExample.recorder.test.ts => test-record/rnpExample/rnpExample.record.test.ts (100%) rename {test-scripts => test-record}/snapshot-resolver.js (53%) delete mode 100644 test-scripts/jest.config.js diff --git a/.cursor/rules/record-and-play-testing-guide.mdc b/.cursor/rules/record-and-play-testing-guide.mdc index fc92f44146..29cb9dd1fd 100644 --- a/.cursor/rules/record-and-play-testing-guide.mdc +++ b/.cursor/rules/record-and-play-testing-guide.mdc @@ -1,6 +1,6 @@ --- -+description: Comprehensive guide for creating tests using the Record and Play (RnP) framework -+globs: ["test-scripts/**/*.recorder.test.ts", "test/**/*.test-harness.ts", "test/**/*.api-test-cases.ts"] +description: Comprehensive guide for creating tests using the Record and Play (RnP) framework +globs: ["test-record/**/*.record.test.ts", "test-play/**/*.play.test.ts", "test/**/*.test-harness.ts", "test/**/*.api-test-cases.ts"] alwaysApply: false --- This guide provides the complete instructions for creating tests using the project's "Record and Play" (RnP) framework. It is intended to be used by LLM agents to autonomously create, run, and maintain tests for new and existing features. Adherence to these rules is not optional; it is critical for the stability and predictability of the testing suite. @@ -11,7 +11,7 @@ This framework applies to any major feature area, including `chains` (e.g., `sol The RnP framework is designed to create high-fidelity tests that are both robust and easy to maintain. The core philosophy is: * **Record against reality:** Tests are initially run against live, real-world services to record their actual responses. This captures the true behavior of our dependencies. -* **Play in isolation:** Once recorded, tests are run in a "play" mode that uses the saved recordings (mocks) instead of making live network calls. This makes the tests fast, deterministic, and isolated from external failures. +* **"Play" in isolation:** Once recorded, tests are run in a "Play" mode that uses the saved recordings (mocks) instead of making live network calls. This makes the tests fast, deterministic, and isolated from external failures. * **Confidence:** This approach ensures that our application logic is tested against realistic data, which gives us high confidence that it will work correctly in production. It prevents "mock drift," where mocks no longer reflect the reality of the API they are simulating. ## 2. Core Components @@ -27,18 +27,18 @@ The RnP framework consists of four key types of files that work together for a g ### `*.api-test-cases.ts` - The Single Source of Truth * **Location:** `test/{chains|connectors}/{feature-name}/{feature-name}.api-test-cases.ts` * **Example:** `test/rnpExample/rnpExample.api-test-cases.ts` -* **Purpose:** This file defines the specific API requests that will be used for both recording and testing. By defining test cases in one place (e.g., `export const callABC = new TestCase(...)`), we prevent code duplication and ensure the recorder and the unit test are perfectly aligned. - -### `*.recorder.test.ts` - The Recorder -* **Location:** `test-scripts/{chains|connectors}/{feature-name}/{feature-name}.recorder.test.ts` -* **Example:** `test-scripts/rnpExample/rnpExample.recorder.test.ts` -* **Purpose:** The sole responsibility of this test suite is to generate mock files. It imports a `TestCase` and uses the `createRecordTest(harness)` method to generate a one-line `it` block that executes the test against live services. -* **Execution:** Recorder tests are slow, make real network calls, and are run individually. - -### `*.test.ts` - The Unit Test -* **Location:** `test/{chains|connectors}/{feature-name}/{feature-name}.test.ts` -* **Example:** `test/rnpExample/rnpExample.test.ts` -* **Purpose:** This is the fast, isolated unit test suite. It imports a `TestCase` and uses the `createPlayTest(harness)` method to generate a one-line `it` block that validates application logic against generated mocks. +* **Purpose:** This file defines the specific API requests that will be used for both recording and testing. By defining test cases in one place (e.g., `export const callABC = new TestCase(...)`), we prevent code duplication and ensure the "Record" and "Play" tests are perfectly aligned. + +### `*.record.test.ts` - The "Record" Tests +* **Location:** `test-record/{chains|connectors}/{feature-name}/` +* **Example:** `test-record/rnpExample/rnpExample.record.test.ts` +* **Purpose:** The sole responsibility of this test suite is to generate mock files. It imports a `TestCase` and uses the `createRecordTest(harness)` method to generate one-line `it` blocks that executes the test against live services. +* **Execution:** "Record" tests are slow, make real network calls, and should be run individually using the `pnpm test-record -t "your exact test name"` command. + +### `*.play.test.ts` - The "Play" Tests +* **Location:** `test-play/{chains|connectors}/{feature-name}/` +* **Example:** `test-play/rnpExample/rnpExample.play.test.ts` +* **Purpose:** This is the fast, isolated unit test suite. It imports a `TestCase` and uses the `createPlayTest(harness)` method to generate one-line `it` blocks that validate application logic against generated mocks. * **Execution:** These tests are fast and run as part of the main `pnpm test` suite. ## 3. Directory and Naming Conventions @@ -47,35 +47,37 @@ The framework relies on a strict set of conventions. These are functional requir ### Directory Structure for Chains * **Source Code:** `src/chains/solana/` -* **Unit Tests & Harness:** `test/chains/solana/` -* **Recorder Tests:** `test-scripts/chains/solana/` +* **Shared Test Artifacts:** `test/chains/solana/` (for harness and API test cases) +* **"Play" Tests:** `test-play/chains/solana/` +* **"Record" Tests:** `test-record/chains/solana/` ### Directory Structure for Connectors * **Source Code:** `src/connectors/raydium/` -* **Unit Tests & Harness:** `test/connectors/raydium/` -* **Recorder Tests:** `test-scripts/connectors/raydium/` +* **Shared Test Artifacts:** `test/connectors/raydium/` (for harness and API test cases) +* **"Play" Tests:** `test-play/connectors/raydium/` +* **"Record" Tests:** `test-record/connectors/raydium/` ### Test Naming -The `describe()` and `it()` block names in the recorder and the unit test **MUST MATCH EXACTLY**. The test case's variable name is used for the `it` block's name to ensure consistency. Compare the `it('callABC', ...)` blocks in `rnpExample.recorder.test.ts` and `rnpExample.test.ts` to see this in practice. This naming convention is how Jest associates API response snapshots with the correct test. +The `describe()` and `it()` block names in the "Record" and "Play" tests **MUST MATCH EXACTLY**. The test case's variable name is used for the `it` block's name to ensure consistency. Compare the `it('callABC', ...)` blocks in `rnpExample.record.test.ts` and `rnpExample.play.test.ts` to see this in practice. This naming convention is how Jest associates API response snapshots with the correct test. ### Command Segregation -* **To run fast unit tests:** +* **To run all fast "Play" tests:** ```bash - pnpm test {feature-name}.test.ts + pnpm test-play ``` -* **To run a slow recorder test and generate mocks:** +* **To run a slow "Record" test and generate mocks:** ```bash - pnpm test:scripts path/to/your/{feature-name}.recorder.test.ts -t "test name" + pnpm test-record -t "your exact test name" ``` ## 4. The Critical Rule of Dependency Management To ensure unit tests are fast and never make accidental live network calls, the framework enforces a strict safety policy. Understanding this is not optional. * When you add even one method from a dependency object (e.g., `Dependency1.A_basicMethod`) to the `dependencyContracts` in `rnpExample.test-harness.ts`, the *entire object* (`dep1`) is now considered "managed." -* **In Recorder Mode:** Managed dependencies behave as expected. The listed methods are spied on, and unlisted methods on the same object (like `Dependency1.unmappedMethod`) call their real implementation. -* **In Play Mode:** This is where the safety rule applies. **Every method** on a managed object must be explicitly mocked. If a method (like `Dependency1.unmappedMethod`) is called without a mock, the test will fail. The `callUnmappedMethodMocked` test case in `rnpExample.api-test-cases.ts` is designed specifically to validate this failure. +* **In "Record" Mode:** Managed dependencies behave as expected. The listed methods are spied on, and unlisted methods on the same object (like `Dependency1.unmappedMethod`) call their real implementation. +* **In "Play" Mode:** This is where the safety rule applies. **Every method** on a managed object must be explicitly mocked. If a method (like `Dependency1.unmappedMethod`) is called without a mock, the test will fail. The `callUnmappedMethodMocked` test case in `rnpExample.api-test-cases.ts` is designed specifically to validate this failure. * **Why?** This strictness forces the agent developer to be fully aware of every interaction with an external service. It makes it impossible for a dependency to add a new network call that would slow down unit tests. -* **Truly Unmanaged Dependencies:** In contrast, `dep2` from `rnpExample` is never mentioned in the `dependencyContracts`. It is "unmanaged," so its methods can be called freely in either mode. The `callUnlistedDep` test case demonstrates this. +* **Truly Unmanaged Dependencies:** In contrast, `dep2` from `rnpExample` is not mentioned in the `dependencyContracts`. It is "unmanaged," so its methods can be called freely in either mode. The `callUnlistedDep` test case demonstrates this. ## 5. Workflow: Adding a New Endpoint Test @@ -86,32 +88,38 @@ Follow this step-by-step process. In these paths, `{feature-type}` is either `ch * Create and export a new `TestCase` instance (e.g., `export const callNewFeature = new TestCase(...)`). **Step 2: Update the Test Harness** -* Open `test/{feature-type}/{feature-name}/{feature-name}.test-harness.ts`. +* Open or create `test/{feature-type}/{feature-name}/{feature-name}.test-harness.ts`. * If your endpoint uses any new dependencies, add them to `dependencyContracts`, as seen in `rnpExample.test-harness.ts`. -**Step 3: Create the Recorder Test** -* Open `test-scripts/{feature-type}/{feature-name}/{feature-name}.recorder.test.ts`. +**Step 3: Create the "Record" Test** +* Open or create `test-record/{feature-type}/{feature-name}/{feature-name}.record.test.ts`. * Add a new one-line `it()` block using the `createRecordTest` helper: ```typescript it('your_test_case_name', yourTestCase.createRecordTest(harness)); ``` -**Step 4: Run the Recorder** -* Execute the recorder test from your terminal to generate mock and snapshot files. +**Step 4: Run the "Record" Test** +* Execute the "Record" test from your terminal to generate mock and snapshot files. ```bash - pnpm test:scripts test-scripts/{feature-type}/{feature-name}/{feature-name}.recorder.test.ts -t "your exact test name" + pnpm test-record {feature-name}.record.test.ts -t "your test case name" ``` -**Step 5: Create the Unit Test** -* Open `test/{feature-type}/{feature-name}/{feature-name}.test.ts`. -* Add a new one-line `it()` block that **exactly matches** the recorder's: +**Step 5: Create the "Play" Test** +* Open or create `test-play/{feature-type}/{feature-name}/{feature-name}.play.test.ts`. +* Add a new one-line `it()` block that **exactly matches** the "Record" test: ```typescript it('your_test_case_name', yourTestCase.createPlayTest(harness)); ``` -**Step 6: Run the Unit Test** +**Step 6: Run the "Play" Test** * Execute the main test suite to verify your logic against the generated mocks. ```bash - pnpm test {feature-name}.test.ts + pnpm test-play {feature-name}.play.test.ts ``` * The test will run, using the mocks you generated. It will pass if the application logic correctly processes the mocked dependency responses to produce the expected final API response. + +**Step 7: Run the whole unit test suite** +* Execute the mocked test suite to verify no breaking changes were introduced. + ```bash + pnpm test + ``` \ No newline at end of file diff --git a/CURSOR_VSCODE_SETUP.md b/CURSOR_VSCODE_SETUP.md index 2a6e4a7ba0..9da5f180d1 100644 --- a/CURSOR_VSCODE_SETUP.md +++ b/CURSOR_VSCODE_SETUP.md @@ -1,6 +1,6 @@ # VS Code Setup Guide -This guide provides the minimal VS Code configuration needed to work with the Gateway project's dual test suite setup (main tests + test scripts) and debug the server. +This guide provides the minimal VS Code configuration needed to work with the Gateway project's dual test suite setup (main tests + RecordAndPlay) and debug the server. ## Jest extension for test discovery and debugging @@ -14,20 +14,20 @@ Configure Jest virtual folders for multiple test suites { "jest.virtualFolders": [ { - "name": "unit-tests", - "jestCommandLine": "pnpm test", + "name": "test-play", + "jestCommandLine": "pnpm test-play", "runMode": "watch" }, { - "name": "test-scripts", - "jestCommandLine": "pnpm test:scripts", + "name": "test-record", + "jestCommandLine": "pnpm test-record", "runMode": "on-demand" } ] } ``` -## Debugging the server and unit-tests +## Debugging the server and test-play ### launch.json (`.vscode/launch.json`) @@ -56,7 +56,7 @@ Launch configuration for debugging the Gateway server and unit tests. "preLaunchTask": "build" }, { - "name": "vscode-jest-tests.v2.unit-tests", + "name": "vscode-jest-tests.v2.test-play", "type": "node", "request": "launch", "program": "${workspaceFolder}/node_modules/jest/bin/jest.js", @@ -80,7 +80,7 @@ Launch configuration for debugging the Gateway server and unit tests. ], }, { - "name": "vscode-jest-tests.v2.test-scripts", + "name": "vscode-jest-tests.v2.test-record", "type": "node", "request": "launch", "program": "${workspaceFolder}/node_modules/jest/bin/jest.js", @@ -90,7 +90,7 @@ Launch configuration for debugging the Gateway server and unit tests. }, "args": [ "--config", - "${workspaceFolder}/test-scripts/jest.config.js", + "${workspaceFolder}/test-record/jest.config.js", "--testNamePattern", "${jest.testNamePattern}", "--runTestsByPath", diff --git a/jest.config.js b/jest.config.js index 0d3084cbd9..ab996b46ed 100644 --- a/jest.config.js +++ b/jest.config.js @@ -27,10 +27,14 @@ module.exports = { ], testPathIgnorePatterns: [ '/node_modules/', + '/dist/', 'test-helpers', - '/test-scripts/', ], - testMatch: ['/test/**/*.test.ts', '/test/**/*.test.js'], + testMatch: ['/test/**/*.test.ts', + '/test/**/*.test.js', + // NOTE: DOES include play tests, does NOT include record tests + '/test-play/**/*.test.ts', + ], transform: { '^.+\\.tsx?$': 'ts-jest', }, diff --git a/package.json b/package.json index e07104e494..904c36ad0b 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,9 @@ "test": "GATEWAY_TEST_MODE=test jest --verbose", "test:clear-cache": "jest --clearCache", "test:debug": "GATEWAY_TEST_MODE=test jest --watch --runInBand", - "test:unit": "GATEWAY_TEST_MODE=test jest --runInBand ./test/", "test:cov": "GATEWAY_TEST_MODE=test jest --runInBand --coverage ./test/", - "test:scripts": "GATEWAY_TEST_MODE=test jest --config=test-scripts/jest.config.js --runInBand -u", + "test-record": "GATEWAY_TEST_MODE=test jest --config=test-record/jest.config.js --runInBand -u", + "test-play": "GATEWAY_TEST_MODE=test jest --config=test-play/jest.config.js --runInBand", "cli": "node dist/index.js", "typecheck": "tsc --noEmit", "generate:openapi": "curl http://localhost:15888/docs/json -o openapi.json && echo 'OpenAPI spec saved to openapi.json'", diff --git a/test-play/jest.config.js b/test-play/jest.config.js new file mode 100644 index 0000000000..a4ffc4c11a --- /dev/null +++ b/test-play/jest.config.js @@ -0,0 +1,13 @@ +const sharedConfig = require('../jest.config.js'); +// Placed in this directory to allow jest runner to discover this config file + +if (process.argv.includes('--updateSnapshot') || process.argv.includes('-u')) { + throw new Error("The '--updateSnapshot' flag is not allowed during \"Play\" mode tests. Use 'test-record' to update snapshots."); +} + +module.exports = { + ...sharedConfig, + rootDir: '..', + displayName: 'test-play', + testMatch: ['/test-play/**/*.test.ts'], +}; \ No newline at end of file diff --git a/test/rnpExample/__snapshots__/rnpExample.test.ts.snap b/test-play/rnpExample/__snapshots__/rnpExample.play.test.ts.snap similarity index 100% rename from test/rnpExample/__snapshots__/rnpExample.test.ts.snap rename to test-play/rnpExample/__snapshots__/rnpExample.play.test.ts.snap diff --git a/test/rnpExample/mocks/rnpExample-callABC_A.json b/test-play/rnpExample/mocks/rnpExample-callABC_A.json similarity index 100% rename from test/rnpExample/mocks/rnpExample-callABC_A.json rename to test-play/rnpExample/mocks/rnpExample-callABC_A.json diff --git a/test/rnpExample/mocks/rnpExample-callABC_B.json b/test-play/rnpExample/mocks/rnpExample-callABC_B.json similarity index 100% rename from test/rnpExample/mocks/rnpExample-callABC_B.json rename to test-play/rnpExample/mocks/rnpExample-callABC_B.json diff --git a/test/rnpExample/mocks/rnpExample-callB_superJsonMethod.json b/test-play/rnpExample/mocks/rnpExample-callB_superJsonMethod.json similarity index 100% rename from test/rnpExample/mocks/rnpExample-callB_superJsonMethod.json rename to test-play/rnpExample/mocks/rnpExample-callB_superJsonMethod.json diff --git a/test/rnpExample/mocks/rnpExample-callDTwice_1.json b/test-play/rnpExample/mocks/rnpExample-callDTwice_1.json similarity index 100% rename from test/rnpExample/mocks/rnpExample-callDTwice_1.json rename to test-play/rnpExample/mocks/rnpExample-callDTwice_1.json diff --git a/test/rnpExample/mocks/rnpExample-callDTwice_2.json b/test-play/rnpExample/mocks/rnpExample-callDTwice_2.json similarity index 100% rename from test/rnpExample/mocks/rnpExample-callDTwice_2.json rename to test-play/rnpExample/mocks/rnpExample-callDTwice_2.json diff --git a/test/rnpExample/mocks/rnpExample-callPrototypeDep.json b/test-play/rnpExample/mocks/rnpExample-callPrototypeDep.json similarity index 100% rename from test/rnpExample/mocks/rnpExample-callPrototypeDep.json rename to test-play/rnpExample/mocks/rnpExample-callPrototypeDep.json diff --git a/test/rnpExample/rnpExample.test.ts b/test-play/rnpExample/rnpExample.play.test.ts similarity index 82% rename from test/rnpExample/rnpExample.test.ts rename to test-play/rnpExample/rnpExample.play.test.ts index 93d90c6e30..e667aebdcc 100644 --- a/test/rnpExample/rnpExample.test.ts +++ b/test-play/rnpExample/rnpExample.play.test.ts @@ -8,8 +8,8 @@ import { callUnlistedDep, callUnmappedMethod_Mocked, callUnmappedMethod_Recorder, -} from './rnpExample.api-test-cases'; -import { RnpExampleTestHarness } from './rnpExample.test-harness'; +} from '#test/rnpExample/rnpExample.api-test-cases'; +import { RnpExampleTestHarness } from '#test/rnpExample/rnpExample.test-harness'; describe('RnpExample', () => { let _harness: RnpExampleTestHarness; @@ -44,13 +44,13 @@ describe('RnpExample', () => { it('callBUnloaded_Recorder', async () => { // Snapshots must match the recorded output and this call fails in "play" mode - // so we force a predictable result by just using the recorder test's propertyMatchers. + // so we force a predictable result by just using the "Record" test's propertyMatchers. expect(callBUnloaded_Recorder.propertyMatchers).toMatchSnapshot(); }); it('callUnmappedMethod_Recorder', async () => { // Snapshots must match the recorded output and this call fails in "play" mode - // so we force a predictable result by just using the recorder test's propertyMatchers. + // so we force a predictable result by just using the "Record" test's propertyMatchers. expect(callUnmappedMethod_Recorder.propertyMatchers).toMatchSnapshot(); }); }); diff --git a/test-record/jest.config.js b/test-record/jest.config.js new file mode 100644 index 0000000000..5f10fa44cd --- /dev/null +++ b/test-record/jest.config.js @@ -0,0 +1,10 @@ +const sharedConfig = require('../jest.config.js'); +// Placed in this directory to allow jest runner to discover this config file + +module.exports = { + ...sharedConfig, + rootDir: '..', + displayName: 'test-record', + testMatch: ['/test-record/**/*.test.ts'], + snapshotResolver: '/test-record/snapshot-resolver.js', +}; \ No newline at end of file diff --git a/test-scripts/rnpExample/rnpExample.recorder.test.ts b/test-record/rnpExample/rnpExample.record.test.ts similarity index 100% rename from test-scripts/rnpExample/rnpExample.recorder.test.ts rename to test-record/rnpExample/rnpExample.record.test.ts diff --git a/test-scripts/snapshot-resolver.js b/test-record/snapshot-resolver.js similarity index 53% rename from test-scripts/snapshot-resolver.js rename to test-record/snapshot-resolver.js index 6b283b1b48..85a3fbf0d4 100644 --- a/test-scripts/snapshot-resolver.js +++ b/test-record/snapshot-resolver.js @@ -3,36 +3,36 @@ const path = require('path'); module.exports = { // resolves from test to snapshot path resolveSnapshotPath: (testPath, snapshotExtension) => { - // This resolver makes a snapshot path from a recorder test path to a - // corresponding unit test snapshot path. - // e.g., test-scripts/rnpExample/rnpExample.recorder.test.ts + // This resolver makes a snapshot path from a "Record" test path to a + // corresponding "Play" test snapshot path. + // e.g., test-record/rnpExample/rnpExample.record.test.ts // -> test/rnpExample/__snapshots__/rnpExample.test.ts.snap return path.join( - path.dirname(testPath).replace('test-scripts', 'test'), + path.dirname(testPath).replace('test-record', 'test-play'), '__snapshots__', - path.basename(testPath).replace('.recorder', '') + snapshotExtension, + path.basename(testPath).replace('.record', '.play') + snapshotExtension, ); }, - // resolves from snapshot to test path + // resolves from snapshot to test path resolveTestPath: (snapshotFilePath, snapshotExtension) => { - // This resolver finds the recorder test path from a unit test snapshot path. + // This resolver finds the "Record" test path from a "Play" test snapshot path. // e.g., test/rnpExample/__snapshots__/rnpExample.test.ts.snap - // -> test-scripts/rnpExample/rnpExample.recorder.test.ts + // -> test-record/rnpExample/rnpExample.record.test.ts const testPath = path .dirname(snapshotFilePath) .replace('__snapshots__', '') - .replace('test', 'test-scripts'); + .replace('test-play', 'test-record'); return path.join( testPath, path .basename(snapshotFilePath, snapshotExtension) - .replace('.test.ts', '.recorder.test.ts'), + .replace('.play.test.ts', '.record.test.ts'), ); }, // Example test path, used for preflight consistency check of the implementation above testPathForConsistencyCheck: - 'test-scripts/rnpExample/rnpExample.recorder.test.ts', + 'test-record/rnpExample/rnpExample.record.test.ts', }; diff --git a/test-scripts/jest.config.js b/test-scripts/jest.config.js deleted file mode 100644 index 2d0fc64b23..0000000000 --- a/test-scripts/jest.config.js +++ /dev/null @@ -1,11 +0,0 @@ -const sharedConfig = require('../jest.config.js'); -// Placed in this directory to allow jest runner to discover this config file - -module.exports = { - ...sharedConfig, - rootDir: '..', - displayName: 'test-scripts', - testMatch: ['/test-scripts/**/*.test.ts'], - testPathIgnorePatterns: ['/node_modules/', '/test/'], - snapshotResolver: '/test-scripts/snapshot-resolver.js', -}; \ No newline at end of file diff --git a/test/record-and-play/abstract-gateway-test-harness.ts b/test/record-and-play/abstract-gateway-test-harness.ts index 8593433b29..114a72b579 100644 --- a/test/record-and-play/abstract-gateway-test-harness.ts +++ b/test/record-and-play/abstract-gateway-test-harness.ts @@ -4,14 +4,9 @@ import * as path from 'path'; import { FastifyInstance } from 'fastify'; import superjson from 'superjson'; -import { - DependencyFactory, - MockProvider, - TestDependencyContract, -} from './test-dependency-contract'; +import { DependencyFactory, MockProvider, TestDependencyContract } from './test-dependency-contract'; -interface ContractWithSpy - extends TestDependencyContract { +interface ContractWithSpy extends TestDependencyContract { spy?: jest.SpyInstance; } @@ -25,9 +20,7 @@ interface ContractWithSpy * * @template TInstance The type of the application class being tested. */ -export abstract class AbstractGatewayTestHarness - implements MockProvider -{ +export abstract class AbstractGatewayTestHarness implements MockProvider { protected _gatewayApp!: FastifyInstance; protected _mockDir: string; protected _instance!: TInstance; @@ -38,16 +31,27 @@ export abstract class AbstractGatewayTestHarness * This is the core of the RnP framework. Each key is a human-readable alias * for a dependency, and the value is a contract defining how to spy on or mock it. */ - abstract readonly dependencyContracts: Record< - string, - ContractWithSpy - >; + abstract readonly dependencyContracts: Record>; /** - * @param mockDir The directory where mock files are stored, typically `__dirname`. + * @param harnessDir The directory of the concrete harness class, typically `__dirname`. + * This is used to calculate the path to the mock directory, assuming a parallel + * structure between a `test` directory and a `test-play` directory. */ - constructor(mockDir: string) { - this._mockDir = mockDir; + constructor(harnessDir: string) { + const parts = harnessDir.split(path.sep); + const testIndex = parts.lastIndexOf('test'); + + if (testIndex === -1) { + throw new Error( + `Failed to resolve mock directory from harness path. ` + + `Ensure the harness is inside a '/test/' directory. ` + + `Path provided: ${harnessDir}`, + ); + } + + parts[testIndex] = 'test-play'; + this._mockDir = parts.join(path.sep); } /** @@ -58,9 +62,7 @@ export abstract class AbstractGatewayTestHarness protected loadMock(fileName: string): TMock { const filePath = path.join(this._mockDir, 'mocks', `${fileName}.json`); if (fs.existsSync(filePath)) { - return superjson.deserialize( - JSON.parse(fs.readFileSync(filePath, 'utf8')), - ); + return superjson.deserialize(JSON.parse(fs.readFileSync(filePath, 'utf8'))); } throw new Error(`Mock file not found: ${filePath}`); } @@ -76,10 +78,7 @@ export abstract class AbstractGatewayTestHarness fs.mkdirSync(mockDir, { recursive: true }); } const serialized = superjson.serialize(data); - fs.writeFileSync( - path.join(mockDir, `${fileName}.json`), - JSON.stringify(serialized, null, 2), - ); + fs.writeFileSync(path.join(mockDir, `${fileName}.json`), JSON.stringify(serialized, null, 2)); } /** @@ -97,8 +96,7 @@ export abstract class AbstractGatewayTestHarness /** The initialized instance of the service being tested. */ get instance(): TInstance { - if (!this._instance) - throw new Error('Instance not initialized. Call setup first.'); + if (!this._instance) throw new Error('Instance not initialized. Call setup first.'); return this._instance; } @@ -144,9 +142,7 @@ export abstract class AbstractGatewayTestHarness * @param requiredMocks A map where keys are dependency contract aliases and * values are the filenames for the mocks to be saved. */ - public async saveMocks( - requiredMocks: Record, - ): Promise> { + public async saveMocks(requiredMocks: Record): Promise> { const errors: Record = {}; for (const [key, filenames] of Object.entries(requiredMocks)) { const dep = this.dependencyContracts[key]; @@ -219,9 +215,7 @@ export abstract class AbstractGatewayTestHarness // If it's not allowed to pass through, set a default error for when // a test case forgets to load a specific mock for it. if (contract.spy && !contract.allowPassThrough) { - const depKey = Object.keys(this.dependencyContracts).find( - (k) => this.dependencyContracts[k] === contract, - ); + const depKey = Object.keys(this.dependencyContracts).find((k) => this.dependencyContracts[k] === contract); contract.spy.mockImplementation(() => { throw new Error( `Mocked dependency was called without a mock loaded: ${depKey}. Either load a mock or allowPassThrough.`, @@ -235,11 +229,8 @@ export abstract class AbstractGatewayTestHarness const spy = jest.spyOn(object, methodName as any); spy.mockImplementation(() => { // Find a representative key for this object from the dependency contracts. - const representativeKey = Object.keys( - this.dependencyContracts, - ).find( - (key) => - this.dependencyContracts[key].getObject(this) === object, + const representativeKey = Object.keys(this.dependencyContracts).find( + (key) => this.dependencyContracts[key].getObject(this) === object, ); const depKey = representativeKey || object.constructor.name; @@ -262,13 +253,9 @@ export abstract class AbstractGatewayTestHarness for (const [key, filenames] of Object.entries(requiredMocks)) { const dep = this.dependencyContracts[key]; if (!dep.spy) { - throw new Error( - `Dependency contract with key '${key}' not found in harness.`, - ); + throw new Error(`Dependency contract with key '${key}' not found in harness.`); } - for (const fileName of Array.isArray(filenames) - ? filenames - : [filenames]) { + for (const fileName of Array.isArray(filenames) ? filenames : [filenames]) { dep.setupMock(dep.spy, this, fileName); } } diff --git a/test/rnpExample/rnpExample.api-test-cases.ts b/test/rnpExample/rnpExample.api-test-cases.ts index 11d686f96b..597c9c1e1e 100644 --- a/test/rnpExample/rnpExample.api-test-cases.ts +++ b/test/rnpExample/rnpExample.api-test-cases.ts @@ -73,7 +73,7 @@ export const callPrototypeDep = new TestCase({ * A test case for a method that calls a truly "unmanaged" dependency. * The `callUnlistedDep` endpoint calls `dep2.unlistedMethod`. Because `dep2` is not * mentioned anywhere in the harness's `dependencyContracts`, it is "unmanaged" - * and will always call its real implementation in both Record and Play modes. + * and will always call its real implementation in both "Record" and "Play" modes. */ export const callUnlistedDep = new TestCase({ method: 'GET', @@ -88,9 +88,9 @@ export const callUnlistedDep = new TestCase({ }); /** - * A recorder-only test case for an unloaded dependency. - * This exists to demonstrate that the recorder and "Play" mode test output - * varies in this scenario as the mock test will fail but recorder test will pass. + * A "Record" only test case for an unloaded dependency. + * This exists to demonstrate that the "Record" and "Play" test output + * varies in this scenario as the mock test will fail but "Record" test will pass. */ export const callBUnloaded_Recorder = new TestCase({ method: 'GET', @@ -118,9 +118,9 @@ export const callBUnloaded_Mocked = new TestCase({ }); /** - * A recorder-only test case for a method that calls an unmapped method on a managed dependency. - * This exists to demonstrate that the recorder and "Play" mode test output - * varies in this scenario as the mock test will fail but recorder test will pass. + * A "Record" only test case for a method that calls an unmapped method on a managed dependency. + * This exists to demonstrate that the "Record" and "Play" test output + * varies in this scenario as the mock test will fail but "Record" test will pass. */ export const callUnmappedMethod_Recorder = new TestCase({ method: 'GET', diff --git a/tsconfig.build.json b/tsconfig.build.json index 18049c3487..39617bae81 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -10,5 +10,5 @@ "sourceMap": true, "inlineSourceMap": false }, - "exclude": ["node_modules", "dist", "coverage", "test", "test-scripts"] + "exclude": ["node_modules", "dist", "coverage", "test", "test-record", "test-play"] } diff --git a/tsconfig.json b/tsconfig.json index 90e58d2f49..a4fb34b9c4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,5 +30,5 @@ "skipLibCheck": true }, "exclude": ["node_modules", "dist", "coverage"], - "include": ["src/**/*.ts", "test/**/*.ts", "test-scripts/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts", "test-record/**/*.ts", "test-play/**/*.ts"] } From 2e9040652f12bd371b45b97b46f4d2fc5ce21c1e Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Thu, 11 Sep 2025 12:38:48 -0500 Subject: [PATCH 43/45] rnp spacing changes. --- test/record-and-play/api-test-case.ts | 16 ++------ .../test-dependency-contract.ts | 37 +++++-------------- test/rnpExample/api/routes/callABC.ts | 4 +- test/rnpExample/api/routes/callDTwice.ts | 4 +- .../rnpExample/api/routes/callPrototypeDep.ts | 4 +- .../api/routes/callSuperJsonMethod.ts | 8 +--- test/rnpExample/api/routes/callUnlistedDep.ts | 4 +- 7 files changed, 19 insertions(+), 58 deletions(-) diff --git a/test/record-and-play/api-test-case.ts b/test/record-and-play/api-test-case.ts index 4baef2dc33..79c2848662 100644 --- a/test/record-and-play/api-test-case.ts +++ b/test/record-and-play/api-test-case.ts @@ -21,9 +21,7 @@ interface APITestCaseParams> { * The key must match a key in the harness's `dependencyContracts`. * The value is the name of the mock file (or an array of names for sequential calls). */ - requiredMocks?: Partial< - Record - >; + requiredMocks?: Partial>; /** An object of Jest property matchers (e.g., `{ a: expect.any(String) }`) * to allow for non-deterministic values in snapshot testing. */ propertyMatchers?: Record; @@ -39,17 +37,13 @@ interface APITestCaseParams> { * * @template Harness The type of the test harness this case will be run with. */ -export class APITestCase> - implements InjectOptions -{ +export class APITestCase> implements InjectOptions { method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | 'HEAD'; url: string; expectedStatus: number; query: Record; payload: Record; - requiredMocks: Partial< - Record - >; + requiredMocks: Partial>; propertyMatchers?: Partial; private params: APITestCaseParams; @@ -80,9 +74,7 @@ export class APITestCase> this.assertStatusCode(response); const errorEntries = Object.entries(saveMockErrors); if (errorEntries.length > 0) { - const errorMessages = errorEntries - .map(([key, error]) => `${key}: ${error.message}`) - .join('\n'); + const errorMessages = errorEntries.map(([key, error]) => `${key}: ${error.message}`).join('\n'); throw new Error(`Failed to save mocks:\n${errorMessages}`); } const body = JSON.parse(response.body); diff --git a/test/record-and-play/test-dependency-contract.ts b/test/record-and-play/test-dependency-contract.ts index e036874be5..92c4ac69d6 100644 --- a/test/record-and-play/test-dependency-contract.ts +++ b/test/record-and-play/test-dependency-contract.ts @@ -23,12 +23,7 @@ export abstract class TestDependencyContract { * Attaches a mock implementation to the spy. * Can be called multiple times to mock subsequent calls. */ - setupMock( - spy: jest.SpyInstance, - provider: MockProvider, - fileName: string, - isAsync = true, - ): void { + setupMock(spy: jest.SpyInstance, provider: MockProvider, fileName: string, isAsync = true): void { const mock = provider.getMock(fileName); if (isAsync) { spy.mockResolvedValueOnce(mock); @@ -44,11 +39,11 @@ export abstract class TestDependencyContract { * dependency that is a property of the main service instance being tested. * It is required for methods defined with arrow functions. */ -export class InstancePropertyDependency< +export class InstancePropertyDependency extends TestDependencyContract< TInstance, TObject, - TMock, -> extends TestDependencyContract { + TMock +> { constructor( private getObjectFn: (provider: MockProvider) => TObject, public readonly methodName: keyof TObject, @@ -75,11 +70,7 @@ export class InstancePropertyDependency< * IMPORTANT: This will NOT work for methods defined with arrow functions, as they * do not exist on the prototype. */ -export class PrototypeDependency< - TInstance, - TObject, - TMock, -> extends TestDependencyContract { +export class PrototypeDependency extends TestDependencyContract { constructor( private ClassConstructor: { new (...args: any[]): TObject }, public readonly methodName: keyof TObject, @@ -102,9 +93,7 @@ export class PrototypeDependency< * This should be used within a concrete TestHarness class. */ export class DependencyFactory { - private _extractMethodName any>( - selector: (obj: T) => TMethod, - ): keyof T { + private _extractMethodName any>(selector: (obj: T) => TMethod): keyof T { // This is a hack: create a Proxy to intercept the property access let prop: string | symbol | undefined; const proxy = new Proxy( @@ -130,10 +119,7 @@ export class DependencyFactory { * @param methodSelector A lambda function that selects the method on the dependency object (e.g., `x => x.myMethod`). * @param allowPassThrough If true, the real method is called during "Play" mode. */ - instanceProperty< - K extends keyof TInstance, - TMethod extends (...args: any[]) => any = any, - >( + instanceProperty any = any>( instancePropertyName: K, methodSelector: (dep: TInstance[K]) => TMethod, allowPassThrough = false, @@ -143,8 +129,7 @@ export class DependencyFactory { type TMock = Awaited>; return new InstancePropertyDependency( - (p: MockProvider): TInstance[K] => - p.instance[instancePropertyName], + (p: MockProvider): TInstance[K] => p.instance[instancePropertyName], methodName, allowPassThrough, ); @@ -164,10 +149,6 @@ export class DependencyFactory { ) { const methodName = this._extractMethodName(methodSelector); type TMock = Awaited>; - return new PrototypeDependency( - ClassConstructor, - methodName, - allowPassThrough, - ); + return new PrototypeDependency(ClassConstructor, methodName, allowPassThrough); } } diff --git a/test/rnpExample/api/routes/callABC.ts b/test/rnpExample/api/routes/callABC.ts index 91518a5ebb..814444d8de 100644 --- a/test/rnpExample/api/routes/callABC.ts +++ b/test/rnpExample/api/routes/callABC.ts @@ -21,9 +21,7 @@ export const callABCRoute: FastifyPluginAsync = async (fastify) => { return await rnpExample.callABC(); } catch (error) { logger.error(`Error getting callABC status: ${error.message}`); - throw fastify.httpErrors.internalServerError( - `Failed to callABC: ${error.message}`, - ); + throw fastify.httpErrors.internalServerError(`Failed to callABC: ${error.message}`); } }, ); diff --git a/test/rnpExample/api/routes/callDTwice.ts b/test/rnpExample/api/routes/callDTwice.ts index 9f0764f476..fe78fcd0b7 100644 --- a/test/rnpExample/api/routes/callDTwice.ts +++ b/test/rnpExample/api/routes/callDTwice.ts @@ -21,9 +21,7 @@ export const callDTwiceRoute: FastifyPluginAsync = async (fastify) => { return await rnpExample.callDTwice(); } catch (error) { logger.error(`Error getting callDTwice status: ${error.message}`); - throw fastify.httpErrors.internalServerError( - `Failed to callDTwice: ${error.message}`, - ); + throw fastify.httpErrors.internalServerError(`Failed to callDTwice: ${error.message}`); } }, ); diff --git a/test/rnpExample/api/routes/callPrototypeDep.ts b/test/rnpExample/api/routes/callPrototypeDep.ts index e36efdfd96..38bd81257b 100644 --- a/test/rnpExample/api/routes/callPrototypeDep.ts +++ b/test/rnpExample/api/routes/callPrototypeDep.ts @@ -21,9 +21,7 @@ export const callPrototypeDepRoute: FastifyPluginAsync = async (fastify) => { return await rnpExample.callPrototypeDep(); } catch (error) { logger.error(`Error getting callPrototypeDep status: ${error.message}`); - throw fastify.httpErrors.internalServerError( - `Failed to callPrototypeDep: ${error.message}`, - ); + throw fastify.httpErrors.internalServerError(`Failed to callPrototypeDep: ${error.message}`); } }, ); diff --git a/test/rnpExample/api/routes/callSuperJsonMethod.ts b/test/rnpExample/api/routes/callSuperJsonMethod.ts index 62ab59213f..6352682413 100644 --- a/test/rnpExample/api/routes/callSuperJsonMethod.ts +++ b/test/rnpExample/api/routes/callSuperJsonMethod.ts @@ -20,12 +20,8 @@ export const callSuperJsonMethodRoute: FastifyPluginAsync = async (fastify) => { const rnpExample = await RnpExample.getInstance(request.query.network); return await rnpExample.callSuperJsonMethod(); } catch (error) { - logger.error( - `Error getting callSuperJsonMethod status: ${error.message}`, - ); - throw fastify.httpErrors.internalServerError( - `Failed to callSuperJsonMethod: ${error.message}`, - ); + logger.error(`Error getting callSuperJsonMethod status: ${error.message}`); + throw fastify.httpErrors.internalServerError(`Failed to callSuperJsonMethod: ${error.message}`); } }, ); diff --git a/test/rnpExample/api/routes/callUnlistedDep.ts b/test/rnpExample/api/routes/callUnlistedDep.ts index 9d2b2b4752..e4f956393d 100644 --- a/test/rnpExample/api/routes/callUnlistedDep.ts +++ b/test/rnpExample/api/routes/callUnlistedDep.ts @@ -21,9 +21,7 @@ export const callUnlistedDepRoute: FastifyPluginAsync = async (fastify) => { return await rnpExample.callUnlistedDep(); } catch (error) { logger.error(`Error getting callUnlistedDep status: ${error.message}`); - throw fastify.httpErrors.internalServerError( - `Failed to callUnlistedDep: ${error.message}`, - ); + throw fastify.httpErrors.internalServerError(`Failed to callUnlistedDep: ${error.message}`); } }, ); From f114828d1a0922deeb4910498f62976d123903ba Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Thu, 11 Sep 2025 15:00:32 -0500 Subject: [PATCH 44/45] fix tests for validateAddress and consolidate validateAddress logic. --- src/wallet/routes/addHardwareWallet.ts | 21 ++++++++++------ src/wallet/routes/removeWallet.ts | 28 ++++++++++++--------- src/wallet/utils.ts | 29 ++++++++++++++-------- test/wallet/hardware-wallet.routes.test.ts | 8 +++++- 4 files changed, 55 insertions(+), 31 deletions(-) diff --git a/src/wallet/routes/addHardwareWallet.ts b/src/wallet/routes/addHardwareWallet.ts index 395079c101..72d0b656cb 100644 --- a/src/wallet/routes/addHardwareWallet.ts +++ b/src/wallet/routes/addHardwareWallet.ts @@ -12,7 +12,13 @@ import { AddHardwareWalletRequestSchema, AddHardwareWalletResponseSchema, } from '../schemas'; -import { validateChainName, getHardwareWallets, saveHardwareWallets, HardwareWalletData } from '../utils'; +import { + validateChainName, + validateAddressByChain, + getHardwareWallets, + saveHardwareWallets, + HardwareWalletData, +} from '../utils'; // Maximum number of account indices to check when searching for an address const MAX_ACCOUNTS_TO_CHECK = 8; @@ -41,12 +47,13 @@ async function addHardwareWallet( let validatedAddress: string; // Validate the provided address based on chain type - if (req.chain.toLowerCase() === 'ethereum') { - validatedAddress = Ethereum.validateAddress(req.address); - } else if (req.chain.toLowerCase() === 'solana') { - validatedAddress = Solana.validateAddress(req.address); - } else { - throw new Error(`Unsupported chain: ${req.chain}`); + try { + validatedAddress = validateAddressByChain(req.chain, req.address); + } catch (error) { + if (error instanceof Error && error.message.includes('Invalid')) { + throw fastify.httpErrors.badRequest(error.message); + } + throw fastify.httpErrors.badRequest(error.message); } // Search for the address on the Ledger device diff --git a/src/wallet/routes/removeWallet.ts b/src/wallet/routes/removeWallet.ts index 64fc4299cd..69f98b43c8 100644 --- a/src/wallet/routes/removeWallet.ts +++ b/src/wallet/routes/removeWallet.ts @@ -1,4 +1,3 @@ -import sensible from '@fastify/sensible'; import { Type } from '@sinclair/typebox'; import { FastifyPluginAsync } from 'fastify'; @@ -11,11 +10,16 @@ import { RemoveWalletRequestSchema, RemoveWalletResponseSchema, } from '../schemas'; -import { removeWallet, validateChainName, isHardwareWallet, getHardwareWallets, saveHardwareWallets } from '../utils'; +import { + removeWallet, + validateChainName, + validateAddressByChain, + isHardwareWallet, + getHardwareWallets, + saveHardwareWallets, +} from '../utils'; export const removeWalletRoute: FastifyPluginAsync = async (fastify) => { - await fastify.register(sensible); - fastify.delete<{ Body: RemoveWalletRequest; Reply: RemoveWalletResponse }>( '/remove', { @@ -38,12 +42,13 @@ export const removeWalletRoute: FastifyPluginAsync = async (fastify) => { // Validate the address based on chain type let validatedAddress: string; - if (chain.toLowerCase() === 'ethereum') { - validatedAddress = Ethereum.validateAddress(address); - } else if (chain.toLowerCase() === 'solana') { - validatedAddress = Solana.validateAddress(address); - } else { - throw new Error(`Unsupported chain: ${chain}`); + try { + validatedAddress = validateAddressByChain(chain, address); + } catch (error) { + if (error instanceof Error && error.message.includes('Invalid')) { + throw fastify.httpErrors.badRequest(error.message); + } + throw fastify.httpErrors.badRequest(error.message); } // Check if it's a hardware wallet @@ -64,8 +69,7 @@ export const removeWalletRoute: FastifyPluginAsync = async (fastify) => { }; } - // Otherwise, it's a regular wallet - logger.info(`Removing wallet: ${validatedAddress} from chain: ${chain}`); + // Otherwise, it's a regular wallet - use the consolidated removeWallet function await removeWallet(fastify, request.body); return { diff --git a/src/wallet/utils.ts b/src/wallet/utils.ts index 63089ded5b..f6763923e7 100644 --- a/src/wallet/utils.ts +++ b/src/wallet/utils.ts @@ -47,6 +47,17 @@ export function validateChainName(chain: string): boolean { } } +// Validate address based on chain type and return the validated address +export function validateAddressByChain(chain: string, address: string): string { + if (chain.toLowerCase() === 'ethereum') { + return Ethereum.validateAddress(address); + } else if (chain.toLowerCase() === 'solana') { + return Solana.validateAddress(address); + } else { + throw new Error(`Unsupported chain: ${chain}`); + } +} + // Get safe path for wallet files, with chain and address validation export function getSafeWalletFilePath(chain: string, address: string): string { // Validate chain name @@ -151,11 +162,9 @@ export async function removeWallet(fastify: FastifyInstance, req: RemoveWalletRe // Validate the address based on chain type let validatedAddress: string; - if (req.chain.toLowerCase() === 'ethereum') { - validatedAddress = Ethereum.validateAddress(req.address); - } else if (req.chain.toLowerCase() === 'solana') { - validatedAddress = Solana.validateAddress(req.address); - } else { + try { + validatedAddress = validateAddressByChain(req.chain, req.address); + } catch (error) { // This should not happen due to validateChainName check, but just in case throw new Error(`Unsupported chain: ${req.chain}`); } @@ -184,11 +193,9 @@ export async function signMessage(fastify: FastifyInstance, req: SignMessageRequ // Validate the address based on chain type let validatedAddress: string; - if (req.chain.toLowerCase() === 'ethereum') { - validatedAddress = Ethereum.validateAddress(req.address); - } else if (req.chain.toLowerCase() === 'solana') { - validatedAddress = Solana.validateAddress(req.address); - } else { + try { + validatedAddress = validateAddressByChain(req.chain, req.address); + } catch (error) { throw new Error(`Unsupported chain: ${req.chain}`); } @@ -246,7 +253,7 @@ export async function getWallets( await mkdirIfDoesNotExist(walletPath); // Get only valid chain directories - const validChains = ['ethereum', 'solana']; + const validChains = getSupportedChains(); const allDirs = await getDirectories(walletPath); const chains = allDirs.filter((dir) => validChains.includes(dir.toLowerCase())); diff --git a/test/wallet/hardware-wallet.routes.test.ts b/test/wallet/hardware-wallet.routes.test.ts index 4480c082ea..f95c20d51a 100644 --- a/test/wallet/hardware-wallet.routes.test.ts +++ b/test/wallet/hardware-wallet.routes.test.ts @@ -10,7 +10,12 @@ import { Ethereum } from '../../src/chains/ethereum/ethereum'; import { Solana } from '../../src/chains/solana/solana'; import { HardwareWalletService } from '../../src/services/hardware-wallet-service'; import { addHardwareWalletRoute } from '../../src/wallet/routes/addHardwareWallet'; -import { getHardwareWallets, saveHardwareWallets, validateChainName } from '../../src/wallet/utils'; +import { + getHardwareWallets, + saveHardwareWallets, + validateChainName, + validateAddressByChain, +} from '../../src/wallet/utils'; describe('Hardware Wallet Routes', () => { let app: FastifyInstance; @@ -33,6 +38,7 @@ describe('Hardware Wallet Routes', () => { (getHardwareWallets as jest.Mock).mockResolvedValue([]); (saveHardwareWallets as jest.Mock).mockResolvedValue(undefined); (validateChainName as jest.Mock).mockReturnValue(true); + (validateAddressByChain as jest.Mock).mockImplementation((_chain, address) => address); // Setup Solana and Ethereum static method mocks (Solana.validateAddress as jest.Mock).mockImplementation((address) => address); From 63df89bce8d4156ddd11311c62b6f6e6bb68f10a Mon Sep 17 00:00:00 2001 From: WuonParticle Date: Mon, 15 Sep 2025 12:14:29 -0500 Subject: [PATCH 45/45] remove refresh-templates in favor of setup:with-defaults --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 904c36ad0b..9b4cccc286 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,6 @@ "setup:with-defaults": "bash ./gateway-setup.sh --with-defaults", "start": "START_SERVER=true node dist/index.js", "copy-files": "copyfiles 'src/templates/namespace/*.json' 'src/templates/*.yml' 'src/templates/chains/**/*.yml' 'src/templates/connectors/*.yml' 'src/templates/tokens/**/*.json' 'src/templates/pools/*.json' 'src/templates/rpc/*.yml' dist", - "refresh-templates": "printf 'n\\ny\\n' | ./gateway-setup.sh", "test": "GATEWAY_TEST_MODE=test jest --verbose", "test:clear-cache": "jest --clearCache", "test:debug": "GATEWAY_TEST_MODE=test jest --watch --runInBand",