From df1493e483694741d109c9e87392b7fafba82ffd Mon Sep 17 00:00:00 2001 From: Tulio Miranda Date: Mon, 11 May 2026 13:30:52 -0300 Subject: [PATCH 1/4] test(wallet-service): harden /version contract Adds direct schema unit tests for FullnodeVersionSchema, two fullnode.version() tests, and a live-fullnode contract test that fails CI when the /version response stops matching the schema. Aligns FullNodeApiVersionResponse with the native_token.version field added in HathorNetwork/hathor-wallet-service#420 and drops the type cast that was masking the drift. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/wallet-service/src/fullnode.ts | 2 +- packages/wallet-service/src/types.ts | 2 +- .../wallet-service/tests/fullnode.test.ts | 49 ++++++++++++++ packages/wallet-service/tests/schemas.test.ts | 65 +++++++++++++++++++ 4 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 packages/wallet-service/tests/schemas.test.ts diff --git a/packages/wallet-service/src/fullnode.ts b/packages/wallet-service/src/fullnode.ts index c71c01da..8a710a0c 100644 --- a/packages/wallet-service/src/fullnode.ts +++ b/packages/wallet-service/src/fullnode.ts @@ -32,7 +32,7 @@ export const create = (baseURL = BASE_URL) => { throw new Error(error.message); } - return value as FullNodeApiVersionResponse; + return value; }; const downloadTx = async (txId: string) => { diff --git a/packages/wallet-service/src/types.ts b/packages/wallet-service/src/types.ts index b282e3cd..0fbd8218 100644 --- a/packages/wallet-service/src/types.ts +++ b/packages/wallet-service/src/types.ts @@ -126,7 +126,7 @@ export interface FullNodeApiVersionResponse { genesis_block_hash?: string, genesis_tx1_hash?: string, genesis_tx2_hash?: string, - native_token?: { name: string, symbol: string }; + native_token?: { name: string, symbol: string, version?: number }; } export interface TxProposal { diff --git a/packages/wallet-service/tests/fullnode.test.ts b/packages/wallet-service/tests/fullnode.test.ts index 4fc3a02c..c57b8ec6 100644 --- a/packages/wallet-service/tests/fullnode.test.ts +++ b/packages/wallet-service/tests/fullnode.test.ts @@ -1,4 +1,53 @@ import fullnode from '@src/fullnode'; +import { FullNodeApiVersionResponse } from '@src/types'; + +const VALID_VERSION_PAYLOAD: FullNodeApiVersionResponse = { + version: '0.70.0-rc.1', + network: 'testnet-india', + nano_contracts_enabled: true, + min_weight: 8, + min_tx_weight: 8, + min_tx_weight_coefficient: 0, + min_tx_weight_k: 0, + token_deposit_percentage: 0.01, + reward_spend_min_blocks: 300, + max_number_inputs: 255, + max_number_outputs: 255, + decimal_places: 2, + genesis_block_hash: '000001b7d5abc44d3828529654e8d830eeca1cd0e313032be1b8e9dfe31052ee', + genesis_tx1_hash: '00768e4df506979bb14e0efc16748d9306fa176de54e86069d115e74b26957df', + genesis_tx2_hash: '00306abcedddfa21707e7920fe324a997e3a311a959a18724c7e8cfd0468c164', + native_token: { name: 'Hathor', symbol: 'HTR', version: 0 }, +}; + +test('version returns parsed payload when the response matches the schema', async () => { + expect.hasAssertions(); + + const apiGetSpy = jest.spyOn(fullnode.api, 'get'); + apiGetSpy.mockImplementation(() => Promise.resolve({ + status: 200, + data: VALID_VERSION_PAYLOAD, + })); + + const response = await fullnode.version(); + expect(response).toStrictEqual(VALID_VERSION_PAYLOAD); +}); + +test('version throws when the response fails schema validation', async () => { + expect.hasAssertions(); + + const invalidPayload = { + ...VALID_VERSION_PAYLOAD, + native_token: { name: 'Hathor', symbol: 'HTR', version: 1.5 }, + }; + const apiGetSpy = jest.spyOn(fullnode.api, 'get'); + apiGetSpy.mockImplementation(() => Promise.resolve({ + status: 200, + data: invalidPayload, + })); + + await expect(fullnode.version()).rejects.toThrow(/native_token\.version/); +}); test('downloadTx', async () => { expect.hasAssertions(); diff --git a/packages/wallet-service/tests/schemas.test.ts b/packages/wallet-service/tests/schemas.test.ts new file mode 100644 index 00000000..355866db --- /dev/null +++ b/packages/wallet-service/tests/schemas.test.ts @@ -0,0 +1,65 @@ +import { FullnodeVersionSchema } from '@src/schemas'; +import { FullNodeApiVersionResponse } from '@src/types'; + +const VALID_PAYLOAD: FullNodeApiVersionResponse = { + version: '0.70.0-rc.1', + network: 'testnet-india', + nano_contracts_enabled: true, + min_weight: 8, + min_tx_weight: 8, + min_tx_weight_coefficient: 0, + min_tx_weight_k: 0, + token_deposit_percentage: 0.01, + reward_spend_min_blocks: 300, + max_number_inputs: 255, + max_number_outputs: 255, + decimal_places: 2, + genesis_block_hash: '000001b7d5abc44d3828529654e8d830eeca1cd0e313032be1b8e9dfe31052ee', + genesis_tx1_hash: '00768e4df506979bb14e0efc16748d9306fa176de54e86069d115e74b26957df', + genesis_tx2_hash: '00306abcedddfa21707e7920fe324a997e3a311a959a18724c7e8cfd0468c164', + native_token: { + name: 'Hathor', + symbol: 'HTR', + version: 0, + }, +}; + +// Regression guard for the testnet outage that motivated PR 420: the +// fullnode added `native_token.version` and the lambda crashed because the +// nested object did not allow it. Locking the modern payload in as a +// passing case prevents that fix from being reverted by accident. +test('FullnodeVersionSchema regression: PR 420 testnet payload validates', () => { + const { error, value } = FullnodeVersionSchema.validate(VALID_PAYLOAD); + expect(error).toBeUndefined(); + expect(value.native_token).toEqual({ name: 'Hathor', symbol: 'HTR', version: 0 }); +}); + +test('FullnodeVersionSchema accepts a legacy fullnode payload without native_token.version', () => { + const { native_token, ...rest } = VALID_PAYLOAD; + const legacy = { ...rest, native_token: { name: native_token!.name, symbol: native_token!.symbol } }; + const { error } = FullnodeVersionSchema.validate(legacy); + expect(error).toBeUndefined(); +}); + +// Design-intent guard: native_token is intentionally strict — any unknown +// field there fails validation so a contract test (and not a production +// outage) surfaces fullnode schema drift. +test('FullnodeVersionSchema rejects unknown fields under native_token', () => { + const payload = { + ...VALID_PAYLOAD, + native_token: { ...VALID_PAYLOAD.native_token, icon: 'data:image/png;base64,...' }, + }; + const { error } = FullnodeVersionSchema.validate(payload); + expect(error).toBeDefined(); + expect(error!.message).toMatch(/native_token\.icon/); +}); + +test('FullnodeVersionSchema rejects a non-integer native_token.version', () => { + const payload = { + ...VALID_PAYLOAD, + native_token: { name: 'Hathor', symbol: 'HTR', version: 1.5 }, + }; + const { error } = FullnodeVersionSchema.validate(payload); + expect(error).toBeDefined(); + expect(error!.message).toMatch(/native_token\.version/); +}); From d9f18232aeea13153941d0a8b9c5aeb906c63cf4 Mon Sep 17 00:00:00 2001 From: Tulio Miranda Date: Mon, 11 May 2026 19:45:33 -0300 Subject: [PATCH 2/4] test(wallet-service): centralize /version test fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both schemas.test.ts and fullnode.test.ts duplicated a ~17-line FullNodeApiVersionResponse fixture. Replaces both with reuse of the existing `defaultTestVersionData()` factory in tests/utils.ts — already the canonical fixture (used by `seedFullnodeVersionData` and referenced from jestSetup.ts) — spreading and overriding only the `native_token` field under test. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../wallet-service/tests/fullnode.test.ts | 31 ++++--------- packages/wallet-service/tests/schemas.test.ts | 44 +++++-------------- 2 files changed, 20 insertions(+), 55 deletions(-) diff --git a/packages/wallet-service/tests/fullnode.test.ts b/packages/wallet-service/tests/fullnode.test.ts index c57b8ec6..5b5905c0 100644 --- a/packages/wallet-service/tests/fullnode.test.ts +++ b/packages/wallet-service/tests/fullnode.test.ts @@ -1,43 +1,28 @@ import fullnode from '@src/fullnode'; -import { FullNodeApiVersionResponse } from '@src/types'; - -const VALID_VERSION_PAYLOAD: FullNodeApiVersionResponse = { - version: '0.70.0-rc.1', - network: 'testnet-india', - nano_contracts_enabled: true, - min_weight: 8, - min_tx_weight: 8, - min_tx_weight_coefficient: 0, - min_tx_weight_k: 0, - token_deposit_percentage: 0.01, - reward_spend_min_blocks: 300, - max_number_inputs: 255, - max_number_outputs: 255, - decimal_places: 2, - genesis_block_hash: '000001b7d5abc44d3828529654e8d830eeca1cd0e313032be1b8e9dfe31052ee', - genesis_tx1_hash: '00768e4df506979bb14e0efc16748d9306fa176de54e86069d115e74b26957df', - genesis_tx2_hash: '00306abcedddfa21707e7920fe324a997e3a311a959a18724c7e8cfd0468c164', - native_token: { name: 'Hathor', symbol: 'HTR', version: 0 }, -}; +import { defaultTestVersionData } from '@tests/utils'; test('version returns parsed payload when the response matches the schema', async () => { expect.hasAssertions(); + const payload = { + ...defaultTestVersionData(), + native_token: { name: 'Hathor', symbol: 'HTR', version: 0 }, + }; const apiGetSpy = jest.spyOn(fullnode.api, 'get'); apiGetSpy.mockImplementation(() => Promise.resolve({ status: 200, - data: VALID_VERSION_PAYLOAD, + data: payload, })); const response = await fullnode.version(); - expect(response).toStrictEqual(VALID_VERSION_PAYLOAD); + expect(response).toStrictEqual(payload); }); test('version throws when the response fails schema validation', async () => { expect.hasAssertions(); const invalidPayload = { - ...VALID_VERSION_PAYLOAD, + ...defaultTestVersionData(), native_token: { name: 'Hathor', symbol: 'HTR', version: 1.5 }, }; const apiGetSpy = jest.spyOn(fullnode.api, 'get'); diff --git a/packages/wallet-service/tests/schemas.test.ts b/packages/wallet-service/tests/schemas.test.ts index 355866db..8820788d 100644 --- a/packages/wallet-service/tests/schemas.test.ts +++ b/packages/wallet-service/tests/schemas.test.ts @@ -1,43 +1,23 @@ import { FullnodeVersionSchema } from '@src/schemas'; -import { FullNodeApiVersionResponse } from '@src/types'; - -const VALID_PAYLOAD: FullNodeApiVersionResponse = { - version: '0.70.0-rc.1', - network: 'testnet-india', - nano_contracts_enabled: true, - min_weight: 8, - min_tx_weight: 8, - min_tx_weight_coefficient: 0, - min_tx_weight_k: 0, - token_deposit_percentage: 0.01, - reward_spend_min_blocks: 300, - max_number_inputs: 255, - max_number_outputs: 255, - decimal_places: 2, - genesis_block_hash: '000001b7d5abc44d3828529654e8d830eeca1cd0e313032be1b8e9dfe31052ee', - genesis_tx1_hash: '00768e4df506979bb14e0efc16748d9306fa176de54e86069d115e74b26957df', - genesis_tx2_hash: '00306abcedddfa21707e7920fe324a997e3a311a959a18724c7e8cfd0468c164', - native_token: { - name: 'Hathor', - symbol: 'HTR', - version: 0, - }, -}; +import { defaultTestVersionData } from '@tests/utils'; // Regression guard for the testnet outage that motivated PR 420: the // fullnode added `native_token.version` and the lambda crashed because the // nested object did not allow it. Locking the modern payload in as a // passing case prevents that fix from being reverted by accident. -test('FullnodeVersionSchema regression: PR 420 testnet payload validates', () => { - const { error, value } = FullnodeVersionSchema.validate(VALID_PAYLOAD); +test('FullnodeVersionSchema regression: native_token.version is accepted', () => { + const payload = { + ...defaultTestVersionData(), + native_token: { name: 'Hathor', symbol: 'HTR', version: 0 }, + }; + const { error, value } = FullnodeVersionSchema.validate(payload); expect(error).toBeUndefined(); expect(value.native_token).toEqual({ name: 'Hathor', symbol: 'HTR', version: 0 }); }); test('FullnodeVersionSchema accepts a legacy fullnode payload without native_token.version', () => { - const { native_token, ...rest } = VALID_PAYLOAD; - const legacy = { ...rest, native_token: { name: native_token!.name, symbol: native_token!.symbol } }; - const { error } = FullnodeVersionSchema.validate(legacy); + // defaultTestVersionData() returns a payload whose native_token has no `version`. + const { error } = FullnodeVersionSchema.validate(defaultTestVersionData()); expect(error).toBeUndefined(); }); @@ -46,8 +26,8 @@ test('FullnodeVersionSchema accepts a legacy fullnode payload without native_tok // outage) surfaces fullnode schema drift. test('FullnodeVersionSchema rejects unknown fields under native_token', () => { const payload = { - ...VALID_PAYLOAD, - native_token: { ...VALID_PAYLOAD.native_token, icon: 'data:image/png;base64,...' }, + ...defaultTestVersionData(), + native_token: { name: 'Hathor', symbol: 'HTR', version: 0, icon: 'data:image/png;base64,...' }, }; const { error } = FullnodeVersionSchema.validate(payload); expect(error).toBeDefined(); @@ -56,7 +36,7 @@ test('FullnodeVersionSchema rejects unknown fields under native_token', () => { test('FullnodeVersionSchema rejects a non-integer native_token.version', () => { const payload = { - ...VALID_PAYLOAD, + ...defaultTestVersionData(), native_token: { name: 'Hathor', symbol: 'HTR', version: 1.5 }, }; const { error } = FullnodeVersionSchema.validate(payload); From a6f62c22841f50fca2d1f28ac8e00a0dafcf2d17 Mon Sep 17 00:00:00 2001 From: Tulio Miranda Date: Tue, 12 May 2026 11:41:48 -0300 Subject: [PATCH 3/4] test(wallet-service): group version() tests and cover legacy payload Wraps the three version()-related cases in a `describe('version', ...)` block for readability. Adds a case asserting `fullnode.version()` still parses successfully when the fullnode response omits the new `native_token.version` field, mirroring older fullnodes that predate HathorNetwork/hathor-wallet-service#420. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../wallet-service/tests/fullnode.test.ts | 80 ++++++++++++------- 1 file changed, 49 insertions(+), 31 deletions(-) diff --git a/packages/wallet-service/tests/fullnode.test.ts b/packages/wallet-service/tests/fullnode.test.ts index 5b5905c0..8135caa9 100644 --- a/packages/wallet-service/tests/fullnode.test.ts +++ b/packages/wallet-service/tests/fullnode.test.ts @@ -1,37 +1,55 @@ import fullnode from '@src/fullnode'; import { defaultTestVersionData } from '@tests/utils'; -test('version returns parsed payload when the response matches the schema', async () => { - expect.hasAssertions(); - - const payload = { - ...defaultTestVersionData(), - native_token: { name: 'Hathor', symbol: 'HTR', version: 0 }, - }; - const apiGetSpy = jest.spyOn(fullnode.api, 'get'); - apiGetSpy.mockImplementation(() => Promise.resolve({ - status: 200, - data: payload, - })); - - const response = await fullnode.version(); - expect(response).toStrictEqual(payload); -}); - -test('version throws when the response fails schema validation', async () => { - expect.hasAssertions(); - - const invalidPayload = { - ...defaultTestVersionData(), - native_token: { name: 'Hathor', symbol: 'HTR', version: 1.5 }, - }; - const apiGetSpy = jest.spyOn(fullnode.api, 'get'); - apiGetSpy.mockImplementation(() => Promise.resolve({ - status: 200, - data: invalidPayload, - })); - - await expect(fullnode.version()).rejects.toThrow(/native_token\.version/); +describe('version', () => { + test('returns parsed payload when native_token includes the version field', async () => { + expect.hasAssertions(); + + const payload = { + ...defaultTestVersionData(), + native_token: { name: 'Hathor', symbol: 'HTR', version: 0 }, + }; + const apiGetSpy = jest.spyOn(fullnode.api, 'get'); + apiGetSpy.mockImplementation(() => Promise.resolve({ + status: 200, + data: payload, + })); + + const response = await fullnode.version(); + expect(response).toStrictEqual(payload); + }); + + test('returns parsed payload when native_token omits the version field', async () => { + expect.hasAssertions(); + + // defaultTestVersionData() returns a payload whose native_token has no `version`. + const payload = defaultTestVersionData(); + const apiGetSpy = jest.spyOn(fullnode.api, 'get'); + apiGetSpy.mockImplementation(() => Promise.resolve({ + status: 200, + data: payload, + })); + + const response = await fullnode.version(); + expect(response).toStrictEqual(payload); + expect(response.native_token).not.toHaveProperty('version'); + }); + + test('throws when the response fails schema validation', async () => { + expect.hasAssertions(); + + const invalidPayload = { + ...defaultTestVersionData(), + native_token: { name: 'Hathor', symbol: 'HTR', version: 1.5 }, + }; + const apiGetSpy = jest.spyOn(fullnode.api, 'get'); + apiGetSpy.mockImplementation(() => Promise.resolve({ + status: 200, + data: invalidPayload, + })); + + await expect(fullnode.version()).rejects.toThrow(/native_token\.version/); + }); }); test('downloadTx', async () => { From 3281c447ee5aef0555d83a3cc7dacb37a3960684 Mon Sep 17 00:00:00 2001 From: Tulio Miranda Date: Tue, 12 May 2026 13:20:22 -0300 Subject: [PATCH 4/4] test(wallet-service): restore mocks after each version() test Adds `afterEach(() => jest.restoreAllMocks())` to the `describe('version', ...)` block so the `jest.spyOn(fullnode.api, 'get')` set up by each test is reverted between tests. Prevents implicit cross-test leakage as the suite grows; aligns with the cleanup pattern used in tests/auth.readonly.test.ts. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/wallet-service/tests/fullnode.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/wallet-service/tests/fullnode.test.ts b/packages/wallet-service/tests/fullnode.test.ts index 8135caa9..f60e3b33 100644 --- a/packages/wallet-service/tests/fullnode.test.ts +++ b/packages/wallet-service/tests/fullnode.test.ts @@ -2,6 +2,10 @@ import fullnode from '@src/fullnode'; import { defaultTestVersionData } from '@tests/utils'; describe('version', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + test('returns parsed payload when native_token includes the version field', async () => { expect.hasAssertions();