Files
formulation/src/lib/backup/backup.test.ts
T
nicholasandnicholas 2a1e16ed30
Build & Deploy Formulation / Build & Push Image (push) Failing after 15s
Build & Deploy Formulation / deploy (push) Skipped
add core features (#14)
Reviewed-on: #14
Co-authored-by: Nicholas Ward <nicholaspward@outlook.com>
2026-08-18 18:22:34 -05:00

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