| """End-to-end: POST /predict -> SELECT FROM predictions_log_test. |
| |
| Runs only when ``TEST_DATABASE_URL`` is set in the environment (CI secret |
| or local override). Without it, the whole module skips gracefully so the |
| default test suite stays self-contained. |
| |
| Every test cleans up after itself by deleting its own row. The CI workflow |
| also runs a TRUNCATE at job end as a safety net. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import importlib |
| import os |
| import uuid |
|
|
| import pytest |
| from fastapi.testclient import TestClient |
| from sqlalchemy import create_engine, text |
|
|
| TEST_DATABASE_URL = os.getenv("TEST_DATABASE_URL") |
| TEST_TABLE = "predictions_log_test" |
|
|
| pytestmark = pytest.mark.skipif( |
| not TEST_DATABASE_URL, |
| reason="TEST_DATABASE_URL not set; integration test against Supabase skipped.", |
| ) |
|
|
|
|
| @pytest.fixture |
| def test_engine(): |
| """Engine pointing at the integration DB. Truncates the test table once.""" |
| engine = create_engine(TEST_DATABASE_URL, future=True) |
| |
| from sqlalchemy import MetaData |
| from database.models import build_predictions_log_table |
|
|
| metadata = MetaData() |
| build_predictions_log_table(TEST_TABLE, metadata) |
| metadata.create_all(engine, checkfirst=True) |
| yield engine |
| engine.dispose() |
|
|
|
|
| @pytest.fixture |
| def client(monkeypatch, patched_settings, test_engine): |
| """FastAPI TestClient with DATABASE_URL pointed at the integration DB |
| and the table pointed at predictions_log_test.""" |
| monkeypatch.setenv("DATABASE_URL", TEST_DATABASE_URL) |
| monkeypatch.setenv("OC_P8_PREDICTIONS_TABLE", TEST_TABLE) |
|
|
| |
| |
| import api.settings as s |
|
|
| importlib.reload(s) |
| from api import db |
|
|
| db.reset_engine() |
|
|
| from api.main import app |
|
|
| with TestClient(app) as tc: |
| yield tc |
|
|
| db.reset_engine() |
|
|
|
|
| def test_predict_inserts_row(client, valid_payload, test_engine): |
| |
| sk_id = 900_000 + int(uuid.uuid4().int % 100_000) |
| payload = dict(valid_payload, SK_ID_CURR=sk_id) |
| try: |
| resp = client.post("/predict", json=payload) |
| assert resp.status_code == 200 |
|
|
| with test_engine.connect() as conn: |
| row = conn.execute( |
| text( |
| f"SELECT decision, probability_default, status_code, features, " |
| f"feature_assembly_ms, inference_ms, inference_cpu_ms, " |
| f"plumbing_ms, db_log_ms " |
| f"FROM {TEST_TABLE} WHERE sk_id_curr = :sk_id" |
| ), |
| {"sk_id": sk_id}, |
| ).one() |
| assert row.status_code == 200 |
| assert row.decision in ("GRANTED", "REFUSED") |
| assert 0.0 <= row.probability_default <= 1.0 |
| assert isinstance(row.features, dict) and len(row.features) > 0 |
| |
| |
| |
| |
| assert row.feature_assembly_ms is not None and 0 <= row.feature_assembly_ms < 10_000 |
| assert row.inference_ms is not None and 0 <= row.inference_ms < 10_000 |
| |
| |
| assert row.inference_cpu_ms is not None and row.inference_cpu_ms >= 0 |
| |
| |
| |
| assert row.plumbing_ms is not None and -3 <= row.plumbing_ms < 10_000 |
| |
| |
| assert row.db_log_ms is not None and 0 <= row.db_log_ms < 10_000 |
| finally: |
| with test_engine.begin() as conn: |
| conn.execute( |
| text(f"DELETE FROM {TEST_TABLE} WHERE sk_id_curr = :sk_id"), |
| {"sk_id": sk_id}, |
| ) |
|
|
|
|
| def test_validation_error_is_still_logged(client, test_engine): |
| """Pydantic 422s should produce a row with status_code=422 so we don't |
| lose visibility on malformed traffic.""" |
| sk_id = 900_000 + int(uuid.uuid4().int % 100_000) |
| bad_payload = {"SK_ID_CURR": sk_id} |
| try: |
| resp = client.post("/predict", json=bad_payload) |
| assert resp.status_code == 422 |
| |
| |
| |
| |
| with test_engine.connect() as conn: |
| count = conn.execute( |
| text( |
| f"SELECT COUNT(*) FROM {TEST_TABLE} WHERE sk_id_curr = :sk_id" |
| ), |
| {"sk_id": sk_id}, |
| ).scalar() |
| assert count == 0 |
| finally: |
| with test_engine.begin() as conn: |
| conn.execute( |
| text(f"DELETE FROM {TEST_TABLE} WHERE sk_id_curr = :sk_id"), |
| {"sk_id": sk_id}, |
| ) |
|
|