Tests. Autocomplete. Few Fixes. Mocks for Electrum Service. Template-to-Json parser. Fix global paths. Use IO Dependency injection for logging from cli. Additional commands in CLI.
This commit is contained in:
86
tests/cli/commands/handler-contracts.test.ts
Normal file
86
tests/cli/commands/handler-contracts.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { handleMnemonicCommand } from "../../../src/cli/commands/mnemonic";
|
||||
import { handleTemplateCommand } from "../../../src/cli/commands/template";
|
||||
import { handleReceiveCommand } from "../../../src/cli/commands/receive";
|
||||
import { handleResourceCommand } from "../../../src/cli/commands/resource";
|
||||
import { CommandError } from "../../../src/cli/commands/types";
|
||||
import {
|
||||
createBaseCommandDeps,
|
||||
createCommandDeps,
|
||||
createMockIO,
|
||||
} from "../mocks/command";
|
||||
|
||||
const fakeApp = {
|
||||
engine: {},
|
||||
invitations: [],
|
||||
unreserveAllResources: async () => 0,
|
||||
} as any;
|
||||
|
||||
describe("command handler contracts", () => {
|
||||
test("mnemonic throws and prints help for missing subcommand", async () => {
|
||||
const { io, capture } = createMockIO();
|
||||
|
||||
await expect(
|
||||
handleMnemonicCommand(createBaseCommandDeps(io), [], {}),
|
||||
).rejects.toThrow(CommandError);
|
||||
|
||||
try {
|
||||
await handleMnemonicCommand(createBaseCommandDeps(io), [], {});
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CommandError);
|
||||
expect((error as CommandError).event).toBe("mnemonic.subcommand.missing");
|
||||
}
|
||||
|
||||
expect(capture.out.join("\n")).toContain("Usage:");
|
||||
});
|
||||
|
||||
test("template throws for missing subcommand", async () => {
|
||||
const { io, capture } = createMockIO();
|
||||
|
||||
await expect(
|
||||
handleTemplateCommand(createCommandDeps(fakeApp, io), [], {}),
|
||||
).rejects.toThrow(CommandError);
|
||||
|
||||
try {
|
||||
await handleTemplateCommand(createCommandDeps(fakeApp, io), [], {});
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CommandError);
|
||||
expect((error as CommandError).event).toBe("template.subcommand.missing");
|
||||
}
|
||||
|
||||
expect(capture.out.join("\n")).toContain("Usage:");
|
||||
});
|
||||
|
||||
test("receive throws for missing args", async () => {
|
||||
const { io, capture } = createMockIO();
|
||||
|
||||
await expect(
|
||||
handleReceiveCommand(createCommandDeps(fakeApp, io), [], {}),
|
||||
).rejects.toThrow(CommandError);
|
||||
|
||||
try {
|
||||
await handleReceiveCommand(createCommandDeps(fakeApp, io), [], {});
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CommandError);
|
||||
expect((error as CommandError).event).toBe("receive.arguments.missing");
|
||||
}
|
||||
|
||||
expect(capture.out.join("\n")).toContain("Usage:");
|
||||
});
|
||||
|
||||
test("resource throws for unknown subcommand", async () => {
|
||||
const { io } = createMockIO();
|
||||
|
||||
await expect(
|
||||
handleResourceCommand(createCommandDeps(fakeApp, io), ["does-not-exist"], {}),
|
||||
).rejects.toThrow(CommandError);
|
||||
|
||||
try {
|
||||
await handleResourceCommand(createCommandDeps(fakeApp, io), ["does-not-exist"], {});
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(CommandError);
|
||||
expect((error as CommandError).event).toBe("resource.subcommand.unknown");
|
||||
}
|
||||
});
|
||||
});
|
||||
1341
tests/cli/commands/invitation.test.ts
Normal file
1341
tests/cli/commands/invitation.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
146
tests/cli/commands/mnemonic.test.ts
Normal file
146
tests/cli/commands/mnemonic.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { DEFAULT_SEED } from "../mocks/engine";
|
||||
|
||||
import { handleMnemonicCommand } from "../../../src/cli/commands/mnemonic";
|
||||
import { CommandError } from "../../../src/cli/commands/types";
|
||||
import { createMockIO, createMockPaths, expectLogs, type LogExpectation } from "../mocks/command";
|
||||
import { BCHMnemonicURL } from "../../../src/utils/bch-mnemonic-url";
|
||||
|
||||
type TestCase = {
|
||||
inputs: string[];
|
||||
options?: Record<string, string>;
|
||||
shouldThrow: boolean;
|
||||
expectedEvent?: string;
|
||||
expectedData?: Record<string, unknown>;
|
||||
logs?: LogExpectation[];
|
||||
};
|
||||
|
||||
const testCases: TestCase[] = [
|
||||
// Successful creation of a mnemonic file
|
||||
{
|
||||
inputs: ["create"],
|
||||
shouldThrow: false,
|
||||
expectedData: {
|
||||
savedAs: expect.stringMatching(/^mnemonic-\w+$/),
|
||||
},
|
||||
logs: [{ out: "Mnemonic file created" }],
|
||||
},
|
||||
// Successfully creating a mnemonic file with a custom filename
|
||||
{
|
||||
inputs: ["create"],
|
||||
options: { output: "custom-filename" },
|
||||
shouldThrow: false,
|
||||
expectedData: {
|
||||
savedAs: "custom-filename",
|
||||
},
|
||||
logs: [{ out: "custom-filename" }],
|
||||
},
|
||||
// Successfully listing mnemonic files
|
||||
{
|
||||
inputs: ["list"],
|
||||
shouldThrow: false,
|
||||
expectedData: {
|
||||
count: expect.toSatisfy((count: number) => count >= 1),
|
||||
},
|
||||
logs: [{ out: "mnemonic-test" }],
|
||||
},
|
||||
// Successfully exposing a mnemonic file
|
||||
{
|
||||
inputs: ["expose", "mnemonic-test"],
|
||||
shouldThrow: false,
|
||||
expectedData: {
|
||||
mnemonic: DEFAULT_SEED,
|
||||
},
|
||||
logs: [{ out: DEFAULT_SEED }],
|
||||
},
|
||||
// Successfully importing a mnemonic file
|
||||
{
|
||||
inputs: ["import", ...DEFAULT_SEED.split(" ")],
|
||||
shouldThrow: false,
|
||||
expectedData: {
|
||||
savedAs: expect.stringMatching(/^mnemonic-\w+$/),
|
||||
},
|
||||
logs: [{ out: "Mnemonic file created" }],
|
||||
},
|
||||
// Failure to import a mnemonic file due to missing arguments
|
||||
{
|
||||
inputs: ["import"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "mnemonic.import.seed_missing",
|
||||
},
|
||||
// Failure to expose a mnemonic file due to missing arguments
|
||||
{
|
||||
inputs: ["expose"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "mnemonic.expose.file_missing",
|
||||
},
|
||||
// Failure to expose a mnemonic file due to unknown mnemonic file
|
||||
{
|
||||
inputs: ["expose", "unknown-mnemonic-file"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "mnemonic.expose.file_not_found",
|
||||
},
|
||||
// Missing sub-command
|
||||
{
|
||||
inputs: [],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "mnemonic.subcommand.missing",
|
||||
},
|
||||
// Unknown sub-command
|
||||
{
|
||||
inputs: ["unknown"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "mnemonic.subcommand.unknown",
|
||||
},
|
||||
];
|
||||
|
||||
describe("mnemonic commands", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(path.join(tmpdir(), "xo-cli-mnemonic-tests-"));
|
||||
|
||||
// Write a single test mnemonic file to the temp directory
|
||||
writeFileSync(
|
||||
path.join(tempDir, "mnemonic-test"),
|
||||
BCHMnemonicURL.fromSeed(DEFAULT_SEED).toURL(),
|
||||
"utf8",
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.each(testCases)("mnemonic command: $inputs", async ({ inputs, options, shouldThrow, expectedEvent, expectedData, logs }) => {
|
||||
const { io, spies } = createMockIO();
|
||||
const paths = createMockPaths(tempDir);
|
||||
|
||||
if (shouldThrow) {
|
||||
try {
|
||||
await handleMnemonicCommand({ io, paths }, 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 handleMnemonicCommand({ io, paths }, inputs, options ?? {});
|
||||
if (expectedData) {
|
||||
Object.entries(expectedData).forEach(([key, value]) => {
|
||||
expect(result[key as keyof typeof result]).toEqual(value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (logs) {
|
||||
expectLogs(spies, logs);
|
||||
}
|
||||
});
|
||||
});
|
||||
114
tests/cli/commands/receive.test.ts
Normal file
114
tests/cli/commands/receive.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
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 { createMockAppService, createMockEngine, DEFAULT_SEED } from "../mocks/engine";
|
||||
import { type Engine } from "@xo-cash/engine";
|
||||
import { p2pkhTemplate } from "../mocks/template-p2pkh";
|
||||
import { AppService } from "../../../src/services/app";
|
||||
|
||||
import { handleReceiveCommand } from "../../../src/cli/commands/receive";
|
||||
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[] = [
|
||||
// Successful address generation with template name and output identifier
|
||||
{
|
||||
name: "generates address with template name and output",
|
||||
inputs: ["Wallet (P2PKH)", "receiveOutput"],
|
||||
shouldThrow: false,
|
||||
expectedData: {
|
||||
address: expect.stringMatching(/^bitcoincash:q[a-z0-9]+$/),
|
||||
},
|
||||
logs: [{ out: "bitcoincash:q" }],
|
||||
},
|
||||
// Successful address generation with role specified
|
||||
{
|
||||
name: "generates address with template name, output, and role",
|
||||
inputs: ["Wallet (P2PKH)", "receiveOutput", "receiver"],
|
||||
shouldThrow: false,
|
||||
expectedData: {
|
||||
address: expect.stringMatching(/^bitcoincash:q[a-z0-9]+$/),
|
||||
},
|
||||
logs: [{ out: "bitcoincash:q" }],
|
||||
},
|
||||
// Missing all required arguments
|
||||
{
|
||||
name: "throws when no arguments provided",
|
||||
inputs: [],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "receive.arguments.missing",
|
||||
},
|
||||
// Missing output identifier
|
||||
{
|
||||
name: "throws when output identifier missing",
|
||||
inputs: ["Wallet (P2PKH)"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "receive.arguments.missing",
|
||||
},
|
||||
// Unknown template
|
||||
{
|
||||
name: "throws when template not found",
|
||||
inputs: ["unknown-template", "receiveOutput"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.resolve.not_found",
|
||||
},
|
||||
];
|
||||
|
||||
describe("receive 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-receive-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 handleReceiveCommand(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 handleReceiveCommand(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);
|
||||
}
|
||||
});
|
||||
});
|
||||
279
tests/cli/commands/resource.test.ts
Normal file
279
tests/cli/commands/resource.test.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
235
tests/cli/commands/template.test.ts
Normal file
235
tests/cli/commands/template.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { createMockAppService, createMockEngine, DEFAULT_SEED } from "../mocks/engine";
|
||||
import { type Engine } from "@xo-cash/engine";
|
||||
import { p2pkhTemplate, p2pkhTemplateIdentifier } from "../mocks/template-p2pkh";
|
||||
import { AppService } from "../../../src/services/app";
|
||||
|
||||
import { handleTemplateCommand } from "../../../src/cli/commands/template";
|
||||
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 command
|
||||
{
|
||||
name: "list returns count of imported templates",
|
||||
inputs: ["list"],
|
||||
shouldThrow: false,
|
||||
expectedData: {
|
||||
count: 1,
|
||||
},
|
||||
logs: [{ out: "Wallet (P2PKH)" }],
|
||||
},
|
||||
// List by category
|
||||
{
|
||||
name: "list action returns actions for template",
|
||||
inputs: ["list", "action", p2pkhTemplateIdentifier],
|
||||
shouldThrow: false,
|
||||
expectedData: {},
|
||||
logs: [{ out: "receive" }],
|
||||
},
|
||||
{
|
||||
name: "list output returns outputs for template",
|
||||
inputs: ["list", "output", p2pkhTemplateIdentifier],
|
||||
shouldThrow: false,
|
||||
expectedData: {},
|
||||
logs: [{ out: "receiveOutput" }],
|
||||
},
|
||||
{
|
||||
name: "list variable returns variables for template",
|
||||
inputs: ["list", "variable", p2pkhTemplateIdentifier],
|
||||
shouldThrow: false,
|
||||
expectedData: {},
|
||||
logs: [{ out: "ownerKey" }],
|
||||
},
|
||||
{
|
||||
name: "list transaction returns transactions for template",
|
||||
inputs: ["list", "transaction", p2pkhTemplateIdentifier],
|
||||
shouldThrow: false,
|
||||
expectedData: {},
|
||||
},
|
||||
{
|
||||
name: "list lockingscript returns locking scripts for template",
|
||||
inputs: ["list", "lockingscript", p2pkhTemplateIdentifier],
|
||||
shouldThrow: false,
|
||||
expectedData: {},
|
||||
},
|
||||
// Inspect command
|
||||
{
|
||||
name: "inspect action returns action details",
|
||||
inputs: ["inspect", "action", p2pkhTemplateIdentifier, "receive"],
|
||||
shouldThrow: false,
|
||||
expectedData: {},
|
||||
},
|
||||
{
|
||||
name: "inspect output returns output details",
|
||||
inputs: ["inspect", "output", p2pkhTemplateIdentifier, "receiveOutput"],
|
||||
shouldThrow: false,
|
||||
expectedData: {},
|
||||
},
|
||||
{
|
||||
name: "inspect variable returns variable details",
|
||||
inputs: ["inspect", "variable", p2pkhTemplateIdentifier, "ownerKey"],
|
||||
shouldThrow: false,
|
||||
expectedData: {},
|
||||
},
|
||||
// Error cases - subcommand
|
||||
{
|
||||
name: "throws when no subcommand provided",
|
||||
inputs: [],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.subcommand.missing",
|
||||
},
|
||||
{
|
||||
name: "throws when unknown subcommand provided",
|
||||
inputs: ["unknown-subcommand"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.subcommand.unknown",
|
||||
},
|
||||
// Error cases - import
|
||||
{
|
||||
name: "throws when import called without file",
|
||||
inputs: ["import"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.import.file_missing",
|
||||
},
|
||||
{
|
||||
name: "throws when import called with non-existent file",
|
||||
inputs: ["import", "non-existent-file.json"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.import.file_not_found",
|
||||
},
|
||||
// Error cases - list category
|
||||
{
|
||||
name: "throws when list category called without template identifier",
|
||||
inputs: ["list", "action"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.list.identifier_missing",
|
||||
},
|
||||
{
|
||||
name: "throws when list category called with unknown template",
|
||||
inputs: ["list", "action", "unknown-template"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.list.not_found",
|
||||
},
|
||||
{
|
||||
name: "throws when list called with unknown category",
|
||||
inputs: ["list", "unknown-category", p2pkhTemplateIdentifier],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.list.category_unknown",
|
||||
},
|
||||
// Error cases - inspect
|
||||
{
|
||||
name: "throws when inspect called without all arguments",
|
||||
inputs: ["inspect", "action"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.inspect.arguments_missing",
|
||||
},
|
||||
{
|
||||
name: "throws when inspect called with unknown template",
|
||||
inputs: ["inspect", "action", "unknown-template", "receive"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.resolve.not_found",
|
||||
},
|
||||
{
|
||||
name: "throws when inspect called with unknown action",
|
||||
inputs: ["inspect", "action", p2pkhTemplateIdentifier, "unknown-action"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.inspect.action_missing",
|
||||
},
|
||||
{
|
||||
name: "throws when inspect called with unknown category",
|
||||
inputs: ["inspect", "unknown-category", p2pkhTemplateIdentifier, "field"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.inspect.category_unknown",
|
||||
},
|
||||
// Error cases - set-default
|
||||
{
|
||||
name: "throws when set-default called without all arguments",
|
||||
inputs: ["set-default", "template"],
|
||||
shouldThrow: true,
|
||||
expectedEvent: "template.default.arguments_missing",
|
||||
},
|
||||
];
|
||||
|
||||
describe("template 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-template-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 handleTemplateCommand(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 handleTemplateCommand(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);
|
||||
}
|
||||
});
|
||||
|
||||
test("import imports template from file", async () => {
|
||||
const templatePath = path.join(tempDir, "test-template.json");
|
||||
writeFileSync(templatePath, JSON.stringify(p2pkhTemplate));
|
||||
|
||||
const { io } = createMockIO();
|
||||
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(tempDir);
|
||||
|
||||
try {
|
||||
const result = await handleTemplateCommand(
|
||||
createCommandDeps(app, io),
|
||||
["import", "test-template.json"],
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result.templateFile).toBe("test-template.json");
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
});
|
||||
28
tests/cli/integration/entry.test.ts
Normal file
28
tests/cli/integration/entry.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
|
||||
const runCli = (args: string[]) => {
|
||||
const tsxPath = path.resolve(process.cwd(), "node_modules/.bin/tsx");
|
||||
const cliPath = path.resolve(process.cwd(), "src/cli/index.ts");
|
||||
|
||||
return spawnSync(tsxPath, [cliPath, ...args], {
|
||||
encoding: "utf8",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
};
|
||||
|
||||
describe("cli entry boundary behavior", () => {
|
||||
test("returns non-zero for incomplete mnemonic invocation", () => {
|
||||
const result = runCli(["mnemonic"]);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toContain("Usage:");
|
||||
});
|
||||
|
||||
test("returns zero for mnemonic list invocation", () => {
|
||||
const result = runCli(["mnemonic", "list"]);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
});
|
||||
192
tests/cli/mnemonic.test.ts
Normal file
192
tests/cli/mnemonic.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
createMnemonicSeed,
|
||||
createMnemonicFile,
|
||||
resolveMnemonicFilePath,
|
||||
loadMnemonic,
|
||||
listMnemonicFiles,
|
||||
} from "../../src/cli/mnemonic";
|
||||
import { BCHMnemonicURL } from "../../src/utils/bch-mnemonic-url";
|
||||
|
||||
const TEST_SEED = "page pencil stock planet limb cluster assault speak off joke private pioneer";
|
||||
|
||||
describe("mnemonic utilities", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = path.join(tmpdir(), `xo-cli-mnemonic-utils-test-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("createMnemonicSeed", () => {
|
||||
test("generates a valid BIP39 mnemonic", () => {
|
||||
const mnemonic = createMnemonicSeed();
|
||||
|
||||
expect(typeof mnemonic).toBe("string");
|
||||
const words = mnemonic.split(" ");
|
||||
expect(words.length).toBe(12);
|
||||
});
|
||||
|
||||
test("generates unique mnemonics on each call", () => {
|
||||
const mnemonic1 = createMnemonicSeed();
|
||||
const mnemonic2 = createMnemonicSeed();
|
||||
|
||||
expect(mnemonic1).not.toBe(mnemonic2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMnemonicFile", () => {
|
||||
test("creates a mnemonic file with auto-generated name", () => {
|
||||
const filename = createMnemonicFile(tempDir, TEST_SEED);
|
||||
|
||||
expect(filename).toMatch(/^mnemonic-page$/);
|
||||
expect(existsSync(path.join(tempDir, filename))).toBe(true);
|
||||
});
|
||||
|
||||
test("creates a mnemonic file with custom name", () => {
|
||||
const filename = createMnemonicFile(tempDir, TEST_SEED, "my-wallet");
|
||||
|
||||
expect(filename).toBe("my-wallet");
|
||||
expect(existsSync(path.join(tempDir, filename))).toBe(true);
|
||||
});
|
||||
|
||||
test("writes valid BCHMnemonicURL format", () => {
|
||||
const filename = createMnemonicFile(tempDir, TEST_SEED, "test-wallet");
|
||||
const content = readFileSync(path.join(tempDir, filename), "utf8");
|
||||
|
||||
expect(content).toMatch(/^bch-mnemonic:/);
|
||||
const parsed = BCHMnemonicURL.fromURL(content);
|
||||
expect(parsed).toBeDefined();
|
||||
});
|
||||
|
||||
test("sanitizes filename to basename only", () => {
|
||||
const filename = createMnemonicFile(tempDir, TEST_SEED, "../../../evil-path");
|
||||
|
||||
expect(filename).toBe("evil-path");
|
||||
expect(existsSync(path.join(tempDir, "evil-path"))).toBe(true);
|
||||
expect(existsSync(path.join(tempDir, "../../../evil-path"))).toBe(false);
|
||||
});
|
||||
|
||||
test("throws when mnemonic is empty", () => {
|
||||
expect(() => createMnemonicFile(tempDir, "")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMnemonicFilePath", () => {
|
||||
test("resolves absolute path when file exists", () => {
|
||||
const filePath = path.join(tempDir, "mnemonic-absolute");
|
||||
writeFileSync(filePath, "test");
|
||||
|
||||
const resolved = resolveMnemonicFilePath(tempDir, filePath);
|
||||
expect(resolved).toBe(filePath);
|
||||
});
|
||||
|
||||
test("resolves path relative to cwd when file exists", () => {
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(tempDir);
|
||||
|
||||
try {
|
||||
writeFileSync(path.join(tempDir, "mnemonic-relative"), "test");
|
||||
const resolved = resolveMnemonicFilePath("/nonexistent", "mnemonic-relative");
|
||||
expect(resolved).toBe(path.join(tempDir, "mnemonic-relative"));
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves from mnemonicsDir when file exists there", () => {
|
||||
writeFileSync(path.join(tempDir, "mnemonic-test"), "test");
|
||||
|
||||
const resolved = resolveMnemonicFilePath(tempDir, "mnemonic-test");
|
||||
expect(resolved).toBe(path.join(tempDir, "mnemonic-test"));
|
||||
});
|
||||
|
||||
test("throws when file not found anywhere", () => {
|
||||
expect(() => resolveMnemonicFilePath(tempDir, "nonexistent-file")).toThrow(
|
||||
/Mnemonic file not found/,
|
||||
);
|
||||
});
|
||||
|
||||
test("strips path components and looks up basename in mnemonicsDir", () => {
|
||||
writeFileSync(path.join(tempDir, "mnemonic-basename"), "test");
|
||||
|
||||
const resolved = resolveMnemonicFilePath(tempDir, "some/path/mnemonic-basename");
|
||||
expect(resolved).toBe(path.join(tempDir, "mnemonic-basename"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadMnemonic", () => {
|
||||
test("loads mnemonic from file", () => {
|
||||
createMnemonicFile(tempDir, TEST_SEED, "test-load");
|
||||
|
||||
const loaded = loadMnemonic(tempDir, "test-load");
|
||||
expect(loaded).toBe(TEST_SEED);
|
||||
});
|
||||
|
||||
test("loads mnemonic from absolute path", () => {
|
||||
const filePath = path.join(tempDir, "mnemonic-absolute-load");
|
||||
createMnemonicFile(tempDir, TEST_SEED, "mnemonic-absolute-load");
|
||||
|
||||
const loaded = loadMnemonic(tempDir, filePath);
|
||||
expect(loaded).toBe(TEST_SEED);
|
||||
});
|
||||
|
||||
test("throws when file not found", () => {
|
||||
expect(() => loadMnemonic(tempDir, "nonexistent")).toThrow(/Mnemonic file not found/);
|
||||
});
|
||||
|
||||
test("throws when file contains invalid data", () => {
|
||||
writeFileSync(path.join(tempDir, "mnemonic-invalid"), "not a valid mnemonic url");
|
||||
|
||||
expect(() => loadMnemonic(tempDir, "mnemonic-invalid")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("listMnemonicFiles", () => {
|
||||
test("returns empty array when no mnemonic files exist", () => {
|
||||
const files = listMnemonicFiles(tempDir);
|
||||
expect(files).toEqual([]);
|
||||
});
|
||||
|
||||
test("lists only files starting with 'mnemonic-'", () => {
|
||||
writeFileSync(path.join(tempDir, "mnemonic-one"), "test");
|
||||
writeFileSync(path.join(tempDir, "mnemonic-two"), "test");
|
||||
writeFileSync(path.join(tempDir, "other-file"), "test");
|
||||
writeFileSync(path.join(tempDir, "wallet.json"), "test");
|
||||
|
||||
const files = listMnemonicFiles(tempDir);
|
||||
expect(files).toHaveLength(2);
|
||||
expect(files).toContain("mnemonic-one");
|
||||
expect(files).toContain("mnemonic-two");
|
||||
expect(files).not.toContain("other-file");
|
||||
expect(files).not.toContain("wallet.json");
|
||||
});
|
||||
|
||||
test("returns sorted or consistent ordering", () => {
|
||||
writeFileSync(path.join(tempDir, "mnemonic-zebra"), "test");
|
||||
writeFileSync(path.join(tempDir, "mnemonic-alpha"), "test");
|
||||
writeFileSync(path.join(tempDir, "mnemonic-beta"), "test");
|
||||
|
||||
const files = listMnemonicFiles(tempDir);
|
||||
expect(files).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("round-trip", () => {
|
||||
test("create and load preserves mnemonic exactly", () => {
|
||||
const original = createMnemonicSeed();
|
||||
createMnemonicFile(tempDir, original, "roundtrip-test");
|
||||
|
||||
const loaded = loadMnemonic(tempDir, "roundtrip-test");
|
||||
expect(loaded).toBe(original);
|
||||
});
|
||||
});
|
||||
});
|
||||
162
tests/cli/mocks/command.ts
Normal file
162
tests/cli/mocks/command.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { vi, expect, type Mock } from "vitest";
|
||||
import type {
|
||||
BaseCommandDependencies,
|
||||
CommandDependencies,
|
||||
CommandIO,
|
||||
CommandPaths,
|
||||
} from "../../../src/cli/commands/types";
|
||||
import type { AppService } from "../../../src/services/app";
|
||||
|
||||
/**
|
||||
* Captured CLI IO buffers used by tests.
|
||||
*/
|
||||
export type MockIOCapture = {
|
||||
out: string[];
|
||||
err: string[];
|
||||
verbose: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Spy functions for each IO channel.
|
||||
*/
|
||||
export type MockIOSpies = {
|
||||
out: Mock;
|
||||
err: Mock;
|
||||
verbose: Mock;
|
||||
};
|
||||
|
||||
/**
|
||||
* Complete mock IO result including the IO adapter, capture buffers, and spies.
|
||||
*/
|
||||
export type MockIO = {
|
||||
io: CommandIO;
|
||||
capture: MockIOCapture;
|
||||
spies: MockIOSpies;
|
||||
};
|
||||
|
||||
/**
|
||||
* Defines an expected log message for assertion.
|
||||
* At least one of out, err, or verbose should be specified.
|
||||
*/
|
||||
export type LogExpectation = {
|
||||
/** Expected substring (or exact match if exact=true) in io.out */
|
||||
out?: string;
|
||||
/** Expected substring (or exact match if exact=true) in io.err */
|
||||
err?: string;
|
||||
/** Expected substring (or exact match if exact=true) in io.verbose */
|
||||
verbose?: string;
|
||||
/** If true, match the string exactly instead of using contains (default: false) */
|
||||
exact?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a command IO adapter that records every message using vi.fn() spies.
|
||||
* This enables vitest's built-in matchers like toHaveBeenCalledWith.
|
||||
*/
|
||||
export const createMockIO = (): MockIO => {
|
||||
const capture: MockIOCapture = {
|
||||
out: [],
|
||||
err: [],
|
||||
verbose: [],
|
||||
};
|
||||
|
||||
const outSpy = vi.fn((message: string) => {
|
||||
capture.out.push(message);
|
||||
});
|
||||
const errSpy = vi.fn((message: string) => {
|
||||
capture.err.push(message);
|
||||
});
|
||||
const verboseSpy = vi.fn((message: string) => {
|
||||
capture.verbose.push(message);
|
||||
});
|
||||
|
||||
const io: CommandIO = {
|
||||
out: outSpy,
|
||||
err: errSpy,
|
||||
verbose: verboseSpy,
|
||||
};
|
||||
|
||||
return {
|
||||
io,
|
||||
capture,
|
||||
spies: {
|
||||
out: outSpy,
|
||||
err: errSpy,
|
||||
verbose: verboseSpy,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Asserts that the expected log messages were printed to the appropriate IO channels.
|
||||
* @param spies - The mock IO spies from createMockIO
|
||||
* @param logs - Array of log expectations to validate
|
||||
*/
|
||||
export const expectLogs = (spies: MockIOSpies, logs: LogExpectation[]): void => {
|
||||
for (const log of logs) {
|
||||
if (log.out !== undefined) {
|
||||
if (log.exact) {
|
||||
expect(spies.out).toHaveBeenCalledWith(log.out);
|
||||
} else {
|
||||
expect(spies.out).toHaveBeenCalledWith(expect.stringContaining(log.out));
|
||||
}
|
||||
}
|
||||
if (log.err !== undefined) {
|
||||
if (log.exact) {
|
||||
expect(spies.err).toHaveBeenCalledWith(log.err);
|
||||
} else {
|
||||
expect(spies.err).toHaveBeenCalledWith(expect.stringContaining(log.err));
|
||||
}
|
||||
}
|
||||
if (log.verbose !== undefined) {
|
||||
if (log.exact) {
|
||||
expect(spies.verbose).toHaveBeenCalledWith(log.verbose);
|
||||
} else {
|
||||
expect(spies.verbose).toHaveBeenCalledWith(expect.stringContaining(log.verbose));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates mock paths for testing.
|
||||
* @param tempDir - Optional temp directory to use as base for all paths
|
||||
*/
|
||||
export const createMockPaths = (tempDir?: string): CommandPaths => {
|
||||
const base = tempDir ?? "/tmp/xo-cli-test";
|
||||
return {
|
||||
mnemonicsDir: base,
|
||||
dataDir: base,
|
||||
walletConfigPath: `${base}/.wallet`,
|
||||
workingDir: base,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates base command dependencies for commands that do not require the app.
|
||||
* @param io - Command IO adapter
|
||||
* @param paths - Optional custom paths (defaults to mock paths)
|
||||
*/
|
||||
export const createBaseCommandDeps = (
|
||||
io: CommandIO,
|
||||
paths?: CommandPaths,
|
||||
): BaseCommandDependencies => ({
|
||||
io,
|
||||
paths: paths ?? createMockPaths(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates command dependencies for app-backed command handlers.
|
||||
* @param app - App service instance
|
||||
* @param io - Command IO adapter
|
||||
* @param paths - Optional custom paths (defaults to mock paths)
|
||||
*/
|
||||
export const createCommandDeps = (
|
||||
app: AppService,
|
||||
io: CommandIO,
|
||||
paths?: CommandPaths,
|
||||
): CommandDependencies => ({
|
||||
app,
|
||||
io,
|
||||
paths: paths ?? createMockPaths(),
|
||||
});
|
||||
7
tests/cli/mocks/electrum-service.ts
Normal file
7
tests/cli/mocks/electrum-service.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export class MockElectrumService {
|
||||
constructor() {}
|
||||
|
||||
async hasSeenTransaction(transactionHash: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
164
tests/cli/mocks/engine.ts
Normal file
164
tests/cli/mocks/engine.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { BlockchainMonitor, Engine } from "@xo-cash/engine";
|
||||
|
||||
import { createStorageAdapter, State, StorageType, type UnspentOutputData } from "@xo-cash/state";
|
||||
import { InMemoryBlockchainProvider } from "@xo-cash/engine";
|
||||
import { convertMnemonicToSeedBytes } from "@xo-cash/crypto";
|
||||
|
||||
import { binToHex, sha256 } from "@bitauth/libauth";
|
||||
import { AppService } from "../../../src/services/app";
|
||||
import { InMemoryStorage } from "../../../src/services/storage";
|
||||
import { MockElectrumService } from "./electrum-service";
|
||||
|
||||
export const DEFAULT_SEED = "page pencil stock planet limb cluster assault speak off joke private pioneer";
|
||||
|
||||
/**
|
||||
* Options for creating a fake resource (UTXO) in tests.
|
||||
*/
|
||||
export type FakeResourceOptions = {
|
||||
/** Transaction hash of the outpoint. Auto-generated if not provided. */
|
||||
outpointTransactionHash?: string;
|
||||
/** Index of the outpoint in the transaction. Defaults to 0. */
|
||||
outpointIndex?: number;
|
||||
/** Value in satoshis. Defaults to 10000. */
|
||||
valueSatoshis?: number;
|
||||
/** Template identifier. Defaults to "test-template". */
|
||||
templateIdentifier?: string;
|
||||
/** Output identifier from the template. Defaults to "receiveOutput". */
|
||||
outputIdentifier?: string;
|
||||
/** Locking bytecode for this output. Defaults to a placeholder. */
|
||||
lockingBytecode?: string;
|
||||
/** Block height where the UTXO was mined. Defaults to 800000. */
|
||||
minedAtHeight?: number;
|
||||
/** Invitation identifier that reserves this output. Undefined means unreserved. */
|
||||
reservedBy?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a random 64-character hex string representing a transaction hash.
|
||||
*/
|
||||
export const randomTxHash = (): string => {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a fake resource (UTXO) to the engine's state for testing purposes.
|
||||
* @param engine - The engine instance to add the resource to.
|
||||
* @param options - Options for the fake resource. All fields have sensible defaults.
|
||||
* @returns The created UnspentOutputData object.
|
||||
*/
|
||||
export const addFakeResource = async (
|
||||
engine: Engine,
|
||||
options: FakeResourceOptions = {},
|
||||
): Promise<UnspentOutputData> => {
|
||||
const resource: UnspentOutputData = {
|
||||
status: "confirmed",
|
||||
selectable: true,
|
||||
privacy: false,
|
||||
templateIdentifier: options.templateIdentifier ?? "test-template",
|
||||
outputIdentifier: options.outputIdentifier ?? "receiveOutput",
|
||||
outpointIndex: options.outpointIndex ?? 0,
|
||||
outpointTransactionHash: options.outpointTransactionHash ?? randomTxHash(),
|
||||
minedAtHeight: options.minedAtHeight ?? 800000,
|
||||
valueSatoshis: options.valueSatoshis ?? 10000,
|
||||
lockingBytecode: options.lockingBytecode ?? "76a914000000000000000000000000000000000000000088ac",
|
||||
reservedBy: options.reservedBy,
|
||||
};
|
||||
|
||||
await engine.state.storeUnspentOutputData(resource);
|
||||
return resource;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reserves a resource for a specific invitation.
|
||||
* @param engine - The engine instance.
|
||||
* @param outpointTransactionHash - The transaction hash of the UTXO to reserve.
|
||||
* @param outpointIndex - The output index of the UTXO to reserve.
|
||||
* @param invitationIdentifier - The invitation identifier to reserve for.
|
||||
*/
|
||||
export const reserveResource = async (
|
||||
engine: Engine,
|
||||
outpointTransactionHash: string,
|
||||
outpointIndex: number,
|
||||
invitationIdentifier: string,
|
||||
): Promise<void> => {
|
||||
await engine.state.executeBulkUnspentOutputReservation(
|
||||
[{ outpointTransactionHash, outpointIndex }],
|
||||
true,
|
||||
invitationIdentifier,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Unreserves a resource from a specific invitation.
|
||||
* @param engine - The engine instance.
|
||||
* @param outpointTransactionHash - The transaction hash of the UTXO to unreserve.
|
||||
* @param outpointIndex - The output index of the UTXO to unreserve.
|
||||
* @param invitationIdentifier - The invitation identifier to unreserve from.
|
||||
*/
|
||||
export const unreserveResource = async (
|
||||
engine: Engine,
|
||||
outpointTransactionHash: string,
|
||||
outpointIndex: number,
|
||||
invitationIdentifier: string,
|
||||
): Promise<void> => {
|
||||
await engine.state.executeBulkUnspentOutputReservation(
|
||||
[{ outpointTransactionHash, outpointIndex }],
|
||||
false,
|
||||
invitationIdentifier,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a mock engine instance with a given seed. Uses the in-memory storage and blockchain provider.
|
||||
* @param seed - The seed to use for the engine.
|
||||
* @returns A mock engine instance.
|
||||
*/
|
||||
export const createMockEngine = async (seed: string) => {
|
||||
// Create the in-memory storage adapter.
|
||||
const storage = await createStorageAdapter({
|
||||
storageType: StorageType.INMEMORY,
|
||||
accountHash: binToHex(sha256.hash(convertMnemonicToSeedBytes(seed))),
|
||||
});
|
||||
|
||||
// Initialize the storage adapter.
|
||||
await storage.initialize();
|
||||
|
||||
// Create the state instance.
|
||||
const state = new State(storage);
|
||||
|
||||
// Create the in-memory blockchain provider.
|
||||
const blockchainProvider = new InMemoryBlockchainProvider();
|
||||
await blockchainProvider.initialize({
|
||||
applicationIdentifier: "xo-cli-tests",
|
||||
electrumOptions: {},
|
||||
});
|
||||
|
||||
// Create the blockchain monitor instance.
|
||||
const blockchainMonitor = new BlockchainMonitor(state, blockchainProvider);
|
||||
await blockchainMonitor.initializeEventListeners();
|
||||
|
||||
// Create the engine instance.
|
||||
const engine = new Engine(seed, state, blockchainMonitor, blockchainProvider);
|
||||
await engine.initializeStateSync();
|
||||
|
||||
return engine;
|
||||
};
|
||||
|
||||
export const createMockAppService = async (engine: Engine) => {
|
||||
const storage = await InMemoryStorage.create();
|
||||
|
||||
const electrum = new MockElectrumService();
|
||||
|
||||
const config = {
|
||||
syncServerUrl: "http://localhost:3000",
|
||||
engineConfig: {
|
||||
databasePath: "test-data",
|
||||
databaseFilename: "xo-wallet.db",
|
||||
},
|
||||
invitationStoragePath: "test-invitations.db",
|
||||
};
|
||||
|
||||
return new AppService(engine, storage, config, electrum);
|
||||
};
|
||||
1395
tests/cli/mocks/template-p2pkh.ts
Normal file
1395
tests/cli/mocks/template-p2pkh.ts
Normal file
File diff suppressed because it is too large
Load Diff
149
tests/cli/paths.test.ts
Normal file
149
tests/cli/paths.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { expect, test, describe, beforeEach, afterEach } from "vitest";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
getConfigDir,
|
||||
getMnemonicsDir,
|
||||
getDataDir,
|
||||
getWalletConfigPath,
|
||||
resolveMnemonicFilePath,
|
||||
} from "../../src/utils/paths";
|
||||
|
||||
describe("paths utilities", () => {
|
||||
describe("getConfigDir", () => {
|
||||
test("returns path under ~/.config/xo-cli", () => {
|
||||
const configDir = getConfigDir();
|
||||
|
||||
expect(configDir).toBe(path.join(homedir(), ".config", "xo-cli"));
|
||||
});
|
||||
|
||||
test("creates the directory if it does not exist", () => {
|
||||
const configDir = getConfigDir();
|
||||
|
||||
expect(existsSync(configDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMnemonicsDir", () => {
|
||||
test("returns path under config dir", () => {
|
||||
const mnemonicsDir = getMnemonicsDir();
|
||||
|
||||
expect(mnemonicsDir).toBe(path.join(homedir(), ".config", "xo-cli", "mnemonics"));
|
||||
});
|
||||
|
||||
test("creates the directory if it does not exist", () => {
|
||||
const mnemonicsDir = getMnemonicsDir();
|
||||
|
||||
expect(existsSync(mnemonicsDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDataDir", () => {
|
||||
test("returns path under config dir", () => {
|
||||
const dataDir = getDataDir();
|
||||
|
||||
expect(dataDir).toBe(path.join(homedir(), ".config", "xo-cli", "data"));
|
||||
});
|
||||
|
||||
test("creates the directory if it does not exist", () => {
|
||||
const dataDir = getDataDir();
|
||||
|
||||
expect(existsSync(dataDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWalletConfigPath", () => {
|
||||
test("returns .wallet file path under config dir", () => {
|
||||
const walletConfigPath = getWalletConfigPath();
|
||||
|
||||
expect(walletConfigPath).toBe(path.join(homedir(), ".config", "xo-cli", ".wallet"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMnemonicFilePath (global)", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = path.join(tmpdir(), `xo-cli-paths-test-${Date.now()}`);
|
||||
mkdirSync(tempDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("resolves absolute path when file exists", () => {
|
||||
const filePath = path.join(tempDir, "mnemonic-test");
|
||||
writeFileSync(filePath, "test");
|
||||
|
||||
const resolved = resolveMnemonicFilePath(filePath);
|
||||
expect(resolved).toBe(filePath);
|
||||
});
|
||||
|
||||
test("resolves path relative to cwd when file exists", () => {
|
||||
const originalCwd = process.cwd();
|
||||
process.chdir(tempDir);
|
||||
|
||||
try {
|
||||
writeFileSync(path.join(tempDir, "mnemonic-cwd-test"), "test");
|
||||
const resolved = resolveMnemonicFilePath("mnemonic-cwd-test");
|
||||
expect(resolved).toBe(path.join(tempDir, "mnemonic-cwd-test"));
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves from global mnemonics dir when file exists there", () => {
|
||||
const mnemonicsDir = getMnemonicsDir();
|
||||
const testFile = path.join(mnemonicsDir, "mnemonic-global-test");
|
||||
|
||||
try {
|
||||
writeFileSync(testFile, "test");
|
||||
const resolved = resolveMnemonicFilePath("mnemonic-global-test");
|
||||
expect(resolved).toBe(testFile);
|
||||
} finally {
|
||||
if (existsSync(testFile)) {
|
||||
rmSync(testFile);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("throws when file not found anywhere", () => {
|
||||
expect(() => resolveMnemonicFilePath("nonexistent-mnemonic-file-xyz")).toThrow(
|
||||
/Mnemonic file not found/,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not resolve absolute path if file does not exist", () => {
|
||||
const nonExistentPath = "/nonexistent/path/mnemonic-test";
|
||||
expect(() => resolveMnemonicFilePath(nonExistentPath)).toThrow(
|
||||
/Mnemonic file not found/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("path hierarchy", () => {
|
||||
test("mnemonics dir is under config dir", () => {
|
||||
const configDir = getConfigDir();
|
||||
const mnemonicsDir = getMnemonicsDir();
|
||||
|
||||
expect(mnemonicsDir.startsWith(configDir)).toBe(true);
|
||||
});
|
||||
|
||||
test("data dir is under config dir", () => {
|
||||
const configDir = getConfigDir();
|
||||
const dataDir = getDataDir();
|
||||
|
||||
expect(dataDir.startsWith(configDir)).toBe(true);
|
||||
});
|
||||
|
||||
test("wallet config is under config dir", () => {
|
||||
const configDir = getConfigDir();
|
||||
const walletConfig = getWalletConfigPath();
|
||||
|
||||
expect(walletConfig.startsWith(configDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user