"""Dual-sink logging config and the static environment processor (ADR-0011). `configure_logging` mutates global logging state (`logging.config.dictConfig`, `structlog.configure`), so these tests assert on the *handler configuration it builds*, plus one end-to-end capture per sink, rather than trying to isolate process-global state across tests. """ import json import logging import pytest import structlog from src.config import AppLimitSettings, LoggingSettings from src.infrastructure.observability.logging import configure_logging pytestmark = pytest.mark.unit # Cross-test pollution from configure_logging() (cache_logger_on_first_use=True # etc.) is reset by the autouse fixture in tests/conftest.py after every test, # not just this module's -- these tests call the real configure_logging() # directly and need the same cleanup any other test does. def test_configure_logging_without_file_path_registers_only_console(tmp_path) -> None: configure_logging(LoggingSettings(file_path=None), AppLimitSettings()) root = logging.getLogger() handler_names = {type(h).__name__ for h in root.handlers} assert handler_names == {"StreamHandler"} def test_configure_logging_with_file_path_adds_a_rotating_file_handler(tmp_path) -> None: log_file = tmp_path / "app.log" configure_logging( LoggingSettings(file_path=str(log_file), file_max_bytes=1024, file_backup_count=2), AppLimitSettings(), ) root = logging.getLogger() handler_names = {type(h).__name__ for h in root.handlers} assert handler_names == {"StreamHandler", "RotatingFileHandler"} def test_file_sink_is_json_even_when_console_is_not(tmp_path, capsys) -> None: """The two sinks render independently: console stays human-readable while the file stays JSON, regardless of LOG_JSON_FORMAT. """ log_file = tmp_path / "app.log" configure_logging( LoggingSettings(json_format=False, file_path=str(log_file)), AppLimitSettings(), ) structlog.get_logger("test").info("logging.dual_sink.test", widget_id="abc123") console_output = capsys.readouterr().out file_output = log_file.read_text().strip() # Console: human-readable, not parseable JSON. with pytest.raises(json.JSONDecodeError): json.loads(console_output) assert "logging.dual_sink.test" in console_output # File: valid JSON with the same event. file_event = json.loads(file_output) assert file_event["event"] == "logging.dual_sink.test" assert file_event["widget_id"] == "abc123" def test_every_event_carries_env_and_service_version(tmp_path, capsys) -> None: """Static environment context, not a per-request contextvar -- it must show up on an event with no request in flight. """ configure_logging( LoggingSettings(json_format=True, file_path=None), AppLimitSettings(env="staging", service_version="abc1234"), ) structlog.get_logger("test").info("logging.env_context.test") event = json.loads(capsys.readouterr().out.strip()) assert event["env"] == "staging" assert event["service_version"] == "abc1234" def test_env_context_survives_request_contextvar_clearing(tmp_path, capsys) -> None: """The bug this design avoids: if env/service_version were bound via contextvars before a request, RequestIdMiddleware's clear_contextvars() would wipe them. They must still appear after a clear. """ configure_logging( LoggingSettings(json_format=True, file_path=None), AppLimitSettings(env="prod", service_version="v42"), ) structlog.contextvars.clear_contextvars() structlog.contextvars.bind_contextvars(request_id="req-1") structlog.get_logger("test").info("logging.post_clear.test") event = json.loads(capsys.readouterr().out.strip()) assert event["env"] == "prod" assert event["service_version"] == "v42" assert event["request_id"] == "req-1"