Files
xo-cli/tests/cli/commands/resource.test.ts
2026-04-20 12:26:35 +00:00

362 lines
9.5 KiB
TypeScript

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<string, string>;
shouldThrow: boolean;
expectedEvent?: string;
expectedData?: Record<string, unknown>;
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" },
]);
});
});