From 79cbaab0924d2460e2040d298cfc60534b3407c8 Mon Sep 17 00:00:00 2001 From: guyg1474 <340572510+guyg1474@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:07:54 +0200 Subject: [PATCH] =?UTF-8?q?Tests:=20pytest-Suite=20f=C3=BCr=20Konten,=20Ha?= =?UTF-8?q?ushalt,=20Kanban,=20Reisen=20und=20Migrationen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 1 + README.md | 11 +++ backend/app/db.py | 8 ++ backend/requirements-dev.txt | 3 + backend/tests/conftest.py | 50 +++++++++++ backend/tests/test_auth.py | 28 ++++++ backend/tests/test_haushalt.py | 140 ++++++++++++++++++++++++++++++ backend/tests/test_kanban.py | 65 ++++++++++++++ backend/tests/test_migrationen.py | 138 +++++++++++++++++++++++++++++ backend/tests/test_reisen.py | 34 ++++++++ 10 files changed, 478 insertions(+) create mode 100644 backend/requirements-dev.txt create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_auth.py create mode 100644 backend/tests/test_haushalt.py create mode 100644 backend/tests/test_kanban.py create mode 100644 backend/tests/test_migrationen.py create mode 100644 backend/tests/test_reisen.py diff --git a/.gitignore b/.gitignore index 5c646ba..390b198 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ /_alt-node/ backend/.venv/ __pycache__/ +.pytest_cache/ diff --git a/README.md b/README.md index baa1d84..7397463 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,17 @@ cd frontend && npm run dev # Oberfläche auf http://loc ``` 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. ## Konfiguration (`config.json`) diff --git a/backend/app/db.py b/backend/app/db.py index 0c60ebd..860e011 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -273,6 +273,13 @@ def _migrate(conn) -> None: """ info = {r["name"]: r for r in conn.execute("PRAGMA table_info(household_items)")} 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 row = conn.execute("SELECT id FROM shopping_lists ORDER BY position, id LIMIT 1").fetchone() if row: @@ -295,6 +302,7 @@ def _migrate(conn) -> None: (default,), ) 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: diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..12999a2 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,3 @@ +# Nur für die Entwicklung: Tests (cd backend && .venv/bin/python -m pytest) +-r requirements.txt +pytest==8.4.2 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..37c2794 --- /dev/null +++ b/backend/tests/conftest.py @@ -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 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..8300741 --- /dev/null +++ b/backend/tests/test_auth.py @@ -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 diff --git a/backend/tests/test_haushalt.py b/backend/tests/test_haushalt.py new file mode 100644 index 0000000..26fb2a0 --- /dev/null +++ b/backend/tests/test_haushalt.py @@ -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")] diff --git a/backend/tests/test_kanban.py b/backend/tests/test_kanban.py new file mode 100644 index 0000000..6a388fd --- /dev/null +++ b/backend/tests/test_kanban.py @@ -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 diff --git a/backend/tests/test_migrationen.py b/backend/tests/test_migrationen.py new file mode 100644 index 0000000..a49b772 --- /dev/null +++ b/backend/tests/test_migrationen.py @@ -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 diff --git a/backend/tests/test_reisen.py b/backend/tests/test_reisen.py new file mode 100644 index 0000000..b01412d --- /dev/null +++ b/backend/tests/test_reisen.py @@ -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