feat: Phase 2 Schritt 11+12 — Essensplan-Modul + Einkaufslisten-Integration
- server/routes/meals.js: vollständige REST-API (GET Woche, POST/PUT/DELETE Mahlzeit, POST/PATCH/DELETE Zutaten, GET Autocomplete-Suggestions, POST to-shopping-list, POST week-to-shopping-list) - public/pages/meals.js: Wochengitter (Mo–So × 4 Mahlzeit-Typen), Navigations-Buttons, CRUD-Modal mit Autocomplete, Zutaten-Verwaltung, Einkaufslisten-Transfer-Button - public/styles/meals.css: Wochengitter, Slot-Karten, Modal-Overlay, Zutaten-Zeilen, Transfer-Panel, Typ-Farben - test-meals.js: 22 Tests (CRUD, Wochensortierung, Constraint, CASCADE, Integration, Autocomplete, Wochenberechnung) - package.json: test:meals + Gesamt-Test-Suite erweitert - public/index.html: meals.css eingebunden Gesamt: 93 Tests bestanden (29 DB + 8 Dashboard + 17 Tasks + 17 Shopping + 22 Meals) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+317
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* Modul: Essensplan-Test
|
||||
* Zweck: Validiert alle Meals-API-Abfragen, Zutaten-CRUD, Wochensortierung,
|
||||
* Einkaufslisten-Integration
|
||||
* Ausführen: node --experimental-sqlite test-meals.js
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { MIGRATIONS_SQL } = require('./server/db-schema-test');
|
||||
|
||||
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'); }
|
||||
|
||||
const db = new DatabaseSync(':memory:');
|
||||
db.exec('PRAGMA foreign_keys = ON;');
|
||||
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY, description TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);`);
|
||||
db.exec(MIGRATIONS_SQL[1]);
|
||||
|
||||
// Test-Benutzer
|
||||
const u1 = db.prepare(`INSERT INTO users (username, display_name, password_hash, role)
|
||||
VALUES ('admin', 'Admin', 'x', 'admin')`).run();
|
||||
const uid = u1.lastInsertRowid;
|
||||
|
||||
// Einkaufsliste für Integration-Tests
|
||||
const sl = db.prepare(`INSERT INTO shopping_lists (name, created_by) VALUES ('REWE', ?)`).run(uid);
|
||||
const listId = sl.lastInsertRowid;
|
||||
|
||||
console.log('\n[Meals-Test] Wochenplan, Zutaten, Einkaufslisten-Integration\n');
|
||||
|
||||
let mealId1, mealId2, mealId3, ingId1, ingId2;
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Mahlzeit CRUD
|
||||
// --------------------------------------------------------
|
||||
test('Mahlzeit erstellen (Mittagessen)', () => {
|
||||
const r = db.prepare(`
|
||||
INSERT INTO meals (date, meal_type, title, notes, created_by)
|
||||
VALUES ('2026-03-23', 'lunch', 'Spaghetti Bolognese', 'Klassiker', ?)
|
||||
`).run(uid);
|
||||
mealId1 = r.lastInsertRowid;
|
||||
assert(mealId1 > 0);
|
||||
});
|
||||
|
||||
test('Mahlzeit erstellen (Frühstück)', () => {
|
||||
const r = db.prepare(`
|
||||
INSERT INTO meals (date, meal_type, title, created_by)
|
||||
VALUES ('2026-03-23', 'breakfast', 'Müsli mit Früchten', ?)
|
||||
`).run(uid);
|
||||
mealId2 = r.lastInsertRowid;
|
||||
assert(mealId2 > 0);
|
||||
});
|
||||
|
||||
test('Mahlzeit erstellen (andere Woche)', () => {
|
||||
const r = db.prepare(`
|
||||
INSERT INTO meals (date, meal_type, title, created_by)
|
||||
VALUES ('2026-03-30', 'dinner', 'Pizza Margherita', ?)
|
||||
`).run(uid);
|
||||
mealId3 = r.lastInsertRowid;
|
||||
assert(mealId3 > 0);
|
||||
});
|
||||
|
||||
test('Mahlzeiten einer Woche abrufen', () => {
|
||||
const meals = db.prepare(`
|
||||
SELECT * FROM meals
|
||||
WHERE date BETWEEN '2026-03-23' AND '2026-03-29'
|
||||
ORDER BY date ASC,
|
||||
CASE meal_type
|
||||
WHEN 'breakfast' THEN 0
|
||||
WHEN 'lunch' THEN 1
|
||||
WHEN 'dinner' THEN 2
|
||||
WHEN 'snack' THEN 3
|
||||
ELSE 4
|
||||
END ASC
|
||||
`).all();
|
||||
assert(meals.length === 2, `Erwartet 2, erhalten ${meals.length}`);
|
||||
assert(meals[0].meal_type === 'breakfast', 'Frühstück zuerst');
|
||||
assert(meals[1].meal_type === 'lunch', 'Mittagessen danach');
|
||||
});
|
||||
|
||||
test('Andere Woche hat nur eigene Mahlzeiten', () => {
|
||||
const meals = db.prepare(`
|
||||
SELECT * FROM meals WHERE date BETWEEN '2026-03-30' AND '2026-04-05'
|
||||
`).all();
|
||||
assert(meals.length === 1, `Erwartet 1, erhalten ${meals.length}`);
|
||||
assert(meals[0].title === 'Pizza Margherita');
|
||||
});
|
||||
|
||||
test('Mahlzeit aktualisieren', () => {
|
||||
db.prepare(`UPDATE meals SET title = 'Spaghetti Carbonara', notes = NULL WHERE id = ?`).run(mealId1);
|
||||
const m = db.prepare('SELECT title, notes FROM meals WHERE id = ?').get(mealId1);
|
||||
assert(m.title === 'Spaghetti Carbonara', 'Titel aktualisiert');
|
||||
assert(m.notes === null, 'Notizen gelöscht');
|
||||
});
|
||||
|
||||
test('Mahlzeit-Typ-Constraint (ungültiger Wert)', () => {
|
||||
let threw = false;
|
||||
try {
|
||||
db.prepare(`INSERT INTO meals (date, meal_type, title, created_by) VALUES ('2026-03-24', 'brunch', 'Test', ?)`).run(uid);
|
||||
} catch { threw = true; }
|
||||
assert(threw, 'Constraint muss verletzt werden');
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Zutaten CRUD
|
||||
// --------------------------------------------------------
|
||||
test('Zutat hinzufügen', () => {
|
||||
const r = db.prepare(`
|
||||
INSERT INTO meal_ingredients (meal_id, name, quantity)
|
||||
VALUES (?, 'Hackfleisch', '500g')
|
||||
`).run(mealId1);
|
||||
ingId1 = r.lastInsertRowid;
|
||||
assert(ingId1 > 0);
|
||||
});
|
||||
|
||||
test('Zweite Zutat hinzufügen', () => {
|
||||
const r = db.prepare(`
|
||||
INSERT INTO meal_ingredients (meal_id, name, quantity)
|
||||
VALUES (?, 'Spaghetti', '400g')
|
||||
`).run(mealId1);
|
||||
ingId2 = r.lastInsertRowid;
|
||||
assert(ingId2 > 0);
|
||||
});
|
||||
|
||||
test('Zutaten einer Mahlzeit abrufen', () => {
|
||||
const ings = db.prepare(`
|
||||
SELECT * FROM meal_ingredients WHERE meal_id = ? ORDER BY id ASC
|
||||
`).all(mealId1);
|
||||
assert(ings.length === 2, `Erwartet 2, erhalten ${ings.length}`);
|
||||
assert(ings[0].name === 'Hackfleisch');
|
||||
assert(ings[1].name === 'Spaghetti');
|
||||
});
|
||||
|
||||
test('Zutat aktualisieren (Menge ändern)', () => {
|
||||
db.prepare(`UPDATE meal_ingredients SET quantity = '600g' WHERE id = ?`).run(ingId1);
|
||||
const ing = db.prepare('SELECT quantity FROM meal_ingredients WHERE id = ?').get(ingId1);
|
||||
assert(ing.quantity === '600g', 'Menge aktualisiert');
|
||||
});
|
||||
|
||||
test('on_shopping_list-Flag setzen', () => {
|
||||
db.prepare(`UPDATE meal_ingredients SET on_shopping_list = 1 WHERE id = ?`).run(ingId1);
|
||||
const ing = db.prepare('SELECT on_shopping_list FROM meal_ingredients WHERE id = ?').get(ingId1);
|
||||
assert(ing.on_shopping_list === 1, 'Flag gesetzt');
|
||||
});
|
||||
|
||||
test('Nur offene Zutaten haben on_shopping_list = 0', () => {
|
||||
const open = db.prepare(`
|
||||
SELECT * FROM meal_ingredients WHERE meal_id = ? AND on_shopping_list = 0
|
||||
`).all(mealId1);
|
||||
assert(open.length === 1, `Erwartet 1 offene Zutat, erhalten ${open.length}`);
|
||||
assert(open[0].name === 'Spaghetti');
|
||||
});
|
||||
|
||||
test('Zutat löschen', () => {
|
||||
db.prepare('DELETE FROM meal_ingredients WHERE id = ?').run(ingId2);
|
||||
const remaining = db.prepare('SELECT * FROM meal_ingredients WHERE meal_id = ?').all(mealId1);
|
||||
assert(remaining.length === 1, 'Nur noch eine Zutat');
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Cascade-Verhalten
|
||||
// --------------------------------------------------------
|
||||
test('Mahlzeit löschen entfernt Zutaten (CASCADE)', () => {
|
||||
// Neue Mahlzeit mit Zutat
|
||||
const m = db.prepare(`
|
||||
INSERT INTO meals (date, meal_type, title, created_by)
|
||||
VALUES ('2026-03-25', 'snack', 'Apfel', ?)
|
||||
`).run(uid);
|
||||
db.prepare(`INSERT INTO meal_ingredients (meal_id, name) VALUES (?, 'Apfel')`).run(m.lastInsertRowid);
|
||||
|
||||
db.prepare('DELETE FROM meals WHERE id = ?').run(m.lastInsertRowid);
|
||||
const ings = db.prepare('SELECT * FROM meal_ingredients WHERE meal_id = ?').all(m.lastInsertRowid);
|
||||
assert(ings.length === 0, 'Zutaten nach Mahlzeit-Löschung entfernt');
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Einkaufslisten-Integration
|
||||
// --------------------------------------------------------
|
||||
test('Offene Zutaten einer Woche abfragen', () => {
|
||||
// Mahlzeit für Integration-Test anlegen
|
||||
const m = db.prepare(`
|
||||
INSERT INTO meals (date, meal_type, title, created_by)
|
||||
VALUES ('2026-03-24', 'dinner', 'Risotto', ?)
|
||||
`).run(uid);
|
||||
const mid = m.lastInsertRowid;
|
||||
|
||||
db.prepare(`INSERT INTO meal_ingredients (meal_id, name, quantity) VALUES (?, 'Reis', '300g')`).run(mid);
|
||||
db.prepare(`INSERT INTO meal_ingredients (meal_id, name, quantity) VALUES (?, 'Parmesan', '100g')`).run(mid);
|
||||
|
||||
const open = db.prepare(`
|
||||
SELECT mi.* FROM meal_ingredients mi
|
||||
JOIN meals mo ON mo.id = mi.meal_id
|
||||
WHERE mo.date BETWEEN '2026-03-23' AND '2026-03-29'
|
||||
AND mi.on_shopping_list = 0
|
||||
`).all();
|
||||
// Spaghetti (ingId1 wurde gelöscht), Hackfleisch (on_shopping_list=1 gesetzt), Reis, Parmesan
|
||||
assert(open.length >= 2, `Mindestens 2 offene Zutaten, erhalten ${open.length}`);
|
||||
});
|
||||
|
||||
test('Zutaten → Einkaufsliste übertragen (INSERT + Flag setzen)', () => {
|
||||
// Frische Mahlzeit mit 2 Zutaten
|
||||
const m = db.prepare(`
|
||||
INSERT INTO meals (date, meal_type, title, created_by)
|
||||
VALUES ('2026-03-24', 'lunch', 'Suppe', ?)
|
||||
`).run(uid);
|
||||
const mid = m.lastInsertRowid;
|
||||
|
||||
const i1 = db.prepare(`INSERT INTO meal_ingredients (meal_id, name, quantity) VALUES (?, 'Karotten', '3 Stück')`).run(mid).lastInsertRowid;
|
||||
const i2 = db.prepare(`INSERT INTO meal_ingredients (meal_id, name) VALUES (?, 'Zwiebeln')`).run(mid).lastInsertRowid;
|
||||
|
||||
// Transfer-Logik aus server/routes/meals.js simulieren
|
||||
const ingredients = db.prepare(`
|
||||
SELECT * FROM meal_ingredients WHERE meal_id = ? AND on_shopping_list = 0
|
||||
`).all(mid);
|
||||
|
||||
assert(ingredients.length === 2, `Erwartet 2, erhalten ${ingredients.length}`);
|
||||
|
||||
const insertItem = db.prepare(`
|
||||
INSERT INTO shopping_items (list_id, name, quantity, category, added_from_meal)
|
||||
VALUES (?, ?, ?, 'Sonstiges', ?)
|
||||
`);
|
||||
const markDone = db.prepare(`UPDATE meal_ingredients SET on_shopping_list = 1 WHERE id = ?`);
|
||||
|
||||
for (const ing of ingredients) {
|
||||
insertItem.run(listId, ing.name, ing.quantity, mid);
|
||||
markDone.run(ing.id);
|
||||
}
|
||||
|
||||
// Prüfen: Artikel in Einkaufsliste
|
||||
const items = db.prepare(`
|
||||
SELECT * FROM shopping_items WHERE added_from_meal = ?
|
||||
`).all(mid);
|
||||
assert(items.length === 2, `Erwartet 2 Einkaufsartikel, erhalten ${items.length}`);
|
||||
assert(items[0].name === 'Karotten', `Erster Artikel: ${items[0].name}`);
|
||||
|
||||
// Prüfen: Flags gesetzt
|
||||
const stillOpen = db.prepare(`
|
||||
SELECT * FROM meal_ingredients WHERE meal_id = ? AND on_shopping_list = 0
|
||||
`).all(mid);
|
||||
assert(stillOpen.length === 0, 'Alle Zutaten als übertragen markiert');
|
||||
});
|
||||
|
||||
test('Zweiter Transfer überträgt nichts (alle bereits markiert)', () => {
|
||||
// Mahlzeit aus vorherigem Test — alle on_shopping_list = 1
|
||||
const suppe = db.prepare(`SELECT id FROM meals WHERE title = 'Suppe'`).get();
|
||||
const open = db.prepare(`
|
||||
SELECT * FROM meal_ingredients WHERE meal_id = ? AND on_shopping_list = 0
|
||||
`).all(suppe.id);
|
||||
assert(open.length === 0, 'Keine offenen Zutaten mehr');
|
||||
});
|
||||
|
||||
test('added_from_meal FK auf meals(id) gesetzt', () => {
|
||||
const items = db.prepare(`
|
||||
SELECT si.*, m.title AS meal_title
|
||||
FROM shopping_items si
|
||||
JOIN meals m ON m.id = si.added_from_meal
|
||||
WHERE si.added_from_meal IS NOT NULL
|
||||
LIMIT 5
|
||||
`).all();
|
||||
assert(items.length > 0, 'Mindestens ein Artikel mit Mahlzeit-Referenz');
|
||||
assert(items[0].meal_title, 'meal_title verknüpft');
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Autocomplete-Simulation
|
||||
// --------------------------------------------------------
|
||||
test('Mahlzeit-Autocomplete nach Prefix', () => {
|
||||
const results = db.prepare(`
|
||||
SELECT DISTINCT title, meal_type FROM meals
|
||||
WHERE title LIKE ? COLLATE NOCASE
|
||||
ORDER BY title ASC LIMIT 10
|
||||
`).all('S%');
|
||||
assert(results.length >= 1, `Mindestens 1 Treffer, erhalten ${results.length}`);
|
||||
const titles = results.map((r) => r.title);
|
||||
assert(titles.some((t) => t.startsWith('S') || t.startsWith('s')), 'Treffer beginnt mit S');
|
||||
});
|
||||
|
||||
test('Autocomplete ohne Treffer gibt leeres Array', () => {
|
||||
const results = db.prepare(`
|
||||
SELECT DISTINCT title FROM meals WHERE title LIKE ? COLLATE NOCASE
|
||||
`).all('XXXXXXXXXXX%');
|
||||
assert(results.length === 0, 'Leeres Ergebnis erwartet');
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Wochenhelfer-Logik (ohne Server)
|
||||
// --------------------------------------------------------
|
||||
test('Wochenberechnung: Montag der aktuellen Woche', () => {
|
||||
// 2026-03-24 ist ein Dienstag → Montag ist 2026-03-23
|
||||
function getMondayOf(dateStr) {
|
||||
const d = new Date(dateStr + 'T00:00:00Z');
|
||||
const day = d.getUTCDay();
|
||||
const diff = (day === 0 ? -6 : 1 - day);
|
||||
d.setUTCDate(d.getUTCDate() + diff);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
assert(getMondayOf('2026-03-24') === '2026-03-23', 'Montag korrekt berechnet');
|
||||
assert(getMondayOf('2026-03-23') === '2026-03-23', 'Montag bleibt Montag');
|
||||
assert(getMondayOf('2026-03-29') === '2026-03-23', 'Sonntag → gleicher Montag');
|
||||
assert(getMondayOf('2026-03-30') === '2026-03-30', 'Nächster Montag');
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Ergebnis
|
||||
// --------------------------------------------------------
|
||||
console.log(`\n[Meals-Test] Ergebnis: ${passed} bestanden, ${failed} fehlgeschlagen\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
Reference in New Issue
Block a user