Startpunkt 02.10.2026: aktueller Stand des Trainingsplans

Historie bewusst neu aufgesetzt, ein einziger Commit als neuer Ausgangspunkt.
This commit is contained in:
AI-Agent committed 2026-10-02 01:23:25 +02:00
commit 8fc77212cb
18 files changed
+10863

No files matched your search

+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""API-Test fuer den Trainingsplan-Server (End-to-End, offener Modus).
Lauf: python3 tests/test_api.py
Voraussetzung: Server-Code liegt eine Ebene ueber tests/.
"""
import json
import os
import shutil
import subprocess
import sys
import time
import urllib.request
import urllib.error
import urllib.parse
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STATE = "/tmp/tp_state"
PORT = 8211
BASE_URL = f"http://127.0.0.1:{PORT}"
PASS = 0
FAIL = 0
def check(name, cond, detail=""):
global PASS, FAIL
if cond:
PASS += 1
print(f" ok {name}")
else:
FAIL += 1
print(f"FAIL {name} {detail}")
def req(method, path, body=None, token=None):
url = BASE_URL + path
data = None
headers = {}
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = "Bearer " + token
r = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(r, timeout=5) as resp:
return resp.status, json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
try:
return e.code, json.loads(e.read().decode("utf-8"))
except Exception:
return e.code, {}
def raw(method, path):
r = urllib.request.Request(BASE_URL + path, method=method)
with urllib.request.urlopen(r, timeout=5) as resp:
return resp.status, resp.read().decode("utf-8")
def main():
if os.path.exists(STATE):
shutil.rmtree(STATE)
os.makedirs(STATE, exist_ok=True)
env = dict(os.environ, TRAINING_STATE_DIR=STATE, PORT=str(PORT))
proc = subprocess.Popen(
[sys.executable, os.path.join(BASE, "server.py")],
env=env, cwd=BASE,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
for _ in range(40):
time.sleep(0.25)
try:
urllib.request.urlopen(BASE_URL + "/api/meta", timeout=1)
break
except Exception:
pass
else:
print("Server nicht erreichbar")
sys.exit(1)
st, meta = req("GET", "/api/meta")
check("meta", st == 200 and "today" in meta and "now" in meta, meta)
st, cfg = req("GET", "/api/config")
acts = cfg["activities"]
check("config hat 3 Aktivitaeten", len(acts) == 3,
[a["id"] for a in acts])
gym = next(a for a in acts if a["id"] == "gym")
check("gym normalisiert (Push/Pull/Legs)",
len(gym["cycle"]["days"]) == 3,
[d["key"] for d in gym["cycle"]["days"]])
push = gym["cycle"]["days"][0]
ex = push["exercises"][0]
check("Uebung hat key + sets", ex["key"] == "Push/Bankdrücken KH (je Hand)"
and ex["sets"]["count"] == 4, ex)
run = next(a for a in acts if a["id"] == "run")
check("run fieldTemplates", len(run["fieldTemplates"]) == 5,
run["fieldTemplates"])
st, static = raw("GET", "/")
check("index.html", st == 200 and "<html" in static)
st, css = raw("GET", "/styles.css")
check("styles.css", st == 200 and "--accent" in css)
st, js = raw("GET", "/app.js")
check("app.js", st == 200 and "makeModel" in js)
st, sess = req("POST", "/api/sessions", {"activity": "gym", "day": "Push"})
check("session starten", st == 200 and sess["status"] == "active"
and sess["day"] == "Push" and sess["start_time"], sess)
sid = sess["id"]
st, act = req("GET", "/api/sessions?status=active")
check("aktive Session sichtbar", st == 200 and len(act) == 1)
st, done = req("GET", "/api/sessions?status=done")
check("kein Geist im Verlauf (vor Abschluss)", st == 200 and len(done) == 0)
setsA = [
{"exercise": ex["key"], "sort": 0, "weight": "18", "reps": "10", "done": 1},
{"exercise": ex["key"], "sort": 1, "weight": "18,5", "reps": "8", "done": 1},
{"exercise": ex["key"], "sort": 2, "weight": "20", "reps": "6", "done": 0},
]
st, r = req("PATCH", f"/api/sessions/{sid}", {"sets": setsA})
check("sets Uebung A schreiben", st == 200, r)
entriesA = r["entries"]
check("3 Sets gespeichert", len(entriesA) == 3, entriesA)
idsA = [e["id"] for e in entriesA]
check("stabile IDs", all(x for x in idsA))
check("Komma-Gewicht bleibt erhalten",
next(e for e in entriesA if e["sort"] == 1)["weight"] == "18,5")
setsA2 = [
{"exercise": ex["key"], "sort": 0, "weight": "18", "reps": "10", "done": 1},
{"exercise": ex["key"], "sort": 1, "weight": "20", "reps": "8", "done": 1},
{"exercise": ex["key"], "sort": 2, "weight": "20", "reps": "6", "done": 0},
]
st, r = req("PATCH", f"/api/sessions/{sid}", {"sets": setsA2})
idsA2 = [e["id"] for e in r["entries"]]
check("Upsert haelt IDs stabil", idsA2 == idsA, (idsA, idsA2))
exB = push["exercises"][1]
setsB = [{"exercise": exB["key"], "sort": 0, "weight": "14", "reps": "12", "done": 0}]
st, r = req("PATCH", f"/api/sessions/{sid}", {"sets": setsB})
eA = next(e for e in r["entries"] if e["exercise"] == ex["key"] and e["sort"] == 1)
eB = next(e for e in r["entries"] if e["exercise"] == exB["key"])
check("Uebung A unberuehrt (ID + Werte)", eA["id"] == idsA2[1]
and eA["weight"] == "20" and eA["reps"] == "8", eA)
check("Uebung B angelegt", eB["weight"] == "14", eB)
setsA3 = [
{"exercise": ex["key"], "sort": 0, "weight": "18", "reps": "10", "done": 1},
{"exercise": ex["key"], "sort": 1, "weight": "20", "reps": "8", "done": 1},
]
st, r = req("PATCH", f"/api/sessions/{sid}", {"sets": setsA3})
check("Set 3 entfernt", len([e for e in r["entries"]
if e["exercise"] == ex["key"]]) == 2)
check("Uebung B nach Entfernen unberuehrt",
any(e["exercise"] == exB["key"] for e in r["entries"]))
st, r = req("PATCH", f"/api/sessions/{sid}",
{"meta": {"note": "gutes Training", "status": "done"}})
check("abschliessen setzt Endzeit", r["end_time"] != ""
and r["status"] == "done" and r["note"] == "gutes Training", r)
st, done = req("GET", "/api/sessions?status=done")
check("Session sofort im Verlauf", st == 200 and len(done) == 1)
st, act = req("GET", "/api/sessions?status=active")
check("nicht mehr aktiv", len(act) == 0)
st, rv = req("GET", f"/api/reset-values?exercise={urllib.parse.quote(ex['key'])}&set=1")
check("reset-values liefert letzte Messung", rv.get("weight") == "20", rv)
st, sess2 = req("POST", "/api/sessions", {"activity": "gym", "day": "Push"})
sid2 = sess2["id"]
st, r = req("PATCH", f"/api/sessions/{sid2}",
{"sets": [{"exercise": ex["key"], "sort": 1, "weight": "99", "reps": "5", "done": 1}]})
st, rv = req("GET", f"/api/reset-values?exercise={urllib.parse.quote(ex['key'])}&set=1")
check("Reset ignoriert heute-laufende Session",
rv.get("weight") == "20", rv)
st, r = req("PATCH", f"/api/sessions/{sid}",
{"meta": {"date": "2026-09-20", "start_time": "17:30",
"end_time": "18:40"}})
check("Verlauf: Datum/Zeit editierbar",
r["date"] == "2026-09-20" and r["start_time"] == "17:30"
and r["end_time"] == "18:40", r)
st, cs = req("POST", "/api/sessions", {"activity": "run"})
cid = cs["id"]
st, r = req("PATCH", f"/api/sessions/{cid}",
{"meta": {"fields": {"distanceKm": "5,2", "minutes": "32",
"pacePerKm": "6:09", "heartRate": "148",
"elevation": "45"},
"status": "done"}})
check("Cardio-Felder gespeichert",
r["fields"]["distanceKm"] == "5,2" and r["fields"]["pacePerKm"] == "6:09", r["fields"])
st, prev = req("POST", "/api/sessions",
{"activity": "gym", "day": "Push", "date": "2026-09-15"})
pid = prev["id"]
req("PATCH", f"/api/sessions/{pid}",
{"sets": [{"exercise": ex["key"], "sort": 0, "weight": "22", "reps": "8", "done": 1}],
"meta": {"status": "done"}})
st, stats = req("GET", f"/api/stats?exercise={urllib.parse.quote(ex['key'])}")
check("Statistik: 2 Termine", st == 200 and len(stats) == 2, stats)
s0, s1 = stats[0], stats[1]
check("Statistik: Delta zur ersten Messung",
s0["delta"] == 0 and abs(s1["delta"]) > 0, stats)
check("Statistik: bestes Gewicht je Termin",
s0["weight"] == 22 and s1["weight"] == 20, stats)
st, exp = req("GET", "/api/export?format=json")
check("Export JSON Format", st == 200
and exp["format"] == "trainingsplan-export-v2"
and len(exp["sessions"]) == 4, exp.get("format"))
st, body = raw("GET", "/api/export?format=csv")
check("Export CSV Header", st == 200 and "session_id" in body
and "distanceKm" in body, body[:120])
st, imp = req("POST", "/api/import",
{"format": "trainingsplan-export-v2",
"data": json.dumps(exp)})
check("Import JSON (Merge) ok", st == 200 and imp["created"] == 0
and imp["updated"] == 4, imp)
st, csvimp = req("POST", "/api/import",
{"format": "csv", "data": body})
check("Import CSV ok", st == 200 and csvimp["updated"] == 4, csvimp)
st, r = req("DELETE", f"/api/sessions/{cid}")
check("Session loeschen", st == 200 and r["deleted"] == 1, r)
st, r = req("DELETE", f"/api/sessions/{sid2}")
check("Session loeschen (sid2)", r.get("deleted") == 1, r)
st, r = req("POST", "/api/sessions", {})
check("POST ohne activity -> 400", st == 400, (st, r))
st, r = req("PATCH", "/api/sessions/99999", {"meta": {"note": "x"}})
check("PATCH unbekannte Session -> 404", st == 404, (st, r))
st, r = req("GET", "/api/nonexistent")
check("unbekannter Pfad -> 404", st == 404)
finally:
proc.terminate()
proc.wait(timeout=5)
print(f"\n{PASS} ok, {FAIL} fail")
sys.exit(1 if FAIL else 0)
if __name__ == "__main__":
main()
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""API-Test fuer Einstellungen + Auto-Reset (offener Modus).
Lauf: python3 tests/test_api_settings.py
"""
import json
import os
import shutil
import subprocess
import sys
import time
import urllib.request
import urllib.error
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STATE = "/tmp/tp_state2"
PORT = 8255
BASE_URL = f"http://127.0.0.1:{PORT}"
PASS = 0
FAIL = 0
def check(name, cond, detail=""):
global PASS, FAIL
if cond:
PASS += 1
print(f" ok {name}")
else:
FAIL += 1
print(f"FAIL {name} {detail}")
def req(method, path, body=None):
url = BASE_URL + path
data = None
headers = {}
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
r = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(r, timeout=5) as resp:
return resp.status, json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
try:
return e.code, json.loads(e.read().decode("utf-8"))
except Exception:
return e.code, {}
def main():
if os.path.exists(STATE):
shutil.rmtree(STATE)
os.makedirs(STATE, exist_ok=True)
env = dict(os.environ, TRAINING_STATE_DIR=STATE, PORT=str(PORT))
proc = subprocess.Popen(
[sys.executable, os.path.join(BASE, "server.py")],
env=env, cwd=BASE,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
for _ in range(40):
time.sleep(0.25)
try:
urllib.request.urlopen(BASE_URL + "/api/meta", timeout=1)
break
except Exception:
pass
else:
print("Server nicht erreichbar")
sys.exit(1)
st, s = req("GET", "/api/settings")
check("Settings Defaults", st == 200
and s["theme"] == "dark" and s["accent"] == "white"
and s["autoReset"] is False and s["showTips"] is True, s)
st, s = req("PUT", "/api/settings",
{"theme": "light", "accent": "green",
"autoReset": True, "showTips": False})
check("Settings speichern", st == 200 and s["theme"] == "light"
and s["accent"] == "green" and s["autoReset"] is True
and s["showTips"] is False, s)
st, s = req("GET", "/api/settings")
check("Settings persistiert", s["theme"] == "light"
and s["autoReset"] is True and s["showTips"] is False, s)
st, s = req("PUT", "/api/settings", {"autoReset": False})
check("Teil-Update", s["autoReset"] is False
and s["theme"] == "light", s)
req("PUT", "/api/settings", {"autoReset": True, "showTips": True})
ex = "Push/Bankdrücken KH (je Hand)"
st, done = req("POST", "/api/sessions",
{"activity": "gym", "day": "Push"})
did = done["id"]
sets = []
for i, w in enumerate(["18", "18,5", "20", ""]):
sets.append({"exercise": ex, "sort": i, "weight": w,
"reps": "10", "done": 1 if i < 3 else 0})
req("PATCH", f"/api/sessions/{did}", {"sets": sets})
req("PATCH", f"/api/sessions/{did}", {"meta": {"status": "done"}})
req("PUT", "/api/settings", {"autoReset": False})
st, s1 = req("POST", "/api/sessions", {"activity": "gym", "day": "Push"})
check("AutoReset OFF: keine vorbefuellten Sets",
len(s1["entries"]) == 0, s1["entries"])
req("DELETE", f"/api/sessions/{s1['id']}")
req("PUT", "/api/settings", {"autoReset": True})
st, s2 = req("POST", "/api/sessions", {"activity": "gym", "day": "Push"})
entries = s2["entries"]
check("AutoReset ON: nur 3 gefuellte Sets geladen",
len(entries) == 3, entries)
check("AutoReset ON: Gewichte uebernommen",
[e["weight"] for e in entries] == ["18", "18,5", "20"],
[e["weight"] for e in entries])
check("AutoReset ON: Abhak-Status zurueckgesetzt",
all(e["done"] == 0 for e in entries), entries)
check("AutoReset ON: Reps uebernommen",
all(e["reps"] == "10" for e in entries), entries)
new_sets = [{"exercise": ex, "sort": 0, "weight": "999", "reps": "1",
"done": 0}]
req("PATCH", f"/api/sessions/{s2['id']}", {"sets": new_sets})
st, filled = req("POST", f"/api/sessions/{s2['id']}/autofill")
check("Autofill: Saetze ersetzt", st == 200
and len(filled["entries"]) == 3
and filled["entries"][0]["weight"] == "18", filled["entries"])
exB = "Push/Schrägbank KH (je Hand)"
req("PATCH", f"/api/sessions/{s2['id']}",
{"sets": [{"exercise": exB, "sort": 0, "weight": "14", "reps": "12",
"done": 0}]})
st, filled = req("POST", f"/api/sessions/{s2['id']}/autofill")
b = [e for e in filled["entries"] if e["exercise"] == exB]
check("Autofill: Uebung ohne Verlauf unberuehrt",
b and b[0]["weight"] == "14", filled["entries"])
req("PUT", "/api/settings",
{"autoReset": "ja", "showTips": 1, "bogus": 42, "theme": "oled"})
st, s = req("GET", "/api/settings")
check("Typen + unbekannte Keys", s["autoReset"] is True
and s["showTips"] is True and "bogus" not in s
and s["theme"] == "oled", s)
# --- Plan-Gewichte vorbefuellen ------------------------------------
req("PUT", "/api/settings", {"autoReset": False, "planPrefill": True})
st, s3 = req("POST", "/api/sessions", {"activity": "gym", "day": "Push"})
bd = [e for e in s3["entries"] if e["exercise"] == "Push/Bankdrücken KH (je Hand)"]
check("PlanPrefill: alle Plan-Saetze mit startKg",
len(bd) == 4 and all(e["weight"] == "18" for e in bd), bd)
check("PlanPrefill: alle Uebungen des Tags befuellt",
len({e["exercise"] for e in s3["entries"]}) == 8,
len(s3["entries"]))
check("PlanPrefill: Reps leer, done=0",
all(e["reps"] == "" and e["done"] == 0 for e in s3["entries"]),
s3["entries"])
req("DELETE", f"/api/sessions/{s3['id']}")
# Auto-Reset hat Vorrang vor Plan-Gewichten (je Uebung)
req("PUT", "/api/settings", {"autoReset": True, "planPrefill": True})
st, s4 = req("POST", "/api/sessions", {"activity": "gym", "day": "Push"})
bd4 = [e for e in s4["entries"] if e["exercise"] == "Push/Bankdrücken KH (je Hand)"]
sb4 = [e for e in s4["entries"] if e["exercise"] == "Push/Schrägbank KH (je Hand)"]
check("PlanPrefill+AutoReset: letztes Workout gewinnt",
len(bd4) == 3
and [e["weight"] for e in bd4] == ["18", "18,5", "20"], bd4)
check("PlanPrefill+AutoReset: ohne Verlauf Plan-Gewicht",
len(sb4) == 3 and all(e["weight"] == "14" for e in sb4), sb4)
req("DELETE", f"/api/sessions/{s4['id']}")
req("PUT", "/api/settings",
{"theme": "dark", "oled": False, "accent": "white",
"autoReset": False, "planPrefill": False, "showTips": True,
"language": "de", "setupDone": False, "onboarded": False,
"weightUnit": "kg", "startTab": "today"})
st, s = req("GET", "/api/settings")
check("Zurueck auf Standard", s == {
"theme": "dark", "oled": False, "accent": "white",
"autoReset": False, "planPrefill": False, "showTips": True,
"language": "de", "setupDone": False, "onboarded": False,
"weightUnit": "kg", "startTab": "today"}, s)
finally:
proc.terminate()
proc.wait(timeout=5)
print(f"\n{PASS} ok, {FAIL} fail")
sys.exit(1 if FAIL else 0)
if __name__ == "__main__":
main()
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env python3
"""Test fuer Mehrbenutzer, Workouts-API und Demo-Import.
Lauf: python3 tests/test_auth.py
"""
import json
import os
import shutil
import subprocess
import sys
import time
import urllib.request
import urllib.error
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STATE = "/tmp/tp_state3"
PORT = 8277
BASE_URL = f"http://127.0.0.1:{PORT}"
PASS = 0
FAIL = 0
def check(name, cond, detail=""):
global PASS, FAIL
if cond:
PASS += 1
print(f" ok {name}")
else:
FAIL += 1
print(f"FAIL {name} {detail}")
def req(method, path, body=None, token=None):
url = BASE_URL + path
data = None
headers = {}
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = "Bearer " + token
r = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(r, timeout=10) as resp:
return resp.status, json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
try:
return e.code, json.loads(e.read().decode("utf-8"))
except Exception:
return e.code, {}
def main():
if os.path.exists(STATE):
shutil.rmtree(STATE)
os.makedirs(STATE, exist_ok=True)
env = dict(os.environ, TRAINING_STATE_DIR=STATE, PORT=str(PORT))
proc = subprocess.Popen(
[sys.executable, os.path.join(BASE, "server.py")],
env=env, cwd=BASE,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
for _ in range(40):
time.sleep(0.25)
try:
urllib.request.urlopen(BASE_URL + "/api/meta", timeout=1)
break
except Exception:
pass
else:
print("Server nicht erreichbar")
sys.exit(1)
# --- Offener Modus -------------------------------------------------
st, meta = req("GET", "/api/meta")
check("offener Modus: auth_required false",
meta["auth_required"] is False and meta["users"] == 0, meta)
st, sess = req("POST", "/api/sessions", {"activity": "gym", "day": "Push"})
check("offener Modus: Session ohne Token", st == 200, sess)
req("PATCH", f"/api/sessions/{sess['id']}",
{"meta": {"status": "done"}})
# --- Registrierung (erster Benutzer) --------------------------------
st, reg = req("POST", "/api/register",
{"username": "alex", "password": "geheim"})
check("Registrierung", st == 200 and reg.get("token"), reg)
token_a = reg["token"]
st, meta = req("GET", "/api/meta")
check("nach Registrierung: auth_required true",
meta["auth_required"] is True and meta["users"] == 1, meta)
st, me = req("GET", "/api/me", token=token_a)
check("me: username + Owner", me["username"] == "alex"
and me["is_owner"] is True, me)
st, _ = req("GET", "/api/me")
check("me ohne Token -> 401", st == 401, st)
st, _ = req("GET", "/api/config")
check("config ohne Token -> 401", st == 401, st)
st, _ = req("GET", "/api/sessions")
check("sessions ohne Token -> 401", st == 401, st)
# Migration: Alt-Daten (offener Modus) gehoeren dem ersten Benutzer
st, done = req("GET", "/api/sessions?status=done", token=token_a)
check("erster Benutzer uebernimmt Alt-Daten", len(done) == 1, done)
# Zweite Registrierung blockiert
st, _ = req("POST", "/api/register",
{"username": "berta", "password": "geheim"})
check("zweite Registrierung -> 403", st == 403, st)
# Login-Fehlerfaelle
st, _ = req("POST", "/api/login",
{"username": "alex", "password": "falsch"})
check("Login falsches Passwort -> 401", st == 401, st)
st, login = req("POST", "/api/login",
{"username": "alex", "password": "geheim"})
check("Login korrekt", st == 200 and login.get("token"), login)
token_a2 = login["token"]
st, me = req("GET", "/api/me", token=token_a2)
check("neuer Token gueltig", me["username"] == "alex", me)
# --- Benutzer-Verwaltung (Owner) -------------------------------------
st, created = req("POST", "/api/users",
{"username": "berta", "password": "geheim"},
token=token_a)
check("Owner legt Benutzer an", st == 200
and created["username"] == "berta", created)
berta_id = created["id"]
st, users = req("GET", "/api/users", token=token_a)
check("Owner listet Benutzer", st == 200 and len(users) == 2, users)
st, login = req("POST", "/api/login",
{"username": "berta", "password": "geheim"})
token_b = login["token"]
st, me = req("GET", "/api/me", token=token_b)
check("Berta: nicht Owner", me["username"] == "berta"
and me["is_owner"] is False, me)
st, _ = req("GET", "/api/users", token=token_b)
check("Berta darf keine Benutzer sehen -> 403", st == 403, st)
# --- Demo-Daten importieren (in Berta, VOR eigener Session) ----------
demo_path = os.path.join(BASE, "demo-data.json")
demo = json.load(open(demo_path, encoding="utf-8"))
st, imp = req("POST", "/api/import",
{"format": "trainingsplan-export-v2",
"data": json.dumps(demo)}, token=token_b)
check("Demo-Import in Berta", st == 200 and imp["created"] == 22, imp)
# --- Berta trainiert selbst -------------------------------------------
st, sess_b = req("POST", "/api/sessions",
{"activity": "gym", "day": "Push"}, token=token_b)
req("PATCH", f"/api/sessions/{sess_b['id']}",
{"sets": [{"exercise": "Push/Bankdrücken KH (je Hand)",
"sort": 0, "weight": "40", "reps": "8", "done": 1}]},
token=token_b)
req("PATCH", f"/api/sessions/{sess_b['id']}",
{"meta": {"status": "done"}}, token=token_b)
st, done_b = req("GET", "/api/sessions?status=done", token=token_b)
st, done_a = req("GET", "/api/sessions?status=done", token=token_a)
check("Isolation: B sieht 23 eigene Sessions (22 Demo + 1)",
len(done_b) == 23, len(done_b))
check("Isolation: A sieht seine 1 Session (nicht B)",
len(done_a) == 1, len(done_a))
st, rv = req("GET", "/api/reset-values?exercise="
+ urllib.parse.quote("Push/Bankdrücken KH (je Hand)")
+ "&set=0", token=token_b)
check("Reset-Werte sind pro Benutzer (eigene heutige Session)",
rv.get("weight") == "40", rv)
# Einstellungen pro Benutzer
req("PUT", "/api/settings", {"theme": "light"}, token=token_a)
st, s_a = req("GET", "/api/settings", token=token_a)
st, s_b = req("GET", "/api/settings", token=token_b)
check("Einstellungen pro Benutzer", s_a["theme"] == "light"
and s_b["theme"] == "dark", (s_a, s_b))
# Owner setzt Sprache/Akzent -> Server-Standard fuer andere Benutzer
req("PUT", "/api/settings", {"language": "en", "accent": "green"},
token=token_a)
st, s_b = req("GET", "/api/settings", token=token_b)
check("Owner-Sprache/Akzent wird Server-Standard",
s_b["language"] == "en" and s_b["accent"] == "green", s_b)
req("PUT", "/api/settings",
{"language": "de", "accent": "orange", "theme": "dark"},
token=token_a)
# --- Workouts-API -----------------------------------------------------
st, wo_b = req("GET", "/api/workouts", token=token_b)
check("Workouts-API: 23 Workouts", st == 200 and len(wo_b) == 23, len(wo_b))
w = wo_b[0] # neueste Session = Berta heute
check("Workouts-API: Shape", w["activity_name"] == "Gym (Kraft)"
and w["day"] == "Push" and w["duration_min"] is not None
and w["exercises"][0]["name"] == "Bankdrücken KH (je Hand)"
and w["exercises"][0]["sets"][0]["weight"] == "40", w)
st, wo_b = req("GET", "/api/workouts?activity=gym", token=token_b)
check("Workouts nach Import (gym): 15 Demo + 1 eigene", len(wo_b) == 16,
len(wo_b))
st, wo_a = req("GET", "/api/workouts", token=token_a)
check("A bleibt unberuehrt vom Import", len(wo_a) == 1, len(wo_a))
st, stats = req("GET", "/api/stats?exercise="
+ urllib.parse.quote("Push/Bankdrücken KH (je Hand)"),
token=token_b)
check("Statistik aus Demo-Daten + eigene Session",
st == 200 and len(stats) == 6
and stats[0]["weight"] < stats[-1]["weight"]
and stats[-1]["weight"] == 40, stats)
# --- Passwort ändern / zurücksetzen ------------------------------------
st, _ = req("POST", "/api/password",
{"current_password": "falsch", "new_password": "neu123"},
token=token_a)
check("Eigenes Passwort: falsches aktuelles -> 400", st == 400, st)
st, _ = req("POST", "/api/password",
{"current_password": "geheim", "new_password": "neu123"},
token=token_a)
check("Eigenes Passwort aendern", st == 200, st)
st, _ = req("POST", "/api/login",
{"username": "alex", "password": "geheim"})
check("altes Passwort ungueltig", st == 401, st)
st, login2 = req("POST", "/api/login",
{"username": "alex", "password": "neu123"})
check("neues Passwort funktioniert", st == 200, st)
token_a = login2["token"]
st, _ = req("PUT", f"/api/users/{berta_id}/password",
{"password": "neu456"}, token=token_a)
check("Owner setzt Passwort von B", st == 200, st)
st, _ = req("GET", "/api/sessions", token=token_b)
check("B: alter Token nach Passwort-Reset invalidiert", st == 401, st)
st, _ = req("POST", "/api/login",
{"username": "berta", "password": "geheim"})
check("B: altes Passwort weg", st == 401, st)
st, login_b2 = req("POST", "/api/login",
{"username": "berta", "password": "neu456"})
check("B: neues Passwort funktioniert", st == 200, st)
token_b = login_b2["token"]
st, _ = req("PUT", f"/api/users/{berta_id}/password",
{"password": "x"}, token=token_a)
check("Passwort zu kurz -> 400", st == 400, st)
st, _ = req("PUT", f"/api/users/{berta_id}/password",
{"password": "neu789"}, token=token_b)
check("Nicht-Owner darf Passwort nicht setzen -> 403", st == 403, st)
# --- Logout -------------------------------------------------------------
st, _ = req("POST", "/api/logout", token=token_b)
st, _ = req("GET", "/api/sessions", token=token_b)
check("Logout invalidiert Token -> 401", st == 401, st)
# --- Benutzer loeschen ---------------------------------------------------
st, r = req("DELETE", f"/api/users/{berta_id}", token=token_a)
check("Owner loescht Benutzer", st == 200 and r["deleted"] == 1, r)
st, login = req("POST", "/api/login",
{"username": "berta", "password": "geheim"})
check("geloeschter Benutzer kann nicht mehr einloggen", st == 401, st)
st, users = req("GET", "/api/users", token=token_a)
check("Benutzerliste nach Loeschung", len(users) == 1, users)
st, done_a = req("GET", "/api/sessions?status=done", token=token_a)
check("A-Daten nach Loeschung von B intakt", len(done_a) == 1, len(done_a))
finally:
proc.terminate()
proc.wait(timeout=5)
print(f"\n{PASS} ok, {FAIL} fail")
sys.exit(1 if FAIL else 0)
if __name__ == "__main__":
main()
+269
View File
@@ -0,0 +1,269 @@
/* UI-Integrationstest (jsdom) gegen einen echten Server-Subprozess.
*
* Lauf: node tests/test_ui.js (jsdom muss installierbar sein, z.B.
* NODE_PATH=/pfad/zu/node_modules node tests/test_ui.js)
*
* Der Test laeuft im offenen Modus (frische DB ohne Benutzer).
*/
const path = require("path");
const BASE = path.join(__dirname, "..");
let jsdom;
try {
jsdom = require("jsdom");
} catch (_) {
console.error("jsdom fehlt. Installieren: npm install jsdom@22.1.0 (z.B. in /tmp/tp_test) und mit NODE_PATH=<dort>/node_modules starten.");
process.exit(2);
}
const { JSDOM } = jsdom;
const { spawn } = require("child_process");
const fs = require("fs");
const http = require("http");
const STATE = "/tmp/tp_state_ui";
const PORT = 8233;
const BASE_URL = `http://127.0.0.1:${PORT}`;
let pass = 0, fail = 0;
let jsErrors = [];
function check(name, cond, detail = "") {
if (cond) { pass++; console.log(" ok " + name); }
else { fail++; console.log("FAIL " + name + " " + detail); }
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
async function waitFor(dom, fn, timeout = 4000) {
const t0 = Date.now();
while (Date.now() - t0 < timeout) {
if (fn(dom)) return true;
await sleep(80);
}
return false;
}
function apiGet(p) {
return new Promise((resolve, reject) => {
http.get(BASE_URL + p, res => {
let d = "";
res.on("data", c => d += c);
res.on("end", () => resolve(JSON.parse(d)));
}).on("error", reject);
});
}
function cleanState() {
fs.rmSync(STATE, { recursive: true, force: true });
fs.mkdirSync(STATE, { recursive: true });
}
async function main() {
cleanState();
const server = spawn("python3", [path.join(BASE, "server.py")], {
env: { ...process.env, TRAINING_STATE_DIR: STATE, PORT: String(PORT) },
cwd: BASE, stdio: "ignore",
});
let window;
try {
for (let i = 0; i < 60; i++) {
try { await apiGet("/api/meta"); break; }
catch (_) { await sleep(200); }
}
const html = fs.readFileSync(path.join(BASE, "index.html"), "utf8")
.replace(/<script src="\/app.js"><\/script>/, "");
const appJs = fs.readFileSync(path.join(BASE, "app.js"), "utf8");
const dom = new JSDOM(html, { runScripts: "dangerously", url: BASE_URL + "/" });
window = dom.window;
window.errors = [];
jsErrors = window.errors;
window.addEventListener("error", e => window.errors.push(e.message || String(e)));
window.fetch = (url, opts) => fetch(BASE_URL + url, opts);
window.eval(appJs);
check("init: 3 Aktivitaets-Karten", await waitFor(dom, d =>
d.window.document.querySelectorAll(".act-card").length === 3));
// --- Ersteinrichtungs-Wizard abschliessen (Einzelmodus) ----------------
const gate = () => window.document.querySelector("#setup-gate");
await waitFor(dom, d => !d.window.document.querySelector("#setup-gate").hidden);
check("Setup: Sprache kommt zuerst", await waitFor(dom, d =>
gate().querySelector(".wizard-title")
&& gate().querySelector(".wizard-title").textContent === "Sprache festlegen"));
check("Setup: Fortschrittsleiste oben",
gate().querySelectorAll(".wp-step").length === 3
&& gate().querySelectorAll(".wp-step.is-current").length === 1
&& gate().querySelectorAll(".wp-step.is-done").length === 0);
Array.from(gate().querySelectorAll(".wizard-nav .btn"))
.find(b => b.textContent === "Weiter").click();
await waitFor(dom, d => gate().querySelectorAll(".setup-option").length === 2);
check("Setup: Fortschritt aktualisiert",
gate().querySelectorAll(".wp-step.is-done").length === 1
&& gate().querySelectorAll(".wp-step.is-current").length === 1);
const setupOptions = () => Array.from(gate().querySelectorAll(".setup-option"));
check("Setup: zwei gleichwertige Optionen",
setupOptions().some(b => b.textContent.includes("Mehrbenutzermodus"))
&& setupOptions().some(b => b.textContent.includes("Einzelmodus")));
setupOptions().find(b => b.textContent.includes("Einzelmodus")).click();
Array.from(gate().querySelectorAll(".wizard-nav .btn"))
.find(b => b.textContent === "Weiter").click();
await waitFor(dom, d => gate().querySelector(".accent-row"));
Array.from(gate().querySelectorAll(".wizard-nav .btn"))
.find(b => b.textContent === "Einrichtung abschließen").click();
check("Setup: Gate geschlossen nach Wizard", await waitFor(dom, d =>
d.window.document.querySelector("#setup-gate").hidden));
const gymCard = () => window.document.querySelectorAll(".act-card")[0];
gymCard().querySelector(".btn-primary").click();
check("gym: Session gestartet", await waitFor(dom, d =>
d.window.document.querySelectorAll(".act-card")[0].querySelectorAll(".set-row").length > 0));
const firstBlock = () => gymCard().querySelector(".ex-block");
const chk1 = () => firstBlock().querySelectorAll(".set-check")[0];
check("Set-Button mit Label 'Set' + Box",
chk1().querySelector(".set-check-label").textContent === "Set"
&& !!chk1().querySelector(".set-check-box"));
check("Set-Button: Box leer (nicht abgehakt)", chk1().querySelector(".set-check-box").textContent === "");
firstBlock().querySelectorAll(".set-check")[2].click();
check("Set-Kette: 1..3 aktiv mit Haken", await waitFor(dom, d => {
const c = d.window.document.querySelectorAll(".act-card")[0].querySelectorAll(".set-check");
return c[0].classList.contains("is-done") && c[2].classList.contains("is-done")
&& !c[3].classList.contains("is-done")
&& c[0].querySelector(".set-check-box").textContent === "✓";
}));
const wIn = () => firstBlock().querySelectorAll(".set-row")[0].querySelector(".field-input");
wIn().value = "18,5";
wIn().dispatchEvent(new window.Event("input", { bubbles: true }));
const wBefore = wIn().value;
chk1().click();
check("Set-Klick veraendert Gewicht nicht", wIn().value === wBefore);
firstBlock().querySelectorAll(".set-check")[2].click();
const picker = () => gymCard().querySelector(".ex-picker");
const pullEx = "Pull/Latzug / Klimmzug-Maschine";
picker().value = pullEx;
picker().dispatchEvent(new window.Event("change", { bubbles: true }));
check("Uebung aus anderem Tag hinzugefuegt", await waitFor(dom, d => {
const blocks = d.window.document.querySelectorAll(".act-card")[0].querySelectorAll(".ex-block");
const last = blocks[blocks.length - 1];
return last.querySelector(".ex-name").textContent === "Latzug / Klimmzug-Maschine";
}));
const blocks = () => gymCard().querySelectorAll(".ex-block");
const addedBlock = blocks()[blocks().length - 1];
const rmBtn = addedBlock.querySelector(".ex-remove");
check("Entfernen-Knopf fuer hinzugefuegte Uebung", !!rmBtn);
rmBtn.click();
check("Uebung entfernt", await waitFor(dom, d =>
!Array.from(d.window.document.querySelectorAll(".act-card")[0].querySelectorAll(".ex-block"))
.some(b => b.querySelector(".ex-name").textContent === "Latzug / Klimmzug-Maschine")));
gymCard().querySelector(".session-head .btn-primary").click();
check("abschliessen: Karte zurueck auf Start", await waitFor(dom, d => {
const c = d.window.document.querySelectorAll(".act-card")[0];
return !!c.querySelector(".start-box");
}));
let done = await apiGet("/api/sessions?status=done");
check("Verlauf (API): 1 abgeschlossene Session", done.length === 1);
window.document.querySelector('.tab[data-view="history"]').click();
await waitFor(dom, d => d.window.document.querySelectorAll(".hist-card").length === 1);
const histCard = () => window.document.querySelector(".hist-card");
check("Verlauf: Haken-Box zeigt Abhak-Status",
!!histCard().querySelector(".set-done-box")
&& histCard().querySelector(".set-done-box").textContent === "✓");
check("Verlauf: Abgehaktes Set mit Gewicht", await waitFor(dom, d =>
d.window.document.querySelector(".hist-sets").textContent.includes("18,5")));
window.document.querySelector('.tab[data-view="settings"]').click();
await waitFor(dom, d => !!d.window.document.querySelector(".accent-row"));
check("Einstellungen: Theme-Buttons",
Array.from(window.document.querySelectorAll("#view-settings .day-picker .day-btn"))
.filter(b => ["Dunkel", "System", "Hell"].includes(b.textContent)).length === 3);
check("Einstellungen: Akzent-Swatches", window.document.querySelectorAll(".accent-swatch").length >= 5);
check("Einstellungen: Toggles + Selects",
window.document.querySelectorAll("#view-settings .toggle-input").length === 4
&& window.document.querySelectorAll("#view-settings .settings-select-row").length === 2);
const themeBtns = () => Array.from(window.document.querySelectorAll("#view-settings .day-picker .day-btn"));
// OLED-Toggle bei dunklem Theme -> reines Schwarz
Array.from(window.document.querySelectorAll("#view-settings .toggle-row"))
.find(r => r.querySelector("strong").textContent === "OLED-Dark")
.querySelector(".toggle-input").click();
await waitFor(dom, d => d.window.document.documentElement.dataset.theme === "oled");
check("OLED-Toggle: dunkles Theme wird pure black",
window.document.documentElement.dataset.theme === "oled");
themeBtns().find(b => b.textContent === "Hell").click();
await waitFor(dom, d => d.window.document.documentElement.dataset.theme === "light");
check("Theme 'Hell' gesetzt", window.document.documentElement.dataset.theme === "light");
const swatches = () => Array.from(window.document.querySelectorAll(".accent-swatch"));
swatches().find(s => s.getAttribute("aria-label") === "Akzent green").click();
await waitFor(dom, d => d.window.document.documentElement.dataset.accent === "green");
check("Akzent 'green' gesetzt", window.document.documentElement.dataset.accent === "green");
const toggles = () => Array.from(window.document.querySelectorAll(".toggle-row"));
const tipRow = toggles().find(r => r.querySelector("strong").textContent === "Übungs-Hinweise");
tipRow.querySelector(".toggle-input").click();
await waitFor(dom, d => d.window.document.querySelector("#view-today").classList.contains("no-tips"));
check("showTips aus: no-tips Klasse", window.document.querySelector("#view-today").classList.contains("no-tips"));
const resetRow = toggles().find(r => r.querySelector("strong").textContent === "Letztes Training vorbefüllen");
resetRow.querySelector(".toggle-input").click();
// Auf Server-Zustand warten (DOM-Check war falsch: [0] ist der OLED-Toggle)
let arOk = false;
for (let i = 0; i < 80; i++) {
if ((await apiGet("/api/settings")).autoReset === true) { arOk = true; break; }
await sleep(100);
}
check("Einstellung 'Letztes Training' serverseitig aktiv", arOk);
window.document.querySelector('.tab[data-view="today"]').click();
gymCard().querySelector(".btn-primary").click();
// Auto-Reset serverseitig pruefen (deterministisch statt DOM-Timing)
let autoOk = false;
for (let i = 0; i < 120; i++) {
const act = await apiGet("/api/sessions?status=active");
if (act.length && act[0].entries.length === 1
&& act[0].entries[0].weight === "18,5") { autoOk = true; break; }
await sleep(100);
}
check("Auto-Reset: 1 gefuellter Satz geladen", autoOk);
await waitFor(dom, d => d.window.document.querySelectorAll(".act-card")[0]
.querySelectorAll(".set-row").length > 0, 9000);
check("Auto-Reset: Satz nicht abgehakt",
gymCard().querySelector(".ex-block .set-check").querySelector(".set-check-box").textContent === "");
check("Auto-Reset: 'Alles zuruecksetzen'-Knopf da",
!!gymCard().querySelector(".session-head .btn[title*='letzte']"));
check("Hinweiszeile ausgeblendet",
!gymCard().querySelector(".ex-block .ex-hint"));
// --- Uebungen per Drag & Drop sortieren --------------------------------
gymCard().querySelectorAll(".ex-block")[0]
.dispatchEvent(new window.Event("dragstart", { bubbles: true }));
const target = gymCard().querySelectorAll(".ex-block")[1];
target.dispatchEvent(new window.Event("drop", { bubbles: true }));
check("Uebungen: Drag & Drop sortiert",
Array.from(gymCard().querySelectorAll(".ex-block"))[0]
.querySelector(".ex-name").textContent !== "Bankdrücken KH (je Hand)");
// --- Wochen-Uebersicht ---------------------------------------------------
window.document.querySelector('.tab[data-view="history"]').click();
await waitFor(dom, d => d.window.document.querySelectorAll(".hist-card").length >= 1);
Array.from(window.document.querySelectorAll(".day-picker .day-btn"))
.find(b => b.textContent === "Woche").click();
check("Wochen-Uebersicht: Raster mit 7 Tagen",
await waitFor(dom, d => d.window.document.querySelectorAll(".week-day").length === 7));
} finally {
server.kill("SIGTERM");
}
if (jsErrors && jsErrors.length) {
console.log("JS-Fehler im Browser:", jsErrors.slice(0, 5).join(" | "));
fail += jsErrors.length;
}
console.log(`\n${pass} ok, ${fail} fail`);
process.exit(fail ? 1 : 0);
}
main().catch(e => { console.error(e); process.exit(1); });
+255
View File
@@ -0,0 +1,255 @@
/* UI-Test Auth-Gate (jsdom) gegen einen echten Server.
*
* Lauf: NODE_PATH=<...>/node_modules node tests/test_ui_auth.js
*/
const path = require("path");
const BASE = path.join(__dirname, "..");
const { JSDOM } = require("jsdom");
const { spawn } = require("child_process");
const fs = require("fs");
const http = require("http");
const STATE = "/tmp/tp_state_ui_auth";
const PORT = 8237;
const BASE_URL = `http://127.0.0.1:${PORT}`;
let pass = 0, fail = 0;
function check(name, cond, detail = "") {
if (cond) { pass++; console.log(" ok " + name); }
else { fail++; console.log("FAIL " + name + " " + detail); }
}
const sleep = ms => new Promise(r => setTimeout(r, ms));
function api(method, p, body, token) {
return new Promise((resolve, reject) => {
const data = body ? JSON.stringify(body) : null;
const req = http.request(BASE_URL + p, {
method,
headers: Object.assign(data ? {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(data),
} : {}, token ? { Authorization: "Bearer " + token } : {}),
}, res => {
let d = "";
res.on("data", c => d += c);
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(d || "{}") }));
});
if (data) req.write(data);
req.end();
req.on("error", reject);
});
}
async function loadApp(html, appJs, token) {
const dom = new JSDOM(html, { runScripts: "dangerously", url: BASE_URL + "/" });
const { window } = dom;
window.errors = [];
window.addEventListener("error", e => window.errors.push(e.message || String(e)));
window.fetch = (url, opts) => fetch(BASE_URL + url, opts);
if (token) window.localStorage.setItem("tp_token", token);
window.eval(appJs);
return window;
}
async function main() {
fs.rmSync(STATE, { recursive: true, force: true });
fs.mkdirSync(STATE, { recursive: true });
const server = spawn("python3", [path.join(BASE, "server.py")], {
env: { ...process.env, TRAINING_STATE_DIR: STATE, PORT: String(PORT) },
cwd: BASE, stdio: "ignore",
});
try {
for (let i = 0; i < 60; i++) {
try { await api("GET", "/api/meta"); break; }
catch (_) { await sleep(200); }
}
const html = fs.readFileSync(path.join(BASE, "index.html"), "utf8")
.replace(/<script src="\/app.js"><\/script>/, "");
const appJs = fs.readFileSync(path.join(BASE, "app.js"), "utf8");
// --- Szenario 0: Ersteinrichtungs-Wizard (offener Modus) ---------------
const winSetup = await loadApp(html, appJs, null);
await sleep(1000);
const sgate = () => winSetup.document.querySelector("#setup-gate");
check("Setup: Gate im offenen Modus sichtbar",
!!sgate() && !sgate().hidden);
check("Setup: Sprache kommt zuerst",
sgate().querySelector(".wizard-title")
&& sgate().querySelector(".wizard-title").textContent === "Sprache festlegen");
Array.from(sgate().querySelectorAll(".day-btn"))
.find(b => b.textContent === "English").click();
await sleep(200);
check("Setup: Wizard wechselt auf Englisch",
Array.from(sgate().querySelectorAll(".wizard-nav .btn"))
.some(b => b.textContent === "Next"));
Array.from(sgate().querySelectorAll(".wizard-nav .btn"))
.find(b => b.textContent === "Next").click();
await sleep(200);
const setupOptions = () => Array.from(sgate().querySelectorAll(".setup-option"));
check("Setup: zwei gleichwertige Optionen",
setupOptions().some(b => b.textContent.includes("Multi-user mode"))
&& setupOptions().some(b => b.textContent.includes("Single-user mode")));
check("Setup: Mehrbenutzer-Infotext",
setupOptions().some(b => b.textContent.includes("administrator")
&& b.textContent.toLowerCase().includes("single user")));
setupOptions().find(b => b.textContent.includes("Multi-user mode")).click();
await sleep(150);
check("Setup: Formular nach Mehrbenutzermodus-Klick",
!!sgate().querySelector("input#setup-user")
&& !!sgate().querySelector("input#setup-pass"));
// Einzelmodus waehlen -> Weiter -> Darstellung -> abschliessen
setupOptions().find(b => b.textContent.includes("Single-user mode")).click();
Array.from(sgate().querySelectorAll(".wizard-nav .btn"))
.find(b => b.textContent === "Next").click();
await sleep(200);
check("Setup: Darstellung-Schritt (Theme + Akzent)",
sgate().querySelector(".wizard-title").textContent === "Appearance"
&& !!sgate().querySelector(".accent-row")
&& !!sgate().querySelector(".toggle-row"));
Array.from(sgate().querySelectorAll(".wizard-nav .btn"))
.find(b => b.textContent === "Finish setup").click();
await sleep(400);
check("Setup: Gate zu + setupDone + Sprache persistiert",
sgate().hidden
&& (await api("GET", "/api/settings")).body.setupDone === true
&& (await api("GET", "/api/settings")).body.language === "en");
const reg = await api("POST", "/api/register", { username: "alex", password: "geheim" });
check("API: Register", reg.status === 200 && !!reg.body.token, reg.body);
// --- Szenario 1: ohne Token -> Auth-Gate, Inline-Fehler, Login in-place ---
const winNoToken = await loadApp(html, appJs, null);
await sleep(1200);
const gate = () => winNoToken.document.querySelector("#auth-gate");
check("ohne Token: Auth-Gate sichtbar",
gate() && !gate().hidden && winNoToken.document.body.classList.contains("no-auth"));
check("ohne Token: Login-Formular + Felder",
!!gate().querySelector('input#auth-user')
&& !!gate().querySelector('input#auth-pass')
&& !!gate().querySelector(".btn-primary"));
check("ohne Token: Login-Screen in Server-Sprache (en)",
Array.from(gate().querySelectorAll(".btn"))
.some(b => b.textContent === "Log in")
&& gate().querySelector(".auth-sub").textContent.includes("Log in to see"));
// falsches Kennwort -> Inline-Fehler
gate().querySelector('input#auth-user').value = "alex";
gate().querySelector('input#auth-pass').value = "falsch";
Array.from(gate().querySelectorAll(".btn")).find(b => b.textContent === "Log in").click();
await sleep(500);
const authErr = gate().querySelector(".auth-error");
check("ohne Token: Inline-Fehler bei falschem Kennwort",
authErr && !authErr.hidden
&& authErr.textContent.includes("Incorrect username or password"),
authErr && authErr.textContent);
// korrektes Kennwort -> Login wechselt OHNE Reload in-place
gate().querySelector('input#auth-pass').value = "geheim";
Array.from(gate().querySelectorAll(".btn")).find(b => b.textContent === "Log in").click();
let loggedIn = false;
for (let i = 0; i < 60; i++) {
const og = winNoToken.document.querySelector("#setup-gate");
if (og && !og.hidden && winNoToken.document.body.classList.contains("no-auth")) {
loggedIn = true; // Onboarding-Gate erscheint -> Anmeldung war erfolgreich
break;
}
if (winNoToken.document.querySelectorAll(".act-card").length === 3
&& winNoToken.document.querySelector("#user-chip").textContent === "alex") {
loggedIn = true;
break;
}
await sleep(100);
}
check("Login: wechselt ohne Reload in den eingeloggten Zustand", loggedIn);
// Onboarding abschliessen (im selben Fenster)
const ogate = () => winNoToken.document.querySelector("#setup-gate");
if (ogate() && !ogate().hidden) {
Array.from(ogate().querySelectorAll(".day-btn"))
.find(b => b.textContent === "German").click();
await sleep(150);
Array.from(ogate().querySelectorAll(".wizard-nav .btn"))
.find(b => b.textContent === "Weiter").click();
await sleep(200);
Array.from(ogate().querySelectorAll(".wizard-nav .btn"))
.find(b => b.textContent === "Einrichtung abschließen").click();
await sleep(400);
}
check("Login: Onboarding abgeschlossen, Heute-Ansicht sichtbar",
ogate() && ogate().hidden
&& winNoToken.document.querySelectorAll(".act-card").length === 3
&& winNoToken.document.querySelector("#user-chip").textContent === "alex");
check("Login: Abmelden-Knopf sichtbar",
!winNoToken.document.querySelector("#logout-btn").hidden);
check("Login: kein Auth-Gate mehr",
winNoToken.document.querySelector("#auth-gate").hidden);
// --- Szenario 2: frisches Fenster mit Token -> direkt eingeloggt ------
const winTok = await loadApp(html, appJs, reg.body.token);
await sleep(1200);
let tokOk = false;
for (let i = 0; i < 50; i++) {
if (winTok.document.querySelectorAll(".act-card").length === 3
&& winTok.document.querySelector("#user-chip").textContent === "alex") {
tokOk = true;
break;
}
await sleep(100);
}
check("mit Token: Heute-Ansicht geladen (kein Onboarding mehr)", tokOk);
check("mit Token: kein Auth-Gate", winTok.document.querySelector("#auth-gate").hidden);
// --- Szenario 3: Einstellungen mit Konto- und Benutzer-Bereich --------
winTok.document.querySelector('.tab[data-view="settings"]').click();
let secTitles = [];
for (let i = 0; i < 40; i++) {
secTitles = Array.from(winTok.document.querySelectorAll(".data-section h3"))
.map(h => h.textContent);
if (secTitles.includes("Konto") && secTitles.includes("Benutzer")) break;
await sleep(100);
}
check("Einstellungen: Konto-Bereich (Kennwort aendern)",
secTitles.includes("Konto")
&& Array.from(winTok.document.querySelectorAll(".data-section"))
.some(s => s.querySelector("h3").textContent === "Konto"
&& !!s.querySelector('input[placeholder="Aktuelles Kennwort"]')
&& !!s.querySelector('input[placeholder="Neues Kennwort"]')));
check("Einstellungen: nur ein Benutzer -> Hinweis statt Liste", await (async () => {
for (let i = 0; i < 40; i++) {
if (Array.from(winTok.document.querySelectorAll(".user-row"))
.some(r => r.textContent.includes("einzige"))) return true;
await sleep(100);
}
return false;
})());
// Zweiten Benutzer anlegen -> Liste zeigt ihn, Admin bleibt draussen
const createdB = await api("POST", "/api/users",
{ username: "berta", password: "geheim" }, reg.body.token);
check("API: Owner legt Berta an", createdB.status === 200, createdB.body);
winTok.document.querySelector('.tab[data-view="today"]').click();
winTok.document.querySelector('.tab[data-view="settings"]').click();
await sleep(400);
const rows = () => Array.from(winTok.document.querySelectorAll(".user-row"));
check("Einstellungen: Berta in Liste, Admin nicht aufgelistet",
rows().length === 1 && rows()[0].textContent.includes("berta")
&& !rows().some(r => r.textContent.includes("alex")));
check("Einstellungen: Kennwort-Knopf pro Benutzer",
Array.from(rows()[0].querySelectorAll(".btn"))
.some(b => b.textContent === "Kennwort ändern"));
// --- Szenario 4: API-Login-Faelle --------------------------------------
check("API: Login mit falschem Passwort -> 401",
(await api("POST", "/api/login", { username: "alex", password: "falsch" })).status === 401);
check("API: Login korrekt -> Token",
(await api("POST", "/api/login", { username: "alex", password: "geheim" })).status === 200);
const errs = winNoToken.errors.concat(winTok.errors);
if (errs.length) {
console.log("JS-Fehler:", errs.slice(0, 5).join(" | "));
fail += errs.length;
}
} finally {
server.kill("SIGTERM");
}
console.log(`\n${pass} ok, ${fail} fail`);
process.exit(fail ? 1 : 0);
}
main().catch(e => { console.error(e); process.exit(1); });