"""API-Oberflaechen-Abgleich: Jeder Frontend-API-Aufruf muss eine Backend-Route haben. Hintergrund: Der Worktree-Merge-Verlust um 459cd42 (v1102) hinterliess Frontend-Code, der nie existierende Endpoints aufrief (/partner/my-profile, /breeder/my-editor, /poison/nearby, /lost/nearby) — teils jahrelang unbemerkt, weil catch() die Fehler verschluckte. Dieser Test macht solche Geister-Aufrufe zum Build-Fehler. """ import re import glob import os from pathlib import Path ROOT = Path(__file__).resolve().parent.parent STATIC = ROOT / "backend" / "static" METHOD = {"get": "GET", "post": "POST", "put": "PUT", "patch": "PATCH", "del": "DELETE", "delete": "DELETE", "upload": "POST"} def _backend_routes(): main = (ROOT / "backend" / "main.py").read_text() mod_of_var = {} for m in re.finditer(r"from routes\.(\w+)\s+import\s+([^\n]+)", main): mod, rest = m.group(1), m.group(2) for part in rest.split(","): part = part.strip() am = re.match(r"(\w+)\s+as\s+(\w+)", part) if am: mod_of_var[am.group(2)] = (mod, am.group(1)) elif re.match(r"^\w+$", part): mod_of_var[part] = (mod, part) routes = set() def norm(p): return re.sub(r"\{[^}]+\}", "*", p).rstrip("/") or "/" for m in re.finditer(r"app\.include_router\((\w+)(?:,\s*prefix=\"([^\"]*)\")?", main): var, prefix = m.group(1), m.group(2) or "" info = mod_of_var.get(var) if not info: continue mod, routername = info fn = ROOT / "backend" / "routes" / f"{mod}.py" if not fn.exists(): continue src = fn.read_text() # Router kann eigenen Prefix mitbringen (z.B. invoices) pm = re.search(rf"{routername}\s*=\s*APIRouter\(\s*prefix=\"([^\"]*)\"", src) or \ re.search(r"router\s*=\s*APIRouter\(\s*prefix=\"([^\"]*)\"", src) own_prefix = pm.group(1) if pm and routername == "router" or pm and f"{routername} =" in src else (pm.group(1) if pm else "") base = prefix + (own_prefix if not prefix else "") if not base and pm: base = pm.group(1) for rm in re.finditer(rf"@{routername}\.(get|post|put|patch|delete)\(\s*[\"']([^\"']*)[\"']", src): routes.add((rm.group(1).upper(), norm(base + rm.group(2)))) for m in re.finditer(r"@app\.(get|post|put|patch|delete)\(\s*[\"']([^\"']*)[\"']", main): routes.add((m.group(1).upper(), norm(m.group(2)))) for m in re.finditer(r"@app\.api_route\(\s*[\"']([^\"']*)[\"'][^)]*methods=\[([^\]]*)\]", main): for meth in re.findall(r"\"(\w+)\"", m.group(2)): routes.add((meth.upper(), norm(m.group(1)))) return routes def _frontend_calls(): calls = [] def norm(p): p = p.split("?")[0] p = re.sub(r"\$\{[^}]+\}", "*", p) return (p if p.startswith("/api") else "/api" + p).rstrip("/") for fn in glob.glob(str(STATIC / "js" / "**" / "*.js"), recursive=True): if any(s in fn for s in ("vendor", "leaflet", "qrcode.min", "maplibre")): continue src = open(fn, encoding="utf-8", errors="replace").read() for m in re.finditer( r"\b(?:API\.)?(get|post|put|patch|del|delete|upload)\(\s*[`'\"](/[^`'\"\s]*)[`'\"]", src, re.S, ): raw = m.group(2) if raw.startswith(("/js/", "/css/", "/icons/", "/img/", "/media/", "/data/", "/q/")): continue line = src[: m.start()].count("\n") + 1 calls.append((METHOD[m.group(1)], norm(raw), f"{os.path.relpath(fn, ROOT)}:{line}")) return calls def _matches(call_path, route_path): c, r = call_path.split("/"), route_path.split("/") return len(c) == len(r) and all(a == b or a == "*" or b == "*" for a, b in zip(c, r)) def test_no_ghost_api_calls(): routes = _backend_routes() assert len(routes) > 400, f"Routen-Parser kaputt? Nur {len(routes)} Routen gefunden." calls = _frontend_calls() assert len(calls) > 300, f"Frontend-Parser kaputt? Nur {len(calls)} Aufrufe gefunden." ghosts = [] for meth, path, loc in calls: if not path.startswith("/api"): continue if any(bm == meth and _matches(path, bp) for bm, bp in routes): continue ghosts.append(f"{meth} {path} ({loc})") assert not ghosts, ( "Frontend ruft Endpoints auf, die im Backend nicht existieren " "(Worktree-Verlust-Muster!):\n " + "\n ".join(sorted(set(ghosts))) )