280 lines
9.0 KiB
TypeScript
280 lines
9.0 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" },
|
|
]);
|
|
});
|
|
});
|