Reviewed-on: #14 Co-authored-by: Nicholas Ward <nicholaspward@outlook.com>
208 lines
9.4 KiB
TypeScript
208 lines
9.4 KiB
TypeScript
import { describe, it, expect, beforeEach } from "vitest";
|
|
import { DatabaseSync } from "node:sqlite";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { exportDatabase } from "./export-database";
|
|
import { importDatabase } from "./import-database";
|
|
import { validateBackupBundle } from "./validate-backup";
|
|
import type { FormulationBackupBundle } from "./types";
|
|
|
|
function createTestDatabase(): DatabaseSync {
|
|
const db = new DatabaseSync(":memory:");
|
|
const root = path.resolve(__dirname, "../../..");
|
|
const migrationsDir = path.join(root, "migrations");
|
|
const files = fs.readdirSync(migrationsDir).filter((f) => f.endsWith(".sql")).sort();
|
|
for (const file of files) {
|
|
const sql = fs.readFileSync(path.join(migrationsDir, file), "utf8");
|
|
db.exec(sql);
|
|
}
|
|
return db;
|
|
}
|
|
|
|
describe("Database Backup & Restore System", () => {
|
|
let db: DatabaseSync;
|
|
|
|
beforeEach(() => {
|
|
db = createTestDatabase();
|
|
|
|
// Populate baseline sample data
|
|
db.exec(`
|
|
INSERT INTO units (id, name, symbol, dimension, system) VALUES
|
|
('gram', 'Gram', 'g', 'mass', 'metric'),
|
|
('kilogram', 'Kilogram', 'kg', 'mass', 'metric'),
|
|
('each', 'Each', 'ea', 'count', 'customary');
|
|
|
|
INSERT INTO equipment (id, name, category, notes) VALUES
|
|
('whisk', 'Whisk', 'hand_tool', 'Stainless steel');
|
|
|
|
INSERT INTO prep_actions (id, name, action_type, default_yield_factor) VALUES
|
|
('dice', 'Dice', 'cut', 0.95);
|
|
|
|
INSERT INTO ingredients (id, schema_version, name, status, categories_json, tags_json, source_json) VALUES
|
|
('flour', 2, 'Flour', 'active', '["dry"]', '["baking"]', '{}'),
|
|
('sugar', 2, 'Sugar', 'active', '["dry"]', '["sweet"]', '{}');
|
|
|
|
INSERT INTO ingredient_aliases (ingredient_id, name, kind) VALUES
|
|
('flour', 'All Purpose Flour', 'common');
|
|
|
|
INSERT INTO ingredient_prep_actions (ingredient_id, action_id, yield_factor) VALUES
|
|
('flour', 'dice', 0.95);
|
|
|
|
INSERT INTO ingredient_measure_conversions (ingredient_id, id, from_quantity, from_unit_id, to_quantity, to_unit_id, source_json) VALUES
|
|
('flour', 'conv_1', 1, 'each', 120, 'gram', '{}');
|
|
|
|
INSERT INTO recipes (id, schema_version, save_version, title, yield_quantity, yield_unit_id, source_json) 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', 'item_1', 1, 'flour', 300, 'gram'),
|
|
('cake', 'main', 'item_2', 2, 'sugar', 200, 'gram');
|
|
|
|
INSERT INTO item_prep_actions (recipe_id, item_id, position, action_id, yield_factor) VALUES
|
|
('cake', 'item_1', 1, 'dice', 1.0);
|
|
|
|
INSERT INTO recipe_steps (recipe_id, id, position, instruction) VALUES
|
|
('cake', 'step_1', 1, 'Mix dry ingredients together.');
|
|
|
|
INSERT INTO purchase_items (id, ingredient_id, name, status, package_quantity, package_unit_id) VALUES
|
|
('pi_flour_bag', 'flour', '50lb Flour Bag', 'active', 50, 'gram');
|
|
|
|
INSERT INTO price_observations (purchase_item_id, effective_at, currency, amount, source_json) VALUES
|
|
('pi_flour_bag', '2026-08-01', 'USD', 24.50, '{}');
|
|
|
|
INSERT INTO collections (id, name, source_json) VALUES
|
|
('bakery_menu', 'Bakery Menu', '{}');
|
|
|
|
INSERT INTO collection_recipes (collection_id, recipe_id, position) VALUES
|
|
('bakery_menu', 'cake', 1);
|
|
|
|
INSERT INTO inventory_locations (id, name, position) VALUES
|
|
('loc_dry', 'Dry Storage', 1);
|
|
|
|
INSERT INTO inventory_counts (id, title, counted_at, created_at) VALUES
|
|
('count_aug', 'August Inventory', '2026-08-01', '2026-08-01');
|
|
|
|
INSERT INTO inventory_count_items (count_id, location_id, ingredient_id, quantity, unit_id, unit_cost, extended_cost) VALUES
|
|
('count_aug', 'loc_dry', 'flour', 10, 'gram', 0.5, 5.0);
|
|
`);
|
|
});
|
|
|
|
it("exports all tables into a valid FormulationBackupBundle", () => {
|
|
const bundle = exportDatabase(db);
|
|
|
|
expect(bundle.format_version).toBe("1.0.0");
|
|
expect(bundle.app_version).toBe("2.0.0");
|
|
expect(bundle.summary.units_count).toBe(3);
|
|
expect(bundle.summary.ingredients_count).toBe(2);
|
|
expect(bundle.summary.recipes_count).toBe(1);
|
|
expect(bundle.summary.purchase_items_count).toBe(1);
|
|
expect(bundle.summary.collections_count).toBe(1);
|
|
expect(bundle.summary.inventory_locations_count).toBe(6); // 5 from baseline seed + 1 custom
|
|
expect(bundle.summary.inventory_counts_count).toBe(1);
|
|
|
|
expect(bundle.data.recipes[0].items.length).toBe(2);
|
|
expect(bundle.data.recipes[0].items[0].prep_actions.length).toBe(1);
|
|
expect(bundle.data.ingredients[0].aliases.length).toBe(1);
|
|
expect(bundle.data.purchase_items[0].prices.length).toBe(1);
|
|
expect(bundle.data.inventory_counts[0].items.length).toBe(1);
|
|
});
|
|
|
|
it("validates backup bundle structure correctly", () => {
|
|
const bundle = exportDatabase(db);
|
|
const result = validateBackupBundle(bundle);
|
|
expect(result.valid).toBe(true);
|
|
expect(result.errors.length).toBe(0);
|
|
|
|
const invalid = validateBackupBundle({ format_version: "2.0.0", data: {} });
|
|
expect(invalid.valid).toBe(false);
|
|
expect(invalid.errors.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("performs full roundtrip export -> wipe -> import without data loss", () => {
|
|
const originalBundle = exportDatabase(db);
|
|
|
|
const targetDb = createTestDatabase();
|
|
// Wipe target db completely
|
|
targetDb.exec("DELETE FROM units;");
|
|
|
|
const result = importDatabase(targetDb, originalBundle, { mode: "replace", rebuildProjections: false });
|
|
expect(result.success).toBe(true);
|
|
expect(result.summary.recipes_count).toBe(1);
|
|
|
|
const restoredBundle = exportDatabase(targetDb);
|
|
expect(restoredBundle.summary.units_count).toBe(originalBundle.summary.units_count);
|
|
expect(restoredBundle.summary.ingredients_count).toBe(originalBundle.summary.ingredients_count);
|
|
expect(restoredBundle.summary.recipes_count).toBe(originalBundle.summary.recipes_count);
|
|
expect(restoredBundle.data.recipes[0].title).toBe("Simple Cake");
|
|
expect(restoredBundle.data.ingredients[0].aliases[0].name).toBe("All Purpose Flour");
|
|
expect(restoredBundle.data.inventory_counts[0].items[0].quantity).toBe(10);
|
|
});
|
|
|
|
it("supports merge mode without wiping existing unmentioned records", () => {
|
|
const targetDb = createTestDatabase();
|
|
targetDb.exec("INSERT INTO units (id, name, symbol, dimension, system) VALUES ('meter', 'Meter', 'm', 'length', 'metric');");
|
|
|
|
const bundle = exportDatabase(db);
|
|
const result = importDatabase(targetDb, bundle, { mode: "merge", rebuildProjections: false });
|
|
expect(result.success).toBe(true);
|
|
|
|
const units = targetDb.prepare("SELECT id FROM units ORDER BY id").all() as Array<{ id: string }>;
|
|
expect(units.some((u) => u.id === "meter")).toBe(true);
|
|
expect(units.some((u) => u.id === "gram")).toBe(true);
|
|
});
|
|
|
|
it("rolls back transaction atomically if an error occurs during import", () => {
|
|
const targetDb = createTestDatabase();
|
|
targetDb.exec("INSERT INTO units (id, name, symbol, dimension, system) VALUES ('gram', 'Gram', 'g', 'mass', 'metric');");
|
|
|
|
const badBundle: FormulationBackupBundle = {
|
|
$schema: "https://formulation.app/schemas/backup-v1.json",
|
|
format_version: "1.0.0",
|
|
app_version: "2.0.0",
|
|
exported_at: new Date().toISOString(),
|
|
summary: {
|
|
units_count: 1, equipment_count: 0, prep_actions_count: 0,
|
|
ingredients_count: 1, recipes_count: 0, purchase_items_count: 0,
|
|
price_observations_count: 0, source_mappings_count: 0, collections_count: 0,
|
|
inventory_locations_count: 0, inventory_counts_count: 0, total_records_count: 2
|
|
},
|
|
data: {
|
|
units: [{ id: "gram", name: "Gram", symbol: "g", dimension: "mass", system: "metric", base_unit_id: null, factor: null, offset: null }],
|
|
equipment: [],
|
|
prep_actions: [],
|
|
ingredients: [{
|
|
id: "flour", schema_version: 2, name: "Flour", status: "active",
|
|
categories_json: "INVALID JSON SYNTAX", tags_json: "[]", description: null, source_json: "{}", deleted_at: null,
|
|
aliases: [], prep_actions: [], measure_conversions: [], density_measurements: []
|
|
}],
|
|
recipes: [{
|
|
id: "bad_recipe", schema_version: 2, save_version: 1, title: "Bad", summary: null,
|
|
categories_json: "[]", tags_json: "[]", yield_quantity: null as any, yield_unit_id: "gram",
|
|
yield_servings: null, yield_basis: null, scaling_mode: null, scaling_basis_id: null,
|
|
scaling_basis_quantity: null, scaling_basis_unit_id: null, notes_json: "[]",
|
|
source_json: "{}", auto_yield: 0, station: null, deleted_at: null, cover_media_url: null,
|
|
equipment_ids: [], components: [], items: [], steps: [], measure_conversions: [], media: []
|
|
}],
|
|
purchase_items: [],
|
|
source_mappings: [],
|
|
collections: [],
|
|
inventory_locations: [],
|
|
inventory_counts: [],
|
|
}
|
|
};
|
|
|
|
// Attempt importing badBundle which should fail validation/execution
|
|
expect(() => {
|
|
importDatabase(targetDb, badBundle, { mode: "replace", rebuildProjections: false });
|
|
}).toThrow();
|
|
|
|
// Verify existing units record remains intact
|
|
const units = targetDb.prepare("SELECT id FROM units").all() as Array<{ id: string }>;
|
|
expect(units.length).toBe(1);
|
|
expect(units[0].id).toBe("gram");
|
|
});
|
|
});
|