Files
xo-cash-utils/test/invitation.test.ts
T
2026-10-05 12:59:22 +00:00

249 lines
10 KiB
TypeScript

import { expect, test } from 'vitest';
import { binToHex, generateRandomBytes, hexToBin } from '@bitauth/libauth';
import {
COMMIT_IDENTIFIER_BYTE_LENGTH,
INVITATION_IDENTIFIER_BYTE_LENGTH,
TEMPLATE_IDENTIFIER_BYTE_LENGTH,
TOKEN_CATEGORY_BYTE_LENGTH,
TRANSACTION_HASH_BYTE_LENGTH,
} from '@xo-cash/primitives';
import type { XOInvitation } from '@xo-cash/types';
import {
InvitationInvalidError,
InvitationJsonMalformedError,
InvitationSerializationFailedError,
deserializeInvitation,
parseInvitation,
serializeInvitation,
} from '../source/index.ts';
const invitationIdentifier = binToHex(generateRandomBytes(INVITATION_IDENTIFIER_BYTE_LENGTH));
const templateIdentifier = binToHex(generateRandomBytes(TEMPLATE_IDENTIFIER_BYTE_LENGTH));
const commitIdentifier = binToHex(generateRandomBytes(COMMIT_IDENTIFIER_BYTE_LENGTH));
const outpointTransactionHash = binToHex(generateRandomBytes(TRANSACTION_HASH_BYTE_LENGTH));
const tokenCategory = binToHex(generateRandomBytes(TOKEN_CATEGORY_BYTE_LENGTH));
// Valid invitation
const buildValidInvitation = (): XOInvitation => {
return {
invitationIdentifier,
createdAtTimestamp: 1700000000000,
templateIdentifier,
actionIdentifier: 'receive',
commits: [
{
commitIdentifier,
previousCommitIdentifier: undefined,
entityIdentifier: 'xpub661MyMwAqRbcEngHLkoUWCKMBMGeqdqCGkqtzrHaTZub9ALw2oRfHA6fQP5n5X9VHStaNTBYomkSb8BFhUGavwD3RG1qvMkEKceTavTp2Tm',
createdAtTimestamp: 1700000000000,
data: {
transactionVersion: 2,
transactionLocktime: 0,
variables: [{ variableIdentifier: 'amount', value: 18014398509481985n, roleIdentifier: 'sender' }],
inputs: [
{
outpointTransactionHash: hexToBin(outpointTransactionHash),
outpointIndex: 0,
sequenceNumber: 0,
inputIdentifier: 'fundingInput',
roleIdentifier: 'sender',
},
],
outputs: [
{
outputIdentifier: 'receiveOutput',
valueSatoshis: 1000n,
lockingBytecode: hexToBin('76a914000000000000000000000000000000000000000088ac'),
token: { category: hexToBin(tokenCategory), amount: 21n },
roleIdentifier: 'receiver',
},
],
},
signature:
'5f487c045657f3939ecfeaaacf239a7cfd44b485c2be591f5280bf0cc3a6e5fe304e8ea23311d82b2afa4f0ad7e0a6d07ec1e0b1aaee9c44097613694390966b',
expiresAtTimestamp: 1700003600000,
},
],
};
};
/**
* Tests that parseInvitation accepts a valid invitation and returns the same object it received.
*/
const testParseInvitationReturnsReceivedObject = (): void => {
const invitation = buildValidInvitation();
expect(parseInvitation(invitation)).toBe(invitation);
};
/**
* Tests that an invitation survives a serialize and deserialize round trip.
*/
const testSerializeInvitationRoundTripsBigintAndBytes = (): void => {
const invitation = buildValidInvitation();
const restoredInvitation = deserializeInvitation(serializeInvitation(invitation));
expect(restoredInvitation).toEqual(invitation);
const [ restoredCommit ] = restoredInvitation.commits;
expect(restoredCommit!.data.variables![0]!.value).toBe(18014398509481985n);
expect(restoredCommit!.data.outputs![0]!.valueSatoshis).toBe(1000n);
expect(restoredCommit!.data.outputs![0]!.lockingBytecode).toBeInstanceOf(Uint8Array);
expect(binToHex(restoredCommit!.data.inputs![0]!.outpointTransactionHash!)).toBe(outpointTransactionHash);
};
/**
* Tests that parseInvitation throws InvitationInvalidError listing every missing required field.
*/
const testParseInvitationThrowsOnMissingRequiredFields = (): void => {
let thrownError: unknown;
try {
parseInvitation({});
} catch (caughtError) {
thrownError = caughtError;
}
expect(thrownError).toBeInstanceOf(InvitationInvalidError);
const expectedMessage =
'Invitation invalid, expected a value matching the XOInvitation type, but got: \n'
+ '- invitationIdentifier: expected string, received undefined\n'
+ '- createdAtTimestamp: expected number, received undefined\n'
+ '- templateIdentifier: expected string, received undefined\n'
+ '- actionIdentifier: expected string, received undefined\n'
+ '- commits: expected array, received undefined';
expect((thrownError as InvitationInvalidError).message).toBe(expectedMessage);
};
/**
* Tests that parseInvitation throws InvitationInvalidError reporting every field with a wrong type or out of range value.
*/
const testParseInvitationThrowsOnFieldTypeViolations = (): void => {
const invitationWithWrongFieldTypes = { ...buildValidInvitation(), createdAtTimestamp: -1, commits: 'none' };
let thrownError: unknown;
try {
parseInvitation(invitationWithWrongFieldTypes);
} catch (caughtError) {
thrownError = caughtError;
}
expect(thrownError).toBeInstanceOf(InvitationInvalidError);
const expectedMessage =
'Invitation invalid, expected a value matching the XOInvitation type, but got: \n'
+ '- createdAtTimestamp: Too small: expected number to be >=0\n'
+ '- commits: expected array, received string';
expect((thrownError as InvitationInvalidError).message).toBe(expectedMessage);
};
/**
* Tests that parseInvitation throws InvitationInvalidError for an unrecognized property at the top level.
*/
const testParseInvitationThrowsOnUnknownProperties = (): void => {
const invitationWithUnknownProperty = { ...buildValidInvitation(), unknownProperty: 'unexpected' };
let thrownError: unknown;
try {
parseInvitation(invitationWithUnknownProperty);
} catch (caughtError) {
thrownError = caughtError;
}
expect(thrownError).toBeInstanceOf(InvitationInvalidError);
const expectedMessage =
'Invitation invalid, expected a value matching the XOInvitation type, but got: \n' + '- (root): Unrecognized key: "unknownProperty"';
expect((thrownError as InvitationInvalidError).message).toBe(expectedMessage);
};
/**
* Tests that parseInvitation throws InvitationInvalidError with the full path of an unrecognized property inside commit data.
*/
const testParseInvitationThrowsOnDeepUnknownProperties = (): void => {
const invitation = buildValidInvitation();
const [ firstCommit ] = invitation.commits;
const invitationWithDeepUnknown = { ...invitation, commits: [{ ...firstCommit, data: { ...firstCommit!.data, unknownData: 1 } }] };
let thrownError: unknown;
try {
parseInvitation(invitationWithDeepUnknown);
} catch (caughtError) {
thrownError = caughtError;
}
expect(thrownError).toBeInstanceOf(InvitationInvalidError);
const expectedMessage =
'Invitation invalid, expected a value matching the XOInvitation type, but got: \n' + '- commits.0.data: Unrecognized key: "unknownData"';
expect((thrownError as InvitationInvalidError).message).toBe(expectedMessage);
};
/**
* Tests that deserializeInvitation throws InvitationJsonMalformedError for text that is not valid JSON.
*/
const testDeserializeInvitationThrowsOnMalformedJson = (): void => {
let thrownError: unknown;
try {
deserializeInvitation('{"invitationIdentifier":');
} catch (caughtError) {
thrownError = caughtError;
}
expect(thrownError).toBeInstanceOf(InvitationJsonMalformedError);
const expectedMessage = 'Invitation JSON malformed: expected a valid JSON string, but got: Unexpected end of JSON input';
expect((thrownError as InvitationJsonMalformedError).message).toBe(expectedMessage);
expect((thrownError as InvitationJsonMalformedError).reason).toBe('Unexpected end of JSON input');
};
/**
* Tests that serializeInvitation throws InvitationSerializationFailedError when the value cannot be written as JSON.
*/
const testSerializeInvitationThrowsOnCircularValue = (): void => {
// A value that refers to itself has no JSON form
const circularInvitation: Record<string, unknown> = { ...buildValidInvitation() };
circularInvitation.self = circularInvitation;
let thrownError: unknown;
try {
serializeInvitation(circularInvitation as XOInvitation);
} catch (caughtError) {
thrownError = caughtError;
}
expect(thrownError).toBeInstanceOf(InvitationSerializationFailedError);
const expectedMessage =
'Invitation serialization failed: Converting circular structure to JSON\n'
+ " --> starting at object with constructor 'Object'\n"
+ " --- property 'self' closes the circle";
expect((thrownError as InvitationSerializationFailedError).message).toBe(expectedMessage);
};
const runTests = async (): Promise<void> => {
test('parseInvitation: returns the received object for a valid invitation', testParseInvitationReturnsReceivedObject);
test('serializeInvitation: round trips bigint and Uint8Array fields', testSerializeInvitationRoundTripsBigintAndBytes);
test('parseInvitation: throws InvitationInvalidError on missing required fields', testParseInvitationThrowsOnMissingRequiredFields);
test('parseInvitation: throws InvitationInvalidError on field type violations', testParseInvitationThrowsOnFieldTypeViolations);
test('parseInvitation: throws InvitationInvalidError on unknown top-level property', testParseInvitationThrowsOnUnknownProperties);
test('parseInvitation: throws InvitationInvalidError on unknown property inside commit data', testParseInvitationThrowsOnDeepUnknownProperties);
test('deserializeInvitation: throws InvitationJsonMalformedError on malformed JSON', testDeserializeInvitationThrowsOnMalformedJson);
test('serializeInvitation: throws InvitationSerializationFailedError on a circular value', testSerializeInvitationThrowsOnCircularValue);
};
await runTests();