From 0fc49cac1c41668162314b89a57681afd446223b Mon Sep 17 00:00:00 2001 From: Srinivas Chippagiri Date: Sat, 24 Oct 2026 14:23:33 -0700 Subject: [PATCH] test: cover request discovery, exception text and fastapi/quart extractors Add tests for call-stack request discovery, cached exception text, config_root_logger, and the FastAPI and Quart request info extractors. Refs #144 Signed-off-by: Srinivas Chippagiri --- tests/test_fastapi_quart_extractors.py | 99 ++++++++++++++++++++++++++ tests/test_init_validation.py | 11 +++ tests/test_request_discovery.py | 75 +++++++++++++++++++ 3 files changed, 185 insertions(+) create mode 100644 tests/test_fastapi_quart_extractors.py create mode 100644 tests/test_request_discovery.py diff --git a/tests/test_fastapi_quart_extractors.py b/tests/test_fastapi_quart_extractors.py new file mode 100644 index 0000000..792579a --- /dev/null +++ b/tests/test_fastapi_quart_extractors.py @@ -0,0 +1,99 @@ +"""Request info extraction for the fastapi and quart backends (issue #137).""" +from types import SimpleNamespace + +import pytest +from helpers.imports import undo_imports_from_package + + +@pytest.fixture +def fresh_json_logging(): + import json_logging + + yield json_logging + undo_imports_from_package("json_logging") + + +class _BrokenHeaders: + """Headers whose membership test fails, like a request that is already closed.""" + + def __contains__(self, item): + raise RuntimeError("headers unavailable") + + +def test_fastapi_init_request_instrument_rejects_non_fastapi_app(fresh_json_logging): + fresh_json_logging.init_fastapi() + + with pytest.raises(RuntimeError, match="not a valid fastapi"): + fresh_json_logging.init_request_instrument(object()) + + +def test_fastapi_protocol_is_built_from_scope(fresh_json_logging): + from json_logging.framework.fastapi.implementation import FastAPIRequestInfoExtractor + + extractor = FastAPIRequestInfoExtractor() + http = SimpleNamespace(scope={"type": "http", "http_version": "1.1"}) + + assert extractor.get_protocol(http) == "HTTP/1.1" + + +@pytest.mark.parametrize("scope", [{"type": "http"}, {"type": "websocket", "http_version": "1.1"}, {}]) +def test_fastapi_protocol_falls_back_to_empty_value(fresh_json_logging, scope): + from json_logging.framework.fastapi.implementation import FastAPIRequestInfoExtractor + + extractor = FastAPIRequestInfoExtractor() + + assert extractor.get_protocol(SimpleNamespace(scope=scope)) == fresh_json_logging.EMPTY_VALUE + + +def test_fastapi_header_lookup_falls_back_to_default(fresh_json_logging): + from json_logging.framework.fastapi.implementation import FastAPIRequestInfoExtractor + + extractor = FastAPIRequestInfoExtractor() + + assert extractor.get_http_header(SimpleNamespace(headers={"X-Present": "yes"}), "X-Present") == "yes" + assert extractor.get_http_header(SimpleNamespace(headers={}), "X-Missing", "fallback") == "fallback" + assert extractor.get_http_header(SimpleNamespace(headers=_BrokenHeaders()), "X", "fallback") == "fallback" + + +def test_quart_remote_user(fresh_json_logging): + from json_logging.framework.quart import QuartRequestInfoExtractor + + extractor = QuartRequestInfoExtractor() + + assert extractor.get_remote_user(SimpleNamespace(authorization=None)) == fresh_json_logging.EMPTY_VALUE + assert extractor.get_remote_user(SimpleNamespace(authorization=SimpleNamespace(username="alice"))) == "alice" + + +def test_quart_header_lookup_falls_back_to_default(fresh_json_logging): + from json_logging.framework.quart import QuartRequestInfoExtractor + + extractor = QuartRequestInfoExtractor() + + assert extractor.get_http_header(SimpleNamespace(headers={"X-Present": "yes"}), "X-Present") == "yes" + assert extractor.get_http_header(SimpleNamespace(headers={}), "X-Missing", "fallback") == "fallback" + assert extractor.get_http_header(SimpleNamespace(headers=_BrokenHeaders()), "X", "fallback") == "fallback" + + +def test_quart_correlation_id_outside_request_context_is_none(fresh_json_logging): + from json_logging.framework.quart import QuartRequestInfoExtractor + + extractor = QuartRequestInfoExtractor() + + extractor.set_correlation_id(None, "ignored") # no app context: must not raise + assert extractor.get_correlation_id_in_request_context(None) is None + + +def test_quart_request_fields_are_read_from_the_request(fresh_json_logging): + from json_logging.framework.quart import QuartRequestInfoExtractor + + extractor = QuartRequestInfoExtractor() + request = SimpleNamespace( + scheme="https", path="/hello", content_length=12, method="POST", remote_addr="10.0.0.1", host="example.com:8443") + + assert extractor.get_protocol(request) == "https" + assert extractor.get_path(request) == "/hello" + assert extractor.get_content_length(request) == 12 + assert extractor.get_method(request) == "POST" + assert extractor.get_remote_ip(request) == "10.0.0.1" + assert extractor.get_remote_port(request) == "8443" + assert extractor.get_remote_port(SimpleNamespace(host="example.com")) is None diff --git a/tests/test_init_validation.py b/tests/test_init_validation.py index 3323878..381e19c 100644 --- a/tests/test_init_validation.py +++ b/tests/test_init_validation.py @@ -73,3 +73,14 @@ def fail(*args, **kwargs): assert len(errors) == 1 assert "No logging handlers found" in errors[0] + + +def test_config_root_logger_switches_root_handlers_to_json(fresh_json_logging, monkeypatch): + handler = logging.StreamHandler() + monkeypatch.setattr(logging.root, "handlers", [handler]) + fresh_json_logging.init_non_web(enable_json=True) + handler.setFormatter(logging.Formatter()) # undo what init_non_web already did to the handler + + fresh_json_logging.config_root_logger() + + assert isinstance(handler.formatter, fresh_json_logging.JSONLogFormatter) diff --git a/tests/test_request_discovery.py b/tests/test_request_discovery.py new file mode 100644 index 0000000..db43f24 --- /dev/null +++ b/tests/test_request_discovery.py @@ -0,0 +1,75 @@ +"""Call-stack request discovery and cached exception text (issue #137).""" +import logging + +import pytest +from helpers.imports import undo_imports_from_package + + +class FakeRequest: + """Stand-in for a framework request class.""" + + +@pytest.fixture +def request_util(): + from json_logging import util + from json_logging.framework_base import BaseRequestInfoExtractor, BaseResponseInfoExtractor + + class FakeRequestInfoExtractor(BaseRequestInfoExtractor): + @staticmethod + def get_request_class_type(): + return FakeRequest + + @staticmethod + def support_global_request_object(): + return False + + yield util.RequestUtil( + request_info_extractor_class=FakeRequestInfoExtractor, + response_info_extractor_class=BaseResponseInfoExtractor, + ) + undo_imports_from_package("json_logging") + + +def test_request_is_found_in_a_local_named_request(request_util): + request = FakeRequest() + + assert request_util.get_request_from_call_stack() is request + + +def test_request_is_found_in_a_local_named_req(request_util): + req = FakeRequest() + + assert request_util.get_request_from_call_stack() is req + + +def test_request_is_found_in_a_local_with_any_name(request_util): + something_else = FakeRequest() + + assert request_util.get_request_from_call_stack() is something_else + + +def test_request_is_found_in_a_calling_frame(request_util): + def inner(): + return request_util.get_request_from_call_stack() + + request = FakeRequest() + + assert inner() is request + + +def test_request_of_the_wrong_type_is_ignored(request_util): + request = object() # noqa: F841 - a "request" local that is not a request + + assert request_util.get_request_from_call_stack() is None + + +def test_exception_text_is_used_when_there_is_no_exc_info(): + from json_logging.formatters import JSONLogFormatter + + record = logging.LogRecord("exc-text-test", logging.ERROR, __file__, 1, "boom", None, None) + record.exc_text = "cached traceback" + + fields = JSONLogFormatter().get_exc_fields(record) + + assert fields["exc_info"] == "cached traceback" + assert fields["filename"] == record.filename