"""Hilfen für die Tests: angemeldeter Browser und häufige Abläufe.""" import re from fastapi.testclient import TestClient PASSWORD = "demo-passwort" class Browser: """TestClient mit Anmeldung und CSRF-Token.""" def __init__(self, client: TestClient): self.client = client self.csrf = "" def login(self, username: str) -> "Browser": response = self.client.post("/login", data={"username": username, "password": PASSWORD}, follow_redirects=False) assert response.status_code == 303, response.text page = self.client.get("/workspaces/new") self.csrf = re.search(r'name="_csrf" value="([^"]+)"', page.text).group(1) return self def get(self, url: str, **kwargs): return self.client.get(url, **kwargs) def post(self, url: str, data: dict | None = None, **kwargs): return self.client.post(url, data={"_csrf": self.csrf, **(data or {})}, follow_redirects=False, **kwargs) def create_workspace(b: Browser, team_id: int, name: str = "Projekt X") -> int: response = b.post("/workspaces", {"team_id": team_id, "name": name}) assert response.status_code == 303, response.text return int(response.headers["location"].rsplit("/", 1)[1]) def connect_repo(b: Browser, workspace_id: int, url: str, branch: str = "main") -> None: response = b.post(f"/workspaces/{workspace_id}/sources", {"name": "Repo", "url": url, "branch": branch, "exposure_confirmed": "1"}) assert response.status_code == 303, response.text class FakeProvider: """Testdouble für den Provider-Adapter. script: Liste von Runden; eine Runde ist Text (Antwort) oder eine Liste von Tool-Aufrufen [(name, argumente)]. Ohne script antwortet es mit reply. """ calls: list[dict] = [] reply = "Antwort vom Modell." script: list = [] error: Exception | None = None # Verbrauch je Aufruf; None = Standardwerte usage: dict | None = None def __init__(self, timeout: float = 0): pass @classmethod def reset(cls) -> None: cls.calls, cls.reply, cls.script, cls.error, cls.usage = [], "Antwort vom Modell.", [], None, None def respond(self, target, *, instructions, input, on_text, tools=None): import json from app.ai.provider import Result cls = type(self) cls.calls.append({"target": target, "instructions": instructions, "input": list(input), "tools": tools}) if self.error: raise self.error step = cls.script.pop(0) if cls.script else self.reply if isinstance(step, list) and tools: round_no = len(cls.calls) items = [{"type": "function_call", "id": f"fc_{round_no}_{i}", "call_id": f"call_{round_no}_{i}", "name": name, "arguments": json.dumps(args) if isinstance(args, dict) else args, "status": "completed"} for i, (name, args) in enumerate(step)] return Result(text="", items=items, model=target.model, usage=cls.usage or {"total_tokens": 3}) text = step if isinstance(step, str) else "Abschluss ohne Tools." for word in text.split(" "): on_text(word + " ") return Result(text=text, model=target.model, usage=cls.usage or {"total_tokens": 7}, items=[{"type": "reasoning", "id": "rs_1", "encrypted_content": "verschluesselt", "summary": []}, {"type": "message", "role": "assistant", "id": "msg_1", "status": "completed", "content": [{"type": "output_text", "text": text, "annotations": []}]}]) def list_models(self, target): return ["gpt-6-luna"] class McpTestServer: """MCP-Server (Streamable HTTP) im selben Prozess, für Tests mit echtem Transport.""" def __init__(self, server): import socket import threading import uvicorn with socket.socket() as s: s.bind(("127.0.0.1", 0)) self.port = s.getsockname()[1] self.server = uvicorn.Server(uvicorn.Config(server.streamable_http_app(), host="127.0.0.1", port=self.port, log_level="warning", lifespan="on")) self.thread = threading.Thread(target=self.server.run, daemon=True) @property def url(self) -> str: return f"http://localhost:{self.port}/mcp" def __enter__(self) -> "McpTestServer": import time self.thread.start() for _ in range(100): if self.server.started: return self time.sleep(0.05) raise RuntimeError("Testserver startet nicht") def __exit__(self, *exc) -> None: self.server.should_exit = True self.thread.join(5) # --- Schreibendes Testwerkzeug für die Freigabe-Mechanik (unabhängig von Git) --- from pydantic import Field # noqa: E402 from app.tools.registry import Args, Proposal, Tool, register # noqa: E402 class DemoWriteArgs(Args): title: str = Field(min_length=1, max_length=200, description="Titel") text: str = Field("", description="Was geschrieben wird") source: str | None = Field(None, description="Quelle") class DemoTarget: """Fremdsystem des Testwerkzeugs: merkt sich Schreibvorgänge; crash simuliert einen Absturz danach.""" written: list[tuple[str, str]] = [] crash = False @classmethod def reset(cls) -> None: cls.written, cls.crash = [], False def demo_prepare(context, args: DemoWriteArgs) -> Proposal: source = context.source("git", args.source) return Proposal(source_id=source.id, arguments=args.model_dump(exclude_none=True) | {"source": source.name}, preview=f"+{args.text}", preconditions={"key": args.title}, summary=args.text) def demo_execute(context, snapshot) -> tuple[str, dict]: key = (snapshot["arguments"]["title"], snapshot["arguments"].get("text", "")) if key not in DemoTarget.written: # idempotent wie echte Schreibtools DemoTarget.written.append(key) if DemoTarget.crash: raise RuntimeError("Verbindung weg") return f"Geschrieben: {key[0]}", {"written": key[0]} register(Tool("demo_write", "git", "Testwerkzeug: schreibt in ein Fremdsystem.", DemoWriteArgs, "write_external", prepare=demo_prepare, execute=demo_execute))