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:
2026-04-20 10:30:38 +00:00
parent df4f438f6d
commit ff2fe126c6
44 changed files with 8220 additions and 1503 deletions

View 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");
}
});
});

File diff suppressed because it is too large Load Diff

View 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);
}
});
});

View 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);
}
});
});

View 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" },
]);
});
});

View 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);
}
});
});

View 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
View 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
View 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(),
});

View 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
View 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);
};

File diff suppressed because it is too large Load Diff

149
tests/cli/paths.test.ts Normal file
View 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);
});
});
});