Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
171 changes: 171 additions & 0 deletions fastapi_startkit/tests/exceptions/test_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
"""Tests for the core ``ExceptionHandler`` (task #722).

Exercises registration, report/render dispatch, the reporting fallbacks
(custom reporter, handler.report, logger, stderr), context building, and the
excepthook/install wiring. The application, logger, and ``atexit`` are all
faked/patched so nothing global is left mutated.
"""

import sys
from types import SimpleNamespace
from unittest.mock import MagicMock, patch

from fastapi_startkit.exceptions.handler import ExceptionHandler


class CustomError(Exception):
pass


class ChildError(CustomError):
pass


def _app(*, has_logger=False, debug=False):
return SimpleNamespace(
has=lambda name: has_logger and name == "logger",
is_debug=lambda: debug,
)


class TestRegistration:
def test_register_is_noop(self):
assert ExceptionHandler().register() is None

def test_dont_report_controls_should_report(self):
handler = ExceptionHandler()
assert handler.dont_report([CustomError]) is handler
assert handler.should_report(CustomError("x")) is False
assert handler.should_report(ValueError("x")) is True

def test_resolve_handler_uses_mro(self):
handler = ExceptionHandler()
registered = MagicMock()
handler.register_handler(CustomError, registered)
# A subclass instance resolves to the parent's registered handler.
assert handler._resolve_handler(ChildError("x")) is registered
assert handler._resolve_handler(KeyError("x")) is None


class TestReport:
def test_skips_when_should_not_report(self):
handler = ExceptionHandler().dont_report([CustomError])
with patch.object(handler, "report_exception") as report_exception:
handler.report(CustomError("x"))
report_exception.assert_not_called()

def test_custom_reporter_takes_priority(self):
handler = ExceptionHandler()
reporter = MagicMock()
handler.register_report(CustomError, reporter)
exc = CustomError("boom")
handler.report(exc)
reporter.assert_called_once_with(exc)

def test_handler_report_used_when_present(self):
handler = ExceptionHandler()
registered = MagicMock()
handler.register_handler(CustomError, registered)
exc = CustomError("boom")
handler.report(exc)
registered.report.assert_called_once_with(exc)

def test_falls_back_to_report_exception(self):
handler = ExceptionHandler()
with patch.object(handler, "report_exception") as report_exception:
exc = ValueError("boom")
handler.report(exc)
report_exception.assert_called_once_with(exc)


class TestReportException:
def test_uses_logger_when_available(self):
handler = ExceptionHandler(_app(has_logger=True))
with patch("fastapi_startkit.logging.logger.Logger.error") as error:
handler.report_exception(ValueError("boom"))
error.assert_called_once()
assert "boom" in error.call_args.args[0]

def test_prints_to_stderr_without_logger(self, capsys):
handler = ExceptionHandler() # no app
handler.report_exception(ValueError("boom"))
assert "boom" in capsys.readouterr().err

def test_build_context_plain_without_debug(self):
handler = ExceptionHandler(_app(debug=False))
assert handler._build_context(ValueError("boom")) == "ValueError: boom"

def test_build_context_includes_traceback_in_debug(self):
handler = ExceptionHandler(_app(debug=True))
try:
raise ValueError("boom")
except ValueError as exc:
context = handler._build_context(exc)
assert context.startswith("ValueError: boom")
assert "Traceback" in context


class TestRenderAndHandle:
async def test_render_uses_custom_callable(self):
handler = ExceptionHandler()
handler.register_render(CustomError, lambda request, exc: ("rendered", request, exc))
exc = CustomError("boom")
result = await handler.render(exc, {"request": "req"})
assert result == ("rendered", "req", exc)

async def test_render_uses_handler_render(self):
handler = ExceptionHandler()

class Registered:
async def render(self, request, exc):
return ("handled", request, exc)

handler.register_handler(CustomError, Registered())
exc = CustomError("boom")
assert await handler.render(exc, {"request": "req"}) == ("handled", "req", exc)

async def test_render_returns_none_by_default(self):
assert await ExceptionHandler().render(ValueError("x"), {}) is None

async def test_handle_reports_then_renders(self):
handler = ExceptionHandler()
handler.register_render(CustomError, lambda request, exc: "done")
with patch.object(handler, "report") as report:
result = await handler.handle(CustomError("boom"), {"request": None})
report.assert_called_once()
assert result == "done"

async def test_handle_defaults_context_to_empty_dict(self):
handler = ExceptionHandler()
with patch.object(handler, "report"):
assert await handler.handle(ValueError("boom")) is None


class TestExcepthookAndInstall:
def test_excepthook_delegates_keyboard_interrupt(self):
handler = ExceptionHandler()
with patch("sys.__excepthook__") as default_hook, patch.object(handler, "report") as report:
handler._excepthook(KeyboardInterrupt, KeyboardInterrupt(), None)
default_hook.assert_called_once()
report.assert_not_called()

def test_excepthook_reports_other_exceptions(self):
handler = ExceptionHandler()
exc = ValueError("boom")
with patch.object(handler, "report") as report:
handler._excepthook(ValueError, exc, None)
report.assert_called_once_with(exc)

def test_handle_shutdown_is_noop(self):
assert ExceptionHandler().handle_shutdown() is None

def test_install_wires_excepthook_and_atexit(self):
handler = ExceptionHandler()
original = sys.excepthook
try:
with patch("atexit.register") as register:
assert handler.install() is handler
assert sys.excepthook == handler._excepthook
register.assert_called_once_with(handler.handle_shutdown)
finally:
sys.excepthook = original
118 changes: 118 additions & 0 deletions fastapi_startkit/tests/fastapi/test_exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Tests for the FastAPI exception handlers (task #722).

Covers ``HTTPExceptionHandler`` (debug vs production rendering) and
``ValidationExceptionHandler`` (JSON vs redirect content negotiation). The
container and request/exception objects are faked so the tests need no live
application, session middleware, or network.
"""

import json
from types import SimpleNamespace
from unittest.mock import patch

from fastapi_startkit.container import Container
from fastapi_startkit.fastapi.exceptions import (
HTTPExceptionHandler,
ValidationExceptionHandler,
)


def _raised(message: str) -> Exception:
"""Return an exception instance carrying a real traceback."""
try:
raise ValueError(message)
except ValueError as exc:
return exc


class FakeRequest:
def __init__(self, headers=None, scope=None, session=None):
self.headers = headers or {}
self.scope = scope or {}
self.session = session if session is not None else {}


class FakeValidationError:
def __init__(self, errors):
self._errors = errors

def errors(self):
return self._errors


class TestHTTPExceptionHandler:
async def test_debug_response_includes_trace(self):
exc = _raised("boom")
app = SimpleNamespace(is_debug=lambda: True)

with patch.object(Container, "instance", return_value=app):
response = await HTTPExceptionHandler().render(FakeRequest(), exc)

assert response.status_code == 500
body = json.loads(response.body)
assert body["message"] == "boom"
assert body["exception"].endswith("ValueError")
assert body["file"] is not None
assert body["line"] is not None
assert isinstance(body["trace"], list) and body["trace"]

async def test_production_response_hides_details(self):
exc = _raised("boom")
app = SimpleNamespace(is_debug=lambda: False)

with patch.object(Container, "instance", return_value=app):
response = await HTTPExceptionHandler().render(FakeRequest(), exc)

assert response.status_code == 500
assert json.loads(response.body) == {"message": "Server Error"}


class TestValidationExceptionHandler:
def test_report_is_noop(self):
assert ValidationExceptionHandler().report(FakeValidationError([])) is None

async def test_json_request_returns_422(self):
exc = FakeValidationError(
[
{"loc": ("body", "email"), "msg": "field required"},
{"loc": ("body", "email"), "msg": "invalid email"},
]
)
request = FakeRequest(headers={"accept": "application/json"})

response = await ValidationExceptionHandler().render(request, exc)

assert response.status_code == 422
assert json.loads(response.body) == {"errors": {"email": ["field required", "invalid email"]}}

async def test_content_type_json_returns_422(self):
exc = FakeValidationError([{"loc": ("body", "name"), "msg": "required"}])
request = FakeRequest(headers={"content-type": "application/json; charset=utf-8"})

response = await ValidationExceptionHandler().render(request, exc)

assert response.status_code == 422
assert json.loads(response.body) == {"errors": {"name": ["required"]}}

async def test_non_json_flashes_errors_and_redirects(self):
exc = FakeValidationError([{"loc": ("body", "name"), "msg": "required"}])
request = FakeRequest(
headers={"accept": "text/html", "referer": "/register"},
scope={"session": {}},
session={},
)

response = await ValidationExceptionHandler().render(request, exc)

assert response.status_code == 303
assert response.headers["location"] == "/register"
assert request.session["errors"] == {"name": ["required"]}

async def test_non_json_without_session_redirects_to_root(self):
exc = FakeValidationError([{"loc": ("body", "name"), "msg": "required"}])
request = FakeRequest(headers={"accept": "text/html"})

response = await ValidationExceptionHandler().render(request, exc)

assert response.status_code == 303
assert response.headers["location"] == "/"
57 changes: 57 additions & 0 deletions fastapi_startkit/tests/fastapi/test_request_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Tests for ``RequestModel`` signature synthesis (task #722).

``RequestModel`` rewrites a subclass's ``__signature__`` so FastAPI binds each
field from form data (or the query string for ``Query`` fields). These tests
assert the generated parameter defaults and the ``validated()`` filter.
"""

import inspect

from fastapi import Query
from fastapi.params import Form
from fastapi.params import Query as QueryParam
from pydantic_core import PydanticUndefined

from fastapi_startkit.fastapi.requests.model import RequestModel


class SampleRequest(RequestModel):
name: str
bio: str = "default-bio"
page: int = Query(default=1)


class TestGeneratedSignature:
def setup_method(self):
self.params = inspect.signature(SampleRequest).parameters

def test_all_fields_become_parameters(self):
assert list(self.params) == ["name", "bio", "page"]

def test_required_field_uses_form_ellipsis(self):
default = self.params["name"].default
assert isinstance(default, Form)
assert default.default is PydanticUndefined

def test_optional_field_uses_form_with_default(self):
default = self.params["bio"].default
assert isinstance(default, Form)
assert default.default == "default-bio"

def test_query_field_keeps_query_param(self):
default = self.params["page"].default
assert isinstance(default, QueryParam)

def test_annotations_are_preserved(self):
assert self.params["name"].annotation is str
assert self.params["page"].annotation is int


class TestValidated:
def test_drops_falsy_values(self):
model = SampleRequest(name="alice", bio="", page=0)
assert model.validated() == {"name": "alice"}

def test_keeps_truthy_values(self):
model = SampleRequest(name="alice", bio="hello", page=3)
assert model.validated() == {"name": "alice", "bio": "hello", "page": 3}
Loading
Loading