From d61cf8beb11a9a3596c3383482137b879322c08c Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Thu, 9 Jul 2026 11:36:46 -0700 Subject: [PATCH] Add coverage tests for core exceptions and structure utilities Cover the FastAPI exception handlers (HTTPExceptionHandler debug/production rendering, ValidationExceptionHandler JSON vs redirect negotiation), the core ExceptionHandler dispatch/report/render paths, the RequestModel signature synthesis, and the data-structure helpers in utils/structures. Per-file coverage: - fastapi/exceptions.py 16% -> 100% - exceptions/handler.py 39% -> 100% - fastapi/requests/model.py 42% -> 100% - utils/structures.py 22% -> 89% Tests only; no framework source changed. Total suite coverage 72%. --- fastapi_startkit/tests/exceptions/__init__.py | 0 .../tests/exceptions/test_handler.py | 171 ++++++++++++++++++ .../tests/fastapi/test_exceptions.py | 118 ++++++++++++ .../tests/fastapi/test_request_model.py | 57 ++++++ .../tests/utils/test_structures.py | 88 +++++++++ 5 files changed, 434 insertions(+) create mode 100644 fastapi_startkit/tests/exceptions/__init__.py create mode 100644 fastapi_startkit/tests/exceptions/test_handler.py create mode 100644 fastapi_startkit/tests/fastapi/test_exceptions.py create mode 100644 fastapi_startkit/tests/fastapi/test_request_model.py create mode 100644 fastapi_startkit/tests/utils/test_structures.py diff --git a/fastapi_startkit/tests/exceptions/__init__.py b/fastapi_startkit/tests/exceptions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastapi_startkit/tests/exceptions/test_handler.py b/fastapi_startkit/tests/exceptions/test_handler.py new file mode 100644 index 00000000..6a93892c --- /dev/null +++ b/fastapi_startkit/tests/exceptions/test_handler.py @@ -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 diff --git a/fastapi_startkit/tests/fastapi/test_exceptions.py b/fastapi_startkit/tests/fastapi/test_exceptions.py new file mode 100644 index 00000000..8cb145b4 --- /dev/null +++ b/fastapi_startkit/tests/fastapi/test_exceptions.py @@ -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"] == "/" diff --git a/fastapi_startkit/tests/fastapi/test_request_model.py b/fastapi_startkit/tests/fastapi/test_request_model.py new file mode 100644 index 00000000..714e84b7 --- /dev/null +++ b/fastapi_startkit/tests/fastapi/test_request_model.py @@ -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} diff --git a/fastapi_startkit/tests/utils/test_structures.py b/fastapi_startkit/tests/utils/test_structures.py new file mode 100644 index 00000000..12b1e9f2 --- /dev/null +++ b/fastapi_startkit/tests/utils/test_structures.py @@ -0,0 +1,88 @@ +"""Tests for the data-structure helpers (task #722). + +Covers ``load`` (module/attribute loading and failure handling) and the dotted +dictionary helpers ``data``, ``data_get`` and ``data_set``. Modules are loaded +from files written under pytest's ``tmp_path`` so no fixtures live on disk. +""" + +import pytest +from dotty_dict import Dotty + +from fastapi_startkit.exceptions.exceptions import LoaderNotFound +from fastapi_startkit.utils.structures import data, data_get, data_set, load + +MODULE_SOURCE = "VALUE = 42\n\n\ndef greet():\n return 'hi'\n" + + +def _write_module(tmp_path, name="sample.py", source=MODULE_SOURCE): + path = tmp_path / name + path.write_text(source) + return str(path) + + +class TestLoad: + def test_returns_module_when_no_object_name(self, tmp_path): + module = load(_write_module(tmp_path)) + assert module.VALUE == 42 + + def test_returns_named_attribute(self, tmp_path): + path = _write_module(tmp_path) + assert load(path, "VALUE") == 42 + assert load(path, "greet")() == "hi" + + def test_bad_path_returns_none(self, tmp_path, capsys): + result = load(str(tmp_path / "missing.py")) + assert result is None + assert "error when loading from file" in capsys.readouterr().out + + def test_bad_path_raises_when_requested(self, tmp_path): + with pytest.raises(LoaderNotFound): + load(str(tmp_path / "missing.py"), raise_exception=True) + + def test_dotted_path_without_slash(self, tmp_path): + # No "/" in the name triggers the ``.replace('.py', '')`` branch. + result = load("definitely_not_a_real_module") + assert result is None + + +class TestData: + def test_returns_dotty(self): + assert isinstance(data({"a": 1}), Dotty) + + def test_defaults_to_empty(self): + assert dict(data()) == {} + + +class TestDataGet: + def test_reads_nested_value(self): + assert data_get({"a": {"b": 1}}, "a.b") == 1 + + def test_returns_default_for_missing_key(self): + assert data_get({"a": {}}, "a.missing", "fallback") == "fallback" + + def test_wildcard_is_translated(self): + # A "*" in the key is rewritten to dotty's ":" wildcard; the lookup + # runs without raising and falls back to the default when unmatched. + assert data_get({"a": {"b": 2}}, "a.*", "fallback") == "fallback" + + +class TestDataSet: + def test_sets_value_under_existing_parent(self): + source = {"a": {"b": 1}} + result = data_set(source, "a.c", 7) + assert result is source + assert source["a"]["c"] == 7 + + def test_does_not_overwrite_when_disabled(self): + source = {"a": {"b": 1}} + assert data_set(source, "a.b", 9, overwrite=False) is None + assert source["a"]["b"] == 1 + + def test_overwrites_by_default(self): + source = {"a": {"b": 1}} + data_set(source, "a.b", 9) + assert source["a"]["b"] == 9 + + def test_wildcard_key_raises(self): + with pytest.raises(ValueError): + data_set({}, "a.*", 1)