import { expect, test } from 'vitest'; import type { XOTemplate } from '@xo-cash/types'; import { p2pkhTemplate } from '@xo-cash/templates'; import { TemplateInvalidError, parseTemplate, serializeTemplate } from '../source/index.ts'; /** * Tests that parseTemplate accepts a valid XOTemplate object and returns it unchanged. */ const testParseTemplateAcceptsValidTemplate = (): void => { // Parse the template const parsedObjectTemplate: XOTemplate = parseTemplate(p2pkhTemplate); // Parse the template from a string const parsedStringTemplate = parseTemplate(serializeTemplate(p2pkhTemplate)); // The parsed template should be equal to the original template expect(parsedObjectTemplate).toEqual(p2pkhTemplate); expect(parsedStringTemplate).toEqual(p2pkhTemplate); }; /** * Tests that parseTemplate accepts a simple bigint value in a satoshi field and preserves it * through the serialize/deserialize round-trip. */ const testParseTemplateAcceptsBigintSatoshis = (): void => { // Get the first output key const firstOutputKey = Object.keys(p2pkhTemplate.outputs)[0]; if (firstOutputKey === undefined) { throw new Error('p2pkhTemplate has no outputs, test fixture is invalid'); } const firstOutput = p2pkhTemplate.outputs[firstOutputKey]; if (firstOutput === undefined) { throw new Error('p2pkhTemplate first output is undefined, test fixture is invalid'); } const templateWithBigint = { ...p2pkhTemplate, outputs: { ...p2pkhTemplate.outputs, [firstOutputKey]: { ...firstOutput, valueSatoshis: 1000n }, }, }; // Parse the template const parsedTemplate = parseTemplate(templateWithBigint); // The parsed template should have the bigint value expect(parsedTemplate.outputs[firstOutputKey]!.valueSatoshis).toBe(1000n); }; /** * Tests that parseTemplate preserves bigint precision for values exceeding Number.MAX_SAFE_INTEGER. * Uses 2^54 + 1, so any naive Number conversion in the serialize/deserialize round-trip would produce the wrong value * but BigInt conversion would preserve the exact value. */ const testParseTemplatePreservesBigintPrecisionBeyondMaxSafeInteger = (): void => { // Get the first output key const firstOutputKey = Object.keys(p2pkhTemplate.outputs)[0]; if (firstOutputKey === undefined) { throw new Error('p2pkhTemplate has no outputs, test fixture is invalid'); } const firstOutput = p2pkhTemplate.outputs[firstOutputKey]; if (firstOutput === undefined) { throw new Error('p2pkhTemplate first output is undefined, test fixture is invalid'); } const templateWithBigint = { ...p2pkhTemplate, outputs: { ...p2pkhTemplate.outputs, [firstOutputKey]: { ...firstOutput, valueSatoshis: 18014398509481985n }, }, }; // Parse the template const parsedTemplate = parseTemplate(templateWithBigint); // The parsed template should have the exact bigint value expect(parsedTemplate.outputs[firstOutputKey]!.valueSatoshis).toBe(18014398509481985n); }; /** * Tests that parseTemplate throws TemplateInvalidError listing all missing required fields */ const testParseTemplateThrowsOnMissingRequiredFields = (): void => { let thrownError: unknown; try { parseTemplate({} as XOTemplate); } catch (caughtError) { thrownError = caughtError; } const isTemplateInvalidError = thrownError instanceof TemplateInvalidError; expect(isTemplateInvalidError).toBe(true); const expectedMessage = 'Template invalid: \n' + '- name: expected string, received undefined\n' + '- description: expected string, received undefined\n' + '- $schema: expected string, received undefined\n' + '- supported: expected array, received undefined\n' + '- roles: expected record, received undefined\n' + '- start: expected array, received undefined\n' + '- actions: expected record, received undefined\n' + '- inputs: expected record, received undefined\n' + '- outputs: expected record, received undefined\n' + '- lockingScripts: expected record, received undefined\n' + '- scripts: expected record, received undefined'; expect((thrownError as TemplateInvalidError).message).toBe(expectedMessage); }; /** * Tests that parseTemplate throws TemplateInvalidError naming the exact field path when a * single required field is removed from a valid template. */ const testParseTemplateThrowsOnMissingScriptsField = (): void => { // Set scripts to undefined to test the field path for a missing record const templateWithoutScripts = { ...p2pkhTemplate, scripts: undefined }; let thrownError: unknown; try { parseTemplate(templateWithoutScripts); } catch (caughtError) { thrownError = caughtError; } const isTemplateInvalidError = thrownError instanceof TemplateInvalidError; expect(isTemplateInvalidError).toBe(true); const expectedMessage = 'Template invalid: \n' + '- scripts: expected record, received undefined'; expect((thrownError as TemplateInvalidError).message).toBe(expectedMessage); }; /** * Tests that parseTemplate throws TemplateInvalidError reporting both field paths for type violations */ const testParseTemplateThrowsOnFieldTypeViolations = (): void => { // Replace version with a number and name with a number to produce two simultaneous type errors. // @ts-expect-error - version and name are intentionally wrong types for this test case const templateWithWrongFieldTypes: XOTemplate = { ...p2pkhTemplate, version: 42, name: 42 }; let thrownError: unknown; try { parseTemplate(templateWithWrongFieldTypes); } catch (caughtError) { thrownError = caughtError; } const isTemplateInvalidError = thrownError instanceof TemplateInvalidError; expect(isTemplateInvalidError).toBe(true); const expectedMessage = 'Template invalid: \n' + '- name: expected string, received number\n' + '- version: expected string, received number'; expect((thrownError as TemplateInvalidError).message).toBe(expectedMessage); }; /** * Tests that parseTemplate throws TemplateInvalidError with the unknown key path for an unrecognized property at the top level */ const testParseTemplateThrowsOnUnknownProperties = (): void => { const templateWithUnknownProperty = { ...p2pkhTemplate, unknownProperty: 'unexpected' }; let thrownError: unknown; try { parseTemplate(templateWithUnknownProperty); } catch (caughtError) { thrownError = caughtError; } const isTemplateInvalidError = thrownError instanceof TemplateInvalidError; expect(isTemplateInvalidError).toBe(true); const expectedMessage = 'Template invalid: \n' + '- (root): Unrecognized key: "unknownProperty"'; expect((thrownError as TemplateInvalidError).message).toBe(expectedMessage); }; /** * Tests that parseTemplate throws TemplateInvalidError reporting both unrecognized keys for unknown properties nested inside an action definition */ const testParseTemplateThrowsOnDeepUnknownProperties = (): void => { if (p2pkhTemplate.actions.receive === undefined) { throw new Error('p2pkhTemplate has no "receive" action, test fixture is invalid'); } const templateWithDeepUnknown = { ...p2pkhTemplate, actions: { ...p2pkhTemplate.actions, receive: { ...p2pkhTemplate.actions.receive, new: 42, new2: 'test' }, }, }; let thrownError: unknown; try { parseTemplate(templateWithDeepUnknown); } catch (caughtError) { thrownError = caughtError; } const isTemplateInvalidError = thrownError instanceof TemplateInvalidError; expect(isTemplateInvalidError).toBe(true); const expectedMessage = 'Template invalid: \n' + '- actions.receive: Unrecognized keys: "new", "new2"'; expect((thrownError as TemplateInvalidError).message).toBe(expectedMessage); }; const runTests = async (): Promise => { test('parseTemplate: accepts a valid template', testParseTemplateAcceptsValidTemplate); test('parseTemplate: accepts native bigint in satoshi fields', testParseTemplateAcceptsBigintSatoshis); test('parseTemplate: preserves bigint precision beyond Number.MAX_SAFE_INTEGER', testParseTemplatePreservesBigintPrecisionBeyondMaxSafeInteger); test('parseTemplate: throws TemplateInvalidError on missing required fields', testParseTemplateThrowsOnMissingRequiredFields); test('parseTemplate: throws TemplateInvalidError on missing scripts field', testParseTemplateThrowsOnMissingScriptsField); test('parseTemplate: throws TemplateInvalidError on field type violations', testParseTemplateThrowsOnFieldTypeViolations); test('parseTemplate: throws TemplateInvalidError on unknown top-level property', testParseTemplateThrowsOnUnknownProperties); test('parseTemplate: throws TemplateInvalidError on deep unknown properties', testParseTemplateThrowsOnDeepUnknownProperties); }; await runTests();