Files
formulation/src/cli/run.test.ts
T

129 lines
5.4 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DatabaseSync } from "node:sqlite";
import { runCli, EXIT_DOMAIN, EXIT_USAGE } from "./run";
describe("Formulation CLI", () => {
let directory: string;
let databasePath: string;
beforeEach(() => {
directory = fs.mkdtempSync(path.join(os.tmpdir(), "formulation-cli-test-"));
databasePath = path.join(directory, "formulation.sqlite");
const database = new DatabaseSync(databasePath);
const migrations = fs.readdirSync(path.resolve("migrations")).filter((file) => file.endsWith(".sql")).sort();
for (const migration of migrations) database.exec(fs.readFileSync(path.resolve("migrations", migration), "utf8"));
database.exec(`
INSERT INTO units (id, name, symbol, dimension, system, base_unit_id, factor) VALUES
('gram', 'Gram', 'g', 'mass', 'metric', NULL, 1),
('kilogram', 'Kilogram', 'kg', 'mass', 'metric', 'gram', 1000);
INSERT INTO ingredients (id, schema_version, name, status) VALUES
('flour', 2, 'Flour', 'active');
INSERT INTO recipes (id, schema_version, save_version, title, yield_quantity, yield_unit_id) VALUES
('cake', 2, 1, 'Simple Cake', 500, 'gram');
INSERT INTO recipe_components (recipe_id, id, position, name) VALUES
('cake', 'main', 1, 'Main');
INSERT INTO recipe_items (recipe_id, component_id, id, position, ingredient_id, quantity, unit_id) VALUES
('cake', 'main', 'flour', 1, 'flour', 500, 'gram');
INSERT INTO recipe_steps (recipe_id, id, position, instruction) VALUES
('cake', 'mix', 1, 'Mix.');
`);
database.close();
});
afterEach(() => fs.rmSync(directory, { recursive: true, force: true }));
async function invoke(args: string[], input = "") {
let stdout = "";
let stderr = "";
const code = await runCli([...args, "--database", databasePath], {
stdout: (text) => { stdout += text; },
stderr: (text) => { stderr += text; },
readStdin: () => input,
});
return {
code,
stdout,
stderr,
output: stdout.startsWith("{") ? JSON.parse(stdout) : undefined,
error: stderr ? JSON.parse(stderr) : undefined,
};
}
it("provides human and machine-readable command discovery", async () => {
const help = await runCli(["help"], { stdout: (text) => expect(text).toContain("recipe save") });
const result = await invoke(["cli", "commands"]);
expect(help).toBe(0);
expect(result.code).toBe(0);
expect(result.output.data["db import"].destructive).toBe(true);
});
it("uses the selected database and coerces command options", async () => {
const list = await invoke(["recipe", "list", "--query", "simple", "--limit", "1"]);
const conversion = await invoke(["unit", "convert", "2", "kilogram", "gram"]);
expect(list.output.data).toHaveLength(1);
expect(list.output.data[0].id).toBe("cake");
expect(conversion.output.data.to.quantity).toBe(2000);
});
it("rejects unknown options and missing required input", async () => {
const unknown = await invoke(["recipe", "list", "--typo", "value"]);
const missing = await invoke(["ingredient", "save"]);
expect(unknown.code).toBe(EXIT_USAGE);
expect(unknown.error.error.code).toBe("USAGE_ERROR");
expect(missing.code).toBe(EXIT_USAGE);
expect(missing.error.error.message).toContain("name");
});
it("runs mutations against a disposable clone with --dry-run", async () => {
const result = await invoke(["ingredient", "save", "--input", "-", "--dry-run"], JSON.stringify({ name: "Salt" }));
const database = new DatabaseSync(databasePath, { readOnly: true });
const saved = database.prepare("SELECT id FROM ingredients WHERE name = 'Salt'").get();
database.close();
expect(result.code).toBe(0);
expect(result.output.dry_run).toBe(true);
expect(result.output.data.ingredient_id).toBe("salt");
expect(saved).toBeUndefined();
});
it("enforces optimistic recipe versions", async () => {
const input = JSON.stringify({
title: "Changed Cake",
yield_quantity: 500,
yield_unit_id: "gram",
expected_save_version: 0,
components: [{ id: "main", name: "Main", items: [{ id: "flour", ingredient_id: "flour", quantity: 500, unit_id: "gram" }] }],
steps: [{ id: "mix", instruction: "Mix." }],
});
const result = await invoke(["recipe", "save", "cake", "--input", "-"], input);
expect(result.code).toBe(EXIT_DOMAIN);
expect(result.error.error.message).toMatch(/version|changed/i);
});
it("exports, validates, and compares backup bundles", async () => {
const backupPath = path.join(directory, "backup.json");
const exported = await invoke(["db", "export", "--output", backupPath]);
const validated = await invoke(["db", "validate", "--input", backupPath]);
const diff = await invoke(["db", "diff", "--input", backupPath]);
expect(exported.code).toBe(0);
expect(fs.existsSync(backupPath)).toBe(true);
expect(validated.output.data.valid).toBe(true);
expect(diff.output.data.equal).toBe(true);
});
it("requires explicit confirmation for destructive operations", async () => {
const result = await invoke(["archive", "purge", "--input", "-"], JSON.stringify({ items: [{ type: "ingredient", id: "flour" }] }));
expect(result.code).toBe(EXIT_USAGE);
expect(result.error.error.message).toContain("--yes");
});
});