| """Factory import regression test (T14 CI gate). |
| |
| Ensures the FastAPI app factory can boot cleanly with >= 40 routes |
| mounted. Catches regressions where new imports break the factory. |
| |
| If you see this test failing: |
| 1. Run `python scripts/export_openapi.py --check --min-paths 40` |
| 2. Look at "router_mount_failed" warnings for the missing module |
| 3. Either create the module (stub OK for CI) or remove from ROUTER_MODULES |
| """ |
|
|
| from __future__ import annotations |
|
|
| import sys |
| from pathlib import Path |
|
|
| import pytest |
|
|
| |
| sys.path.insert(0, str(Path(__file__).resolve().parents[2])) |
|
|
|
|
| @pytest.fixture(scope="module") |
| def app(): |
| """Build the FastAPI app once per module.""" |
| from app.factory import create_app |
|
|
| return create_app() |
|
|
|
|
| def test_factory_boots_without_error(app) -> None: |
| """Factory must boot without raising — any import failure = bug.""" |
| assert app is not None |
|
|
|
|
| def test_factory_has_minimum_routes(app) -> None: |
| """App must expose >= 40 routes (T14 G09 gate).""" |
| routes = [r for r in app.routes if hasattr(r, "path")] |
| assert len(routes) >= 40, ( |
| f"Only {len(routes)} routes mounted. " |
| f"Need >= 40 for T14 gate. Check factory logs for 'router_mount_failed'." |
| ) |
|
|
|
|
| def test_health_endpoint_responds(app) -> None: |
| """GET /health must return 200 or 503 (not 500).""" |
| from fastapi.testclient import TestClient |
|
|
| client = TestClient(app) |
| r = client.get("/health") |
| assert r.status_code in (200, 503), f"Got {r.status_code}: {r.text}" |
|
|
|
|
| def test_liveness_endpoint_always_ok(app) -> None: |
| """GET /live must always return 200 (no dependency checks).""" |
| from fastapi.testclient import TestClient |
|
|
| client = TestClient(app) |
| r = client.get("/live") |
| assert r.status_code == 200 |
| assert r.json() == {"status": "alive"} |
|
|
|
|
| def test_openapi_schema_is_valid(app) -> None: |
| """GET /openapi.json must return a valid OpenAPI 3.x schema.""" |
| from fastapi.testclient import TestClient |
|
|
| client = TestClient(app) |
| r = client.get("/openapi.json") |
| assert r.status_code == 200 |
| schema = r.json() |
| assert "openapi" in schema |
| assert schema["openapi"].startswith("3.") |
| assert "paths" in schema |
| assert len(schema["paths"]) >= 40 |
|
|
|
|
| def test_metrics_endpoint_returns_prometheus_format(app) -> None: |
| """GET /metrics must return text/plain with Prometheus exposition format.""" |
| from fastapi.testclient import TestClient |
|
|
| client = TestClient(app) |
| r = client.get("/metrics") |
| assert r.status_code == 200 |
| assert "text/plain" in r.headers.get("content-type", "") |
| |
| assert "# HELP" in r.text or "# TYPE" in r.text |
|
|
|
|
| def test_homepage_returns_service_metadata(app) -> None: |
| """GET / must return service name + version.""" |
| from fastapi.testclient import TestClient |
|
|
| client = TestClient(app) |
| r = client.get("/") |
| assert r.status_code == 200 |
| body = r.json() |
| assert "service" in body |
| assert "version" in body |
| assert body["service"] == "RMI Backend" |
|
|
|
|
| def test_alerts_endpoint_list(app) -> None: |
| """GET /alerts must return 200 (stub returns empty list). |
| |
| Note: the factory mounts each router directly via app.include_router, |
| so /alerts (not /api/v1/alerts) is the actual path. The /api/v1 |
| aggregator in app/api/v1/__init__.py is for future use. |
| """ |
| from fastapi.testclient import TestClient |
|
|
| client = TestClient(app) |
| r = client.get("/alerts") |
| assert r.status_code == 200, f"Got {r.status_code}: {r.text}" |
| body = r.json() |
| assert body["count"] == 0 |
| assert body["items"] == [] |
|
|