Tests: pytest-Suite für Konten, Haushalt, Kanban, Reisen und Migrationen
- backend/tests: jede Test-Funktion bekommt eine frische Datenbank; test_migrationen.py baut ältere Datenbank-Stände nach und prüft die verlustfreie Umstellung (auch zweiter Start ohne Änderung) - requirements-dev.txt mit pytest - Fix (von den Tests gefunden): Eine neue, leere Datenbank bekam keine erste Liste "Zuhause"; jetzt einmalig beim ersten Start Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dc4ce18593
commit
79cbaab092
10 files changed
+478
No files matched your search
@@ -5,3 +5,4 @@
|
|||||||
/_alt-node/
|
/_alt-node/
|
||||||
backend/.venv/
|
backend/.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
@@ -46,6 +46,17 @@ cd frontend && npm run dev # Oberfläche auf http://loc
|
|||||||
```
|
```
|
||||||
|
|
||||||
Vite leitet `/api` und `/uploads` automatisch ans Backend weiter.
|
Vite leitet `/api` und `/uploads` automatisch ans Backend weiter.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python3 -m pip --python .venv/bin/python install -r requirements-dev.txt # einmalig: pytest
|
||||||
|
.venv/bin/python -m pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Jeder Test bekommt eine frische, leere Datenbank. `tests/test_migrationen.py` baut ältere
|
||||||
|
Datenbank-Stände nach und prüft, dass der Start sie verlustfrei umstellt.
|
||||||
Die API-Dokumentation gibt es unter http://localhost:3000/api/docs.
|
Die API-Dokumentation gibt es unter http://localhost:3000/api/docs.
|
||||||
|
|
||||||
## Konfiguration (`config.json`)
|
## Konfiguration (`config.json`)
|
||||||
|
|||||||
@@ -273,6 +273,13 @@ def _migrate(conn) -> None:
|
|||||||
"""
|
"""
|
||||||
info = {r["name"]: r for r in conn.execute("PRAGMA table_info(household_items)")}
|
info = {r["name"]: r for r in conn.execute("PRAGMA table_info(household_items)")}
|
||||||
if "list_id" in info and info["list_id"]["notnull"]:
|
if "list_id" in info and info["list_id"]["notnull"]:
|
||||||
|
# Neue Datenbank: einmalig eine erste Liste anlegen (wer später alle löscht, bekommt keine zurück)
|
||||||
|
if get_meta(conn, "haushalt_start") is None:
|
||||||
|
if not conn.execute("SELECT 1 FROM shopping_lists").fetchone():
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO shopping_lists (name, everyone, position, created_at) VALUES ('Zuhause', 1, 1, ?)", (now_iso(),)
|
||||||
|
)
|
||||||
|
conn.execute("INSERT INTO meta (key, value) VALUES ('haushalt_start', '1')")
|
||||||
return
|
return
|
||||||
row = conn.execute("SELECT id FROM shopping_lists ORDER BY position, id LIMIT 1").fetchone()
|
row = conn.execute("SELECT id FROM shopping_lists ORDER BY position, id LIMIT 1").fetchone()
|
||||||
if row:
|
if row:
|
||||||
@@ -295,6 +302,7 @@ def _migrate(conn) -> None:
|
|||||||
(default,),
|
(default,),
|
||||||
)
|
)
|
||||||
conn.execute("DROP TABLE household_items_alt")
|
conn.execute("DROP TABLE household_items_alt")
|
||||||
|
conn.execute("INSERT OR REPLACE INTO meta (key, value) VALUES ('haushalt_start', '1')")
|
||||||
|
|
||||||
|
|
||||||
def _migrate_checked(conn) -> None:
|
def _migrate_checked(conn) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Nur für die Entwicklung: Tests (cd backend && .venv/bin/python -m pytest)
|
||||||
|
-r requirements.txt
|
||||||
|
pytest==8.4.2
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Gemeinsame Test-Hilfen: jede Test-Funktion bekommt eine frische, leere Datenbank.
|
||||||
|
|
||||||
|
Die App liest den Datenordner beim Import; deshalb zeigt P42_DATA_DIR schon vorher auf einen
|
||||||
|
Wegwerf-Ordner. Pro Test wird db.DB_FILE auf eine neue Datei umgebogen und das Schema angelegt.
|
||||||
|
Hintergrund-Aufgaben (lifespan) laufen nicht, weil TestClient ohne "with" benutzt wird.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
os.environ["P42_DATA_DIR"] = tempfile.mkdtemp(prefix="p42-test-")
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
from fastapi.testclient import TestClient # noqa: E402
|
||||||
|
|
||||||
|
from app import auth, db # noqa: E402
|
||||||
|
from app.main import app # noqa: E402
|
||||||
|
|
||||||
|
PASSWORD = "passwort123"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fresh_db(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(db, "DB_FILE", tmp_path / "p42.db")
|
||||||
|
auth._failures.clear()
|
||||||
|
db.init()
|
||||||
|
return tmp_path / "p42.db"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def conn(fresh_db):
|
||||||
|
c = db.connect()
|
||||||
|
yield c
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def login():
|
||||||
|
"""login("anna", admin=True) → angemeldeter TestClient mit .user (id, name)."""
|
||||||
|
def make(username: str, admin: bool = False) -> TestClient:
|
||||||
|
with db.transaction() as c:
|
||||||
|
user_id = auth._create_user(c, username, username.capitalize(), PASSWORD, admin)
|
||||||
|
client = TestClient(app)
|
||||||
|
res = client.post("/api/auth/login", json={"username": username, "password": PASSWORD})
|
||||||
|
assert res.status_code == 200, res.text
|
||||||
|
client.user = {"id": user_id, "name": username.capitalize()}
|
||||||
|
return client
|
||||||
|
return make
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_anmeldung_gesperrt():
|
||||||
|
assert TestClient(app).get("/api/haushalt").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_erstes_konto_wird_admin():
|
||||||
|
client = TestClient(app)
|
||||||
|
assert client.get("/api/auth/status").json()["setupRequired"] is True
|
||||||
|
res = client.post("/api/auth/setup", json={"username": "erste", "password": "passwort123"})
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["isAdmin"] is True
|
||||||
|
# Danach gibt es kein zweites "erstes Konto"
|
||||||
|
assert TestClient(app).post("/api/auth/setup", json={"username": "zweite", "password": "passwort123"}).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_falsches_passwort(login):
|
||||||
|
login("anna")
|
||||||
|
res = TestClient(app).post("/api/auth/login", json={"username": "anna", "password": "falsch"})
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_benutzerverwaltung_nur_admin(login):
|
||||||
|
assert login("anna").get("/api/users").status_code == 403
|
||||||
|
assert login("chef", admin=True).get("/api/users").status_code == 200
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
def state(client):
|
||||||
|
return client.get("/api/haushalt").json()
|
||||||
|
|
||||||
|
|
||||||
|
def add(client, list_id, name, area="einkauf", quantity=1, unit="stk"):
|
||||||
|
return client.post("/api/haushalt/items", json={"list": area, "name": name, "quantity": quantity, "unit": unit, "listId": list_id})
|
||||||
|
|
||||||
|
|
||||||
|
def items(data, list_id, area=None):
|
||||||
|
return [i for i in data["items"] if i["listId"] == list_id and (area is None or i["list"] == area)]
|
||||||
|
|
||||||
|
|
||||||
|
def new_list(client, name, everyone=True, members=()):
|
||||||
|
res = client.post("/api/haushalt/listen", json={"name": name, "everyone": everyone, "members": list(members)})
|
||||||
|
assert res.status_code == 201, res.text
|
||||||
|
return res.json()["created"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_neue_db_hat_liste_zuhause(login):
|
||||||
|
data = state(login("anna"))
|
||||||
|
assert [l["name"] for l in data["lists"]] == ["Zuhause"]
|
||||||
|
assert data["lists"][0]["everyone"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_hinzufuegen_fuehrt_zusammen_und_vereinheitlicht_namen(login):
|
||||||
|
anna = login("anna")
|
||||||
|
home = state(anna)["lists"][0]["id"]
|
||||||
|
add(anna, home, "milch", quantity=2)
|
||||||
|
data = add(anna, home, "Milch", quantity=1).json()
|
||||||
|
assert [(i["name"], i["quantity"]) for i in items(data, home, "einkauf")] == [("Milch", 3)]
|
||||||
|
# Andere Einheit = eigene Zeile
|
||||||
|
data = add(anna, home, "Milch", quantity=1.5, unit="l").json()
|
||||||
|
assert len(items(data, home, "einkauf")) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_listen_haben_eigenen_vorrat(login):
|
||||||
|
anna = login("anna")
|
||||||
|
home = state(anna)["lists"][0]["id"]
|
||||||
|
urlaub = new_list(anna, "Urlaub")
|
||||||
|
add(anna, urlaub, "Brot", area="vorrat")
|
||||||
|
data = state(anna)
|
||||||
|
assert [i["name"] for i in items(data, urlaub, "vorrat")] == ["Brot"]
|
||||||
|
assert items(data, home, "vorrat") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_private_liste_nur_fuer_mitglieder(login):
|
||||||
|
anna, ben, chef = login("anna"), login("ben"), login("chef", admin=True)
|
||||||
|
geheim = new_list(anna, "Geheim", everyone=False)
|
||||||
|
item = add(anna, geheim, "Kuchen").json()
|
||||||
|
item_id = items(item, geheim)[0]["id"]
|
||||||
|
|
||||||
|
for other in (ben, chef): # auch Admins sehen fremde private Listen nicht
|
||||||
|
assert all(l["id"] != geheim for l in state(other)["lists"])
|
||||||
|
assert add(other, geheim, "x").status_code == 404
|
||||||
|
assert other.patch(f"/api/haushalt/items/{item_id}", json={"quantity": 5}).status_code == 404
|
||||||
|
|
||||||
|
anna.patch(f"/api/haushalt/listen/{geheim}", json={"name": "Geheim", "everyone": False, "members": [ben.user["id"]]})
|
||||||
|
lst = next(l for l in state(ben)["lists"] if l["id"] == geheim)
|
||||||
|
assert lst["canManage"] is False
|
||||||
|
assert ben.patch(f"/api/haushalt/listen/{geheim}", json={"name": "x"}).status_code == 403
|
||||||
|
assert ben.delete(f"/api/haushalt/listen/{geheim}").status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_liste_loeschen_entfernt_eintraege(login, conn):
|
||||||
|
anna = login("anna")
|
||||||
|
urlaub = new_list(anna, "Urlaub")
|
||||||
|
add(anna, urlaub, "Brot", area="vorrat")
|
||||||
|
add(anna, urlaub, "Wasser")
|
||||||
|
assert anna.delete(f"/api/haushalt/listen/{urlaub}").status_code == 200
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM household_items WHERE list_id = ?", (urlaub,)).fetchone()[0] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_ungueltiges_mitglied(login):
|
||||||
|
res = login("anna").post("/api/haushalt/listen", json={"name": "x", "everyone": False, "members": [9999]})
|
||||||
|
assert res.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_abhaken_und_einkauf_beenden(login):
|
||||||
|
anna = login("anna")
|
||||||
|
home = state(anna)["lists"][0]["id"]
|
||||||
|
add(anna, home, "Milch", quantity=2)
|
||||||
|
add(anna, home, "Brot")
|
||||||
|
add(anna, home, "Milch", area="vorrat", quantity=1)
|
||||||
|
milch = next(i for i in items(state(anna), home, "einkauf") if i["name"] == "Milch")
|
||||||
|
|
||||||
|
data = anna.patch(f"/api/haushalt/items/{milch['id']}", json={"checked": True}).json()
|
||||||
|
# Abgehakt bleibt auf der Einkaufsliste
|
||||||
|
assert {i["name"]: i["checked"] for i in items(data, home, "einkauf")} == {"Milch": True, "Brot": False}
|
||||||
|
|
||||||
|
res = anna.post(f"/api/haushalt/listen/{home}/einkauf-beenden")
|
||||||
|
assert res.json()["moved"] == 1
|
||||||
|
data = res.json()
|
||||||
|
assert [i["name"] for i in items(data, home, "einkauf")] == ["Brot"]
|
||||||
|
assert [(i["name"], i["quantity"]) for i in items(data, home, "vorrat")] == [("Milch", 3)]
|
||||||
|
# Nichts mehr abgehakt
|
||||||
|
assert anna.post(f"/api/haushalt/listen/{home}/einkauf-beenden").status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_vorrat_laesst_sich_nicht_abhaken(login):
|
||||||
|
anna = login("anna")
|
||||||
|
home = state(anna)["lists"][0]["id"]
|
||||||
|
vorrat = items(add(anna, home, "Reis", area="vorrat").json(), home, "vorrat")[0]
|
||||||
|
assert anna.patch(f"/api/haushalt/items/{vorrat['id']}", json={"checked": True}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_erneut_hinzufuegen_hebt_haken_auf(login):
|
||||||
|
anna = login("anna")
|
||||||
|
home = state(anna)["lists"][0]["id"]
|
||||||
|
milch = items(add(anna, home, "Milch").json(), home)[0]
|
||||||
|
anna.patch(f"/api/haushalt/items/{milch['id']}", json={"checked": True})
|
||||||
|
data = add(anna, home, "Milch").json()
|
||||||
|
assert items(data, home)[0]["checked"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_aufgebraucht_kommt_mit_menge_1_auf_einkauf(login):
|
||||||
|
anna = login("anna")
|
||||||
|
home = state(anna)["lists"][0]["id"]
|
||||||
|
reis = items(add(anna, home, "Reis", area="vorrat", quantity=4).json(), home)[0]
|
||||||
|
data = anna.post(f"/api/haushalt/items/{reis['id']}/move").json()
|
||||||
|
assert [(i["list"], i["quantity"]) for i in items(data, home)] == [("einkauf", 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_rezept_uebernehmen_in_gewaehlte_liste(login):
|
||||||
|
anna = login("anna")
|
||||||
|
urlaub = new_list(anna, "Urlaub")
|
||||||
|
res = anna.post("/api/haushalt/rezept/uebernehmen", json={
|
||||||
|
"items": [{"name": "Mehl", "quantity": 0.5, "unit": "kg"}], "aliases": [], "listId": urlaub,
|
||||||
|
})
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert [(i["name"], i["quantity"], i["unit"]) for i in items(res.json(), urlaub, "einkauf")] == [("Mehl", 0.5, "kg")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_einheitenwechsel_fuehrt_zusammen(login):
|
||||||
|
anna = login("anna")
|
||||||
|
home = state(anna)["lists"][0]["id"]
|
||||||
|
add(anna, home, "Mehl", quantity=1, unit="kg")
|
||||||
|
stk = items(add(anna, home, "Mehl", quantity=2).json(), home)
|
||||||
|
stk = next(i for i in stk if i["unit"] == "stk")
|
||||||
|
data = anna.patch(f"/api/haushalt/items/{stk['id']}", json={"unit": "kg"}).json()
|
||||||
|
assert [(i["quantity"], i["unit"]) for i in items(data, home)] == [(3, "kg")]
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
|
||||||
|
def ago(days):
|
||||||
|
return (datetime.now(timezone.utc) - timedelta(days=days)).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def board(client, project_id):
|
||||||
|
return client.get(f"/api/kanban/projekte/{project_id}").json()
|
||||||
|
|
||||||
|
|
||||||
|
def make_board(client):
|
||||||
|
return client.post("/api/kanban/projekte", json={"name": "Test"}).json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def task(client, project_id, title, status="offen"):
|
||||||
|
data = client.post(f"/api/kanban/projekte/{project_id}/aufgaben", json={"title": title, "status": status}).json()
|
||||||
|
return next(t for t in data["tasks"] if t["title"] == title)
|
||||||
|
|
||||||
|
|
||||||
|
def test_erledigt_setzt_und_loescht_zeitpunkt(login):
|
||||||
|
anna = login("anna")
|
||||||
|
pid = make_board(anna)
|
||||||
|
t = task(anna, pid, "A")
|
||||||
|
assert t["doneAt"] is None
|
||||||
|
data = anna.patch(f"/api/kanban/aufgaben/{t['id']}", json={"status": "erledigt"}).json()
|
||||||
|
assert next(x for x in data["tasks"] if x["id"] == t["id"])["doneAt"]
|
||||||
|
data = anna.patch(f"/api/kanban/aufgaben/{t['id']}", json={"status": "arbeit"}).json()
|
||||||
|
assert next(x for x in data["tasks"] if x["id"] == t["id"])["doneAt"] is None
|
||||||
|
assert task(anna, pid, "Gleich erledigt", status="erledigt")["doneAt"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_archiv_nach_2_tagen_loeschen_nach_14(login, conn):
|
||||||
|
anna = login("anna")
|
||||||
|
pid = make_board(anna)
|
||||||
|
for title in ("frisch", "alt", "uralt"):
|
||||||
|
task(anna, pid, title, status="erledigt")
|
||||||
|
with conn:
|
||||||
|
conn.execute("UPDATE kanban_tasks SET done_at = ? WHERE title = 'alt'", (ago(3),))
|
||||||
|
conn.execute("UPDATE kanban_tasks SET done_at = ? WHERE title = 'uralt'", (ago(15),))
|
||||||
|
data = board(anna, pid)
|
||||||
|
assert [t["title"] for t in data["tasks"]] == ["frisch"]
|
||||||
|
assert [t["title"] for t in data["archived"]] == ["alt"]
|
||||||
|
assert data["archived"][0]["deleteAt"] > ago(-10)
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM kanban_tasks WHERE title = 'uralt'").fetchone()[0] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_zurueckholen_startet_fristen_neu(login, conn):
|
||||||
|
anna = login("anna")
|
||||||
|
pid = make_board(anna)
|
||||||
|
t = task(anna, pid, "alt", status="erledigt")
|
||||||
|
with conn:
|
||||||
|
conn.execute("UPDATE kanban_tasks SET done_at = ? WHERE id = ?", (ago(5), t["id"]))
|
||||||
|
data = anna.post(f"/api/kanban/aufgaben/{t['id']}/zurueckholen").json()
|
||||||
|
assert data["archived"] == []
|
||||||
|
assert [x["title"] for x in data["tasks"]] == ["alt"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_loeschen_nur_ersteller_oder_admin(login):
|
||||||
|
anna, ben, chef = login("anna"), login("ben"), login("chef", admin=True)
|
||||||
|
pid = make_board(anna)
|
||||||
|
t1, t2 = task(anna, pid, "eins"), task(anna, pid, "zwei")
|
||||||
|
assert ben.delete(f"/api/kanban/aufgaben/{t1['id']}").status_code == 403
|
||||||
|
assert anna.delete(f"/api/kanban/aufgaben/{t1['id']}").status_code == 200
|
||||||
|
assert chef.delete(f"/api/kanban/aufgaben/{t2['id']}").status_code == 200
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""Alte Datenbank-Stände nachbauen und prüfen, dass der Start sie verlustfrei auf den aktuellen Stand bringt."""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
T = "2026-01-01T00:00:00.000Z"
|
||||||
|
|
||||||
|
KANBAN_PROJECTS = """CREATE TABLE kanban_projects (
|
||||||
|
id INTEGER PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', position REAL NOT NULL,
|
||||||
|
completed_at TEXT, created_at TEXT NOT NULL, created_by INTEGER, updated_at TEXT NOT NULL, updated_by INTEGER)"""
|
||||||
|
# Kanban-Aufgaben vor "erledigt seit"
|
||||||
|
KANBAN_TASKS_OHNE_DONE = """CREATE TABLE kanban_tasks (
|
||||||
|
id INTEGER PRIMARY KEY, project_id INTEGER NOT NULL REFERENCES kanban_projects(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL, assignee INTEGER,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 2, position REAL NOT NULL, created_at TEXT NOT NULL, created_by INTEGER,
|
||||||
|
updated_at TEXT NOT NULL, updated_by INTEGER)"""
|
||||||
|
# Reise-Städte vor Höhe, Zeitzone und Hauptstadt
|
||||||
|
TRAVEL_ALT = """CREATE TABLE travel_cities (
|
||||||
|
id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, geoname_id INTEGER NOT NULL, name TEXT NOT NULL,
|
||||||
|
admin1 TEXT NOT NULL DEFAULT '', country TEXT NOT NULL, lat REAL NOT NULL, lon REAL NOT NULL,
|
||||||
|
population INTEGER, created_at TEXT NOT NULL, UNIQUE (user_id, geoname_id))"""
|
||||||
|
# Vorrat/Einkauf ganz alt: ohne Einheit
|
||||||
|
HAUSHALT_OHNE_EINHEIT = """CREATE TABLE household_items (
|
||||||
|
id INTEGER PRIMARY KEY, key TEXT NOT NULL, name TEXT NOT NULL, list TEXT NOT NULL, quantity INTEGER NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL, updated_by INTEGER, UNIQUE (key, list))"""
|
||||||
|
# Vorrat/Einkauf mit Einheit, aber ohne Listen (Stand auf dem Server vor den Listen)
|
||||||
|
HAUSHALT_OHNE_LISTEN = """CREATE TABLE household_items (
|
||||||
|
id INTEGER PRIMARY KEY, key TEXT NOT NULL, name TEXT NOT NULL, list TEXT NOT NULL, quantity REAL NOT NULL,
|
||||||
|
unit TEXT NOT NULL DEFAULT 'stk', updated_at TEXT NOT NULL, updated_by INTEGER, UNIQUE (key, list, unit))"""
|
||||||
|
# Zwischenstand: mehrere Einkaufslisten, Vorrat ohne Liste (list_id NULL)
|
||||||
|
ZWISCHENSTAND = [
|
||||||
|
"""CREATE TABLE shopping_lists (id INTEGER PRIMARY KEY, name TEXT NOT NULL, everyone INTEGER NOT NULL DEFAULT 1,
|
||||||
|
position REAL NOT NULL, created_at TEXT NOT NULL, created_by INTEGER)""",
|
||||||
|
"""CREATE TABLE household_items (
|
||||||
|
id INTEGER PRIMARY KEY, key TEXT NOT NULL, name TEXT NOT NULL, list TEXT NOT NULL,
|
||||||
|
list_id INTEGER REFERENCES shopping_lists(id) ON DELETE CASCADE, quantity REAL NOT NULL,
|
||||||
|
unit TEXT NOT NULL DEFAULT 'stk', updated_at TEXT NOT NULL, updated_by INTEGER,
|
||||||
|
CHECK ((list = 'einkauf') = (list_id IS NOT NULL)))""",
|
||||||
|
"CREATE UNIQUE INDEX household_vorrat ON household_items (key, unit) WHERE list_id IS NULL",
|
||||||
|
"CREATE UNIQUE INDEX household_einkauf ON household_items (list_id, key, unit) WHERE list_id IS NOT NULL",
|
||||||
|
f"INSERT INTO shopping_lists VALUES (1, 'Einkaufsliste', 1, 1, '{T}', NULL)",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def old_db(tmp_path, monkeypatch):
|
||||||
|
"""Leere Datei mit den angegebenen alten Tabellen; danach db.init() wie beim Serverstart."""
|
||||||
|
path = tmp_path / "alt.db"
|
||||||
|
monkeypatch.setattr(db, "DB_FILE", path)
|
||||||
|
|
||||||
|
def build(*statements):
|
||||||
|
c = sqlite3.connect(path)
|
||||||
|
for sql in statements:
|
||||||
|
c.execute(sql)
|
||||||
|
c.commit()
|
||||||
|
c.close()
|
||||||
|
db.init()
|
||||||
|
conn = db.connect()
|
||||||
|
return conn
|
||||||
|
return build
|
||||||
|
|
||||||
|
|
||||||
|
def household(conn):
|
||||||
|
return sorted(tuple(r) for r in conn.execute("SELECT name, list, list_id, quantity, unit, checked FROM household_items"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_haushalt_ohne_einheit(old_db):
|
||||||
|
conn = old_db(
|
||||||
|
HAUSHALT_OHNE_EINHEIT,
|
||||||
|
f"INSERT INTO household_items VALUES (1, 'milch', 'Milch', 'vorrat', 2, '{T}', NULL)",
|
||||||
|
f"INSERT INTO household_items VALUES (2, 'brot', 'Brot', 'einkauf', 1, '{T}', NULL)",
|
||||||
|
)
|
||||||
|
assert [tuple(r) for r in conn.execute("SELECT id, name FROM shopping_lists")] == [(1, "Zuhause")]
|
||||||
|
assert household(conn) == [("Brot", "einkauf", 1, 1, "stk", 0), ("Milch", "vorrat", 1, 2, "stk", 0)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_haushalt_ohne_listen(old_db):
|
||||||
|
conn = old_db(
|
||||||
|
HAUSHALT_OHNE_LISTEN,
|
||||||
|
f"INSERT INTO household_items VALUES (1, 'mehl', 'Mehl', 'vorrat', 1.5, 'kg', '{T}', NULL)",
|
||||||
|
f"INSERT INTO household_items VALUES (2, 'mehl', 'Mehl', 'einkauf', 2, 'stk', '{T}', NULL)",
|
||||||
|
)
|
||||||
|
assert [r["name"] for r in conn.execute("SELECT name FROM shopping_lists")] == ["Zuhause"]
|
||||||
|
assert household(conn) == [("Mehl", "einkauf", 1, 2, "stk", 0), ("Mehl", "vorrat", 1, 1.5, "kg", 0)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_haushalt_zwischenstand(old_db):
|
||||||
|
conn = old_db(
|
||||||
|
*ZWISCHENSTAND,
|
||||||
|
f"INSERT INTO household_items VALUES (1, 'reis', 'Reis', 'vorrat', NULL, 1, 'stk', '{T}', NULL)",
|
||||||
|
f"INSERT INTO household_items VALUES (2, 'reis', 'Reis', 'einkauf', 1, 3, 'stk', '{T}', NULL)",
|
||||||
|
)
|
||||||
|
# "Einkaufsliste" heißt jetzt "Zuhause" und enthält auch den Vorrat
|
||||||
|
assert [tuple(r) for r in conn.execute("SELECT id, name FROM shopping_lists")] == [(1, "Zuhause")]
|
||||||
|
assert household(conn) == [("Reis", "einkauf", 1, 3, "stk", 0), ("Reis", "vorrat", 1, 1, "stk", 0)]
|
||||||
|
indexes = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'index'")}
|
||||||
|
assert not {"household_vorrat", "household_einkauf"} & indexes
|
||||||
|
|
||||||
|
|
||||||
|
def test_kanban_ohne_erledigt_zeitpunkt(old_db):
|
||||||
|
conn = old_db(
|
||||||
|
KANBAN_PROJECTS,
|
||||||
|
KANBAN_TASKS_OHNE_DONE,
|
||||||
|
f"INSERT INTO kanban_projects VALUES (1, 'P', '', 1, NULL, '{T}', NULL, '{T}', NULL)",
|
||||||
|
f"INSERT INTO kanban_tasks VALUES (1, 1, 'fertig', '', 'erledigt', NULL, 2, 1, '{T}', NULL, '{T}', NULL)",
|
||||||
|
f"INSERT INTO kanban_tasks VALUES (2, 1, 'offen', '', 'offen', NULL, 2, 2, '{T}', NULL, '{T}', NULL)",
|
||||||
|
)
|
||||||
|
rows = dict(conn.execute("SELECT title, done_at FROM kanban_tasks").fetchall())
|
||||||
|
# Erledigte zählen ab dem Update – nicht ab dem alten Änderungsdatum (sonst sofort gelöscht)
|
||||||
|
assert rows["fertig"] > T and rows["offen"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_reisen_ohne_neue_spalten(old_db):
|
||||||
|
conn = old_db(
|
||||||
|
TRAVEL_ALT,
|
||||||
|
f"INSERT INTO travel_cities VALUES (1, 1, 2950159, 'Berlin', '', 'DE', 52.5, 13.4, NULL, '{T}')",
|
||||||
|
)
|
||||||
|
row = conn.execute("SELECT name, elevation, timezone, capital FROM travel_cities").fetchone()
|
||||||
|
assert tuple(row) == ("Berlin", None, None, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zweiter_start_aendert_nichts(old_db):
|
||||||
|
conn = old_db(
|
||||||
|
HAUSHALT_OHNE_LISTEN,
|
||||||
|
f"INSERT INTO household_items VALUES (1, 'mehl', 'Mehl', 'vorrat', 1, 'stk', '{T}', NULL)",
|
||||||
|
)
|
||||||
|
before = household(conn), [tuple(r) for r in conn.execute("SELECT * FROM shopping_lists")]
|
||||||
|
db.init()
|
||||||
|
assert (household(conn), [tuple(r) for r in conn.execute("SELECT * FROM shopping_lists")]) == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_geloeschte_listen_kommen_nicht_zurueck(conn):
|
||||||
|
with conn:
|
||||||
|
conn.execute("DELETE FROM shopping_lists")
|
||||||
|
db.init()
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM shopping_lists").fetchone()[0] == 0
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
BERLIN = {"geonameId": 2950159, "name": "Berlin", "country": "DE", "lat": 52.52, "lon": 13.41, "population": 3426354, "capital": True}
|
||||||
|
LEIDEN = {"geonameId": 2751773, "name": "Leiden", "country": "NL", "lat": 52.16, "lon": 4.49}
|
||||||
|
|
||||||
|
|
||||||
|
def test_staedte_anderer_nur_lesend(login):
|
||||||
|
anna, ben = login("anna"), login("ben")
|
||||||
|
anna.post("/api/reisen/staedte", json=BERLIN)
|
||||||
|
data = ben.get("/api/reisen").json()
|
||||||
|
assert data["cities"] == []
|
||||||
|
assert [(p["name"], [c["name"] for c in p["cities"]]) for p in data["others"]] == [("Anna", ["Berlin"])]
|
||||||
|
city_id = data["others"][0]["cities"][0]["id"]
|
||||||
|
assert ben.delete(f"/api/reisen/staedte/{city_id}").status_code == 404
|
||||||
|
assert anna.get("/api/reisen").json()["others"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_hauptstadt_kennzeichen(login):
|
||||||
|
anna = login("anna")
|
||||||
|
anna.post("/api/reisen/staedte", json=BERLIN)
|
||||||
|
data = anna.post("/api/reisen/staedte", json=LEIDEN).json()
|
||||||
|
# Mit Angabe aus der Suche gespeichert, ohne Angabe "unbekannt" (wird im Hintergrund nachgetragen)
|
||||||
|
assert {c["name"]: c["capital"] for c in data["cities"]} == {"Berlin": True, "Leiden": None}
|
||||||
|
|
||||||
|
|
||||||
|
def test_doppelte_stadt_wird_ignoriert(login):
|
||||||
|
anna = login("anna")
|
||||||
|
anna.post("/api/reisen/staedte", json=BERLIN)
|
||||||
|
assert len(anna.post("/api/reisen/staedte", json=BERLIN).json()["cities"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_land_markieren(login):
|
||||||
|
anna = login("anna")
|
||||||
|
assert anna.put("/api/reisen/laender/IS").json()["countries"] == ["IS"]
|
||||||
|
assert anna.delete("/api/reisen/laender/IS").json()["countries"] == []
|
||||||
|
assert anna.put("/api/reisen/laender/xx").status_code == 400
|
||||||
Reference in new issue
Block a user