mirror of
https://github.com/mwisnowski/mtg_python_deckbuilder.git
synced 2026-03-18 19:26:31 +01:00
253 lines
8.8 KiB
Python
253 lines
8.8 KiB
Python
|
|
"""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
|