feat: Phase 2 Schritt 10 — Einkaufslisten-Modul
Backend: - GET /shopping — alle Listen mit item_total/item_checked Zähler - POST/PUT/DELETE /shopping/:listId — Listen-CRUD - GET /shopping/:listId/items — Artikel nach Supermarkt-Gang sortiert (Obst→Backwaren→Milch→Fleisch→Tiefkühl→Getränke→Haushalt→Drogerie→Sonstiges) Abgehakte innerhalb der Kategorie ans Ende - POST /shopping/:listId/items — Artikel hinzufügen - PATCH /shopping/items/:id — Artikel aktualisieren (abhaken, umbenennen) - DELETE /shopping/items/:id — Einzelartikel löschen - DELETE /shopping/:listId/items/checked — nur abgehakte löschen - GET /shopping/suggestions?q= — Autocomplete aus bisherigen Einträgen Frontend: - Multi-Listen-Tabs (horizontal scrollbar, Artikel-Zähler im Tab) - Quick-Add: Name + Menge + Kategorie-Dropdown in einer Zeile - Autocomplete-Dropdown mit Tastaturnavigation (↑↓ Enter Escape) - Optimistisches Toggle: Checkbox reagiert sofort, Rollback bei Fehler - "Abgehakt löschen"-Button erscheint dynamisch bei checked > 0 - Listen umbenennen/löschen direkt im Header - Kategorie-Icons (Lucide) in Gruppen-Überschriften Tests: 17/17 bestanden (71 gesamt) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Modul: Einkaufslisten-Test
|
||||
* Zweck: Validiert alle Shopping-API-Abfragen, Sortierung, Constraints
|
||||
* Ausführen: node --experimental-sqlite test-shopping.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]);
|
||||
|
||||
const u1 = db.prepare(`INSERT INTO users (username, display_name, password_hash, role)
|
||||
VALUES ('admin', 'Admin', 'x', 'admin')`).run();
|
||||
const uid = u1.lastInsertRowid;
|
||||
|
||||
console.log('\n[Shopping-Test] Listen, Artikel, Sortierung\n');
|
||||
|
||||
let listId, list2Id, itemId1, itemId2, itemId3;
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Listen-CRUD
|
||||
// --------------------------------------------------------
|
||||
test('Liste erstellen', () => {
|
||||
const r = db.prepare(`INSERT INTO shopping_lists (name, created_by) VALUES ('REWE', ?)`).run(uid);
|
||||
listId = r.lastInsertRowid;
|
||||
assert(listId > 0);
|
||||
});
|
||||
|
||||
test('Zweite Liste erstellen', () => {
|
||||
const r = db.prepare(`INSERT INTO shopping_lists (name, created_by) VALUES ('dm', ?)`).run(uid);
|
||||
list2Id = r.lastInsertRowid;
|
||||
assert(list2Id > 0);
|
||||
});
|
||||
|
||||
test('Alle Listen mit Zähler abrufbar', () => {
|
||||
const lists = db.prepare(`
|
||||
SELECT sl.*,
|
||||
COUNT(si.id) AS item_total,
|
||||
SUM(CASE WHEN si.is_checked = 1 THEN 1 ELSE 0 END) AS item_checked
|
||||
FROM shopping_lists sl
|
||||
LEFT JOIN shopping_items si ON si.list_id = sl.id
|
||||
GROUP BY sl.id ORDER BY sl.created_at ASC
|
||||
`).all();
|
||||
assert(lists.length === 2, `Erwartet 2, erhalten ${lists.length}`);
|
||||
assert(lists[0].name === 'REWE');
|
||||
assert(lists[0].item_total === 0, 'Noch keine Artikel');
|
||||
});
|
||||
|
||||
test('Liste umbenennen', () => {
|
||||
db.prepare(`UPDATE shopping_lists SET name = 'REWE Wocheneinkauf' WHERE id = ?`).run(listId);
|
||||
const l = db.prepare('SELECT name FROM shopping_lists WHERE id = ?').get(listId);
|
||||
assert(l.name === 'REWE Wocheneinkauf', 'Name aktualisiert');
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Artikel-CRUD
|
||||
// --------------------------------------------------------
|
||||
test('Artikel hinzufügen — Obst & Gemüse', () => {
|
||||
const r = db.prepare(`INSERT INTO shopping_items (list_id, name, quantity, category)
|
||||
VALUES (?, 'Äpfel', '1 kg', 'Obst & Gemüse')`).run(listId);
|
||||
itemId1 = r.lastInsertRowid;
|
||||
assert(itemId1 > 0);
|
||||
});
|
||||
|
||||
test('Artikel hinzufügen — Milchprodukte', () => {
|
||||
const r = db.prepare(`INSERT INTO shopping_items (list_id, name, quantity, category)
|
||||
VALUES (?, 'Milch', '1 Liter', 'Milchprodukte')`).run(listId);
|
||||
itemId2 = r.lastInsertRowid;
|
||||
assert(itemId2 > 0);
|
||||
});
|
||||
|
||||
test('Artikel hinzufügen — Backwaren', () => {
|
||||
const r = db.prepare(`INSERT INTO shopping_items (list_id, name, category)
|
||||
VALUES (?, 'Brot', 'Backwaren')`).run(listId);
|
||||
itemId3 = r.lastInsertRowid;
|
||||
assert(itemId3 > 0);
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Supermarkt-Gang-Sortierung
|
||||
// --------------------------------------------------------
|
||||
test('Sortierung nach Supermarkt-Gang-Logik', () => {
|
||||
const categories = [
|
||||
'Obst & Gemüse', 'Backwaren', 'Milchprodukte', 'Fleisch & Fisch',
|
||||
'Tiefkühl', 'Getränke', 'Haushalt', 'Drogerie', 'Sonstiges',
|
||||
];
|
||||
const caseExpr = categories.map((c, i) => `WHEN '${c}' THEN ${i}`).join(' ');
|
||||
|
||||
const items = db.prepare(`
|
||||
SELECT * FROM shopping_items
|
||||
WHERE list_id = ?
|
||||
ORDER BY CASE category ${caseExpr} ELSE 9 END, is_checked ASC, created_at ASC
|
||||
`).all(listId);
|
||||
|
||||
assert(items.length === 3, `Erwartet 3, erhalten ${items.length}`);
|
||||
assert(items[0].category === 'Obst & Gemüse', `Erste Kategorie: ${items[0].category}`);
|
||||
assert(items[1].category === 'Backwaren', `Zweite Kategorie: ${items[1].category}`);
|
||||
assert(items[2].category === 'Milchprodukte', `Dritte Kategorie: ${items[2].category}`);
|
||||
});
|
||||
|
||||
test('Abgehakte Artikel ans Ende innerhalb der Kategorie', () => {
|
||||
// Zweiten Artikel in Obst einfügen
|
||||
db.prepare(`INSERT INTO shopping_items (list_id, name, category, is_checked)
|
||||
VALUES (?, 'Bananen', 'Obst & Gemüse', 1)`).run(listId);
|
||||
|
||||
const categories = [
|
||||
'Obst & Gemüse', 'Backwaren', 'Milchprodukte', 'Fleisch & Fisch',
|
||||
'Tiefkühl', 'Getränke', 'Haushalt', 'Drogerie', 'Sonstiges',
|
||||
];
|
||||
const caseExpr = categories.map((c, i) => `WHEN '${c}' THEN ${i}`).join(' ');
|
||||
|
||||
const items = db.prepare(`
|
||||
SELECT * FROM shopping_items WHERE list_id = ?
|
||||
ORDER BY CASE category ${caseExpr} ELSE 9 END, is_checked ASC, created_at ASC
|
||||
`).all(listId);
|
||||
|
||||
const obst = items.filter((i) => i.category === 'Obst & Gemüse');
|
||||
assert(obst[0].name === 'Äpfel', 'Nicht abgehakt zuerst');
|
||||
assert(obst[1].name === 'Bananen', 'Abgehakt danach');
|
||||
assert(obst[1].is_checked === 1, 'Bananen ist abgehakt');
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Artikel abhaken
|
||||
// --------------------------------------------------------
|
||||
test('Artikel abhaken (toggle)', () => {
|
||||
db.prepare(`UPDATE shopping_items SET is_checked = 1 WHERE id = ?`).run(itemId1);
|
||||
const item = db.prepare('SELECT is_checked FROM shopping_items WHERE id = ?').get(itemId1);
|
||||
assert(item.is_checked === 1, 'Artikel abgehakt');
|
||||
});
|
||||
|
||||
test('Artikel wieder aktivieren', () => {
|
||||
db.prepare(`UPDATE shopping_items SET is_checked = 0 WHERE id = ?`).run(itemId1);
|
||||
const item = db.prepare('SELECT is_checked FROM shopping_items WHERE id = ?').get(itemId1);
|
||||
assert(item.is_checked === 0, 'Artikel wieder aktiv');
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Abgehakte löschen
|
||||
// --------------------------------------------------------
|
||||
test('"Abgehakte löschen" entfernt nur is_checked=1', () => {
|
||||
db.prepare(`UPDATE shopping_items SET is_checked = 1 WHERE id IN (?, ?)`).run(itemId1, itemId2);
|
||||
|
||||
// Äpfel (itemId1) + Milch (itemId2) + Bananen (bereits checked aus vorherigem Test) = 3
|
||||
const result = db.prepare(`DELETE FROM shopping_items WHERE list_id = ? AND is_checked = 1`).run(listId);
|
||||
assert(result.changes === 3, `Gelöscht: ${result.changes}, erwartet: 3`);
|
||||
|
||||
const remaining = db.prepare(`SELECT * FROM shopping_items WHERE list_id = ?`).all(listId);
|
||||
assert(remaining.every((i) => i.is_checked === 0), 'Nur nicht-abgehakte verbleiben');
|
||||
assert(remaining.length === 1, `Verbleibend: ${remaining.length} (nur Brot)`);
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Autocomplete
|
||||
// --------------------------------------------------------
|
||||
test('Autocomplete-Suggestions nach Prefix', () => {
|
||||
db.prepare(`INSERT INTO shopping_items (list_id, name, category) VALUES (?, 'Joghurt', 'Milchprodukte')`).run(listId);
|
||||
db.prepare(`INSERT INTO shopping_items (list_id, name, category) VALUES (?, 'Käse', 'Milchprodukte')`).run(listId);
|
||||
|
||||
const results = db.prepare(`
|
||||
SELECT DISTINCT name FROM shopping_items
|
||||
WHERE name LIKE ? COLLATE NOCASE
|
||||
ORDER BY name ASC LIMIT 8
|
||||
`).all('J%');
|
||||
|
||||
assert(results.length >= 1, 'Mindestens 1 Vorschlag');
|
||||
assert(results[0].name === 'Joghurt', `Erwartet Joghurt, erhalten: ${results[0].name}`);
|
||||
});
|
||||
|
||||
test('Autocomplete — kein Match gibt leeres Array', () => {
|
||||
const results = db.prepare(`
|
||||
SELECT DISTINCT name FROM shopping_items WHERE name LIKE ? COLLATE NOCASE
|
||||
`).all('XXXXXXXX%');
|
||||
assert(results.length === 0, 'Kein Match erwartet');
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Zähler-Abfrage
|
||||
// --------------------------------------------------------
|
||||
test('Listen-Zähler korrekt nach Änderungen', () => {
|
||||
const list = db.prepare(`
|
||||
SELECT sl.*,
|
||||
COUNT(si.id) AS item_total,
|
||||
SUM(CASE WHEN si.is_checked = 1 THEN 1 ELSE 0 END) AS item_checked
|
||||
FROM shopping_lists sl
|
||||
LEFT JOIN shopping_items si ON si.list_id = sl.id
|
||||
WHERE sl.id = ?
|
||||
GROUP BY sl.id
|
||||
`).get(listId);
|
||||
assert(list.item_total > 0, `item_total=${list.item_total}`);
|
||||
assert(list.item_checked === 0, 'Keine abgehakten mehr');
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Cascade-Löschung
|
||||
// --------------------------------------------------------
|
||||
test('Liste löschen entfernt alle Artikel (CASCADE)', () => {
|
||||
db.prepare('DELETE FROM shopping_lists WHERE id = ?').run(list2Id);
|
||||
const items = db.prepare('SELECT * FROM shopping_items WHERE list_id = ?').all(list2Id);
|
||||
assert(items.length === 0, 'Keine Artikel nach Listen-Löschung');
|
||||
});
|
||||
|
||||
test('Nicht existierende Liste gibt keine Zeile', () => {
|
||||
const list = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(99999);
|
||||
assert(!list, 'Sollte undefined sein');
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Ergebnis
|
||||
// --------------------------------------------------------
|
||||
console.log(`\n[Shopping-Test] Ergebnis: ${passed} bestanden, ${failed} fehlgeschlagen\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
Reference in New Issue
Block a user