Skip to content

Commit c42c7b0

Browse files
amosttAygentic
andcommitted
feat(api): wire app assembly with lifespan, Sentry, and error tests [AYG-71]
Move Sentry SDK init into FastAPI lifespan startup (conditional on SENTRY_DSN) so it activates after settings validation. Add structured startup/shutdown log events with service_name, version, environment. Flush Sentry on shutdown for graceful SIGTERM handling. Remove broken legacy route imports from api/main.py (items, login, private, users, utils) that reference deleted deps — entities is the only active router. Rewrite conftest.py with modern test fixtures using Supabase and Clerk dependency overrides against the real assembled app. Legacy fixtures remain guarded for AYG-72 cleanup. Add 7 integration tests verifying unified error response shape (401/404/422/500), request_id propagation, security headers, and X-Request-ID header across the full middleware + handler pipeline. Fixes AYG-71 Related to AYG-64 🤖 Generated by Aygentic Co-Authored-By: Aygentic <noreply@aygentic.com>
1 parent 527d999 commit c42c7b0

File tree

4 files changed

+297
-29
lines changed

4 files changed

+297
-29
lines changed

backend/app/api/main.py

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,6 @@
11
from fastapi import APIRouter
22

3-
from app.api.routes import entities, items, login, private, users, utils
4-
from app.core.config import settings
3+
from app.api.routes import entities
54

65
api_router = APIRouter()
7-
api_router.include_router(login.router)
8-
api_router.include_router(users.router)
9-
api_router.include_router(utils.router)
10-
api_router.include_router(items.router)
116
api_router.include_router(entities.router)
12-
13-
14-
if settings.ENVIRONMENT == "local":
15-
api_router.include_router(private.router)

backend/app/main.py

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import sentry_sdk
55
from fastapi import FastAPI
66
from fastapi.routing import APIRoute
7+
from sentry_sdk.integrations.fastapi import FastApiIntegration
8+
from sentry_sdk.integrations.starlette import StarletteIntegration
79
from starlette.middleware.cors import CORSMiddleware
810

911
from app.api.main import api_router
@@ -33,7 +35,24 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
3335
read_timeout=float(settings.HTTP_CLIENT_TIMEOUT),
3436
max_retries=settings.HTTP_CLIENT_MAX_RETRIES,
3537
)
36-
logger.info("app_startup_complete")
38+
if settings.SENTRY_DSN:
39+
sentry_sdk.init(
40+
dsn=str(settings.SENTRY_DSN),
41+
integrations=[
42+
StarletteIntegration(transaction_style="endpoint"),
43+
FastApiIntegration(transaction_style="endpoint"),
44+
],
45+
enable_tracing=True,
46+
traces_sample_rate=0.1,
47+
send_default_pii=False,
48+
environment=settings.ENVIRONMENT,
49+
)
50+
logger.info(
51+
"app_startup",
52+
service_name=settings.SERVICE_NAME,
53+
version=settings.SERVICE_VERSION,
54+
environment=settings.ENVIRONMENT,
55+
)
3756

3857
yield
3958

@@ -42,16 +61,19 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
4261
await app.state.http_client.close()
4362
except Exception:
4463
logger.exception("http_client_close_failed")
45-
logger.info("app_shutdown_complete")
64+
logger.info(
65+
"app_shutdown",
66+
service_name=settings.SERVICE_NAME,
67+
version=settings.SERVICE_VERSION,
68+
environment=settings.ENVIRONMENT,
69+
)
70+
sentry_sdk.flush(timeout=2.0)
4671

4772

4873
def custom_generate_unique_id(route: APIRoute) -> str:
4974
return f"{route.tags[0]}-{route.name}"
5075

5176

52-
if settings.SENTRY_DSN and settings.ENVIRONMENT != "local":
53-
sentry_sdk.init(dsn=str(settings.SENTRY_DSN), enable_tracing=True)
54-
5577
app = FastAPI(
5678
title=settings.SERVICE_NAME,
5779
openapi_url=f"{settings.API_V1_STR}/openapi.json",

backend/tests/conftest.py

Lines changed: 110 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,128 @@
1+
"""Shared pytest fixtures for the backend test suite.
2+
3+
Modern fixtures (always available):
4+
- mock_supabase — MagicMock Supabase client (function-scoped)
5+
- test_principal — Principal with test user data (function-scoped)
6+
- client — TestClient with Supabase + auth overrides (function-scoped)
7+
- unauthenticated_client — TestClient with only Supabase override (function-scoped)
8+
9+
Legacy fixtures (guarded — remove in AYG-72):
10+
- db, superuser_token_headers, normal_user_token_headers
11+
12+
Env var defaults are set BEFORE any app imports to satisfy module-level
13+
Settings validation (SUPABASE_URL, SUPABASE_SERVICE_KEY, CLERK_SECRET_KEY).
14+
"""
15+
16+
import os
117
from collections.abc import Generator
18+
from unittest.mock import MagicMock
19+
20+
# IMPORTANT: these defaults MUST be set before any `from app.*` imports.
21+
# app/core/config.py validates required settings at import time.
22+
os.environ.setdefault("SUPABASE_URL", "http://localhost:54321")
23+
os.environ.setdefault("SUPABASE_SERVICE_KEY", "test-service-key")
24+
os.environ.setdefault("CLERK_SECRET_KEY", "test-clerk-key")
225

326
import pytest
27+
from fastapi.testclient import TestClient
28+
29+
from app.core.auth import get_current_principal
30+
from app.core.supabase import get_supabase
31+
from app.main import app
32+
from app.models.auth import Principal
33+
34+
# ---------------------------------------------------------------------------
35+
# Constants
36+
# ---------------------------------------------------------------------------
37+
38+
TEST_USER_ID = "user_test123"
39+
TEST_SESSION_ID = "sess_test"
40+
41+
# ---------------------------------------------------------------------------
42+
# Modern fixtures
43+
# ---------------------------------------------------------------------------
44+
45+
46+
@pytest.fixture
47+
def mock_supabase() -> MagicMock:
48+
"""Return a fresh MagicMock Supabase client for each test."""
49+
return MagicMock()
50+
51+
52+
@pytest.fixture
53+
def test_principal() -> Principal:
54+
"""Return a Principal populated with deterministic test data."""
55+
return Principal(
56+
user_id=TEST_USER_ID,
57+
session_id=TEST_SESSION_ID,
58+
roles=[],
59+
org_id=None,
60+
)
61+
62+
63+
@pytest.fixture
64+
def client(
65+
mock_supabase: MagicMock,
66+
test_principal: Principal,
67+
) -> Generator[TestClient, None, None]:
68+
"""TestClient for the full app with Supabase and auth dependencies overridden.
469
5-
# Integration test fixtures require database and legacy settings that are being
6-
# migrated away (AYG-65 through AYG-74). Guard imports so unit tests can run
7-
# without --noconftest while integration fixtures are unavailable.
70+
Both ``get_supabase`` and ``get_current_principal`` are replaced with
71+
test doubles so no real database or Clerk credentials are required.
72+
73+
Dependency overrides are cleared on teardown to prevent state leakage
74+
between tests that share the same ``app`` instance.
75+
"""
76+
app.dependency_overrides[get_supabase] = lambda: mock_supabase
77+
app.dependency_overrides[get_current_principal] = lambda: test_principal
78+
79+
with TestClient(app) as test_client:
80+
yield test_client
81+
82+
app.dependency_overrides.clear()
83+
84+
85+
@pytest.fixture
86+
def unauthenticated_client(
87+
mock_supabase: MagicMock,
88+
) -> Generator[TestClient, None, None]:
89+
"""TestClient with only the Supabase dependency overridden.
90+
91+
The real ``get_current_principal`` dependency runs, so any request to an
92+
authenticated endpoint without a valid Clerk JWT will receive a 401 response.
93+
94+
Dependency overrides are cleared on teardown.
95+
"""
96+
app.dependency_overrides[get_supabase] = lambda: mock_supabase
97+
98+
with TestClient(app, raise_server_exceptions=False) as test_client:
99+
yield test_client
100+
101+
app.dependency_overrides.clear()
102+
103+
104+
# ---------------------------------------------------------------------------
105+
# Legacy fixtures (AYG-72: remove these once legacy test files are deleted)
106+
# ---------------------------------------------------------------------------
107+
108+
# Integration test fixtures require legacy SQLAlchemy/DB dependencies that are
109+
# being migrated away (AYG-65 through AYG-74). Guard imports so unit tests and
110+
# modern integration tests can run even when legacy deps are missing.
8111
try:
9-
from fastapi.testclient import TestClient
10112
from sqlmodel import Session, delete
11113

12114
from app.core.config import settings
13115
from app.core.db import engine, init_db
14-
from app.main import app
15116
from app.models import Item, User
16117
from tests.utils.user import authentication_token_from_email
17118
from tests.utils.utils import get_superuser_token_headers
18119

19-
_INTEGRATION_DEPS_AVAILABLE = True
20-
except (ImportError, AttributeError, Exception):
21-
_INTEGRATION_DEPS_AVAILABLE = False
120+
_LEGACY_DEPS_AVAILABLE = True
121+
except (ImportError, AttributeError):
122+
_LEGACY_DEPS_AVAILABLE = False
22123

23124

24-
if _INTEGRATION_DEPS_AVAILABLE:
125+
if _LEGACY_DEPS_AVAILABLE:
25126

26127
@pytest.fixture(scope="session", autouse=True)
27128
def db() -> Generator[Session, None, None]: # type: ignore[type-arg]
@@ -34,11 +135,6 @@ def db() -> Generator[Session, None, None]: # type: ignore[type-arg]
34135
session.execute(statement)
35136
session.commit()
36137

37-
@pytest.fixture(scope="module")
38-
def client() -> Generator[TestClient, None, None]:
39-
with TestClient(app) as c:
40-
yield c
41-
42138
@pytest.fixture(scope="module")
43139
def superuser_token_headers(client: TestClient) -> dict[str, str]:
44140
return get_superuser_token_headers(client)
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
"""Integration tests for unified error response shape across the full app.
2+
3+
Verifies that ALL error status codes (401, 404, 422, 500) return the unified
4+
JSON error shape when using the full assembled app. Tests the complete
5+
middleware + error handler pipeline end-to-end.
6+
7+
Uses conftest.py fixtures:
8+
- client — authenticated TestClient (Supabase + auth overridden)
9+
- unauthenticated_client — TestClient with only Supabase overridden (401 on auth endpoints)
10+
- mock_supabase — MagicMock Supabase client (shared with client fixture)
11+
12+
Run:
13+
uv run pytest backend/tests/integration/test_error_responses.py -v
14+
"""
15+
16+
import uuid
17+
from unittest.mock import MagicMock
18+
19+
from fastapi.testclient import TestClient
20+
from postgrest.exceptions import APIError
21+
22+
_PREFIX = "/api/v1"
23+
24+
# ---------------------------------------------------------------------------
25+
# Security headers expected on every response
26+
# ---------------------------------------------------------------------------
27+
28+
_EXPECTED_SECURITY_HEADERS: dict[str, str] = {
29+
"X-Content-Type-Options": "nosniff",
30+
"X-Frame-Options": "DENY",
31+
"X-XSS-Protection": "0",
32+
"Referrer-Policy": "strict-origin-when-cross-origin",
33+
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
34+
}
35+
36+
37+
# ---------------------------------------------------------------------------
38+
# TestUnifiedErrorShape — verifies shape for 401 / 404 / 422 / 500
39+
# ---------------------------------------------------------------------------
40+
41+
42+
class TestUnifiedErrorShape:
43+
"""Each error status code returns the unified JSON error shape."""
44+
45+
def test_401_returns_unified_error_shape(
46+
self, unauthenticated_client: TestClient
47+
) -> None:
48+
"""Unauthenticated request returns 401 with unified error body."""
49+
response = unauthenticated_client.get(f"{_PREFIX}/entities/")
50+
51+
assert response.status_code == 401
52+
body = response.json()
53+
assert "error" in body
54+
assert "message" in body
55+
assert "code" in body
56+
assert "request_id" in body
57+
assert body["error"] == "UNAUTHORIZED"
58+
59+
def test_404_returns_unified_error_shape(
60+
self, client: TestClient, mock_supabase: MagicMock
61+
) -> None:
62+
"""Request for a non-existent entity returns 404 with ENTITY_NOT_FOUND."""
63+
mock_supabase.table.return_value.select.return_value.eq.return_value.eq.return_value.single.return_value.execute.side_effect = APIError(
64+
{"message": "No rows found", "code": "PGRST116"}
65+
)
66+
67+
nonexistent_id = str(uuid.uuid4())
68+
response = client.get(f"{_PREFIX}/entities/{nonexistent_id}")
69+
70+
assert response.status_code == 404
71+
body = response.json()
72+
assert "error" in body
73+
assert "message" in body
74+
assert "code" in body
75+
assert "request_id" in body
76+
assert body["error"] == "NOT_FOUND"
77+
assert body["code"] == "ENTITY_NOT_FOUND"
78+
79+
def test_422_returns_unified_error_shape(self, client: TestClient) -> None:
80+
"""POST with missing required field returns 422 with details array."""
81+
response = client.post(
82+
f"{_PREFIX}/entities/",
83+
json={"description": "no title"},
84+
)
85+
86+
assert response.status_code == 422
87+
body = response.json()
88+
assert "error" in body
89+
assert "message" in body
90+
assert "code" in body
91+
assert "request_id" in body
92+
assert "details" in body
93+
assert body["error"] == "VALIDATION_ERROR"
94+
assert isinstance(body["details"], list)
95+
assert len(body["details"]) >= 1
96+
for detail in body["details"]:
97+
assert "field" in detail
98+
assert "message" in detail
99+
assert "type" in detail
100+
101+
def test_500_returns_unified_error_shape(
102+
self, client: TestClient, mock_supabase: MagicMock
103+
) -> None:
104+
"""Unhandled server exception returns 500 without leaking internal details."""
105+
mock_supabase.table.side_effect = RuntimeError("db crash")
106+
107+
response = client.get(f"{_PREFIX}/entities/")
108+
109+
assert response.status_code == 500
110+
body = response.json()
111+
assert "error" in body
112+
assert "message" in body
113+
assert "code" in body
114+
assert "request_id" in body
115+
assert body["error"] == "INTERNAL_ERROR"
116+
assert "db crash" not in body["message"]
117+
118+
119+
# ---------------------------------------------------------------------------
120+
# TestErrorResponseMetadata — request_id and security headers
121+
# ---------------------------------------------------------------------------
122+
123+
124+
class TestErrorResponseMetadata:
125+
"""Error responses include valid request_id and security headers."""
126+
127+
def test_error_response_includes_valid_request_id(
128+
self, unauthenticated_client: TestClient
129+
) -> None:
130+
"""request_id in error body is a valid UUID string."""
131+
response = unauthenticated_client.get(f"{_PREFIX}/entities/")
132+
133+
body = response.json()
134+
assert "request_id" in body
135+
# Raises ValueError if not a valid UUID — that is the assertion.
136+
uuid.UUID(body["request_id"])
137+
138+
def test_error_response_has_security_headers(
139+
self, unauthenticated_client: TestClient
140+
) -> None:
141+
"""All five security headers are present on error responses."""
142+
response = unauthenticated_client.get(f"{_PREFIX}/entities/")
143+
144+
for header, expected_value in _EXPECTED_SECURITY_HEADERS.items():
145+
assert header in response.headers, f"Missing security header: {header}"
146+
assert response.headers[header] == expected_value, (
147+
f"Header {header!r}: expected {expected_value!r}, "
148+
f"got {response.headers[header]!r}"
149+
)
150+
151+
def test_error_response_has_request_id_header(
152+
self, unauthenticated_client: TestClient
153+
) -> None:
154+
"""X-Request-ID response header is present and is a valid UUID."""
155+
response = unauthenticated_client.get(f"{_PREFIX}/entities/")
156+
157+
assert "X-Request-ID" in response.headers, "Missing X-Request-ID header"
158+
# Raises ValueError if not a valid UUID — that is the assertion.
159+
uuid.UUID(response.headers["X-Request-ID"])

0 commit comments

Comments
 (0)