| """Unit tests for api.logger. |
| |
| We never connect to a real database — the engine and connection are |
| replaced by mocks. The point is to verify that: |
| - log_prediction silently no-ops when no engine is configured; |
| - it scrubs NaN/Inf to None in the JSONB payloads; |
| - it builds an INSERT with the right values; |
| - it never raises, even when the DB fails. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from unittest.mock import MagicMock |
|
|
| import numpy as np |
| import pandas as pd |
| import pytest |
|
|
| from api import db, logger as api_logger |
|
|
|
|
| @pytest.fixture(autouse=True) |
| def _reset_engine(): |
| db.reset_engine() |
| yield |
| db.reset_engine() |
|
|
|
|
| def _call(features: pd.DataFrame | None = None, **overrides): |
| defaults = dict( |
| sk_id_curr=100002, |
| client_known=True, |
| raw_input={"CODE_GENDER": "M", "AMT_INCOME_TOTAL": 200_000.0}, |
| features=features if features is not None else pd.DataFrame([{"EXT_SOURCE_1": 0.3}]), |
| probability_default=0.27, |
| decision="GRANTED", |
| threshold=0.33, |
| model_version="test-1", |
| latency_ms=42, |
| ) |
| defaults.update(overrides) |
| api_logger.log_prediction(**defaults) |
|
|
|
|
| def test_noop_when_engine_unset() -> None: |
| """No engine = no DB call = no error.""" |
| _call() |
|
|
|
|
| def _build_mock_engine(captured: dict, monkeypatch, fake_id: int = 42) -> None: |
| """Wire a MagicMock engine that captures INSERT and UPDATE payloads. |
| |
| log_prediction now performs two roundtrips: |
| 1. INSERT ... RETURNING id -> mock returns fake_id from scalar_one() |
| 2. UPDATE ... SET db_log_ms WHERE id = X |
| The mock distinguishes them by inspecting the compiled SQL string. |
| """ |
| fake_engine = MagicMock() |
|
|
| class _Result: |
| def scalar_one(self): |
| return fake_id |
|
|
| class _Conn: |
| def execute(self, stmt): |
| compiled = stmt.compile() |
| params = compiled.params |
| if "INSERT" in str(compiled).upper(): |
| captured["insert"] = params |
| else: |
| captured["update"] = params |
| return _Result() |
|
|
| class _Ctx: |
| def __enter__(self): |
| return _Conn() |
|
|
| def __exit__(self, *a): |
| return False |
|
|
| fake_engine.begin.return_value = _Ctx() |
| monkeypatch.setattr(db, "_engine", fake_engine) |
|
|
|
|
| def test_insert_payload_contains_expected_fields(monkeypatch) -> None: |
| captured: dict = {} |
| _build_mock_engine(captured, monkeypatch) |
|
|
| _call( |
| features=pd.DataFrame([{"EXT_SOURCE_1": 0.42, "FOO": np.nan, "BAR": np.inf}]), |
| feature_assembly_ms=12, |
| inference_ms=3, |
| inference_cpu_ms=3, |
| plumbing_ms=1, |
| ) |
|
|
| values = captured["insert"] |
| assert values["sk_id_curr"] == 100002 |
| assert values["decision"] == "GRANTED" |
| assert values["probability_default"] == 0.27 |
| assert values["client_known"] is True |
| assert values["features"]["EXT_SOURCE_1"] == pytest.approx(0.42) |
| |
| assert values["features"]["FOO"] is None |
| assert values["features"]["BAR"] is None |
| |
| assert values["feature_assembly_ms"] == 12 |
| assert values["inference_ms"] == 3 |
| assert values["inference_cpu_ms"] == 3 |
| assert values["plumbing_ms"] == 1 |
| |
| assert values["db_log_ms"] is None |
| |
| assert "update" in captured |
| assert isinstance(captured["update"]["db_log_ms"], int) |
| assert captured["update"]["db_log_ms"] >= 0 |
|
|
|
|
| def test_db_failure_is_swallowed(monkeypatch, caplog) -> None: |
| fake_engine = MagicMock() |
| fake_engine.begin.side_effect = RuntimeError("connection refused") |
| monkeypatch.setattr(db, "_engine", fake_engine) |
|
|
| _call() |
| assert any("Failed to log prediction" in rec.message for rec in caplog.records) |
|
|
|
|
| def test_error_path_logs_status_and_message(monkeypatch) -> None: |
| captured: dict = {} |
| _build_mock_engine(captured, monkeypatch) |
|
|
| api_logger.log_prediction( |
| sk_id_curr=999, |
| client_known=False, |
| raw_input={"CODE_GENDER": "F"}, |
| features=None, |
| probability_default=None, |
| decision=None, |
| threshold=0.33, |
| model_version="test-1", |
| latency_ms=5, |
| status_code=500, |
| error_message="boom", |
| ) |
|
|
| values = captured["insert"] |
| assert values["status_code"] == 500 |
| assert values["error_message"] == "boom" |
| assert values["decision"] == "ERROR" |
| assert values["features"] == {} |
| |
| assert values["feature_assembly_ms"] is None |
| assert values["inference_ms"] is None |
| assert values["inference_cpu_ms"] is None |
| assert values["plumbing_ms"] is None |
| |
| assert "update" in captured |
| assert isinstance(captured["update"]["db_log_ms"], int) |
|
|