diff --git a/package.json b/package.json index 162542d..816b01e 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "test:backup-scheduler": "node --experimental-sqlite test-backup-scheduler.js", "test:caldav": "node --experimental-sqlite test-caldav-sync.js", "test:carddav": "node --experimental-sqlite test-carddav.js", - "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-multi-assignment.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs && npm run test:backup-scheduler && npm run test:caldav && npm run test:carddav" + "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-multi-assignment.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs && npm run test:backup-scheduler && npm run test:caldav && npm run test:carddav", + "test:meal-planning": "node --experimental-sqlite test-meal-planning.js" }, "dependencies": { "bcrypt": "^6.0.0", diff --git a/test-meal-planning.js b/test-meal-planning.js new file mode 100644 index 0000000..a259af4 --- /dev/null +++ b/test-meal-planning.js @@ -0,0 +1,140 @@ +/** + * Modul: Meal-Planning-Test + * Zweck: Validiert native Meal-Planning-Signal-Tabellen und Constraints. + * Ausführen: node --experimental-sqlite test-meal-planning.js + */ + +import { DatabaseSync } from 'node:sqlite'; +import fs from 'node:fs'; + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { fn(); console.log(` ✓ ${name}`); passed++; } + catch (err) { console.error(` ✗ ${name}: ${err.message}`); failed++; } +} +function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion fehlgeschlagen'); } + +function migrationSql(version) { + const source = fs.readFileSync(new URL('./server/db.js', import.meta.url), 'utf8'); + const re = new RegExp('version:\\s*' + version + ',[\\s\\S]*?up:\\s*`([\\s\\S]*?)`'); + const match = source.match(re); + if (!match) throw new Error(`Migration v${version} nicht gefunden`); + return match[1]; +} + +const db = new DatabaseSync(':memory:'); +db.exec('PRAGMA foreign_keys = ON;'); +db.exec(` + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + display_name TEXT NOT NULL, + avatar_color TEXT + ); + CREATE TABLE recipes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL + ); + CREATE TABLE meals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + meal_type TEXT NOT NULL CHECK(meal_type IN ('breakfast', 'lunch', 'dinner', 'snack')), + title TEXT NOT NULL + ); +`); +db.exec(migrationSql(39)); + +console.log('\n[Meal-Planning-Test] Native Signaltabellen\n'); + +const tables = [ + 'meal_cooking_rules', + 'recipe_family_preferences', + 'recipe_variation_meta', + 'planned_meal_cooks', + 'meal_plan_feedback', + 'kids_cookbooks', +]; + +for (const table of tables) { + test(`Tabelle "${table}" existiert`, () => { + const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table); + assert(row, `Tabelle ${table} fehlt`); + }); +} + +let userId, recipeId, mealId; + +test('Fixture-Daten erstellen', () => { + userId = db.prepare("INSERT INTO users (display_name, avatar_color) VALUES ('Liv', '#FF6B9D')").run().lastInsertRowid; + recipeId = db.prepare("INSERT INTO recipes (title) VALUES ('Pasta med tomat')").run().lastInsertRowid; + mealId = db.prepare("INSERT INTO meals (date, meal_type, title) VALUES ('2026-05-12', 'dinner', 'Pasta med tomat')").run().lastInsertRowid; + assert(userId > 0 && recipeId > 0 && mealId > 0); +}); + +test('Recurring cook rule gemmes', () => { + db.prepare('INSERT INTO meal_cooking_rules (user_id, weekday, meal_type, priority) VALUES (?, 0, ?, 100)').run(userId, 'dinner'); + const row = db.prepare('SELECT * FROM meal_cooking_rules WHERE user_id = ?').get(userId); + assert(row.weekday === 0 && row.meal_type === 'dinner'); +}); + +test('Recipe preference/capability-signaler gemmes', () => { + db.prepare(` + INSERT INTO recipe_family_preferences ( + recipe_id, user_id, preference, can_cook, can_help_cook, will_eat_modified, adult_only, swap_in_count, swap_away_count + ) VALUES (?, ?, 'favorite', 1, 1, 1, 0, 2, 1) + `).run(recipeId, userId); + const row = db.prepare('SELECT * FROM recipe_family_preferences WHERE recipe_id = ? AND user_id = ?').get(recipeId, userId); + assert(row.preference === 'favorite'); + assert(row.can_cook === 1 && row.can_help_cook === 1 && row.will_eat_modified === 1 && row.adult_only === 0); + assert(row.swap_in_count === 2 && row.swap_away_count === 1); +}); + +test('Variation metadata gemmes', () => { + db.prepare('INSERT INTO recipe_variation_meta (recipe_id, protein, style, kid_suitable_confidence) VALUES (?, ?, ?, 85)') + .run(recipeId, 'vegetarian', 'quick'); + const row = db.prepare('SELECT * FROM recipe_variation_meta WHERE recipe_id = ?').get(recipeId); + assert(row.protein === 'vegetarian' && row.style === 'quick' && row.kid_suitable_confidence === 85); +}); + +test('Planned meal cook assignment gemmes', () => { + db.prepare('INSERT INTO planned_meal_cooks (meal_id, user_id, planned_for_date, meal_type, source_plan_id) VALUES (?, ?, ?, ?, ?)') + .run(mealId, userId, '2026-05-12', 'dinner', 'plan-test'); + const row = db.prepare('SELECT * FROM planned_meal_cooks WHERE meal_id = ?').get(mealId); + assert(row.user_id === userId && row.source_plan_id === 'plan-test'); +}); + +test('Feedback event gemmes', () => { + const id = db.prepare(` + INSERT INTO meal_plan_feedback (plan_id, meal_id, recipe_id, slot_date, meal_type, action, original_title, final_title, user_id) + VALUES ('plan-test', ?, ?, '2026-05-12', 'dinner', 'accept', 'Pasta', 'Pasta med tomat', ?) + `).run(mealId, recipeId, userId).lastInsertRowid; + const row = db.prepare('SELECT * FROM meal_plan_feedback WHERE id = ?').get(id); + assert(row.action === 'accept' && row.recipe_id === recipeId); +}); + +test('Kids cookbook gemmes som JSON', () => { + const content = JSON.stringify({ title: 'Pasta for børn', steps: ['Vask hænder', 'Rør sovs'] }); + const id = db.prepare('INSERT INTO kids_cookbooks (recipe_id, title, content_json, created_by) VALUES (?, ?, ?, ?)') + .run(recipeId, 'Pasta for børn', content, userId).lastInsertRowid; + const row = db.prepare('SELECT * FROM kids_cookbooks WHERE id = ?').get(id); + assert(JSON.parse(row.content_json).steps.length === 2); +}); + +test('Ugyldig preference afvises', () => { + let failedConstraint = false; + try { + db.prepare('INSERT INTO recipe_family_preferences (recipe_id, user_id, preference) VALUES (?, ?, ?)').run(recipeId, userId + 1, 'maybe'); + } catch { failedConstraint = true; } + assert(failedConstraint, 'CHECK constraint skulle afvise ugyldig preference'); +}); + +test('FK cascade sletter recipe-signaler', () => { + db.prepare('DELETE FROM recipes WHERE id = ?').run(recipeId); + const prefs = db.prepare('SELECT count(*) AS n FROM recipe_family_preferences WHERE recipe_id = ?').get(recipeId).n; + const meta = db.prepare('SELECT count(*) AS n FROM recipe_variation_meta WHERE recipe_id = ?').get(recipeId).n; + assert(prefs === 0 && meta === 0); +}); + +console.log(`\n[Meal-Planning-Test] Ergebnis: ${passed} bestanden, ${failed} fehlgeschlagen\n`); +if (failed > 0) process.exit(1);