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..29cb9dd1fd --- /dev/null +++ b/.cursor/rules/record-and-play-testing-guide.mdc @@ -0,0 +1,125 @@ +--- +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. + +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 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 + +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/` +* **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/` +* **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 "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 all fast "Play" tests:** + ```bash + pnpm test-play + ``` +* **To run a slow "Record" test and generate mocks:** + ```bash + 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 "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 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 + +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 callNewFeature = new TestCase(...)`). + +**Step 2: Update the Test Harness** +* 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 "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 "Record" Test** +* Execute the "Record" test from your terminal to generate mock and snapshot files. + ```bash + pnpm test-record {feature-name}.record.test.ts -t "your test case name" + ``` + +**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 "Play" Test** +* Execute the main test suite to verify your logic against the generated mocks. + ```bash + 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 new file mode 100644 index 0000000000..9da5f180d1 --- /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 + RecordAndPlay) 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": "test-play", + "jestCommandLine": "pnpm test-play", + "runMode": "watch" + }, + { + "name": "test-record", + "jestCommandLine": "pnpm test-record", + "runMode": "on-demand" + } + ] +} +``` + +## Debugging the server and test-play + +### 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.test-play", + "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-record", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/node_modules/jest/bin/jest.js", + "cwd": "${workspaceFolder}", + "env": { + "GATEWAY_TEST_MODE": "test" + }, + "args": [ + "--config", + "${workspaceFolder}/test-record/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" + } + ] +} +``` diff --git a/jest.config.js b/jest.config.js index c95efcdb11..ab996b46ed 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,11 +1,13 @@ const { pathsToModuleNameMapper } = require('ts-jest'); const { compilerOptions } = require('./tsconfig.json'); +process.env.GATEWAY_TEST_MODE = 'test'; module.exports = { preset: 'ts-jest', testEnvironment: 'node', forceExit: true, detectOpenHandles: false, + coveragePathIgnorePatterns: [ 'src/app.ts', 'src/https.ts', @@ -19,13 +21,20 @@ module.exports = { 'test/*', ], modulePathIgnorePatterns: ['/dist/'], - setupFilesAfterEnv: ['/test/jest-setup.js'], + setupFilesAfterEnv: [ + '/test/jest-setup.js', + '/test/superjson-setup.ts', + ], 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 8dc65fa6aa..9b4cccc286 100644 --- a/package.json +++ b/package.json @@ -25,12 +25,13 @@ "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", + "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:cov": "GATEWAY_TEST_MODE=test jest --runInBand --coverage ./test/", + "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'", "rebuild-bigint": "cd node_modules/bigint-buffer && pnpm run rebuild", @@ -93,6 +94,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/src/app.ts b/src/app.ts index 0f6c3c27ae..2d9f33e813 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') ? { @@ -202,6 +208,8 @@ 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' }); @@ -284,11 +292,21 @@ 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/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'); } 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-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-play/rnpExample/__snapshots__/rnpExample.play.test.ts.snap b/test-play/rnpExample/__snapshots__/rnpExample.play.test.ts.snap new file mode 100644 index 0000000000..b208e2286d --- /dev/null +++ b/test-play/rnpExample/__snapshots__/rnpExample.play.test.ts.snap @@ -0,0 +1,62 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RnpExample callABC 1`] = ` +{ + "a": "real A_basicMethod-48", + "b": "54", + "c": Any, +} +`; + +exports[`RnpExample callB_superJsonMethod 1`] = ` +{ + "b": "56", +} +`; + +exports[`RnpExample callBUnloaded_Mocked 1`] = ` +{ + "error": "InternalServerError", + "message": "Failed to callSuperJsonMethod: Mocked dependency was called without a mock loaded: dep1_B. Either load a mock or allowPassThrough.", + "statusCode": 500, +} +`; + +exports[`RnpExample callBUnloaded_Recorder 1`] = ` +{ + "b": Any, +} +`; + +exports[`RnpExample callDTwice 1`] = ` +{ + "d1": "real D_usedTwiceInOneCallMethod-67", + "d2": "real D_usedTwiceInOneCallMethod-4", +} +`; + +exports[`RnpExample callPrototypeDep 1`] = ` +{ + "x": "real prototypeMethod-67", +} +`; + +exports[`RnpExample callUnlistedDep 1`] = ` +{ + "z": Any, +} +`; + +exports[`RnpExample callUnmappedMethod_Mocked 1`] = ` +{ + "error": "NotImplementedError", + "message": "Failed to callUnmappedMethod: Unmapped method was called: dep1_A.unmappedMethod. Method must be listed and either mocked or specify allowPassThrough.", + "statusCode": 501, +} +`; + +exports[`RnpExample callUnmappedMethod_Recorder 1`] = ` +{ + "unmapped": Any, +} +`; diff --git a/test-play/rnpExample/mocks/rnpExample-callABC_A.json b/test-play/rnpExample/mocks/rnpExample-callABC_A.json new file mode 100644 index 0000000000..55d3ad9d79 --- /dev/null +++ b/test-play/rnpExample/mocks/rnpExample-callABC_A.json @@ -0,0 +1,3 @@ +{ + "json": "real A_basicMethod-48" +} diff --git a/test-play/rnpExample/mocks/rnpExample-callABC_B.json b/test-play/rnpExample/mocks/rnpExample-callABC_B.json new file mode 100644 index 0000000000..98155d038b --- /dev/null +++ b/test-play/rnpExample/mocks/rnpExample-callABC_B.json @@ -0,0 +1,6 @@ +{ + "json": "54", + "meta": { + "values": [["custom", "ethers.BigNumber"]] + } +} diff --git a/test-play/rnpExample/mocks/rnpExample-callB_superJsonMethod.json b/test-play/rnpExample/mocks/rnpExample-callB_superJsonMethod.json new file mode 100644 index 0000000000..668e2d774b --- /dev/null +++ b/test-play/rnpExample/mocks/rnpExample-callB_superJsonMethod.json @@ -0,0 +1,6 @@ +{ + "json": "56", + "meta": { + "values": [["custom", "ethers.BigNumber"]] + } +} diff --git a/test-play/rnpExample/mocks/rnpExample-callDTwice_1.json b/test-play/rnpExample/mocks/rnpExample-callDTwice_1.json new file mode 100644 index 0000000000..ba8947c229 --- /dev/null +++ b/test-play/rnpExample/mocks/rnpExample-callDTwice_1.json @@ -0,0 +1,3 @@ +{ + "json": "real D_usedTwiceInOneCallMethod-67" +} diff --git a/test-play/rnpExample/mocks/rnpExample-callDTwice_2.json b/test-play/rnpExample/mocks/rnpExample-callDTwice_2.json new file mode 100644 index 0000000000..0285f60980 --- /dev/null +++ b/test-play/rnpExample/mocks/rnpExample-callDTwice_2.json @@ -0,0 +1,3 @@ +{ + "json": "real D_usedTwiceInOneCallMethod-4" +} diff --git a/test-play/rnpExample/mocks/rnpExample-callPrototypeDep.json b/test-play/rnpExample/mocks/rnpExample-callPrototypeDep.json new file mode 100644 index 0000000000..0b6a7da4ee --- /dev/null +++ b/test-play/rnpExample/mocks/rnpExample-callPrototypeDep.json @@ -0,0 +1,3 @@ +{ + "json": "real prototypeMethod-67" +} diff --git a/test-play/rnpExample/rnpExample.play.test.ts b/test-play/rnpExample/rnpExample.play.test.ts new file mode 100644 index 0000000000..e667aebdcc --- /dev/null +++ b/test-play/rnpExample/rnpExample.play.test.ts @@ -0,0 +1,56 @@ +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'; + +describe('RnpExample', () => { + let _harness: RnpExampleTestHarness; + const harness = () => _harness; + + beforeAll(async () => { + _harness = new RnpExampleTestHarness(); + await _harness.initMockedTests(); + }); + + afterEach(async () => { + await _harness.reset(); + }); + + afterAll(async () => { + await _harness.teardown(); + }); + + it('callABC', callABC.createPlayTest(harness)); + + it('callB_superJsonMethod', callB_superJsonMethod.createPlayTest(harness)); + + it('callDTwice', callDTwice.createPlayTest(harness)); + + it('callPrototypeDep', callPrototypeDep.createPlayTest(harness)); + + it('callUnlistedDep', callUnlistedDep.createPlayTest(harness)); + + it('callBUnloaded_Mocked', callBUnloaded_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 + // 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 "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-record/rnpExample/rnpExample.record.test.ts b/test-record/rnpExample/rnpExample.record.test.ts new file mode 100644 index 0000000000..ced175f875 --- /dev/null +++ b/test-record/rnpExample/rnpExample.record.test.ts @@ -0,0 +1,65 @@ +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'; + +describe('RnpExample', () => { + let _harness: RnpExampleTestHarness; + const harness = () => _harness; + jest.setTimeout(10000); + + beforeAll(async () => { + _harness = new RnpExampleTestHarness(); + await _harness.initRecorderTests(); + }); + + afterEach(async () => { + await _harness.reset(); + }); + + afterAll(async () => { + await _harness.teardown(); + }); + + it('callABC', callABC.createRecordTest(harness)); + + it('callB_superJsonMethod', callB_superJsonMethod.createRecordTest(harness)); + + it('callDTwice', callDTwice.createRecordTest(harness)); + + it('callPrototypeDep', callPrototypeDep.createRecordTest(harness)); + + it('callUnlistedDep', callUnlistedDep.createRecordTest(harness)); + + it('callBUnloaded_Recorder', callBUnloaded_Recorder.createRecordTest(harness)); + + it('callUnmappedMethod_Recorder', callUnmappedMethod_Recorder.createRecordTest(harness)); + + it('callBUnloaded_Mocked', async () => { + // Create expected snapshot for running test in "Play" mode + expect({ + error: 'InternalServerError', + message: + 'Failed to callSuperJsonMethod: Mocked dependency was called without a mock loaded: dep1_B. Either load a mock or allowPassThrough.', + statusCode: callBUnloaded_Mocked.expectedStatus, + }).toMatchSnapshot({}); + }); + + it('callUnmappedMethod_Mocked', async () => { + // Create expected snapshot for running test in "Play" mode + expect({ + error: 'NotImplementedError', + message: + 'Failed to callUnmappedMethod: Unmapped method was called: dep1_A.unmappedMethod. Method must be listed and either mocked or specify allowPassThrough.', + statusCode: callUnmappedMethod_Mocked.expectedStatus, + }).toMatchSnapshot({}); + }); +}); \ No newline at end of file diff --git a/test-record/snapshot-resolver.js b/test-record/snapshot-resolver.js new file mode 100644 index 0000000000..85a3fbf0d4 --- /dev/null +++ b/test-record/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 "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-record', 'test-play'), + '__snapshots__', + path.basename(testPath).replace('.record', '.play') + snapshotExtension, + ); + }, + + // resolves from snapshot to test path + resolveTestPath: (snapshotFilePath, snapshotExtension) => { + // This resolver finds the "Record" test path from a "Play" test snapshot path. + // e.g., test/rnpExample/__snapshots__/rnpExample.test.ts.snap + // -> test-record/rnpExample/rnpExample.record.test.ts + const testPath = path + .dirname(snapshotFilePath) + .replace('__snapshots__', '') + .replace('test-play', 'test-record'); + + return path.join( + testPath, + path + .basename(snapshotFilePath, snapshotExtension) + .replace('.play.test.ts', '.record.test.ts'), + ); + }, + + // Example test path, used for preflight consistency check of the implementation above + testPathForConsistencyCheck: + 'test-record/rnpExample/rnpExample.record.test.ts', +}; 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); }); }); }); 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..114a72b579 --- /dev/null +++ b/test/record-and-play/abstract-gateway-test-harness.ts @@ -0,0 +1,290 @@ +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'; + +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 { + protected _gatewayApp!: FastifyInstance; + protected _mockDir: string; + 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>; + + /** + * @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(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); + } + + /** + * 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(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: TMock): 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)); + } + + /** + * 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 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) { + const spy = dep.setupSpy(this); + dep.spy = spy; + } + } + } + + /** + * 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): Promise> { + const errors: Record = {}; + for (const [key, filenames] of Object.entries(requiredMocks)) { + const dep = this.dependencyContracts[key]; + if (!dep.spy) { + errors[key] = new Error(`Spy for mock key "${key}" not found.`); + continue; + } + + 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. + + // 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; + } + + 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 ${numCalls} time(s), but a mock was required for call number ${callIndex + 1}.`, + ); + continue; + } + const data = await result.value; + this._saveMock(filename, data); + } + } + return errors; + } + + /** + * 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(); + + // 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.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]; + if (!dep.spy) { + throw new Error(`Dependency contract with key '${key}' not found in harness.`); + } + for (const fileName of Array.isArray(filenames) ? filenames : [filenames]) { + dep.setupMock(dep.spy, this, fileName); + } + } + } + + /** + * 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) { + dep.spy.mockClear(); + } + }); + } + + /** + * 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) { + 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 new file mode 100644 index 0000000000..79c2848662 --- /dev/null +++ b/test/record-and-play/api-test-case.ts @@ -0,0 +1,146 @@ +import { InjectOptions, LightMyRequestResponse } from 'fastify'; + +import { AbstractGatewayTestHarness } from './abstract-gateway-test-harness'; + +/** + * 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 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>; + /** An object of Jest property matchers (e.g., `{ a: expect.any(String) }`) + * to allow for non-deterministic values in snapshot testing. */ + propertyMatchers?: Record; +} + +/** + * 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'; + url: string; + expectedStatus: number; + query: Record; + payload: Record; + requiredMocks: Partial>; + propertyMatchers?: Partial; + private params: APITestCaseParams; + + 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; + } + + /** + * 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; + }> { + 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); + 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); + return { response, body }; + } + + /** + * 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; + }> { + await harness.loadMocks(this.requiredMocks); + const response = await harness.gatewayApp.inject(this); + this.assertStatusCode(response); + const body = JSON.parse(response.body); + this.assertSnapshot(body); + return { response, body }; + } + + public assertSnapshot(body: any) { + if (this.propertyMatchers) { + expect(body).toMatchSnapshot(this.propertyMatchers); + } else { + expect(body).toMatchSnapshot(); + } + } + + public assertStatusCode(response: LightMyRequestResponse) { + if (response.statusCode !== this.expectedStatus) { + 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; + } + } + + 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/record-and-play/test-dependency-contract.ts b/test/record-and-play/test-dependency-contract.ts new file mode 100644 index 0000000000..92c4ac69d6 --- /dev/null +++ b/test/record-and-play/test-dependency-contract.ts @@ -0,0 +1,154 @@ +/** + * 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): TMock; + instance: TInstance; +} + +/** + * 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; + /** 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): TObject; + /** Creates and returns a Jest spy on the dependency's method. */ + abstract setupSpy(provider: MockProvider): jest.SpyInstance; + /** + * 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 { + const mock = provider.getMock(fileName); + if (isAsync) { + spy.mockResolvedValueOnce(mock); + } else { + spy.mockReturnValueOnce(mock); + } + } +} + +/** + * 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 extends TestDependencyContract< + TInstance, + TObject, + TMock +> { + constructor( + private getObjectFn: (provider: MockProvider) => TObject, + public readonly methodName: keyof TObject, + public allowPassThrough = false, + ) { + super(); + } + + getObject(provider: MockProvider) { + return this.getObjectFn(provider); + } + + setupSpy(provider: MockProvider): jest.SpyInstance { + const object = this.getObject(provider); + return jest.spyOn(object, this.methodName as any); + } +} + +/** + * 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 extends TestDependencyContract { + constructor( + private ClassConstructor: { new (...args: any[]): TObject }, + public readonly methodName: keyof TObject, + public allowPassThrough = false, + ) { + super(); + } + + getObject(_provider: MockProvider): TObject { + return this.ClassConstructor.prototype; + } + + setupSpy(_provider: MockProvider): jest.SpyInstance { + return jest.spyOn(this.ClassConstructor.prototype, this.methodName as any); + } +} + +/** + * Provides factory methods for creating dependency contracts. + * 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 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 any = any>( + instancePropertyName: K, + methodSelector: (dep: TInstance[K]) => TMethod, + allowPassThrough = false, + ) { + const methodName = this._extractMethodName(methodSelector); + + type TMock = Awaited>; + + return new InstancePropertyDependency( + (p: MockProvider): TInstance[K] => p.instance[instancePropertyName], + methodName, + allowPassThrough, + ); + } + + /** + * Creates a contract for a method on a class prototype. + * WARNING: This will not work for methods defined as arrow functions. + * @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 any = any>( + ClassConstructor: { new (...args: any[]): TObject }, + methodSelector: (obj: TObject) => TMethod, + allowPassThrough = false, + ) { + const methodName = this._extractMethodName(methodSelector); + type TMock = Awaited>; + return new PrototypeDependency(ClassConstructor, methodName, allowPassThrough); + } +} diff --git a/test/rnpExample/api/rnpExample.routes.ts b/test/rnpExample/api/rnpExample.routes.ts new file mode 100644 index 0000000000..a519cc24b4 --- /dev/null +++ b/test/rnpExample/api/rnpExample.routes.ts @@ -0,0 +1,19 @@ +import { FastifyPluginAsync } from 'fastify'; + +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(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 new file mode 100644 index 0000000000..9cb5b60e27 --- /dev/null +++ b/test/rnpExample/api/rnpExample.ts @@ -0,0 +1,86 @@ +import { BigNumber } from 'ethers'; + +const randomInt = () => Math.floor(Math.random() * 100); + +export class Dependency1 { + A_basicMethod = async () => `real A_basicMethod-${randomInt()}`; + + B_superJsonMethod = async () => BigNumber.from(randomInt()); + + C_passthroughMethod = async () => `real C_passthroughMethod-${randomInt()}`; + + D_usedTwiceInOneCallMethod = async () => `real D_usedTwiceInOneCallMethod-${randomInt()}`; + + unmappedMethod = async () => `real unmappedMethod-${randomInt()}`; +} + +export class UnlistedDependency { + 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-${randomInt()}`; + } +} + +export class RnpExample { + private static _instances: { [name: string]: RnpExample }; + public dep1: Dependency1; + public dep2: UnlistedDependency; + + constructor() { + this.dep1 = new Dependency1(); + this.dep2 = new UnlistedDependency(); + } + + 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 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 callSuperJsonMethod() { + const b = await this.dep1.B_superJsonMethod(); + if (!BigNumber.isBigNumber(b)) { + throw new Error('b is not a BigNumber'); + } + return { b: b.toString() }; + } + + async callDTwice() { + const d1 = await this.dep1.D_usedTwiceInOneCallMethod(); + const d2 = await this.dep1.D_usedTwiceInOneCallMethod(); + return { d1, d2 }; + } + + async callUnmappedMethod() { + const unmapped = await this.dep1.unmappedMethod(); + return { unmapped }; + } + + async callPrototypeDep() { + const localDep = new LocallyInitializedDependency(); + const x = await localDep.prototypeMethod(); + return { x }; + } + + async callUnlistedDep() { + const z = await this.dep2.unlistedMethod(); + return { z }; + } +} diff --git a/test/rnpExample/api/routes/callABC.ts b/test/rnpExample/api/routes/callABC.ts new file mode 100644 index 0000000000..814444d8de --- /dev/null +++ b/test/rnpExample/api/routes/callABC.ts @@ -0,0 +1,28 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { logger } from '#src/services/logger'; + +import { RnpExample } from '../rnpExample'; + +export const callABCRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: { network: string }; + Reply: { a: string; b: string; c: string }; + }>( + '/callABC', + { + schema: { + summary: 'A RnpExample route for testing', + }, + }, + async (request) => { + try { + const rnpExample = await RnpExample.getInstance(request.query.network); + return await rnpExample.callABC(); + } catch (error) { + logger.error(`Error getting callABC status: ${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 new file mode 100644 index 0000000000..fe78fcd0b7 --- /dev/null +++ b/test/rnpExample/api/routes/callDTwice.ts @@ -0,0 +1,28 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { logger } from '#src/services/logger'; + +import { RnpExample } from '../rnpExample'; + +export const callDTwiceRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: { network: string }; + Reply: { d1: string; d2: string }; + }>( + '/callDTwice', + { + schema: { + summary: 'A RnpExample route for testing', + }, + }, + async (request) => { + try { + const rnpExample = await RnpExample.getInstance(request.query.network); + return await rnpExample.callDTwice(); + } catch (error) { + logger.error(`Error getting callDTwice status: ${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 new file mode 100644 index 0000000000..38bd81257b --- /dev/null +++ b/test/rnpExample/api/routes/callPrototypeDep.ts @@ -0,0 +1,28 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { logger } from '#src/services/logger'; + +import { RnpExample } from '../rnpExample'; + +export const callPrototypeDepRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: { network: string }; + Reply: { x: string }; + }>( + '/callPrototypeDep', + { + schema: { + summary: 'A RnpExample route for testing prototype dependencies', + }, + }, + async (request) => { + try { + const rnpExample = await RnpExample.getInstance(request.query.network); + return await rnpExample.callPrototypeDep(); + } catch (error) { + logger.error(`Error getting callPrototypeDep status: ${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 new file mode 100644 index 0000000000..6352682413 --- /dev/null +++ b/test/rnpExample/api/routes/callSuperJsonMethod.ts @@ -0,0 +1,28 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { logger } from '#src/services/logger'; + +import { RnpExample } from '../rnpExample'; + +export const callSuperJsonMethodRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: { network: string }; + Reply: { b: string }; + }>( + '/callSuperJsonMethod', + { + schema: { + summary: 'A RnpExample route for testing', + }, + }, + async (request) => { + try { + 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}`); + } + }, + ); +}; diff --git a/test/rnpExample/api/routes/callUnlistedDep.ts b/test/rnpExample/api/routes/callUnlistedDep.ts new file mode 100644 index 0000000000..e4f956393d --- /dev/null +++ b/test/rnpExample/api/routes/callUnlistedDep.ts @@ -0,0 +1,28 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { logger } from '#src/services/logger'; + +import { RnpExample } from '../rnpExample'; + +export const callUnlistedDepRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: { network: string }; + Reply: { z: string }; + }>( + '/callUnlistedDep', + { + schema: { + summary: 'A RnpExample route for testing', + }, + }, + async (request) => { + try { + const rnpExample = await RnpExample.getInstance(request.query.network); + return await rnpExample.callUnlistedDep(); + } catch (error) { + logger.error(`Error getting callUnlistedDep status: ${error.message}`); + throw fastify.httpErrors.internalServerError(`Failed to callUnlistedDep: ${error.message}`); + } + }, + ); +}; diff --git a/test/rnpExample/api/routes/callUnmappedMethod.ts b/test/rnpExample/api/routes/callUnmappedMethod.ts new file mode 100644 index 0000000000..bf1ea93dc3 --- /dev/null +++ b/test/rnpExample/api/routes/callUnmappedMethod.ts @@ -0,0 +1,29 @@ +import { FastifyPluginAsync } from 'fastify'; + +import { logger } from '#src/services/logger'; + +import { RnpExample } from '../rnpExample'; + +export const callUnmappedMethodRoute: FastifyPluginAsync = async (fastify) => { + fastify.get<{ + Querystring: { network: string }; + Reply: { unmapped: string }; + }>( + '/callUnmappedMethod', + { + schema: { + summary: 'A RnpExample route for testing', + }, + }, + async (request) => { + try { + 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 501 is returned for snapshot + throw fastify.httpErrors.notImplemented(`Failed to callUnmappedMethod: ${error.message}`); + } + }, + ); +}; diff --git a/test/rnpExample/rnpExample.api-test-cases.ts b/test/rnpExample/rnpExample.api-test-cases.ts new file mode 100644 index 0000000000..597c9c1e1e --- /dev/null +++ b/test/rnpExample/rnpExample.api-test-cases.ts @@ -0,0 +1,150 @@ +import { APITestCase } from '#test/record-and-play/api-test-case'; + +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 callABC = new TestCase({ + method: 'GET', + url: '/rnpExample/callABC', + expectedStatus: 200, + query: { network: 'TEST' }, + payload: {}, + requiredMocks: { + dep1_A: 'rnpExample-callABC_A', + dep1_B: 'rnpExample-callABC_B', + }, + propertyMatchers: { + c: expect.any(String), + }, +}); + +/** + * A simple test case that calls one mocked dependency. + */ +export const callB_superJsonMethod = new TestCase({ + method: 'GET', + url: '/rnpExample/callSuperJsonMethod', + expectedStatus: 200, + query: { network: 'TEST' }, + payload: {}, + requiredMocks: { + dep1_B: 'rnpExample-callB_superJsonMethod', + }, +}); + +/** + * 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.D_usedTwiceInOneCallMethod()`. + */ +export const callDTwice = new TestCase({ + method: 'GET', + url: '/rnpExample/callDTwice', + expectedStatus: 200, + query: { network: 'TEST' }, + payload: {}, + requiredMocks: { + dep1_D: ['rnpExample-callDTwice_1', 'rnpExample-callDTwice_2'], + }, +}); + +/** + * A test case for a dependency defined on a class prototype. + */ +export const callPrototypeDep = new TestCase({ + method: 'GET', + url: '/rnpExample/callPrototypeDep', + expectedStatus: 200, + query: { network: 'TEST' }, + payload: {}, + requiredMocks: { + localDep: 'rnpExample-callPrototypeDep', + }, +}); + +/** + * 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. + */ +export const callUnlistedDep = new TestCase({ + method: 'GET', + url: '/rnpExample/callUnlistedDep', + expectedStatus: 200, + query: { network: 'TEST' }, + payload: {}, + requiredMocks: {}, + propertyMatchers: { + z: expect.any(String), + }, +}); + +/** + * 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', + url: '/rnpExample/callSuperJsonMethod', + expectedStatus: 200, + query: { network: 'TEST' }, + payload: {}, + requiredMocks: {}, + propertyMatchers: { b: expect.any(String) }, +}); + +/** + * 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. + */ +export const callBUnloaded_Mocked = new TestCase({ + method: 'GET', + url: '/rnpExample/callSuperJsonMethod', + expectedStatus: 500, + query: { network: 'TEST' }, + payload: {}, + requiredMocks: {}, +}); + +/** + * 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', + url: '/rnpExample/callUnmappedMethod', + expectedStatus: 200, + query: { network: 'TEST' }, + payload: {}, + propertyMatchers: { unmapped: expect.any(String) }, +}); + +/** + * A failure test case for a "Play" mode test case that calls a method with an unmapped dependency. + * 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 + * that prevents calls to unmapped methods. + */ +export const callUnmappedMethod_Mocked = new TestCase({ + method: 'GET', + url: '/rnpExample/callUnmappedMethod', + expectedStatus: 501, + query: { network: 'TEST' }, + payload: {}, + requiredMocks: {}, + propertyMatchers: {}, +}); diff --git a/test/rnpExample/rnpExample.test-harness.ts b/test/rnpExample/rnpExample.test-harness.ts new file mode 100644 index 0000000000..d13909db59 --- /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 { LocallyInitializedDependency, RnpExample } from './api/rnpExample'; + +export class RnpExampleTestHarness extends AbstractGatewayTestHarness { + readonly dependencyContracts = { + // 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), + + // 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.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. + 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.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.unlistedMethod()` will always call its real + // implementation in both "Record" and "Play" modes. + }; + + 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'); + } + + async teardown() { + await super.teardown(); + } +} diff --git a/test/superjson-setup.ts b/test/superjson-setup.ts new file mode 100644 index 0000000000..c0e4a4da0c --- /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.toString(), + deserialize: (v) => BigNumber.from(v), + }, + 'ethers.BigNumber', +); 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); 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"] }