refactor: error handling integration and testing standards

This commit is contained in:
matt 2026-03-17 17:29:14 -07:00
parent f784741416
commit f23c0dbf2c
10 changed files with 1038 additions and 8 deletions

View file

@ -0,0 +1,224 @@
"""Base test case classes for the MTG Python Deckbuilder web layer.
Provides reusable base classes and mixins that reduce boilerplate in route,
service, and validation tests. Import what you need don't inherit everything.
Usage:
from code.tests.base_test_cases import RouteTestCase, ServiceTestCase
class TestMyRoute(RouteTestCase):
def test_something(self):
resp = self.client.get("/my-route")
self.assert_ok(resp)
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
import pytest
from fastapi.testclient import TestClient
# ---------------------------------------------------------------------------
# Route test base
# ---------------------------------------------------------------------------
class RouteTestCase:
"""Base class for route integration tests.
Provides a shared TestClient and assertion helpers. Subclasses can override
`app_fixture` to use a different FastAPI app (e.g., a minimal test app).
Example:
class TestBuildWizard(RouteTestCase):
def test_step1_renders(self):
resp = self.get("/build/step1")
self.assert_ok(resp)
assert "Commander" in resp.text
"""
@pytest.fixture(autouse=True)
def setup_client(self, monkeypatch):
"""Create a TestClient for the full app. Override to customise."""
from code.web.app import app
with TestClient(app) as c:
self.client = c
yield
# --- Shorthand request helpers ---
def get(self, path: str, *, headers: dict | None = None, cookies: dict | None = None, **params) -> Any:
return self.client.get(path, headers=headers or {}, cookies=cookies or {}, params=params or {})
def post(self, path: str, data: dict | None = None, *, json: dict | None = None,
headers: dict | None = None, cookies: dict | None = None) -> Any:
return self.client.post(path, data=data, json=json, headers=headers or {}, cookies=cookies or {})
def htmx_get(self, path: str, *, cookies: dict | None = None, **params) -> Any:
"""GET with HX-Request header set (simulates HTMX fetch)."""
return self.client.get(path, headers={"HX-Request": "true"}, cookies=cookies or {}, params=params or {})
def htmx_post(self, path: str, data: dict | None = None, *, cookies: dict | None = None) -> Any:
"""POST with HX-Request header set (simulates HTMX form submission)."""
return self.client.post(path, data=data, headers={"HX-Request": "true"}, cookies=cookies or {})
# --- Assertion helpers ---
def assert_ok(self, resp, *, contains: str | None = None) -> None:
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text[:200]}"
if contains:
assert contains in resp.text, f"Expected {contains!r} in response"
def assert_status(self, resp, status: int) -> None:
assert resp.status_code == status, f"Expected {status}, got {resp.status_code}: {resp.text[:200]}"
def assert_json_error(self, resp, *, status: int, error_type: str | None = None) -> dict:
"""Assert a standardized error JSON response (M4 format)."""
assert resp.status_code == status
data = resp.json()
assert data.get("error") is True, f"Expected error=True in: {data}"
assert "request_id" in data
if error_type:
assert data.get("error_type") == error_type, f"Expected error_type={error_type!r}, got {data.get('error_type')!r}"
return data
def assert_redirect(self, resp, *, to: str | None = None) -> None:
assert resp.status_code in (301, 302, 303, 307, 308), f"Expected redirect, got {resp.status_code}"
if to:
assert to in resp.headers.get("location", "")
def with_session(self, commander: str = "Atraxa, Praetors' Voice", **extra) -> tuple[str, dict]:
"""Create a session with basic commander state. Returns (sid, session_dict)."""
from code.web.services.tasks import get_session, new_sid
sid = new_sid()
sess = get_session(sid)
sess["commander"] = {"name": commander, "ok": True}
sess.update(extra)
return sid, sess
# ---------------------------------------------------------------------------
# Error handler test base
# ---------------------------------------------------------------------------
class ErrorHandlerTestCase:
"""Base for tests targeting the DeckBuilderError → HTTP response pipeline.
Spins up a minimal FastAPI app with only the error handler no routes from
the real app, so tests are fast and isolated.
Example:
class TestMyErrors(ErrorHandlerTestCase):
def test_custom_error(self):
from code.exceptions import ThemeSelectionError
self._register_raiser("/raise", ThemeSelectionError("bad theme"))
resp = self.error_client.get("/raise")
self.assert_json_error(resp, status=400, error_type="ThemeSelectionError")
"""
@pytest.fixture(autouse=True)
def setup_error_app(self):
from fastapi import FastAPI, Request
from code.exceptions import DeckBuilderError
from code.web.utils.responses import deck_builder_error_response
self._mini_app = FastAPI()
self._raisers: dict[str, Exception] = {}
app = self._mini_app
@app.exception_handler(Exception)
async def handler(request: Request, exc: Exception):
if isinstance(exc, DeckBuilderError):
return deck_builder_error_response(request, exc)
from fastapi.responses import JSONResponse
return JSONResponse({"error": "unhandled", "detail": str(exc)}, status_code=500)
with TestClient(app, raise_server_exceptions=False) as c:
self.error_client = c
yield
def _register_raiser(self, path: str, exc: Exception) -> None:
"""Add a GET endpoint that raises `exc` when called."""
from fastapi import Request
@self._mini_app.get(path)
async def _raiser(request: Request):
raise exc
# Rebuild the client after adding the route
with TestClient(self._mini_app, raise_server_exceptions=False) as c:
self.error_client = c
def assert_json_error(self, resp, *, status: int, error_type: str | None = None) -> dict:
assert resp.status_code == status, f"Expected {status}, got {resp.status_code}: {resp.text[:200]}"
data = resp.json()
assert data.get("error") is True
if error_type:
assert data.get("error_type") == error_type
return data
# ---------------------------------------------------------------------------
# Service test base
# ---------------------------------------------------------------------------
class ServiceTestCase:
"""Base class for service unit tests.
Provides helpers for creating mock dependencies and asserting common
service behaviors. No TestClient services are tested directly.
Example:
class TestSessionManager(ServiceTestCase):
def test_new_session_is_empty(self):
from code.web.services.tasks import SessionManager
mgr = SessionManager()
sess = mgr.get("new-key")
assert sess == {}
"""
def make_mock(self, **attrs) -> MagicMock:
"""Create a MagicMock with the given attributes pre-set."""
m = MagicMock()
for k, v in attrs.items():
setattr(m, k, v)
return m
def assert_raises(self, exc_type, fn, *args, **kwargs):
"""Assert that fn(*args, **kwargs) raises exc_type."""
with pytest.raises(exc_type):
fn(*args, **kwargs)
# ---------------------------------------------------------------------------
# Validation test mixin
# ---------------------------------------------------------------------------
class ValidationTestMixin:
"""Mixin for Pydantic model validation tests.
Example:
class TestBuildRequest(ValidationTestMixin):
MODEL = BuildRequest
def test_commander_required(self):
self.assert_validation_error(commander="")
"""
MODEL = None # Set in subclass
def build(self, **kwargs) -> Any:
"""Instantiate MODEL with kwargs. Raises ValidationError on invalid input."""
assert self.MODEL is not None, "Set MODEL in your test class"
return self.MODEL(**kwargs)
def assert_validation_error(self, **kwargs) -> None:
"""Assert that MODEL(**kwargs) raises a Pydantic ValidationError."""
from pydantic import ValidationError
assert self.MODEL is not None
with pytest.raises(ValidationError):
self.MODEL(**kwargs)

View file

@ -0,0 +1,252 @@
"""Tests for M4 error handling integration.
Covers:
- DeckBuilderError HTTP response conversion
- HTMX vs JSON response detection
- Status code mapping for exception hierarchy
- app.py DeckBuilderError exception handler
- Web-specific exceptions (SessionExpiredError, BuildNotFoundError, FeatureDisabledError)
"""
from __future__ import annotations
import pytest
from unittest.mock import MagicMock
from fastapi.testclient import TestClient
from fastapi import FastAPI, Request
from code.exceptions import (
DeckBuilderError,
CommanderValidationError,
CommanderTypeError,
ThemeSelectionError,
SessionExpiredError,
BuildNotFoundError,
FeatureDisabledError,
CSVFileNotFoundError,
PriceAPIError,
)
from code.web.utils.responses import (
deck_error_to_status,
deck_builder_error_response,
is_htmx_request,
)
# ---------------------------------------------------------------------------
# Unit tests: exception → status mapping
# ---------------------------------------------------------------------------
class TestDeckErrorToStatus:
def test_commander_validation_400(self):
assert deck_error_to_status(CommanderValidationError("bad")) == 400
def test_commander_type_400(self):
assert deck_error_to_status(CommanderTypeError("bad type")) == 400
def test_theme_selection_400(self):
assert deck_error_to_status(ThemeSelectionError("bad theme")) == 400
def test_session_expired_401(self):
assert deck_error_to_status(SessionExpiredError()) == 401
def test_build_not_found_404(self):
assert deck_error_to_status(BuildNotFoundError()) == 404
def test_feature_disabled_404(self):
assert deck_error_to_status(FeatureDisabledError("test_feature")) == 404
def test_csv_file_not_found_503(self):
assert deck_error_to_status(CSVFileNotFoundError("cards.csv")) == 503
def test_price_api_error_503(self):
assert deck_error_to_status(PriceAPIError("http://x", 500)) == 503
def test_base_deck_builder_error_500(self):
assert deck_error_to_status(DeckBuilderError("generic")) == 500
def test_subclass_not_in_map_falls_back_via_mro(self):
# CommanderTypeError is a subclass of CommanderValidationError
exc = CommanderTypeError("oops")
assert deck_error_to_status(exc) == 400
# ---------------------------------------------------------------------------
# Unit tests: HTMX detection
# ---------------------------------------------------------------------------
class TestIsHtmxRequest:
def _make_request(self, hx_header: str | None = None) -> Request:
scope = {
"type": "http",
"method": "GET",
"path": "/test",
"query_string": b"",
"headers": [],
}
if hx_header is not None:
scope["headers"] = [(b"hx-request", hx_header.encode())]
return Request(scope)
def test_htmx_true_header(self):
req = self._make_request("true")
assert is_htmx_request(req) is True
def test_no_htmx_header(self):
req = self._make_request()
assert is_htmx_request(req) is False
def test_htmx_false_header(self):
req = self._make_request("false")
assert is_htmx_request(req) is False
# ---------------------------------------------------------------------------
# Unit tests: deck_builder_error_response
# ---------------------------------------------------------------------------
class TestDeckBuilderErrorResponse:
def _make_request(self, htmx: bool = False) -> Request:
headers = []
if htmx:
headers.append((b"hx-request", b"true"))
scope = {
"type": "http",
"method": "GET",
"path": "/build/new",
"query_string": b"",
"headers": headers,
}
req = Request(scope)
req.state.request_id = "test-rid-123"
return req
def test_json_response_structure(self):
from fastapi.responses import JSONResponse
req = self._make_request(htmx=False)
exc = CommanderValidationError("Invalid commander")
resp = deck_builder_error_response(req, exc)
assert isinstance(resp, JSONResponse)
assert resp.status_code == 400
import json
body = json.loads(resp.body)
assert body["error"] is True
assert body["status"] == 400
assert body["error_type"] == "CommanderValidationError"
assert body["message"] == "Invalid commander"
assert body["request_id"] == "test-rid-123"
assert "timestamp" in body
assert "path" in body
def test_htmx_response_is_html(self):
from fastapi.responses import HTMLResponse
req = self._make_request(htmx=True)
exc = ThemeSelectionError("Invalid theme")
resp = deck_builder_error_response(req, exc)
assert isinstance(resp, HTMLResponse)
assert resp.status_code == 400
assert "Invalid theme" in resp.body.decode()
assert resp.headers.get("X-Request-ID") == "test-rid-123"
def test_request_id_in_response_header(self):
req = self._make_request(htmx=False)
exc = BuildNotFoundError()
resp = deck_builder_error_response(req, exc)
assert resp.headers.get("X-Request-ID") == "test-rid-123"
# ---------------------------------------------------------------------------
# Integration tests: app exception handler
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def test_app():
"""Minimal FastAPI app that raises DeckBuilderErrors for testing."""
app = FastAPI()
@app.get("/raise/commander")
async def raise_commander(request: Request):
raise CommanderValidationError("Commander 'Foo' not found")
@app.get("/raise/session")
async def raise_session(request: Request):
raise SessionExpiredError(sid="abc123")
@app.get("/raise/feature")
async def raise_feature(request: Request):
raise FeatureDisabledError("partner_suggestions")
@app.get("/raise/generic")
async def raise_generic(request: Request):
raise DeckBuilderError("Something went wrong")
# Wire the same handler as app.py
from code.exceptions import DeckBuilderError as DBE
from code.web.utils.responses import deck_builder_error_response
@app.exception_handler(Exception)
async def handler(request: Request, exc: Exception):
if isinstance(exc, DBE):
return deck_builder_error_response(request, exc)
from fastapi.responses import JSONResponse
return JSONResponse({"error": "unhandled"}, status_code=500)
return app
@pytest.fixture(scope="module")
def client(test_app):
return TestClient(test_app, raise_server_exceptions=False)
class TestAppExceptionHandler:
def test_commander_validation_returns_400(self, client):
resp = client.get("/raise/commander")
assert resp.status_code == 400
data = resp.json()
assert data["error"] is True
assert data["error_type"] == "CommanderValidationError"
assert "Commander 'Foo' not found" in data["message"]
def test_session_expired_returns_401(self, client):
resp = client.get("/raise/session")
assert resp.status_code == 401
data = resp.json()
assert data["error_type"] == "SessionExpiredError"
def test_feature_disabled_returns_404(self, client):
resp = client.get("/raise/feature")
assert resp.status_code == 404
data = resp.json()
assert data["error_type"] == "FeatureDisabledError"
def test_generic_deck_builder_error_returns_500(self, client):
resp = client.get("/raise/generic")
assert resp.status_code == 500
data = resp.json()
assert data["error_type"] == "DeckBuilderError"
def test_htmx_commander_error_returns_html(self, client):
resp = client.get("/raise/commander", headers={"HX-Request": "true"})
assert resp.status_code == 400
assert "text/html" in resp.headers.get("content-type", "")
assert "Commander 'Foo' not found" in resp.text
# ---------------------------------------------------------------------------
# Web-specific exception constructors
# ---------------------------------------------------------------------------
class TestWebExceptions:
def test_session_expired_has_code(self):
exc = SessionExpiredError(sid="xyz")
assert exc.code == "SESSION_EXPIRED"
assert "xyz" in str(exc.details)
def test_build_not_found_has_code(self):
exc = BuildNotFoundError(sid="abc")
assert exc.code == "BUILD_NOT_FOUND"
def test_feature_disabled_has_feature_name(self):
exc = FeatureDisabledError("partner_suggestions")
assert exc.code == "FEATURE_DISABLED"
assert "partner_suggestions" in exc.message