/** * 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(); }); });