Files
oikos/test-shopping.js
T
ulsklyc 2ab250cc35 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>
2026-03-24 18:10:01 +01:00

230 lines
9.1 KiB
JavaScript

/**
* 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);