Files
xo-cli/tests/cli/commands/invitation.test.ts

1342 lines
41 KiB
TypeScript

/**
* Comprehensive tests for the invitation CLI command.
*
* These tests are organized by flow complexity:
* 1. Error cases - Validate argument parsing and error handling
* 2. Receive flow - Simple invitation to receive funds
* 3. Request satoshis flow - Invitation with variables
* 4. Send flow - Requires UTXOs (uses addFakeResource)
* 5. Multi-step append flow - Incremental building of invitations
* 6. List and inspect - Viewing invitations
*/
import { expect, test, describe, beforeEach, afterEach } from "vitest";
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { addFakeResource, createMockAppService, createMockEngine, DEFAULT_SEED, randomTxHash } from "../mocks/engine";
import { type Engine } from "@xo-cash/engine";
import { p2pkhTemplate, p2pkhTemplateIdentifier } from "../mocks/template-p2pkh";
import { AppService } from "../../../src/services/app";
import { handleInvitationCommand } from "../../../src/cli/commands/invitation";
import { CommandError, CommandPaths } from "../../../src/cli/commands/types";
import { createCommandDeps, createMockIO, createMockPaths, expectLogs, type LogExpectation } from "../mocks/command";
// ============================================================================
// Error Cases - Validate argument parsing and error handling
// ============================================================================
type ErrorTestCase = {
name: string;
inputs: string[];
expectedEvent: string;
};
const errorTestCases: ErrorTestCase[] = [
// Subcommand errors
{
name: "throws when no subcommand provided",
inputs: [],
expectedEvent: "invitation.subcommand.missing",
},
{
name: "throws when unknown subcommand provided",
inputs: ["unknown-subcommand"],
expectedEvent: "invitation.subcommand.unknown",
},
// Create errors
{
name: "create throws when no arguments provided",
inputs: ["create"],
expectedEvent: "invitation.create.arguments_missing",
},
{
name: "create throws when only template provided (missing action)",
inputs: ["create", "Wallet (P2PKH)"],
expectedEvent: "invitation.create.arguments_missing",
},
{
name: "create throws when template not found",
inputs: ["create", "unknown-template", "receive"],
expectedEvent: "template.resolve.not_found",
},
// Append errors
{
name: "append throws when no invitation ID provided",
inputs: ["append"],
expectedEvent: "invitation.append.identifier_missing",
},
{
name: "append throws when invitation not found",
inputs: ["append", "nonexistent-invitation-id"],
expectedEvent: "invitation.append.not_found",
},
// Sign errors
{
name: "sign throws when no invitation ID provided",
inputs: ["sign"],
expectedEvent: "invitation.sign.identifier_missing",
},
{
name: "sign throws when invitation not found",
inputs: ["sign", "nonexistent-invitation-id"],
expectedEvent: "invitation.sign.not_found",
},
// Broadcast errors
{
name: "broadcast throws when no invitation ID provided",
inputs: ["broadcast"],
expectedEvent: "invitation.broadcast.identifier_missing",
},
{
name: "broadcast throws when invitation not found",
inputs: ["broadcast", "nonexistent-invitation-id"],
expectedEvent: "invitation.broadcast.not_found",
},
// Requirements errors
{
name: "requirements throws when no invitation ID provided",
inputs: ["requirements"],
expectedEvent: "invitation.requirements.identifier_missing",
},
{
name: "requirements throws when invitation not found",
inputs: ["requirements", "nonexistent-invitation-id"],
expectedEvent: "invitation.requirements.not_found",
},
// Import errors
{
name: "import throws when no file provided",
inputs: ["import"],
expectedEvent: "invitation.import.file_missing",
},
// Inspect errors
{
name: "inspect throws when no file provided",
inputs: ["inspect"],
expectedEvent: "invitation.inspect.file_missing",
},
];
describe("invitation command - error cases", () => {
let engine: Engine;
let app: AppService;
let tempDir: string;
let paths: CommandPaths;
beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED);
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-errors-"));
paths = createMockPaths(tempDir);
});
afterEach(async () => {
await engine.stop();
rmSync(tempDir, { recursive: true, force: true });
});
test.each(errorTestCases)("$name", async ({ inputs, expectedEvent }) => {
const { io } = createMockIO();
try {
await handleInvitationCommand(createCommandDeps(app, io, paths), inputs, {});
expect.fail("Expected command to throw");
} catch (error) {
expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe(expectedEvent);
}
});
});
// ============================================================================
// Receive Flow - Simple invitation to receive funds
// ============================================================================
describe("invitation command - receive flow", () => {
let engine: Engine;
let app: AppService;
let tempDir: string;
let paths: CommandPaths;
beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED);
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-receive-"));
paths = createMockPaths(tempDir);
});
afterEach(async () => {
await engine.stop();
rmSync(tempDir, { recursive: true, force: true });
});
/**
* Tests that a basic receive invitation can be created.
* The receive action generates an address for receiving funds.
*/
test("creates receive invitation successfully", async () => {
const { io, spies } = createMockIO();
const result = await handleInvitationCommand(
createCommandDeps(app, io, paths),
["create", "Wallet (P2PKH)", "receive"],
{},
);
expect(result.invitationIdentifier).toMatch(/^[a-f0-9]{32}$/);
expectLogs(spies, [{ out: "Invitation created" }]);
});
/**
* Tests that an invitation file is written to the working directory.
*/
test("writes invitation file to working directory", async () => {
const { io } = createMockIO();
const result = await handleInvitationCommand(
createCommandDeps(app, io, paths),
["create", "Wallet (P2PKH)", "receive"],
{},
);
const expectedFile = path.join(tempDir, `inv-${result.invitationIdentifier}.json`);
expect(existsSync(expectedFile)).toBe(true);
});
/**
* Tests that the invitation is tracked by the app service.
*/
test("invitation is tracked by app service", async () => {
const { io } = createMockIO();
const result = await handleInvitationCommand(
createCommandDeps(app, io, paths),
["create", "Wallet (P2PKH)", "receive"],
{},
);
const tracked = app.invitations.find(
(inv) => inv.data.invitationIdentifier === result.invitationIdentifier,
);
expect(tracked).toBeDefined();
expect(tracked?.data.actionIdentifier).toBe("receive");
});
/**
* Tests that appropriate message is shown after creation.
* The receive action for receiver role is self-complete, so it shows "All requirements satisfied".
*/
test("shows completion message after creation", async () => {
const { io, spies } = createMockIO();
await handleInvitationCommand(
createCommandDeps(app, io, paths),
["create", "Wallet (P2PKH)", "receive"],
{},
);
expectLogs(spies, [{ out: "All requirements satisfied" }]);
});
/**
* Tests that the invitation identifier is returned in the result.
*/
test("returns invitation identifier in result", async () => {
const { io } = createMockIO();
const result = await handleInvitationCommand(
createCommandDeps(app, io, paths),
["create", "Wallet (P2PKH)", "receive"],
{},
);
expect(result).toHaveProperty("invitationIdentifier");
expect(typeof result.invitationIdentifier).toBe("string");
expect(result.invitationIdentifier).toHaveLength(32);
});
});
// ============================================================================
// Request Satoshis Flow - Invitation with variables
// ============================================================================
describe("invitation command - request satoshis flow", () => {
let engine: Engine;
let app: AppService;
let tempDir: string;
let paths: CommandPaths;
beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED);
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-request-"));
paths = createMockPaths(tempDir);
});
afterEach(async () => {
await engine.stop();
rmSync(tempDir, { recursive: true, force: true });
});
/**
* Tests creating an invitation with a variable via -var-requested-satoshis.
* The requestSatoshis action requires the receiver to specify an amount.
*/
test("creates invitation with variable", async () => {
const { io, spies } = createMockIO();
const result = await handleInvitationCommand(
createCommandDeps(app, io, paths),
["create", "Wallet (P2PKH)", "requestSatoshis"],
{ varRequestedSatoshis: "10000", role: "receiver" },
);
expect(result.invitationIdentifier).toMatch(/^[a-f0-9]{32}$/);
expectLogs(spies, [{ out: "Invitation created" }]);
});
/**
* Tests that the variable is committed to the invitation.
*/
test("variable is committed to invitation", async () => {
const { io } = createMockIO();
const result = await handleInvitationCommand(
createCommandDeps(app, io, paths),
["create", "Wallet (P2PKH)", "requestSatoshis"],
{ varRequestedSatoshis: "10000", role: "receiver" },
);
const invitation = app.invitations.find(
(inv) => inv.data.invitationIdentifier === result.invitationIdentifier,
);
expect(invitation).toBeDefined();
const variables = invitation?.data.commits.flatMap((c) => c.data.variables ?? []);
const requestedSatoshis = variables?.find((v) => v.variableIdentifier === "requestedSatoshis");
expect(requestedSatoshis).toBeDefined();
expect(requestedSatoshis?.value).toBe("10000");
});
/**
* Tests that the role identifier is attached to the variable.
*/
test("role is attached to variable", async () => {
const { io } = createMockIO();
const result = await handleInvitationCommand(
createCommandDeps(app, io, paths),
["create", "Wallet (P2PKH)", "requestSatoshis"],
{ varRequestedSatoshis: "10000", role: "receiver" },
);
const invitation = app.invitations.find(
(inv) => inv.data.invitationIdentifier === result.invitationIdentifier,
);
const variables = invitation?.data.commits.flatMap((c) => c.data.variables ?? []);
const requestedSatoshis = variables?.find((v) => v.variableIdentifier === "requestedSatoshis");
expect(requestedSatoshis?.roleIdentifier).toBe("receiver");
});
});
// ============================================================================
// Send Flow - Requires UTXOs (uses addFakeResource)
// ============================================================================
describe("invitation command - send flow with resources", () => {
let engine: Engine;
let app: AppService;
let tempDir: string;
let paths: CommandPaths;
beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED);
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-send-"));
paths = createMockPaths(tempDir);
});
afterEach(async () => {
await engine.stop();
rmSync(tempDir, { recursive: true, force: true });
});
/**
* Tests that sendSatoshis invitation can be created with variables.
* Note: This doesn't add inputs yet, just tests variable handling.
*/
test("creates sendSatoshis invitation with variables", async () => {
const { io, spies } = createMockIO();
const result = await handleInvitationCommand(
createCommandDeps(app, io, paths),
["create", "Wallet (P2PKH)", "sendSatoshis"],
{
varTransferredSatoshis: "10000",
varRecipientLockingscript: "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac",
role: "sender",
},
);
expect(result.invitationIdentifier).toMatch(/^[a-f0-9]{32}$/);
expectLogs(spies, [{ out: "Invitation created" }]);
});
/**
* Tests that variables are committed to sendSatoshis invitation.
*/
test("variables are committed to sendSatoshis invitation", async () => {
const { io } = createMockIO();
const result = await handleInvitationCommand(
createCommandDeps(app, io, paths),
["create", "Wallet (P2PKH)", "sendSatoshis"],
{
varTransferredSatoshis: "10000",
varRecipientLockingscript: "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac",
role: "sender",
},
);
const invitation = app.invitations.find(
(inv) => inv.data.invitationIdentifier === result.invitationIdentifier,
);
expect(invitation).toBeDefined();
const variables = invitation?.data.commits.flatMap((c) => c.data.variables ?? []);
const transferredSatoshis = variables?.find((v) => v.variableIdentifier === "transferredSatoshis");
expect(transferredSatoshis).toBeDefined();
expect(transferredSatoshis?.value).toBe("10000");
});
/**
* Tests that an error is thrown when the specified UTXO doesn't exist.
*/
test("throws when specified UTXO not found", async () => {
const { io } = createMockIO();
try {
await handleInvitationCommand(
createCommandDeps(app, io, paths),
["create", "Wallet (P2PKH)", "sendSatoshis"],
{
varTransferredSatoshis: "10000",
varRecipientLockingscript: "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac",
addInput: "0000000000000000000000000000000000000000000000000000000000000000:0",
role: "sender",
},
);
expect.fail("Expected command to throw");
} catch (error) {
expect(error).toBeInstanceOf(CommandError);
}
});
/**
* Tests that fake resources can be added and listed.
* This validates our test infrastructure works correctly.
*/
test("fake resources are accessible via engine", async () => {
const resource = await addFakeResource(engine, {
valueSatoshis: 50000,
templateIdentifier: p2pkhTemplateIdentifier,
outputIdentifier: "receiveOutput",
});
const allResources = await engine.listUnspentOutputsData();
const found = allResources.find(
(r) =>
r.outpointTransactionHash === resource.outpointTransactionHash &&
r.outpointIndex === resource.outpointIndex,
);
expect(found).toBeDefined();
expect(found?.valueSatoshis).toBe(50000);
});
});
// ============================================================================
// Multi-Step Append Flow - Incremental building of invitations
// ============================================================================
describe("invitation command - multi-step append", () => {
let engine: Engine;
let app: AppService;
let tempDir: string;
let paths: CommandPaths;
beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED);
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-append-"));
paths = createMockPaths(tempDir);
});
afterEach(async () => {
await engine.stop();
rmSync(tempDir, { recursive: true, force: true });
});
/**
* Tests appending variables to an existing invitation.
*/
test("appends variables to existing invitation", async () => {
const { io: createIO } = createMockIO();
const createResult = await handleInvitationCommand(
createCommandDeps(app, createIO, paths),
["create", "Wallet (P2PKH)", "requestSatoshis"],
{},
);
const { io: appendIO, spies } = createMockIO();
await handleInvitationCommand(
createCommandDeps(app, appendIO, paths),
["append", createResult.invitationIdentifier!],
{ varRequestedSatoshis: "25000", role: "receiver" },
);
expectLogs(spies, [{ out: "Invitation appended" }]);
const invitation = app.invitations.find(
(inv) => inv.data.invitationIdentifier === createResult.invitationIdentifier,
);
const variables = invitation?.data.commits.flatMap((c) => c.data.variables ?? []);
const requestedSatoshis = variables?.find((v) => v.variableIdentifier === "requestedSatoshis");
expect(requestedSatoshis?.value).toBe("25000");
});
/**
* Tests that append succeeds when only variables are provided.
* When outputs are auto-discovered from the template, append can succeed with just variables.
*/
test("append succeeds with just variables on fresh invitation", async () => {
const { io: createIO } = createMockIO();
const createResult = await handleInvitationCommand(
createCommandDeps(app, createIO, paths),
["create", "Wallet (P2PKH)", "requestSatoshis"],
{},
);
const { io: appendIO, spies } = createMockIO();
await handleInvitationCommand(
createCommandDeps(app, appendIO, paths),
["append", createResult.invitationIdentifier!],
{ varRequestedSatoshis: "5000", role: "receiver" },
);
expectLogs(spies, [{ out: "Invitation appended" }]);
});
/**
* Tests that the invitation file is updated after append.
*/
test("updates invitation file after append", async () => {
const { io: createIO } = createMockIO();
const createResult = await handleInvitationCommand(
createCommandDeps(app, createIO, paths),
["create", "Wallet (P2PKH)", "requestSatoshis"],
{},
);
const { io: appendIO, spies } = createMockIO();
await handleInvitationCommand(
createCommandDeps(app, appendIO, paths),
["append", createResult.invitationIdentifier!],
{ varRequestedSatoshis: "25000", role: "receiver" },
);
expectLogs(spies, [{ out: "Invitation updated" }]);
const expectedFile = path.join(tempDir, `inv-${createResult.invitationIdentifier}.json`);
expect(existsSync(expectedFile)).toBe(true);
});
/**
* Tests that multiple append operations can add variables incrementally.
* Uses requestSatoshis which has straightforward requirements.
*/
test("multiple appends add variables incrementally", async () => {
const { io: createIO } = createMockIO();
const createResult = await handleInvitationCommand(
createCommandDeps(app, createIO, paths),
["create", "Wallet (P2PKH)", "requestSatoshis"],
{},
);
const { io: append1IO } = createMockIO();
await handleInvitationCommand(
createCommandDeps(app, append1IO, paths),
["append", createResult.invitationIdentifier!],
{ varRequestedSatoshis: "10000", role: "receiver" },
);
const invitation = app.invitations.find(
(inv) => inv.data.invitationIdentifier === createResult.invitationIdentifier,
);
expect(invitation?.data.commits.length).toBeGreaterThan(1);
const variables = invitation?.data.commits.flatMap((c) => c.data.variables ?? []);
const requestedSatoshis = variables?.find((v) => v.variableIdentifier === "requestedSatoshis");
expect(requestedSatoshis?.value).toBe("10000");
});
});
// ============================================================================
// List and Inspect - Viewing invitations
// ============================================================================
describe("invitation command - list and inspect", () => {
let engine: Engine;
let app: AppService;
let tempDir: string;
let paths: CommandPaths;
beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED);
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-list-"));
paths = createMockPaths(tempDir);
});
afterEach(async () => {
await engine.stop();
rmSync(tempDir, { recursive: true, force: true });
});
/**
* Tests that list returns count of created invitations.
*/
test("list returns count of invitations", async () => {
const { io: createIO } = createMockIO();
await handleInvitationCommand(
createCommandDeps(app, createIO, paths),
["create", "Wallet (P2PKH)", "receive"],
{},
);
const { io: listIO } = createMockIO();
const result = await handleInvitationCommand(
createCommandDeps(app, listIO, paths),
["list"],
{},
);
expect(result.count).toBe(1);
});
/**
* Tests that list shows template name.
*/
test("list shows template name", async () => {
const { io: createIO } = createMockIO();
await handleInvitationCommand(
createCommandDeps(app, createIO, paths),
["create", "Wallet (P2PKH)", "receive"],
{},
);
const { io: listIO, spies } = createMockIO();
await handleInvitationCommand(
createCommandDeps(app, listIO, paths),
["list"],
{},
);
expectLogs(spies, [{ out: "Wallet (P2PKH)" }]);
});
/**
* Tests that list returns zero when no invitations exist.
*/
test("list returns zero when no invitations", async () => {
const { io } = createMockIO();
const result = await handleInvitationCommand(
createCommandDeps(app, io, paths),
["list"],
{},
);
expect(result.count).toBe(0);
});
/**
* Tests that requirements shows invitation requirements.
*/
test("requirements shows invitation requirements", async () => {
const { io: createIO } = createMockIO();
const createResult = await handleInvitationCommand(
createCommandDeps(app, createIO, paths),
["create", "Wallet (P2PKH)", "receive"],
{},
);
const { io: reqIO, spies } = createMockIO();
const result = await handleInvitationCommand(
createCommandDeps(app, reqIO, paths),
["requirements", createResult.invitationIdentifier!],
{},
);
expect(result.invitationIdentifier).toBe(createResult.invitationIdentifier);
expect(spies.out).toHaveBeenCalled();
});
/**
* Tests that inspect reads an invitation file and shows details.
*/
test("inspect reads invitation file", async () => {
const { io: createIO } = createMockIO();
const createResult = await handleInvitationCommand(
createCommandDeps(app, createIO, paths),
["create", "Wallet (P2PKH)", "receive"],
{},
);
const invitationFilePath = path.join(tempDir, `inv-${createResult.invitationIdentifier}.json`);
const { io: inspectIO } = createMockIO();
const result = await handleInvitationCommand(
createCommandDeps(app, inspectIO, paths),
["inspect", invitationFilePath],
{},
);
expect(result.templateName).toBe("Wallet (P2PKH)");
expect(result.actionIdentifier).toBe("receive");
expect(result.status).toBeDefined();
});
/**
* Tests that multiple invitations can be listed.
*/
test("list shows multiple invitations", async () => {
const { io: create1IO } = createMockIO();
await handleInvitationCommand(
createCommandDeps(app, create1IO, paths),
["create", "Wallet (P2PKH)", "receive"],
{},
);
const { io: create2IO } = createMockIO();
await handleInvitationCommand(
createCommandDeps(app, create2IO, paths),
["create", "Wallet (P2PKH)", "requestSatoshis"],
{},
);
const { io: listIO } = createMockIO();
const result = await handleInvitationCommand(
createCommandDeps(app, listIO, paths),
["list"],
{},
);
expect(result.count).toBe(2);
});
});
// ============================================================================
// Sign Flow - Signing invitations
// ============================================================================
describe("invitation command - sign flow", () => {
let engine: Engine;
let app: AppService;
let tempDir: string;
let paths: CommandPaths;
beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED);
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-sign-"));
paths = createMockPaths(tempDir);
});
afterEach(async () => {
await engine.stop();
rmSync(tempDir, { recursive: true, force: true });
});
/**
* Tests that a satisfied invitation can be signed.
* The receive action is self-complete, so it can be signed immediately.
*/
test("signs a satisfied invitation", async () => {
const { io: createIO } = createMockIO();
const createResult = await handleInvitationCommand(
createCommandDeps(app, createIO, paths),
["create", "Wallet (P2PKH)", "receive"],
{},
);
const { io: signIO, spies } = createMockIO();
const signResult = await handleInvitationCommand(
createCommandDeps(app, signIO, paths),
["sign", createResult.invitationIdentifier!],
{},
);
expect(signResult.invitationIdentifier).toBe(createResult.invitationIdentifier);
expectLogs(spies, [{ out: "Invitation signed" }]);
});
/**
* Tests that the invitation has signed commits after signing.
* Note: Status check depends on internal Invitation class logic which
* may require specific conditions (inputs with mergesWith and unlockingBytecode).
*/
test("invitation has signed commits after signing", async () => {
const { io: createIO } = createMockIO();
const createResult = await handleInvitationCommand(
createCommandDeps(app, createIO, paths),
["create", "Wallet (P2PKH)", "receive"],
{},
);
const { io: signIO } = createMockIO();
await handleInvitationCommand(
createCommandDeps(app, signIO, paths),
["sign", createResult.invitationIdentifier!],
{},
);
const invitation = app.invitations.find(
(inv) => inv.data.invitationIdentifier === createResult.invitationIdentifier,
);
expect(invitation).toBeDefined();
expect(invitation?.data.commits.length).toBeGreaterThan(0);
});
/**
* Tests the --sign flag on create auto-signs when requirements are satisfied.
*/
test("--sign flag auto-signs on create", async () => {
const { io, spies } = createMockIO();
const result = await handleInvitationCommand(
createCommandDeps(app, io, paths),
["create", "Wallet (P2PKH)", "receive"],
{ sign: "true" },
);
expect(result.invitationIdentifier).toMatch(/^[a-f0-9]{32}$/);
expectLogs(spies, [
{ out: "Invitation created" },
{ out: "Invitation signed" },
]);
});
/**
* Tests that signing fails when invitation doesn't exist.
*/
test("sign throws when invitation not found", async () => {
const { io } = createMockIO();
try {
await handleInvitationCommand(
createCommandDeps(app, io, paths),
["sign", "nonexistent-id"],
{},
);
expect.fail("Expected command to throw");
} catch (error) {
expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe("invitation.sign.not_found");
}
});
});
// ============================================================================
// Import Flow - Importing invitation files
// ============================================================================
describe("invitation command - import flow", () => {
let engine: Engine;
let app: AppService;
let tempDir: string;
let paths: CommandPaths;
beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED);
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-import-"));
paths = createMockPaths(tempDir);
});
afterEach(async () => {
await engine.stop();
rmSync(tempDir, { recursive: true, force: true });
});
/**
* Tests that an invitation file can be imported.
*/
test("imports invitation from file", async () => {
const { io: createIO } = createMockIO();
const createResult = await handleInvitationCommand(
createCommandDeps(app, createIO, paths),
["create", "Wallet (P2PKH)", "receive"],
{},
);
const invitationFilePath = path.join(tempDir, `inv-${createResult.invitationIdentifier}.json`);
const secondApp = await createMockAppService(engine);
const { io: importIO } = createMockIO();
const importResult = await handleInvitationCommand(
createCommandDeps(secondApp, importIO, paths),
["import", invitationFilePath],
{},
);
expect(importResult.invitationIdentifier).toBeDefined();
});
/**
* Tests that import fails when file doesn't exist.
*/
test("import throws when file not found", async () => {
const { io } = createMockIO();
try {
await handleInvitationCommand(
createCommandDeps(app, io, paths),
["import", "/nonexistent/path/invitation.json"],
{},
);
expect.fail("Expected command to throw");
} catch (error) {
expect(error).toBeDefined();
}
});
/**
* Tests that imported invitation is tracked by app service.
*/
test("imported invitation is tracked by app service", async () => {
const { io: createIO } = createMockIO();
const createResult = await handleInvitationCommand(
createCommandDeps(app, createIO, paths),
["create", "Wallet (P2PKH)", "receive"],
{},
);
const invitationFilePath = path.join(tempDir, `inv-${createResult.invitationIdentifier}.json`);
const secondApp = await createMockAppService(engine);
const { io: importIO } = createMockIO();
await handleInvitationCommand(
createCommandDeps(secondApp, importIO, paths),
["import", invitationFilePath],
{},
);
expect(secondApp.invitations.length).toBe(1);
});
/**
* Tests that an imported invitation retains its data.
*/
test("imported invitation retains action identifier", async () => {
const { io: createIO } = createMockIO();
const createResult = await handleInvitationCommand(
createCommandDeps(app, createIO, paths),
["create", "Wallet (P2PKH)", "receive"],
{},
);
const invitationFilePath = path.join(tempDir, `inv-${createResult.invitationIdentifier}.json`);
const secondApp = await createMockAppService(engine);
const { io: importIO } = createMockIO();
await handleInvitationCommand(
createCommandDeps(secondApp, importIO, paths),
["import", invitationFilePath],
{},
);
const imported = secondApp.invitations[0];
expect(imported.data.actionIdentifier).toBe("receive");
expect(imported.data.templateIdentifier).toBe(p2pkhTemplateIdentifier);
});
});
// ============================================================================
// Auto-Inputs Flow - Automatic UTXO selection
// ============================================================================
describe("invitation command - auto-inputs flow", () => {
let engine: Engine;
let app: AppService;
let tempDir: string;
let paths: CommandPaths;
beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED);
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-autoinputs-"));
paths = createMockPaths(tempDir);
});
afterEach(async () => {
await engine.stop();
rmSync(tempDir, { recursive: true, force: true });
});
/**
* Tests that --auto-inputs flag triggers automatic UTXO selection.
* When no suitable UTXOs exist, it should fail gracefully.
*/
test("auto-inputs fails when no suitable UTXOs available", async () => {
const { io } = createMockIO();
try {
await handleInvitationCommand(
createCommandDeps(app, io, paths),
["create", "Wallet (P2PKH)", "sendSatoshis"],
{
varTransferredSatoshis: "10000",
varRecipientLockingscript: "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac",
role: "sender",
autoInputs: "true",
},
);
expect.fail("Expected command to throw");
} catch (error) {
expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe("invitation.create.append_params_failed");
}
});
/**
* Tests that auto-inputs logs when no UTXOs are found.
*/
test("auto-inputs logs error when no UTXOs found", async () => {
const { io, spies } = createMockIO();
try {
await handleInvitationCommand(
createCommandDeps(app, io, paths),
["create", "Wallet (P2PKH)", "sendSatoshis"],
{
varTransferredSatoshis: "10000",
varRecipientLockingscript: "76a91489abcdefabbaabbaabbaabbaabbaabbaabbaabba88ac",
role: "sender",
autoInputs: "true",
},
);
} catch (error) {
expectLogs(spies, [{ err: "No suitable UTXOs found" }]);
}
});
});
// ============================================================================
// Broadcast Flow - Broadcasting invitations
// ============================================================================
describe("invitation command - broadcast flow", () => {
let engine: Engine;
let app: AppService;
let tempDir: string;
let paths: CommandPaths;
beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED);
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-broadcast-"));
paths = createMockPaths(tempDir);
});
afterEach(async () => {
await engine.stop();
rmSync(tempDir, { recursive: true, force: true });
});
/**
* Tests that broadcast fails when invitation not found.
*/
test("broadcast throws when invitation not found", async () => {
const { io } = createMockIO();
try {
await handleInvitationCommand(
createCommandDeps(app, io, paths),
["broadcast", "nonexistent-id"],
{},
);
expect.fail("Expected command to throw");
} catch (error) {
expect(error).toBeInstanceOf(CommandError);
expect((error as CommandError).event).toBe("invitation.broadcast.not_found");
}
});
/**
* Tests the --broadcast flag on create.
* Note: This will attempt to broadcast, which may fail in test environment
* without proper mocking, but the flag should be recognized.
*/
test("--broadcast flag is recognized on create", async () => {
const { io } = createMockIO();
try {
await handleInvitationCommand(
createCommandDeps(app, io, paths),
["create", "Wallet (P2PKH)", "receive"],
{ broadcast: "true" },
);
} catch (error) {
expect((error as Error).message).not.toContain("unknown option");
}
});
});
// ============================================================================
// Full Lifecycle - End-to-end flows
// ============================================================================
describe("invitation command - full lifecycle", () => {
let engine: Engine;
let app: AppService;
let tempDir: string;
let paths: CommandPaths;
beforeEach(async () => {
engine = await createMockEngine(DEFAULT_SEED);
await engine.importTemplate(p2pkhTemplate);
app = await createMockAppService(engine);
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-invitation-lifecycle-"));
paths = createMockPaths(tempDir);
});
afterEach(async () => {
await engine.stop();
rmSync(tempDir, { recursive: true, force: true });
});
/**
* Tests complete create → sign flow for receive action.
* The receive action is self-complete and can be signed immediately.
*/
test("create → sign flow for receive", async () => {
const { io: createIO, spies: createSpies } = createMockIO();
const createResult = await handleInvitationCommand(
createCommandDeps(app, createIO, paths),
["create", "Wallet (P2PKH)", "receive"],
{},
);
expect(createResult.invitationIdentifier).toMatch(/^[a-f0-9]{32}$/);
expectLogs(createSpies, [{ out: "Invitation created" }]);
const { io: signIO, spies: signSpies } = createMockIO();
const signResult = await handleInvitationCommand(
createCommandDeps(app, signIO, paths),
["sign", createResult.invitationIdentifier!],
{},
);
expect(signResult.invitationIdentifier).toBe(createResult.invitationIdentifier);
expectLogs(signSpies, [{ out: "Invitation signed" }]);
const invitation = app.invitations.find(
(inv) => inv.data.invitationIdentifier === createResult.invitationIdentifier,
);
expect(invitation).toBeDefined();
expect(invitation?.data.commits.length).toBeGreaterThan(0);
});
/**
* Tests that invitation file is persisted and can be inspected after creation.
*/
test("create → inspect round-trip", async () => {
const { io: createIO } = createMockIO();
const createResult = await handleInvitationCommand(
createCommandDeps(app, createIO, paths),
["create", "Wallet (P2PKH)", "receive"],
{},
);
const invitationFilePath = path.join(tempDir, `inv-${createResult.invitationIdentifier}.json`);
expect(existsSync(invitationFilePath)).toBe(true);
const { io: inspectIO } = createMockIO();
const inspectResult = await handleInvitationCommand(
createCommandDeps(app, inspectIO, paths),
["inspect", invitationFilePath],
{},
);
expect(inspectResult.templateName).toBe("Wallet (P2PKH)");
expect(inspectResult.actionIdentifier).toBe("receive");
});
/**
* Tests create → append → sign flow for requestSatoshis.
* First creates without variables, then appends them, then signs.
*/
test("create → append → sign flow for requestSatoshis", async () => {
const { io: createIO } = createMockIO();
const createResult = await handleInvitationCommand(
createCommandDeps(app, createIO, paths),
["create", "Wallet (P2PKH)", "requestSatoshis"],
{},
);
const { io: appendIO } = createMockIO();
await handleInvitationCommand(
createCommandDeps(app, appendIO, paths),
["append", createResult.invitationIdentifier!],
{ varRequestedSatoshis: "10000", role: "receiver" },
);
const { io: signIO, spies: signSpies } = createMockIO();
const signResult = await handleInvitationCommand(
createCommandDeps(app, signIO, paths),
["sign", createResult.invitationIdentifier!],
{},
);
expect(signResult.invitationIdentifier).toBe(createResult.invitationIdentifier);
expectLogs(signSpies, [{ out: "Invitation signed" }]);
});
/**
* Tests that invitation file is updated after each operation.
*/
test("invitation file is updated at each step", async () => {
const { io: createIO } = createMockIO();
const createResult = await handleInvitationCommand(
createCommandDeps(app, createIO, paths),
["create", "Wallet (P2PKH)", "requestSatoshis"],
{},
);
const invitationFilePath = path.join(tempDir, `inv-${createResult.invitationIdentifier}.json`);
const afterCreate = JSON.parse(readFileSync(invitationFilePath, "utf-8"));
const createCommitCount = afterCreate.commits?.length ?? 0;
const { io: appendIO } = createMockIO();
await handleInvitationCommand(
createCommandDeps(app, appendIO, paths),
["append", createResult.invitationIdentifier!],
{ varRequestedSatoshis: "10000", role: "receiver" },
);
const afterAppend = JSON.parse(readFileSync(invitationFilePath, "utf-8"));
const appendCommitCount = afterAppend.commits?.length ?? 0;
expect(appendCommitCount).toBeGreaterThan(createCommitCount);
});
/**
* Tests create with --sign flag for immediate signing.
*/
test("create with --sign for immediate signing", async () => {
const { io, spies } = createMockIO();
const result = await handleInvitationCommand(
createCommandDeps(app, io, paths),
["create", "Wallet (P2PKH)", "receive"],
{ sign: "true" },
);
expect(result.invitationIdentifier).toMatch(/^[a-f0-9]{32}$/);
const invitation = app.invitations.find(
(inv) => inv.data.invitationIdentifier === result.invitationIdentifier,
);
expect(invitation).toBeDefined();
expect(invitation?.data.commits.length).toBeGreaterThan(0);
expectLogs(spies, [
{ out: "Invitation created" },
{ out: "Invitation signed" },
]);
});
/**
* Tests that requirements can be checked at any point.
*/
test("requirements can be checked mid-flow", async () => {
const { io: createIO } = createMockIO();
const createResult = await handleInvitationCommand(
createCommandDeps(app, createIO, paths),
["create", "Wallet (P2PKH)", "requestSatoshis"],
{},
);
const { io: reqIO, spies } = createMockIO();
const reqResult = await handleInvitationCommand(
createCommandDeps(app, reqIO, paths),
["requirements", createResult.invitationIdentifier!],
{},
);
expect(reqResult.invitationIdentifier).toBe(createResult.invitationIdentifier);
expect(spies.out).toHaveBeenCalled();
});
});