import { expect, test, describe, beforeEach, afterEach } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { addFakeResource, createMockAppService, createMockEngine, DEFAULT_SEED, reserveResource } from "../mocks/engine"; import { type Engine } from "@xo-cash/engine"; import { p2pkhTemplate } from "../mocks/template-p2pkh"; import { AppService } from "../../../src/services/app"; import { handleResourceCommand } from "../../../src/cli/commands/resource"; import { CommandError } from "../../../src/cli/commands/types"; import { createCommandDeps, createMockIO, expectLogs, type LogExpectation } from "../mocks/command"; type TestCase = { name: string; inputs: string[]; options?: Record; shouldThrow: boolean; expectedEvent?: string; expectedData?: Record; logs?: LogExpectation[]; }; const testCases: TestCase[] = [ // List commands (no resources in empty wallet) { name: "list returns empty count when no resources", inputs: ["list"], shouldThrow: false, expectedData: { count: 0, }, logs: [{ out: "No resources found" }], }, { name: "list reserved returns empty count when no reserved resources", inputs: ["list", "reserved"], shouldThrow: false, expectedData: { count: 0, }, logs: [{ out: "No resources found" }], }, { name: "list all returns empty count when no resources", inputs: ["list", "all"], shouldThrow: false, expectedData: { count: 0, }, logs: [{ out: "No resources found" }], }, // Unreserve-all with no resources { name: "unreserve-all returns zero count when no reserved resources", inputs: ["unreserve-all"], shouldThrow: false, expectedData: { count: 0, }, logs: [{ out: "No reserved resources" }], }, // Error cases { name: "throws when no subcommand provided", inputs: [], shouldThrow: true, expectedEvent: "resource.subcommand.missing", }, { name: "throws when unknown subcommand provided", inputs: ["unknown-subcommand"], shouldThrow: true, expectedEvent: "resource.subcommand.unknown", }, { name: "throws when unreserve called without outpoint", inputs: ["unreserve"], shouldThrow: true, expectedEvent: "resource.unreserve.outpoint_missing", }, { name: "throws when unreserve called with invalid outpoint format (no colon)", inputs: ["unreserve", "invalid-outpoint"], shouldThrow: true, expectedEvent: "resource.unreserve.outpoint_invalid", }, { name: "throws when unreserve called with invalid outpoint format (no vout)", inputs: ["unreserve", "abc123:"], shouldThrow: true, expectedEvent: "resource.unreserve.outpoint_invalid", }, { name: "throws when unreserve called with non-existent UTXO", inputs: ["unreserve", "0000000000000000000000000000000000000000000000000000000000000000:0"], shouldThrow: true, expectedEvent: "resource.unreserve.utxo_missing", }, ]; describe("resource command", () => { let engine: Engine; let app: AppService; let tempDir: string; beforeEach(async () => { engine = await createMockEngine(DEFAULT_SEED); await engine.importTemplate(p2pkhTemplate); app = await createMockAppService(engine); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-resource-tests-")); }); afterEach(async () => { await engine.stop(); rmSync(tempDir, { recursive: true, force: true }); }); test.each(testCases)("$name", async ({ inputs, options, shouldThrow, expectedEvent, expectedData, logs }) => { const { io, spies } = createMockIO(); if (shouldThrow) { try { await handleResourceCommand(createCommandDeps(app, io), inputs, options ?? {}); expect.fail("Expected command to throw"); } catch (error) { if (expectedEvent) { expect(error).toBeInstanceOf(CommandError); expect((error as CommandError).event).toBe(expectedEvent); } } } else { const result = await handleResourceCommand(createCommandDeps(app, io), inputs, options ?? {}); if (expectedData) { Object.entries(expectedData).forEach(([key, value]) => { expect(result[key as keyof typeof result]).toEqual(value); }); } } if (logs) { expectLogs(spies, logs); } }); }); describe("resource command with populated data", () => { let engine: Engine; let app: AppService; let tempDir: string; beforeEach(async () => { engine = await createMockEngine(DEFAULT_SEED); await engine.importTemplate(p2pkhTemplate); app = await createMockAppService(engine); tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-resource-tests-")); }); afterEach(async () => { await engine.stop(); rmSync(tempDir, { recursive: true, force: true }); }); test("list returns count when resources exist", async () => { await addFakeResource(engine, { valueSatoshis: 50000 }); await addFakeResource(engine, { valueSatoshis: 25000 }); const { io, spies } = createMockIO(); const result = await handleResourceCommand(createCommandDeps(app, io), ["list"], {}); expect(result.count).toBe(2); expectLogs(spies, [{ out: "Total resources: 2" }]); }); test("list shows total satoshis", async () => { await addFakeResource(engine, { valueSatoshis: 50000 }); await addFakeResource(engine, { valueSatoshis: 25000 }); const { io, spies } = createMockIO(); await handleResourceCommand(createCommandDeps(app, io), ["list"], {}); expectLogs(spies, [{ out: "Total satoshis: 75000" }]); }); test("list excludes reserved resources by default", async () => { await addFakeResource(engine, { valueSatoshis: 50000 }); await addFakeResource(engine, { valueSatoshis: 25000, reservedBy: "inv-123" }); const { io } = createMockIO(); const result = await handleResourceCommand(createCommandDeps(app, io), ["list"], {}); expect(result.count).toBe(1); }); test("list reserved shows only reserved resources", async () => { await addFakeResource(engine, { valueSatoshis: 50000 }); await addFakeResource(engine, { valueSatoshis: 25000, reservedBy: "inv-123" }); await addFakeResource(engine, { valueSatoshis: 10000, reservedBy: "inv-456" }); const { io, spies } = createMockIO(); const result = await handleResourceCommand(createCommandDeps(app, io), ["list", "reserved"], {}); expect(result.count).toBe(2); expectLogs(spies, [{ out: "reserved for inv-123" }]); }); test("list all shows both reserved and unreserved", async () => { await addFakeResource(engine, { valueSatoshis: 50000 }); await addFakeResource(engine, { valueSatoshis: 25000, reservedBy: "inv-123" }); const { io } = createMockIO(); const result = await handleResourceCommand(createCommandDeps(app, io), ["list", "all"], {}); expect(result.count).toBe(2); }); test("unreserve releases a reserved UTXO", async () => { const resource = await addFakeResource(engine, { valueSatoshis: 25000, reservedBy: "inv-123" }); const { io, spies } = createMockIO(); await handleResourceCommand( createCommandDeps(app, io), ["unreserve", `${resource.outpointTransactionHash}:${resource.outpointIndex}`], {}, ); expectLogs(spies, [{ out: "Unreserved" }, { out: "was reserved for inv-123" }]); const resources = await engine.listUnspentOutputsData(); const target = resources.find( r => r.outpointTransactionHash === resource.outpointTransactionHash, ); expect(target?.reservedBy).toBeUndefined(); }); test("unreserve reports when UTXO is not reserved", async () => { const resource = await addFakeResource(engine, { valueSatoshis: 25000 }); const { io, spies } = createMockIO(); await handleResourceCommand( createCommandDeps(app, io), ["unreserve", `${resource.outpointTransactionHash}:${resource.outpointIndex}`], {}, ); expectLogs(spies, [{ out: "UTXO is not reserved" }]); }); test("unreserve-all releases all reserved UTXOs", async () => { await addFakeResource(engine, { valueSatoshis: 50000 }); await addFakeResource(engine, { valueSatoshis: 25000, reservedBy: "inv-123" }); await addFakeResource(engine, { valueSatoshis: 10000, reservedBy: "inv-456" }); const { io, spies } = createMockIO(); const result = await handleResourceCommand(createCommandDeps(app, io), ["unreserve-all"], {}); expect(result.count).toBe(2); expectLogs(spies, [{ out: "Unreserved" }, { out: "2" }]); const resources = await engine.listUnspentOutputsData(); const reserved = resources.filter(r => r.reservedBy); expect(reserved).toHaveLength(0); }); test("list displays outpoint information", async () => { const resource = await addFakeResource(engine, { valueSatoshis: 12345 }); const { io, spies } = createMockIO(); await handleResourceCommand(createCommandDeps(app, io), ["list"], {}); expectLogs(spies, [ { out: resource.outpointTransactionHash }, { out: "12345 sats" }, ]); }); });