Startpunkt 02.10.2026: aktueller Stand des Trainingsplans
Historie bewusst neu aufgesetzt, ein einziger Commit als neuer Ausgangspunkt.
This commit is contained in:
commit
8fc77212cb
18 files changed
+10863
No files matched your search
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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); });
|
||||
@@ -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); });
|
||||
Reference in new issue
Block a user