banyaro/tests/test_api_surface.py
rene 8c76263ea0 Worktree-Verlust-Audit: alle Geister-Endpoints gefunden + Regressions-Test
Systematischer Abgleich aller 528 Frontend-API-Aufrufe gegen 576 Backend-
Routen (methodengenau, Wildcard-Matching für Parameter):

Echte Opfer (zusätzlich zu partner/* und breeder/my-editor):
- worlds.js Nearby-Alerts riefen /poison/nearby + /lost/nearby auf — beide
  existierten NIE; doppeltes catch verschluckte die 404s → die Welten zeigten
  seit jeher keine Giftköder-/Vermisst-Warnungen. Fix: bestehende Listen-
  Endpoints mit Geo-Filter nutzen (/poison?radius=Meter, /lost?radius_km).
- API.weather.alerts → /weather/alerts existierte nie, hatte aber auch keinen
  Aufrufer — toter Wrapper entfernt.

False Positives geprüft: invoices (Router bringt eigenen Prefix mit, alle 9
Routen ok), Seiten↔Dateien↔window.Page_*↔index.html-Sections alle konsistent.

Neu: tests/test_api_surface.py — statischer API-Oberflächen-Abgleich als
Dauertest; Geister-Aufrufe sind ab jetzt Build-Fehler. Suite: 59 passed.
2026-06-07 20:22:20 +02:00

114 lines
4.5 KiB
Python

"""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)))
)