129 lines
5.4 KiB
TypeScript
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");
|
|
});
|
|
});
|