367 lines
9.7 KiB
TypeScript
367 lines
9.7 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";
|
|
import { State } from "@xo-cash/state";
|
|
|
|
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 () => {
|
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
|
engine = mockEngine.engine;
|
|
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 state: State;
|
|
let app: AppService;
|
|
let tempDir: string;
|
|
|
|
beforeEach(async () => {
|
|
const mockEngine = await createMockEngine(DEFAULT_SEED);
|
|
engine = mockEngine.engine;
|
|
state = mockEngine.state;
|
|
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(state, { valueSatoshis: 50000 });
|
|
await addFakeResource(state, { 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(state, { valueSatoshis: 50000 });
|
|
await addFakeResource(state, { 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(state, { valueSatoshis: 50000 });
|
|
await addFakeResource(state, {
|
|
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(state, { valueSatoshis: 50000 });
|
|
await addFakeResource(state, {
|
|
valueSatoshis: 25000,
|
|
reservedBy: "inv-123",
|
|
});
|
|
await addFakeResource(state, {
|
|
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(state, { valueSatoshis: 50000 });
|
|
await addFakeResource(state, {
|
|
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(state, {
|
|
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(state, { 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(state, { valueSatoshis: 50000 });
|
|
await addFakeResource(state, {
|
|
valueSatoshis: 25000,
|
|
reservedBy: "inv-123",
|
|
});
|
|
await addFakeResource(state, {
|
|
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(state, { valueSatoshis: 12345 });
|
|
|
|
const { io, spies } = createMockIO();
|
|
await handleResourceCommand(createCommandDeps(app, io), ["list"], {});
|
|
|
|
expectLogs(spies, [
|
|
{ out: resource.outpointTransactionHash },
|
|
{ out: "12345 sats" },
|
|
]);
|
|
});
|
|
});
|