test: Die iOS-Modelle werden gegen echte Serverantworten geprueft

Die Swift-Modelle sind aus den Pydantic-Schemas abgeschrieben. Ein vertippter
Feldname uebersetzt anstandslos und faellt erst dem Nutzer auf, wenn eine
Liste leer bleibt - das ist beim blinden Schreiben die einzige Luecke, die
kein Uebersetzer schliesst.

backend/scripts/erzeuge_ios_fixture.py legt eine Wegwerfdatenbank an, befuellt
sie so, dass jedes Feld einmal belegt ist (Tags, Uebertrag am Stueck,
verknuepfter Uebertrag, Vertragszuordnung, gebundenes Konto, jaehrliches Abo)
und schreibt 15 echte Antworten nach ios/Tests/Fixtures/antworten.json. Der
Swift-Test "Serverantworten" entschluesselt daraus jedes Modell.

Nach jeder Schema-Aenderung neu laufen lassen und das Ergebnis mit committen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-10-21 20:23:49 +02:00
co-authored by Claude Opus 5
parent 3dff435156
commit a071422e23
2 changed files with 1266 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Echte Antworten des Backends als Prüfdatei für die iOS-Modelle.
Aufruf aus ``backend/``::
.venv/bin/python -m scripts.erzeuge_ios_fixture
Schreibt ``ios/Tests/Fixtures/antworten.json``. Der Swift-Test
``Serverantworten`` entschlüsselt daraus jedes Modell und schlägt fehl, sobald
ein Feldname nicht mehr passt.
**Warum es das gibt.** Die iOS-App wird gegen Modelle geschrieben, die jemand
aus den Pydantic-Schemas abgeschrieben hat. Ein vertippter Feldname fällt beim
Übersetzen nicht auf -- er fällt erst dem Nutzer auf, wenn die Liste leer
bleibt. Diese Datei ist die einzige Prüfung, die das fängt.
Nach jeder Änderung an einem Schema neu laufen lassen und das Ergebnis
mit committen. Die Datenbank ist eine Wegwerfdatenbank; es landen keine
echten Daten in der Datei.
"""
import json
import os
import tempfile
from pathlib import Path
# Muss vor dem Import von app stehen -- die Konfiguration wird beim Import gelesen.
os.environ["APP_DATA_DIR"] = tempfile.mkdtemp(prefix="cf-fixture-")
os.environ.setdefault("APP_ECB_FETCH_ON_START", "false")
os.environ.setdefault("APP_ADMIN_USERNAME", "admin")
os.environ.setdefault("APP_ADMIN_PASSWORD", "AdminPass1")
os.environ.setdefault("APP_SESSION_SECRET", "fixture-secret")
from datetime import date # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from app.main import app # noqa: E402
ZIEL = Path(__file__).resolve().parents[2] / "ios" / "Tests" / "Fixtures" / "antworten.json"
def _seed(c: TestClient) -> None:
"""Genug Daten, dass jedes Feld einmal belegt ist.
Leere Listen pruefen nichts: Ein falscher Feldname in einer Zeile faellt
nur auf, wenn es eine Zeile gibt.
"""
c.put("/api/settings/budget_start_day", params={"value": "1"})
giro = c.post("/api/accounts", json={
"name": "Girokonto", "type": "girokonto", "currency": "EUR",
"opening_balance_cents": 120000}).json()
sparen = c.post("/api/accounts", json={
"name": "Sparen", "type": "sparkonto", "currency": "CHF",
"opening_balance_cents": 500000, "is_locked": True}).json()
gruppen = c.get("/api/categories").json()
ausgabe = next(g for g in gruppen if g["kind"] == "expense")["categories"][0]
einnahme = next(g for g in gruppen if g["kind"] == "income")["categories"][0]
tag = c.post("/api/tags", json={"name": "Urlaub", "color": "#2a78d6"}).json()
abo = c.post("/api/subscriptions", json={
"name": "Streaming", "amount_cents": 1299, "currency": "EUR",
"interval_months": 12, "category_id": ausgabe["id"]}).json()
heute = str(date.today())
kauf = c.post("/api/transactions", json={
"type": "expense", "date": heute, "account_id": giro["id"],
"amount_cents": 4250, "category_id": ausgabe["id"], "payee": "Supermarkt",
"description": "Wocheneinkauf", "notes": "mit Pfand"}).json()
c.patch(f"/api/transactions/{kauf['id']}", json={"tag_ids": [tag["id"]]})
c.post("/api/transactions", json={
"type": "income", "date": heute, "account_id": giro["id"],
"amount_cents": 350000, "category_id": einnahme["id"], "payee": "Arbeitgeber"})
c.post("/api/transactions", json={
"type": "transfer", "date": heute, "account_id": giro["id"],
"to_account_id": sparen["id"], "amount_cents": 20000})
# Zwei getrennte Buchungen, von Hand zu einem Uebertrag verknuepft -- der
# zweite Mechanismus, den die App nachbauen muss.
raus = c.post("/api/transactions", json={
"type": "expense", "date": heute, "account_id": giro["id"],
"amount_cents": 15000, "payee": "Ueberweisung"}).json()
rein = c.post("/api/transactions", json={
"type": "income", "date": heute, "account_id": sparen["id"],
"amount_cents": 15000, "payee": "Gutschrift"}).json()
c.post("/api/uebertraege/verbinden",
json={"a_id": raus["id"], "b_id": rein["id"]})
c.put(f"/api/budget/template/category/{ausgabe['id']}",
params={"planned_cents": 50000})
c.post(f"/api/subscriptions/{abo['id']}/buchungen/{kauf['id']}")
PFADE = {
"version": "/api/version",
"ichSelbst": "/api/auth/me",
"einstellungen": "/api/settings",
"konten": "/api/accounts",
"kategoriegruppen": "/api/categories",
"schlagwoerter": "/api/tags",
"buchungen": "/api/transactions",
"uebertragsverknuepfungen": "/api/transactions/links",
"budget": "/api/budget/current",
"budgetvorlage": "/api/budget/template",
"abos": "/api/subscriptions",
"kontostaende": "/api/dashboard/balances",
"budgetfortschritt": "/api/dashboard/budget-progress",
"einnahmenAusgaben": "/api/dashboard/income-expense",
"letzteBuchungen": "/api/dashboard/recent",
}
def main() -> int:
with TestClient(app) as c:
r = c.post("/api/auth/login",
data={"username": "admin", "password": "AdminPass1"})
if r.status_code != 200:
print("Anmeldung fehlgeschlagen:", r.text)
return 1
_seed(c)
heraus = {}
for name, pfad in PFADE.items():
antwort = c.get(pfad)
if antwort.status_code != 200:
print(f"{pfad} antwortet {antwort.status_code}: {antwort.text[:200]}")
return 1
heraus[name] = antwort.json()
ZIEL.parent.mkdir(parents=True, exist_ok=True)
ZIEL.write_text(json.dumps(heraus, indent=1, ensure_ascii=False) + "\n",
encoding="utf-8")
print(f"{ZIEL} geschrieben, {len(heraus)} Antworten.")
return 0
if __name__ == "__main__":
raise SystemExit(main())