test(FN-1283): expand CLI settings and routing coverage

- Add comprehensive settings-import tests for global/project scope handling and error paths
- Add settings-export tests covering output targets, filtering, and serialization behavior
- Expand project-resolver test matrix for flag/default/CWD resolution and fallback cases
- Extend bin entrypoint tests for command routing, argument forwarding, and failure handling
This commit is contained in:
gsxdsm
2026-04-08 10:38:15 -07:00
parent a4bba623fa
commit 110971333f
4 changed files with 932 additions and 8 deletions

View File

@@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { writeFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { TaskStore, exportSettings, generateExportFilename } from "@fusion/core";
import { resolveProject } from "../../project-context.js";
const mockStoreInit = vi.fn().mockResolvedValue(undefined);
vi.mock("node:fs/promises", () => ({
writeFile: vi.fn(),
}));
vi.mock("@fusion/core", () => ({
TaskStore: vi.fn().mockImplementation(() => ({
init: mockStoreInit,
})),
exportSettings: vi.fn(),
generateExportFilename: vi.fn(),
}));
vi.mock("../../project-context.js", () => ({
resolveProject: vi.fn(),
}));
import { runSettingsExport } from "../settings-export.js";
describe("runSettingsExport", () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
if ((code ?? 0) === 0) {
return undefined as never;
}
throw new Error(`process.exit:${code ?? 0}`);
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj-1",
projectName: "demo",
projectPath: "/tmp/demo",
isRegistered: true,
store: {} as any,
});
vi.mocked(generateExportFilename).mockReturnValue("fusion-settings-2026-04-08-120000.json");
vi.mocked(exportSettings).mockResolvedValue({
version: 1,
exportedAt: "2026-04-08T12:00:00.000Z",
global: { ntfyEnabled: true, defaultProvider: "anthropic" },
project: { maxConcurrent: 3, maxWorktrees: 4 },
} as any);
vi.mocked(writeFile).mockResolvedValue(undefined);
});
afterEach(() => {
vi.clearAllMocks();
});
it("exports successfully with an auto-generated filename", async () => {
await runSettingsExport();
const expectedPath = join(process.cwd(), "fusion-settings-2026-04-08-120000.json");
expect(generateExportFilename).toHaveBeenCalled();
expect(writeFile).toHaveBeenCalledWith(expectedPath, expect.any(String));
expect(logSpy).toHaveBeenCalledWith(` ✓ Settings exported to ${expectedPath}`);
expect(logSpy).toHaveBeenCalledWith(" Exported: 2 global setting(s), 2 project setting(s)");
expect(exitSpy).toHaveBeenCalledWith(0);
});
it("exports successfully with a custom --output path", async () => {
await runSettingsExport({ output: "./tmp/exported.json" });
expect(writeFile).toHaveBeenCalledWith(resolve("./tmp/exported.json"), expect.any(String));
});
it("exports only global settings with --scope global", async () => {
vi.mocked(exportSettings).mockResolvedValue({
version: 1,
exportedAt: "2026-04-08T12:00:00.000Z",
global: { ntfyEnabled: true },
} as any);
await runSettingsExport({ scope: "global" });
expect(exportSettings).toHaveBeenCalledWith(expect.any(Object), { scope: "global" });
expect(logSpy).toHaveBeenCalledWith(" Exported: 1 global setting(s)");
expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("project setting"));
});
it("exports only project settings with --scope project", async () => {
vi.mocked(exportSettings).mockResolvedValue({
version: 1,
exportedAt: "2026-04-08T12:00:00.000Z",
project: { maxConcurrent: 6 },
} as any);
await runSettingsExport({ scope: "project" });
expect(exportSettings).toHaveBeenCalledWith(expect.any(Object), { scope: "project" });
expect(logSpy).toHaveBeenCalledWith(" Exported: 1 project setting(s)");
expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("global setting"));
});
it("handles export with empty global/project settings", async () => {
vi.mocked(exportSettings).mockResolvedValue({
version: 1,
exportedAt: "2026-04-08T12:00:00.000Z",
global: {},
project: {},
} as any);
await runSettingsExport();
expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining("Exported:"));
expect(exitSpy).toHaveBeenCalledWith(0);
});
it("prints an error and exits when exportSettings throws", async () => {
vi.mocked(exportSettings).mockRejectedValue(new Error("export failed"));
await expect(runSettingsExport()).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: export failed");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("prints an error and exits when writing the file fails", async () => {
vi.mocked(writeFile).mockRejectedValue(new Error("permission denied"));
await expect(runSettingsExport()).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: permission denied");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("resolves project name and initializes store at the resolved path", async () => {
await runSettingsExport({ projectName: "alpha" });
expect(resolveProject).toHaveBeenCalledWith("alpha");
expect(TaskStore).toHaveBeenCalledWith("/tmp/demo");
expect(mockStoreInit).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,194 @@
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { existsSync } from "node:fs";
import { TaskStore, importSettings, readExportFile, validateImportData } from "@fusion/core";
import { resolveProject } from "../../project-context.js";
const mockStoreInit = vi.fn().mockResolvedValue(undefined);
vi.mock("node:fs", () => ({
existsSync: vi.fn(),
}));
vi.mock("@fusion/core", () => ({
TaskStore: vi.fn().mockImplementation(() => ({
init: mockStoreInit,
})),
importSettings: vi.fn(),
readExportFile: vi.fn(),
validateImportData: vi.fn(),
}));
vi.mock("../../project-context.js", () => ({
resolveProject: vi.fn(),
}));
import { runSettingsImport } from "../settings-import.js";
describe("runSettingsImport", () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
if ((code ?? 0) === 0) {
return undefined as never;
}
throw new Error(`process.exit:${code ?? 0}`);
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj-1",
projectName: "demo",
projectPath: "/tmp/demo",
isRegistered: true,
store: {} as any,
});
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readExportFile).mockResolvedValue({
version: 1,
exportedAt: "2026-04-08T00:00:00.000Z",
global: { ntfyEnabled: true, defaultProvider: "anthropic" },
project: { maxConcurrent: 3, autoResolveConflicts: true, maxWorktrees: 4 },
} as any);
vi.mocked(validateImportData).mockReturnValue([]);
vi.mocked(importSettings).mockResolvedValue({
success: true,
globalCount: 2,
projectCount: 3,
});
});
afterEach(() => {
vi.clearAllMocks();
});
it("exits when import file does not exist", async () => {
vi.mocked(existsSync).mockReturnValue(false);
await expect(runSettingsImport("./missing.json", { yes: true })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("File not found"));
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("exits when reading the import file fails", async () => {
vi.mocked(readExportFile).mockRejectedValue(new Error("read denied"));
await expect(runSettingsImport("./settings.json", { yes: true })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: Failed to read import file: read denied");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("prints validation errors and exits", async () => {
vi.mocked(validateImportData).mockReturnValue(["bad version", "missing global"]);
await expect(runSettingsImport("./settings.json", { yes: true })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: Invalid import file:");
expect(errorSpy).toHaveBeenCalledWith(" - bad version");
expect(errorSpy).toHaveBeenCalledWith(" - missing global");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("fails when selected scope has no settings to import", async () => {
vi.mocked(readExportFile).mockResolvedValue({
version: 1,
exportedAt: "2026-04-08T00:00:00.000Z",
global: {},
project: {},
} as any);
await expect(runSettingsImport("./settings.json", { yes: true })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: No settings to import in the specified scope");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("requires --yes confirmation before importing", async () => {
await expect(runSettingsImport("./settings.json")).rejects.toThrow("process.exit:1");
expect(logSpy).toHaveBeenCalledWith(" Use --yes to confirm this import operation");
expect(importSettings).not.toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("imports successfully with --yes", async () => {
await runSettingsImport("./settings.json", { yes: true });
expect(importSettings).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Object),
{ scope: "both", merge: true },
);
expect(logSpy).toHaveBeenCalledWith(" ✓ Settings imported successfully");
expect(logSpy).toHaveBeenCalledWith(" Imported 2 global setting(s)");
expect(logSpy).toHaveBeenCalledWith(" Imported 3 project setting(s)");
expect(exitSpy).toHaveBeenCalledWith(0);
});
it("imports only global settings when --scope global is used", async () => {
vi.mocked(importSettings).mockResolvedValue({
success: true,
globalCount: 2,
projectCount: 0,
});
await runSettingsImport("./settings.json", { scope: "global", yes: true });
expect(importSettings).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), {
scope: "global",
merge: true,
});
expect(logSpy).toHaveBeenCalledWith(" Imported 2 global setting(s)");
expect(logSpy).not.toHaveBeenCalledWith(" Imported 3 project setting(s)");
});
it("imports only project settings when --scope project is used", async () => {
vi.mocked(importSettings).mockResolvedValue({
success: true,
globalCount: 0,
projectCount: 3,
});
await runSettingsImport("./settings.json", { scope: "project", yes: true });
expect(importSettings).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), {
scope: "project",
merge: true,
});
expect(logSpy).toHaveBeenCalledWith(" Imported 3 project setting(s)");
expect(logSpy).not.toHaveBeenCalledWith(" Imported 2 global setting(s)");
});
it("prints core import failure and exits", async () => {
vi.mocked(importSettings).mockResolvedValue({
success: false,
globalCount: 0,
projectCount: 0,
error: "DB error",
});
await expect(runSettingsImport("./settings.json", { yes: true })).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Error: Import failed: DB error");
expect(exitSpy).toHaveBeenCalledWith(1);
});
it("shows replace mode when merge is false", async () => {
await expect(runSettingsImport("./settings.json", { merge: false })).rejects.toThrow("process.exit:1");
expect(logSpy).toHaveBeenCalledWith(" Mode: replace");
expect(importSettings).not.toHaveBeenCalled();
});
it("resolves project name and initializes store at resolved path", async () => {
await runSettingsImport("./settings.json", { projectName: "alpha", yes: true });
expect(resolveProject).toHaveBeenCalledWith("alpha");
expect(TaskStore).toHaveBeenCalledWith("/tmp/demo");
expect(mockStoreInit).toHaveBeenCalledOnce();
});
});