145 lines
4.8 KiB
Python
145 lines
4.8 KiB
Python
|
|
"""Shared pytest fixtures for testing the TryMe FastMCP HTTP server."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
from dataclasses import dataclass, field
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import httpx
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from app.client import TryMeClient
|
||
|
|
from app.config import Settings
|
||
|
|
from app.main import get_client, set_client
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class RecordedCall:
|
||
|
|
"""Captured upstream HTTP request made by TryMeClient."""
|
||
|
|
|
||
|
|
method: str
|
||
|
|
path: str
|
||
|
|
query: dict[str, str]
|
||
|
|
headers: dict[str, str]
|
||
|
|
body: Any
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class UpstreamMockState:
|
||
|
|
"""Stateful recorder and configurable responder for MockTransport."""
|
||
|
|
|
||
|
|
calls: list[RecordedCall] = field(default_factory=list)
|
||
|
|
next_status: int | None = None
|
||
|
|
next_json: Any = None
|
||
|
|
next_headers: dict[str, str] = field(default_factory=dict)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def mock_upstream() -> Any:
|
||
|
|
"""Install a MockTransport-backed TryMeClient and restore original client afterward."""
|
||
|
|
state = UpstreamMockState()
|
||
|
|
original_client = get_client()
|
||
|
|
|
||
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
||
|
|
raw_body = request.content.decode("utf-8") if request.content else ""
|
||
|
|
parsed_body: Any = None
|
||
|
|
if raw_body:
|
||
|
|
try:
|
||
|
|
parsed_body = json.loads(raw_body)
|
||
|
|
except Exception:
|
||
|
|
parsed_body = raw_body
|
||
|
|
|
||
|
|
state.calls.append(
|
||
|
|
RecordedCall(
|
||
|
|
method=request.method,
|
||
|
|
path=request.url.path,
|
||
|
|
query=dict(request.url.params),
|
||
|
|
headers={k.lower(): v for k, v in request.headers.items()},
|
||
|
|
body=parsed_body,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
if state.next_status is not None:
|
||
|
|
status = state.next_status
|
||
|
|
payload = state.next_json
|
||
|
|
resp_headers = dict(state.next_headers)
|
||
|
|
state.next_status = None
|
||
|
|
state.next_json = None
|
||
|
|
state.next_headers.clear()
|
||
|
|
if status == 204 or (300 <= status < 400 and payload is None):
|
||
|
|
return httpx.Response(status_code=status, headers=resp_headers)
|
||
|
|
return httpx.Response(status_code=status, json=payload, headers=resp_headers)
|
||
|
|
|
||
|
|
# Default realistic responses by method + path
|
||
|
|
path = request.url.path
|
||
|
|
method = request.method.upper()
|
||
|
|
|
||
|
|
if path == "/health":
|
||
|
|
return httpx.Response(200, json={"status": "ok", "service": "trymeapp", "version": "0.2"})
|
||
|
|
if path == "/api/v1/auth/config":
|
||
|
|
return httpx.Response(
|
||
|
|
200,
|
||
|
|
json={
|
||
|
|
"auth0_enabled": True,
|
||
|
|
"auth0_domain": "example.us.auth0.com",
|
||
|
|
"auth0_client_id": "client123",
|
||
|
|
"auth0_audience": "",
|
||
|
|
"dev_auth_enabled": True,
|
||
|
|
"admin_unclaimed": False,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
if path in ("/api/v1/auth/login", "/api/v1/auth/callback"):
|
||
|
|
return httpx.Response(
|
||
|
|
302,
|
||
|
|
headers={"location": "https://example.us.auth0.com/authorize"},
|
||
|
|
)
|
||
|
|
if path == "/api/v1/auth/dev-login":
|
||
|
|
return httpx.Response(
|
||
|
|
200,
|
||
|
|
json={
|
||
|
|
"access_token": "dev-jwt-token",
|
||
|
|
"token_type": "bearer",
|
||
|
|
"user": {"id": 1, "nickname": parsed_body.get("nickname", "tester")},
|
||
|
|
},
|
||
|
|
)
|
||
|
|
if method in ("DELETE", "POST") and path in (
|
||
|
|
"/api/v1/auth/logout",
|
||
|
|
):
|
||
|
|
return httpx.Response(204)
|
||
|
|
if method == "DELETE" and (
|
||
|
|
path.startswith("/api/v1/quizzes/") or path.startswith("/api/v1/admin/quizzes/")
|
||
|
|
):
|
||
|
|
return httpx.Response(204)
|
||
|
|
if path in (
|
||
|
|
"/api/v1/users/me/scores",
|
||
|
|
"/api/v1/users/me/api-keys",
|
||
|
|
"/api/v1/quizzes",
|
||
|
|
"/api/v1/favourites",
|
||
|
|
"/api/v1/admin/users",
|
||
|
|
"/api/v1/admin/quizzes",
|
||
|
|
) and method == "GET":
|
||
|
|
return httpx.Response(200, json=[{"ok": True, "path": path}])
|
||
|
|
if path.startswith("/api/v1/favourites") and method in ("POST", "PUT", "DELETE"):
|
||
|
|
return httpx.Response(200, json=[{"favourite_id": 1, "position": 0, "quiz": {"id": 10}}])
|
||
|
|
|
||
|
|
return httpx.Response(200, json={"ok": True, "method": method, "path": path, "body": parsed_body})
|
||
|
|
|
||
|
|
test_settings = Settings(
|
||
|
|
version="0.1",
|
||
|
|
mcp_host="0.0.0.0",
|
||
|
|
mcp_port=8000,
|
||
|
|
mcp_path="/mcp",
|
||
|
|
tryme_base_url="https://tryme.tpk.pw",
|
||
|
|
tryme_timeout_seconds=10.0,
|
||
|
|
tryme_api_key="env-default-key",
|
||
|
|
tryme_bearer_token=None,
|
||
|
|
tryme_session_cookie=None,
|
||
|
|
)
|
||
|
|
client = TryMeClient(config=test_settings, transport=httpx.MockTransport(handler))
|
||
|
|
set_client(client)
|
||
|
|
try:
|
||
|
|
yield state
|
||
|
|
finally:
|
||
|
|
set_client(original_client)
|