refactor: engineering improvements -- API versioning, structured logging, Alembic, error standardization, test coverage
- API versioning: all REST endpoints prefixed with /api/v1/ - Structured logging: replaced stdlib logging with structlog (console/JSON modes) - Alembic migrations: versioned DB schema with initial migration - Error standardization: global exception handlers for consistent envelope format - Interrupt cleanup: asyncio background task for expired interrupt removal - Integration tests: +30 tests (analytics, replay, openapi, error, session APIs) - Frontend tests: +57 tests (all components, pages, useWebSocket hook) - Backend: 557 tests, 89.75% coverage | Frontend: 80 tests, 16 test files
This commit is contained in:
@@ -174,7 +174,7 @@ def create_e2e_app(
|
||||
app.state.analytics_recorder = AsyncMock()
|
||||
app.state.conversation_tracker = AsyncMock()
|
||||
|
||||
@app.get("/api/health")
|
||||
@app.get("/api/v1/health")
|
||||
def health_check() -> dict:
|
||||
return {"status": "ok", "version": "test"}
|
||||
|
||||
|
||||
@@ -341,7 +341,7 @@ class TestChatEdgeCases:
|
||||
def test_health_endpoint(self) -> None:
|
||||
app = create_e2e_app()
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/health")
|
||||
resp = client.get("/api/v1/health")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ class TestFlow5OpenAPIImport:
|
||||
with TestClient(app) as client:
|
||||
# Step 1: Start import job
|
||||
resp = client.post(
|
||||
"/api/openapi/import",
|
||||
"/api/v1/openapi/import",
|
||||
json={"url": "https://api.example.com/openapi.json"},
|
||||
)
|
||||
assert resp.status_code == 202
|
||||
@@ -71,7 +71,7 @@ class TestFlow5OpenAPIImport:
|
||||
job_id = body["job_id"]
|
||||
|
||||
# Step 2: Check job status (still pending since background task hasn't run)
|
||||
resp = client.get(f"/api/openapi/jobs/{job_id}")
|
||||
resp = client.get(f"/api/v1/openapi/jobs/{job_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["job_id"] == job_id
|
||||
|
||||
@@ -99,7 +99,7 @@ class TestFlow5OpenAPIImport:
|
||||
|
||||
with TestClient(app) as client:
|
||||
# Step 1: Get classifications
|
||||
resp = client.get(f"/api/openapi/jobs/{job_id}/classifications")
|
||||
resp = client.get(f"/api/v1/openapi/jobs/{job_id}/classifications")
|
||||
assert resp.status_code == 200
|
||||
classifications = resp.json()
|
||||
assert len(classifications) == 2
|
||||
@@ -118,7 +118,7 @@ class TestFlow5OpenAPIImport:
|
||||
|
||||
# Step 2: Update a classification
|
||||
resp = client.put(
|
||||
f"/api/openapi/jobs/{job_id}/classifications/0",
|
||||
f"/api/v1/openapi/jobs/{job_id}/classifications/0",
|
||||
json={
|
||||
"access_type": "write",
|
||||
"needs_interrupt": True,
|
||||
@@ -132,7 +132,7 @@ class TestFlow5OpenAPIImport:
|
||||
assert updated["agent_group"] == "order_actions"
|
||||
|
||||
# Step 3: Approve the job
|
||||
resp = client.post(f"/api/openapi/jobs/{job_id}/approve")
|
||||
resp = client.post(f"/api/v1/openapi/jobs/{job_id}/approve")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "approved"
|
||||
|
||||
@@ -140,14 +140,14 @@ class TestFlow5OpenAPIImport:
|
||||
app = create_e2e_app()
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/openapi/jobs/nonexistent")
|
||||
resp = client.get("/api/v1/openapi/jobs/nonexistent")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_import_invalid_url_returns_422(self) -> None:
|
||||
app = create_e2e_app()
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.post("/api/openapi/import", json={"url": "not-a-url"})
|
||||
resp = client.post("/api/v1/openapi/import", json={"url": "not-a-url"})
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_classification_index_out_of_range(self) -> None:
|
||||
@@ -166,7 +166,7 @@ class TestFlow5OpenAPIImport:
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.put(
|
||||
f"/api/openapi/jobs/{job_id}/classifications/99",
|
||||
f"/api/v1/openapi/jobs/{job_id}/classifications/99",
|
||||
json={
|
||||
"access_type": "read",
|
||||
"needs_interrupt": False,
|
||||
@@ -191,7 +191,7 @@ class TestFlow5OpenAPIImport:
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.put(
|
||||
f"/api/openapi/jobs/{job_id}/classifications/0",
|
||||
f"/api/v1/openapi/jobs/{job_id}/classifications/0",
|
||||
json={
|
||||
"access_type": "read",
|
||||
"needs_interrupt": False,
|
||||
|
||||
@@ -98,7 +98,7 @@ class TestFlow6ReplayConversation:
|
||||
app = create_e2e_app(pool=pool)
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/conversations")
|
||||
resp = client.get("/api/v1/conversations")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
@@ -124,7 +124,7 @@ class TestFlow6ReplayConversation:
|
||||
app = create_e2e_app(pool=pool)
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/conversations", params={"page": 1, "per_page": 2})
|
||||
resp = client.get("/api/v1/conversations", params={"page": 1, "per_page": 2})
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
@@ -139,7 +139,7 @@ class TestFlow6ReplayConversation:
|
||||
app = create_e2e_app(pool=pool)
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/replay/nonexistent-thread")
|
||||
resp = client.get("/api/v1/replay/nonexistent-thread")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_replay_invalid_thread_id_format(self) -> None:
|
||||
@@ -147,7 +147,7 @@ class TestFlow6ReplayConversation:
|
||||
|
||||
with TestClient(app) as client:
|
||||
# Thread ID with special chars fails regex validation
|
||||
resp = client.get("/api/replay/invalid%20thread%21%40")
|
||||
resp = client.get("/api/v1/replay/invalid%20thread%21%40")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@@ -158,21 +158,21 @@ class TestAnalyticsDashboard:
|
||||
app = create_e2e_app()
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/analytics", params={"range": "invalid"})
|
||||
resp = client.get("/api/v1/analytics", params={"range": "invalid"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_analytics_range_too_large(self) -> None:
|
||||
app = create_e2e_app()
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/analytics", params={"range": "999d"})
|
||||
resp = client.get("/api/v1/analytics", params={"range": "999d"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_analytics_range_zero_rejected(self) -> None:
|
||||
app = create_e2e_app()
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/analytics", params={"range": "0d"})
|
||||
resp = client.get("/api/v1/analytics", params={"range": "0d"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ class TestFullUserJourney:
|
||||
assert any(m["type"] == "message_complete" for m in messages)
|
||||
|
||||
# Step 2: Check conversations endpoint
|
||||
resp = client.get("/api/conversations")
|
||||
resp = client.get("/api/v1/conversations")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
@@ -226,5 +226,5 @@ class TestFullUserJourney:
|
||||
)
|
||||
|
||||
# Step 3: Health check still works
|
||||
resp = client.get("/api/health")
|
||||
resp = client.get("/api/v1/health")
|
||||
assert resp.status_code == 200
|
||||
|
||||
183
backend/tests/integration/test_analytics_api.py
Normal file
183
backend/tests/integration/test_analytics_api.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""Integration tests for the /api/v1/analytics endpoint.
|
||||
|
||||
Tests the full API layer (routing, parameter validation, serialization,
|
||||
error handling) with a mocked database pool.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.analytics.models import AnalyticsResult, InterruptStats
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_SAMPLE_RESULT = AnalyticsResult(
|
||||
range="7d",
|
||||
total_conversations=42,
|
||||
resolution_rate=0.85,
|
||||
escalation_rate=0.05,
|
||||
avg_turns_per_conversation=3.2,
|
||||
avg_cost_per_conversation_usd=0.012,
|
||||
agent_usage=(),
|
||||
interrupt_stats=InterruptStats(total=10, approved=7, rejected=2, expired=1),
|
||||
)
|
||||
|
||||
|
||||
def _build_app():
|
||||
"""Build a minimal FastAPI app with the analytics router and mocked deps."""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.analytics.api import router as analytics_router
|
||||
from app.api_utils import envelope
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(analytics_router)
|
||||
|
||||
@test_app.exception_handler(Exception)
|
||||
async def _catch_all(request, exc):
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=envelope(None, success=False, error="Internal server error"),
|
||||
)
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
@test_app.exception_handler(HTTPException)
|
||||
async def _http_exc(request, exc):
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=envelope(None, success=False, error=exc.detail),
|
||||
)
|
||||
|
||||
@test_app.exception_handler(RequestValidationError)
|
||||
async def _validation_exc(request, exc):
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=envelope(None, success=False, error=str(exc)),
|
||||
)
|
||||
|
||||
# No admin_api_key set -> auth is skipped
|
||||
test_app.state.settings = MagicMock(admin_api_key="")
|
||||
test_app.state.pool = MagicMock()
|
||||
|
||||
return test_app
|
||||
|
||||
|
||||
class TestAnalyticsValidRange:
|
||||
"""Test analytics endpoint with valid range parameters."""
|
||||
|
||||
async def test_valid_range_7d_returns_envelope(self) -> None:
|
||||
"""GET /api/v1/analytics?range=7d returns success envelope with data."""
|
||||
test_app = _build_app()
|
||||
with patch(
|
||||
"app.analytics.api.get_analytics",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_SAMPLE_RESULT,
|
||||
):
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=test_app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.get("/api/v1/analytics", params={"range": "7d"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
assert body["error"] is None
|
||||
assert body["data"]["total_conversations"] == 42
|
||||
assert body["data"]["resolution_rate"] == 0.85
|
||||
|
||||
async def test_default_range_returns_success(self) -> None:
|
||||
"""GET /api/v1/analytics with no range param defaults to 7d."""
|
||||
test_app = _build_app()
|
||||
with patch(
|
||||
"app.analytics.api.get_analytics",
|
||||
new_callable=AsyncMock,
|
||||
return_value=_SAMPLE_RESULT,
|
||||
) as mock_get:
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=test_app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.get("/api/v1/analytics")
|
||||
|
||||
assert resp.status_code == 200
|
||||
# Verify default range of 7 days was passed
|
||||
mock_get.assert_called_once()
|
||||
call_args = mock_get.call_args
|
||||
assert call_args[1].get("range_days", call_args[0][1] if len(call_args[0]) > 1 else None) in (7, None) or call_args[0][1] == 7
|
||||
|
||||
async def test_large_range_365d_works(self) -> None:
|
||||
"""GET /api/v1/analytics?range=365d is accepted (max boundary)."""
|
||||
test_app = _build_app()
|
||||
result = AnalyticsResult(
|
||||
range="365d",
|
||||
total_conversations=1000,
|
||||
resolution_rate=0.9,
|
||||
escalation_rate=0.02,
|
||||
avg_turns_per_conversation=4.0,
|
||||
avg_cost_per_conversation_usd=0.01,
|
||||
agent_usage=(),
|
||||
interrupt_stats=InterruptStats(),
|
||||
)
|
||||
with patch(
|
||||
"app.analytics.api.get_analytics",
|
||||
new_callable=AsyncMock,
|
||||
return_value=result,
|
||||
):
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=test_app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.get("/api/v1/analytics", params={"range": "365d"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["success"] is True
|
||||
|
||||
|
||||
class TestAnalyticsInvalidRange:
|
||||
"""Test analytics endpoint with invalid range parameters."""
|
||||
|
||||
async def test_invalid_range_format_returns_400(self) -> None:
|
||||
"""GET /api/v1/analytics?range=abc returns 400 error envelope."""
|
||||
test_app = _build_app()
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=test_app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.get("/api/v1/analytics", params={"range": "abc"})
|
||||
|
||||
assert resp.status_code == 400
|
||||
body = resp.json()
|
||||
assert body["success"] is False
|
||||
assert body["data"] is None
|
||||
assert "Invalid range format" in body["error"]
|
||||
|
||||
async def test_zero_day_range_returns_400(self) -> None:
|
||||
"""GET /api/v1/analytics?range=0d returns 400 because 0 is below minimum."""
|
||||
test_app = _build_app()
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=test_app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.get("/api/v1/analytics", params={"range": "0d"})
|
||||
|
||||
assert resp.status_code == 400
|
||||
body = resp.json()
|
||||
assert body["success"] is False
|
||||
assert "between 1 and 365" in body["error"]
|
||||
|
||||
async def test_range_exceeding_max_returns_400(self) -> None:
|
||||
"""GET /api/v1/analytics?range=999d returns 400 because it exceeds 365."""
|
||||
test_app = _build_app()
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=test_app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.get("/api/v1/analytics", params={"range": "999d"})
|
||||
|
||||
assert resp.status_code == 400
|
||||
body = resp.json()
|
||||
assert body["success"] is False
|
||||
assert "between 1 and 365" in body["error"]
|
||||
128
backend/tests/integration/test_error_responses.py
Normal file
128
backend/tests/integration/test_error_responses.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Integration tests for global error handling and envelope format consistency.
|
||||
|
||||
Tests that all error responses from the FastAPI app conform to the
|
||||
standard envelope: {"success": false, "data": null, "error": "..."}.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _build_app():
|
||||
"""Build the actual FastAPI app with exception handlers but mocked state."""
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.analytics.api import router as analytics_router
|
||||
from app.api_utils import envelope
|
||||
from app.replay.api import router as replay_router
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(analytics_router)
|
||||
test_app.include_router(replay_router)
|
||||
|
||||
@test_app.exception_handler(HTTPException)
|
||||
async def _http_exc(request, exc):
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=envelope(None, success=False, error=exc.detail),
|
||||
)
|
||||
|
||||
@test_app.exception_handler(RequestValidationError)
|
||||
async def _validation_exc(request, exc):
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=envelope(None, success=False, error=str(exc)),
|
||||
)
|
||||
|
||||
@test_app.exception_handler(Exception)
|
||||
async def _catch_all(request, exc):
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=envelope(None, success=False, error="Internal server error"),
|
||||
)
|
||||
|
||||
@test_app.get("/api/v1/health")
|
||||
def health_check():
|
||||
return {"status": "ok", "version": "0.6.0"}
|
||||
|
||||
test_app.state.settings = MagicMock(admin_api_key="")
|
||||
test_app.state.pool = MagicMock()
|
||||
|
||||
return test_app
|
||||
|
||||
|
||||
class TestEnvelopeFormat:
|
||||
"""Tests that error responses consistently follow envelope format."""
|
||||
|
||||
async def test_http_400_produces_envelope(self) -> None:
|
||||
"""A 400 error returns standard envelope with success=false."""
|
||||
test_app = _build_app()
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=test_app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.get("/api/v1/analytics", params={"range": "invalid"})
|
||||
|
||||
assert resp.status_code == 400
|
||||
body = resp.json()
|
||||
assert body["success"] is False
|
||||
assert body["data"] is None
|
||||
assert isinstance(body["error"], str)
|
||||
assert len(body["error"]) > 0
|
||||
|
||||
async def test_validation_error_produces_422_envelope(self) -> None:
|
||||
"""Invalid query param type returns 422 with envelope format."""
|
||||
test_app = _build_app()
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=test_app), base_url="http://test"
|
||||
) as client:
|
||||
# page must be >= 1; passing 0 triggers validation error
|
||||
resp = await client.get("/api/v1/conversations", params={"page": 0})
|
||||
|
||||
assert resp.status_code == 422
|
||||
body = resp.json()
|
||||
assert body["success"] is False
|
||||
assert body["data"] is None
|
||||
assert isinstance(body["error"], str)
|
||||
|
||||
async def test_all_error_fields_present(self) -> None:
|
||||
"""Error envelope contains exactly success, data, and error keys."""
|
||||
test_app = _build_app()
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=test_app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.get("/api/v1/analytics", params={"range": "bad"})
|
||||
|
||||
body = resp.json()
|
||||
assert set(body.keys()) == {"success", "data", "error"}
|
||||
|
||||
async def test_health_endpoint_returns_200(self) -> None:
|
||||
"""Health check returns 200 with status ok."""
|
||||
test_app = _build_app()
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=test_app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.get("/api/v1/health")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "ok"
|
||||
assert "version" in body
|
||||
|
||||
async def test_unknown_endpoint_returns_404(self) -> None:
|
||||
"""Requesting a non-existent path returns 404."""
|
||||
test_app = _build_app()
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=test_app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.get("/api/v1/nonexistent-path")
|
||||
|
||||
# FastAPI returns 404 for unknown routes; may or may not be wrapped
|
||||
assert resp.status_code == 404
|
||||
164
backend/tests/integration/test_openapi_api.py
Normal file
164
backend/tests/integration/test_openapi_api.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""Integration tests for /api/v1/openapi/ endpoints.
|
||||
|
||||
Tests the full API layer for the OpenAPI import review workflow,
|
||||
including job creation, status retrieval, classification updates,
|
||||
and approval triggering.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _build_app():
|
||||
"""Build a minimal FastAPI app with the openapi router and mocked deps."""
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.api_utils import envelope
|
||||
from app.openapi.review_api import router as openapi_router
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(openapi_router)
|
||||
|
||||
@test_app.exception_handler(HTTPException)
|
||||
async def _http_exc(request, exc):
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=envelope(None, success=False, error=exc.detail),
|
||||
)
|
||||
|
||||
@test_app.exception_handler(RequestValidationError)
|
||||
async def _validation_exc(request, exc):
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=envelope(None, success=False, error=str(exc)),
|
||||
)
|
||||
|
||||
test_app.state.settings = MagicMock(admin_api_key="")
|
||||
|
||||
return test_app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_job_store():
|
||||
"""Clear the in-memory job store between tests."""
|
||||
from app.openapi.review_api import _job_store
|
||||
|
||||
_job_store.clear()
|
||||
yield
|
||||
_job_store.clear()
|
||||
|
||||
|
||||
class TestImportEndpoint:
|
||||
"""Tests for POST /api/v1/openapi/import."""
|
||||
|
||||
async def test_import_returns_202_with_job_id(self) -> None:
|
||||
"""Starting an import returns 202 with a job_id."""
|
||||
test_app = _build_app()
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=test_app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.post(
|
||||
"/api/v1/openapi/import",
|
||||
json={"url": "https://example.com/api/spec.json"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 202
|
||||
body = resp.json()
|
||||
assert "job_id" in body
|
||||
assert body["status"] == "pending"
|
||||
assert body["spec_url"] == "https://example.com/api/spec.json"
|
||||
|
||||
async def test_import_invalid_url_returns_422(self) -> None:
|
||||
"""POST with invalid URL (no http/https) returns 422."""
|
||||
test_app = _build_app()
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=test_app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.post(
|
||||
"/api/v1/openapi/import",
|
||||
json={"url": "ftp://example.com/spec.json"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 422
|
||||
body = resp.json()
|
||||
assert body["success"] is False
|
||||
|
||||
|
||||
class TestJobStatusEndpoint:
|
||||
"""Tests for GET /api/v1/openapi/jobs/{job_id}."""
|
||||
|
||||
async def test_get_existing_job_returns_status(self) -> None:
|
||||
"""Retrieving an existing job returns its status."""
|
||||
from app.openapi.review_api import _job_store
|
||||
|
||||
_job_store["test-job-1"] = {
|
||||
"job_id": "test-job-1",
|
||||
"status": "done",
|
||||
"spec_url": "https://example.com/spec.json",
|
||||
"total_endpoints": 5,
|
||||
"classified_count": 5,
|
||||
"error_message": None,
|
||||
"classifications": [],
|
||||
}
|
||||
|
||||
test_app = _build_app()
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=test_app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.get("/api/v1/openapi/jobs/test-job-1")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["job_id"] == "test-job-1"
|
||||
assert body["status"] == "done"
|
||||
assert body["total_endpoints"] == 5
|
||||
|
||||
async def test_get_unknown_job_returns_404(self) -> None:
|
||||
"""Retrieving a non-existent job returns 404 error envelope."""
|
||||
test_app = _build_app()
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=test_app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.get("/api/v1/openapi/jobs/unknown-id-999")
|
||||
|
||||
assert resp.status_code == 404
|
||||
body = resp.json()
|
||||
assert body["success"] is False
|
||||
assert "not found" in body["error"].lower()
|
||||
|
||||
|
||||
class TestApproveEndpoint:
|
||||
"""Tests for POST /api/v1/openapi/jobs/{job_id}/approve."""
|
||||
|
||||
async def test_approve_with_no_classifications_returns_400(self) -> None:
|
||||
"""Approving a job with no classifications returns 400."""
|
||||
from app.openapi.review_api import _job_store
|
||||
|
||||
_job_store["empty-job"] = {
|
||||
"job_id": "empty-job",
|
||||
"status": "done",
|
||||
"spec_url": "https://example.com/spec.json",
|
||||
"total_endpoints": 0,
|
||||
"classified_count": 0,
|
||||
"error_message": None,
|
||||
"classifications": [],
|
||||
}
|
||||
|
||||
test_app = _build_app()
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=test_app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.post("/api/v1/openapi/jobs/empty-job/approve")
|
||||
|
||||
assert resp.status_code == 400
|
||||
body = resp.json()
|
||||
assert body["success"] is False
|
||||
assert "no classifications" in body["error"].lower()
|
||||
213
backend/tests/integration/test_replay_api.py
Normal file
213
backend/tests/integration/test_replay_api.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""Integration tests for /api/v1/conversations and /api/v1/replay/{thread_id}.
|
||||
|
||||
Tests the full API layer with a mocked database pool, verifying routing,
|
||||
serialization, pagination, and error handling in envelope format.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def _make_fake_cursor(rows, *, fetchone_value=None):
|
||||
"""Build a fake async cursor returning the given rows on fetchall."""
|
||||
cursor = AsyncMock()
|
||||
cursor.fetchall = AsyncMock(return_value=rows)
|
||||
if fetchone_value is not None:
|
||||
cursor.fetchone = AsyncMock(return_value=fetchone_value)
|
||||
return cursor
|
||||
|
||||
|
||||
class _FakeConnection:
|
||||
"""Fake async connection that returns pre-configured cursors in order."""
|
||||
|
||||
def __init__(self, cursors: list) -> None:
|
||||
self._cursors = list(cursors)
|
||||
self._idx = 0
|
||||
|
||||
async def execute(self, sql, params=None):
|
||||
cursor = self._cursors[self._idx]
|
||||
self._idx += 1
|
||||
return cursor
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
class _FakePool:
|
||||
"""Fake connection pool that yields a fake connection."""
|
||||
|
||||
def __init__(self, conn: _FakeConnection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def connection(self):
|
||||
return self._conn
|
||||
|
||||
|
||||
def _build_app(pool=None):
|
||||
"""Build a minimal FastAPI app with the replay router and mocked deps."""
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.api_utils import envelope
|
||||
from app.replay.api import router as replay_router
|
||||
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(replay_router)
|
||||
|
||||
@test_app.exception_handler(HTTPException)
|
||||
async def _http_exc(request, exc):
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=envelope(None, success=False, error=exc.detail),
|
||||
)
|
||||
|
||||
@test_app.exception_handler(RequestValidationError)
|
||||
async def _validation_exc(request, exc):
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=envelope(None, success=False, error=str(exc)),
|
||||
)
|
||||
|
||||
test_app.state.settings = MagicMock(admin_api_key="")
|
||||
test_app.state.pool = pool or MagicMock()
|
||||
|
||||
return test_app
|
||||
|
||||
|
||||
class TestListConversations:
|
||||
"""Tests for GET /api/v1/conversations endpoint."""
|
||||
|
||||
async def test_returns_paginated_envelope(self) -> None:
|
||||
"""Conversations list returns envelope with pagination metadata."""
|
||||
count_cursor = _make_fake_cursor([], fetchone_value=(3,))
|
||||
rows = [
|
||||
{"thread_id": "t1", "created_at": "2026-01-01", "last_activity": "2026-01-01",
|
||||
"status": "active", "total_tokens": 100, "total_cost_usd": 0.01},
|
||||
{"thread_id": "t2", "created_at": "2026-01-02", "last_activity": "2026-01-02",
|
||||
"status": "resolved", "total_tokens": 200, "total_cost_usd": 0.02},
|
||||
]
|
||||
list_cursor = _make_fake_cursor(rows)
|
||||
conn = _FakeConnection([count_cursor, list_cursor])
|
||||
pool = _FakePool(conn)
|
||||
test_app = _build_app(pool)
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=test_app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.get("/api/v1/conversations")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
assert body["data"]["total"] == 3
|
||||
assert len(body["data"]["conversations"]) == 2
|
||||
assert body["data"]["page"] == 1
|
||||
assert body["data"]["per_page"] == 20
|
||||
|
||||
async def test_custom_page_and_per_page(self) -> None:
|
||||
"""Custom page/per_page params are reflected in the response."""
|
||||
count_cursor = _make_fake_cursor([], fetchone_value=(50,))
|
||||
list_cursor = _make_fake_cursor([])
|
||||
conn = _FakeConnection([count_cursor, list_cursor])
|
||||
pool = _FakePool(conn)
|
||||
test_app = _build_app(pool)
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=test_app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.get("/api/v1/conversations", params={"page": 3, "per_page": 10})
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["data"]["page"] == 3
|
||||
assert body["data"]["per_page"] == 10
|
||||
|
||||
async def test_invalid_page_returns_422(self) -> None:
|
||||
"""page=0 violates ge=1 constraint and returns 422 error envelope."""
|
||||
test_app = _build_app()
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=test_app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.get("/api/v1/conversations", params={"page": 0})
|
||||
|
||||
assert resp.status_code == 422
|
||||
body = resp.json()
|
||||
assert body["success"] is False
|
||||
|
||||
|
||||
class TestReplayEndpoint:
|
||||
"""Tests for GET /api/v1/replay/{thread_id} endpoint."""
|
||||
|
||||
async def test_valid_thread_returns_timeline(self) -> None:
|
||||
"""Replay with valid thread_id returns steps in envelope format."""
|
||||
checkpoint_rows = [
|
||||
{
|
||||
"thread_id": "abc123",
|
||||
"checkpoint_id": "cp1",
|
||||
"checkpoint": {
|
||||
"channel_values": {
|
||||
"messages": [
|
||||
{"type": "human", "content": "Hello", "created_at": "2026-01-01T00:00:00Z"},
|
||||
{"type": "ai", "content": "Hi there!", "created_at": "2026-01-01T00:00:01Z"},
|
||||
]
|
||||
}
|
||||
},
|
||||
"metadata": {},
|
||||
}
|
||||
]
|
||||
cursor = _make_fake_cursor(checkpoint_rows)
|
||||
conn = _FakeConnection([cursor])
|
||||
pool = _FakePool(conn)
|
||||
test_app = _build_app(pool)
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=test_app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.get("/api/v1/replay/abc123")
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
assert body["data"]["thread_id"] == "abc123"
|
||||
assert body["data"]["total_steps"] == 2
|
||||
assert len(body["data"]["steps"]) == 2
|
||||
assert body["data"]["steps"][0]["type"] == "user_message"
|
||||
assert body["data"]["steps"][1]["type"] == "agent_response"
|
||||
|
||||
async def test_invalid_thread_id_format_returns_400(self) -> None:
|
||||
"""Thread IDs with path traversal characters are rejected with 400."""
|
||||
test_app = _build_app()
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=test_app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.get("/api/v1/replay/../../etc/passwd")
|
||||
|
||||
# FastAPI may return 400 from our handler or 404 from routing
|
||||
assert resp.status_code in (400, 404, 422)
|
||||
|
||||
async def test_nonexistent_thread_returns_404(self) -> None:
|
||||
"""Replay with a thread_id that has no checkpoints returns 404."""
|
||||
cursor = _make_fake_cursor([])
|
||||
conn = _FakeConnection([cursor])
|
||||
pool = _FakePool(conn)
|
||||
test_app = _build_app(pool)
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=test_app), base_url="http://test"
|
||||
) as client:
|
||||
resp = await client.get("/api/v1/replay/nonexistent-thread")
|
||||
|
||||
assert resp.status_code == 404
|
||||
body = resp.json()
|
||||
assert body["success"] is False
|
||||
assert "not found" in body["error"].lower()
|
||||
159
backend/tests/integration/test_session_interrupt_lifecycle.py
Normal file
159
backend/tests/integration/test_session_interrupt_lifecycle.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Integration tests for SessionManager + InterruptManager lifecycle.
|
||||
|
||||
These tests exercise the in-memory managers together, verifying the full
|
||||
lifecycle of sessions and interrupts: creation, TTL sliding, interrupt
|
||||
registration/resolution, and expired-interrupt cleanup.
|
||||
|
||||
No database required -- both managers are in-memory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.interrupt_manager import InterruptManager
|
||||
from app.session_manager import SessionManager
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
class TestSessionInterruptLifecycle:
|
||||
"""Tests for the combined session + interrupt lifecycle."""
|
||||
|
||||
def test_create_session_register_interrupt_check_status(self) -> None:
|
||||
"""Full lifecycle: create session, register interrupt, verify both states."""
|
||||
sm = SessionManager(session_ttl_seconds=3600)
|
||||
im = InterruptManager(ttl_seconds=300)
|
||||
|
||||
# Create a session
|
||||
state = sm.touch("thread-1")
|
||||
assert state.thread_id == "thread-1"
|
||||
assert not state.has_pending_interrupt
|
||||
assert not sm.is_expired("thread-1")
|
||||
|
||||
# Register an interrupt
|
||||
record = im.register("thread-1", "cancel_order", {"order_id": "1042"})
|
||||
sm.extend_for_interrupt("thread-1")
|
||||
|
||||
assert im.has_pending("thread-1")
|
||||
session_state = sm.get_state("thread-1")
|
||||
assert session_state is not None
|
||||
assert session_state.has_pending_interrupt
|
||||
|
||||
# Session should not expire while interrupt is pending
|
||||
assert not sm.is_expired("thread-1")
|
||||
|
||||
def test_interrupt_expiry_after_ttl(self) -> None:
|
||||
"""Interrupt expires when TTL elapses, even if session is alive."""
|
||||
im = InterruptManager(ttl_seconds=5)
|
||||
|
||||
record = im.register("thread-2", "refund", {"amount": 50})
|
||||
assert im.has_pending("thread-2")
|
||||
|
||||
# Simulate time passing beyond TTL
|
||||
with patch("app.interrupt_manager.time") as mock_time:
|
||||
mock_time.time.return_value = record.created_at + 10
|
||||
assert not im.has_pending("thread-2")
|
||||
|
||||
status = im.check_status("thread-2")
|
||||
assert status is not None
|
||||
assert status.is_expired
|
||||
assert status.remaining_seconds == 0.0
|
||||
|
||||
def test_interrupt_resolve_flow(self) -> None:
|
||||
"""Resolving an interrupt removes it from pending and resets session."""
|
||||
sm = SessionManager(session_ttl_seconds=3600)
|
||||
im = InterruptManager(ttl_seconds=300)
|
||||
|
||||
sm.touch("thread-3")
|
||||
im.register("thread-3", "delete_account", {"user_id": "u1"})
|
||||
sm.extend_for_interrupt("thread-3")
|
||||
|
||||
# Verify pending state
|
||||
assert im.has_pending("thread-3")
|
||||
assert sm.get_state("thread-3").has_pending_interrupt
|
||||
|
||||
# Resolve
|
||||
im.resolve("thread-3")
|
||||
sm.resolve_interrupt("thread-3")
|
||||
|
||||
assert not im.has_pending("thread-3")
|
||||
session_state = sm.get_state("thread-3")
|
||||
assert session_state is not None
|
||||
assert not session_state.has_pending_interrupt
|
||||
|
||||
def test_cleanup_expired_removes_old_interrupts(self) -> None:
|
||||
"""cleanup_expired removes only expired interrupts, keeping active ones."""
|
||||
im = InterruptManager(ttl_seconds=10)
|
||||
|
||||
# Register two interrupts at different times
|
||||
old_record = im.register("thread-old", "action_old", {})
|
||||
new_record = im.register("thread-new", "action_new", {})
|
||||
|
||||
# Simulate time where only old one expired
|
||||
with patch("app.interrupt_manager.time") as mock_time:
|
||||
# Move old record's creation to the past
|
||||
im._interrupts["thread-old"] = old_record.__class__(
|
||||
interrupt_id=old_record.interrupt_id,
|
||||
thread_id=old_record.thread_id,
|
||||
action=old_record.action,
|
||||
params=old_record.params,
|
||||
created_at=time.time() - 20,
|
||||
ttl_seconds=old_record.ttl_seconds,
|
||||
)
|
||||
mock_time.time.return_value = time.time()
|
||||
|
||||
expired = im.cleanup_expired()
|
||||
assert len(expired) == 1
|
||||
assert expired[0].thread_id == "thread-old"
|
||||
|
||||
# New one should still be pending
|
||||
assert im.has_pending("thread-new")
|
||||
assert not im.has_pending("thread-old")
|
||||
|
||||
def test_session_ttl_sliding_window(self) -> None:
|
||||
"""Touching a session resets the sliding window TTL."""
|
||||
sm = SessionManager(session_ttl_seconds=3600)
|
||||
|
||||
state1 = sm.touch("thread-5")
|
||||
first_activity = state1.last_activity
|
||||
|
||||
time.sleep(0.01)
|
||||
state2 = sm.touch("thread-5")
|
||||
second_activity = state2.last_activity
|
||||
|
||||
assert second_activity > first_activity
|
||||
assert not sm.is_expired("thread-5")
|
||||
|
||||
def test_session_expires_after_ttl_without_activity(self) -> None:
|
||||
"""Session expires when TTL passes without a touch or interrupt."""
|
||||
sm = SessionManager(session_ttl_seconds=0)
|
||||
sm.touch("thread-6")
|
||||
|
||||
# TTL is 0 so session is immediately expired
|
||||
assert sm.is_expired("thread-6")
|
||||
|
||||
def test_pending_interrupt_prevents_session_expiry(self) -> None:
|
||||
"""A session with pending interrupt does not expire even with TTL=0."""
|
||||
sm = SessionManager(session_ttl_seconds=0)
|
||||
sm.touch("thread-7")
|
||||
sm.extend_for_interrupt("thread-7")
|
||||
|
||||
# Even with TTL=0, session should not expire because of pending interrupt
|
||||
assert not sm.is_expired("thread-7")
|
||||
|
||||
def test_retry_prompt_for_expired_interrupt(self) -> None:
|
||||
"""InterruptManager generates a retry prompt for expired interrupts."""
|
||||
im = InterruptManager(ttl_seconds=300)
|
||||
record = im.register("thread-8", "cancel_order", {"order_id": "1042"})
|
||||
|
||||
prompt = im.generate_retry_prompt(record)
|
||||
|
||||
assert prompt["type"] == "interrupt_expired"
|
||||
assert prompt["thread_id"] == "thread-8"
|
||||
assert "cancel_order" in prompt["action"]
|
||||
assert "cancel_order" in prompt["message"]
|
||||
assert "expired" in prompt["message"].lower()
|
||||
@@ -44,7 +44,7 @@ def _make_analytics_result() -> object:
|
||||
)
|
||||
|
||||
|
||||
def _get_analytics(app: FastAPI, path: str = "/api/analytics", **patch_kwargs: object) -> object:
|
||||
def _get_analytics(app: FastAPI, path: str = "/api/v1/analytics", **patch_kwargs: object) -> object:
|
||||
"""Helper: patch get_analytics, make request, return (response, mock)."""
|
||||
analytics_result = _make_analytics_result()
|
||||
with (
|
||||
@@ -84,7 +84,7 @@ class TestAnalyticsEndpoint:
|
||||
def test_custom_range_7d(self) -> None:
|
||||
app = _build_app()
|
||||
app.state.pool = _make_mock_pool()
|
||||
resp, mock_ga = _get_analytics(app, "/api/analytics?range=7d")
|
||||
resp, mock_ga = _get_analytics(app, "/api/v1/analytics?range=7d")
|
||||
|
||||
assert resp.status_code == 200
|
||||
mock_ga.assert_called_once()
|
||||
@@ -94,7 +94,7 @@ class TestAnalyticsEndpoint:
|
||||
def test_custom_range_30d(self) -> None:
|
||||
app = _build_app()
|
||||
app.state.pool = _make_mock_pool()
|
||||
resp, mock_ga = _get_analytics(app, "/api/analytics?range=30d")
|
||||
resp, mock_ga = _get_analytics(app, "/api/v1/analytics?range=30d")
|
||||
|
||||
assert resp.status_code == 200
|
||||
call_kwargs = mock_ga.call_args
|
||||
@@ -107,7 +107,7 @@ class TestAnalyticsEndpoint:
|
||||
app.state.pool = _make_mock_pool()
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/analytics?range=invalid")
|
||||
resp = client.get("/api/v1/analytics?range=invalid")
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
@@ -116,7 +116,7 @@ class TestAnalyticsEndpoint:
|
||||
app.state.pool = _make_mock_pool()
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/analytics?range=7")
|
||||
resp = client.get("/api/v1/analytics?range=7")
|
||||
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ def client():
|
||||
@pytest.fixture
|
||||
def job_id(client):
|
||||
"""Create a job and return its ID."""
|
||||
response = client.post("/api/openapi/import", json={"url": _SAMPLE_URL})
|
||||
response = client.post("/api/v1/openapi/import", json={"url": _SAMPLE_URL})
|
||||
assert response.status_code == 202
|
||||
return response.json()["job_id"]
|
||||
|
||||
@@ -61,11 +61,11 @@ def job_with_classifications(client, job_id):
|
||||
|
||||
|
||||
class TestImportEndpoint:
|
||||
"""Tests for POST /api/openapi/import."""
|
||||
"""Tests for POST /api/v1/openapi/import."""
|
||||
|
||||
def test_post_import_returns_job_id(self, client) -> None:
|
||||
"""POST /import returns 202 with a job_id."""
|
||||
response = client.post("/api/openapi/import", json={"url": _SAMPLE_URL})
|
||||
response = client.post("/api/v1/openapi/import", json={"url": _SAMPLE_URL})
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
assert "job_id" in data
|
||||
@@ -73,38 +73,38 @@ class TestImportEndpoint:
|
||||
|
||||
def test_post_import_empty_url_returns_422(self, client) -> None:
|
||||
"""POST /import with empty URL returns 422 validation error."""
|
||||
response = client.post("/api/openapi/import", json={"url": ""})
|
||||
response = client.post("/api/v1/openapi/import", json={"url": ""})
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_post_import_missing_url_returns_422(self, client) -> None:
|
||||
"""POST /import with missing URL field returns 422."""
|
||||
response = client.post("/api/openapi/import", json={})
|
||||
response = client.post("/api/v1/openapi/import", json={})
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_post_import_invalid_scheme_returns_422(self, client) -> None:
|
||||
"""POST /import with non-http URL returns 422."""
|
||||
response = client.post("/api/openapi/import", json={"url": "ftp://evil.com/spec"})
|
||||
response = client.post("/api/v1/openapi/import", json={"url": "ftp://evil.com/spec"})
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_post_import_returns_pending_status(self, client) -> None:
|
||||
"""Newly created job has pending status."""
|
||||
response = client.post("/api/openapi/import", json={"url": _SAMPLE_URL})
|
||||
response = client.post("/api/v1/openapi/import", json={"url": _SAMPLE_URL})
|
||||
data = response.json()
|
||||
assert data["status"] == "pending"
|
||||
|
||||
def test_post_import_returns_spec_url(self, client) -> None:
|
||||
"""Response includes the original spec URL."""
|
||||
response = client.post("/api/openapi/import", json={"url": _SAMPLE_URL})
|
||||
response = client.post("/api/v1/openapi/import", json={"url": _SAMPLE_URL})
|
||||
data = response.json()
|
||||
assert data["spec_url"] == _SAMPLE_URL
|
||||
|
||||
|
||||
class TestGetJobEndpoint:
|
||||
"""Tests for GET /api/openapi/jobs/{job_id}."""
|
||||
"""Tests for GET /api/v1/openapi/jobs/{job_id}."""
|
||||
|
||||
def test_get_job_returns_status(self, client, job_id) -> None:
|
||||
"""GET /jobs/{id} returns job status."""
|
||||
response = client.get(f"/api/openapi/jobs/{job_id}")
|
||||
response = client.get(f"/api/v1/openapi/jobs/{job_id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "status" in data
|
||||
@@ -112,23 +112,23 @@ class TestGetJobEndpoint:
|
||||
|
||||
def test_get_unknown_job_returns_404(self, client) -> None:
|
||||
"""GET /jobs/nonexistent returns 404."""
|
||||
response = client.get("/api/openapi/jobs/nonexistent-id")
|
||||
response = client.get("/api/v1/openapi/jobs/nonexistent-id")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_get_job_includes_spec_url(self, client, job_id) -> None:
|
||||
"""Job response includes the spec URL."""
|
||||
response = client.get(f"/api/openapi/jobs/{job_id}")
|
||||
response = client.get(f"/api/v1/openapi/jobs/{job_id}")
|
||||
data = response.json()
|
||||
assert data["spec_url"] == _SAMPLE_URL
|
||||
|
||||
|
||||
class TestGetClassificationsEndpoint:
|
||||
"""Tests for GET /api/openapi/jobs/{job_id}/classifications."""
|
||||
"""Tests for GET /api/v1/openapi/jobs/{job_id}/classifications."""
|
||||
|
||||
def test_get_classifications_returns_list(self, client, job_with_classifications) -> None:
|
||||
"""GET /classifications returns a list."""
|
||||
response = client.get(
|
||||
f"/api/openapi/jobs/{job_with_classifications}/classifications"
|
||||
f"/api/v1/openapi/jobs/{job_with_classifications}/classifications"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -137,13 +137,13 @@ class TestGetClassificationsEndpoint:
|
||||
|
||||
def test_get_classifications_unknown_job_returns_404(self, client) -> None:
|
||||
"""GET /classifications for unknown job returns 404."""
|
||||
response = client.get("/api/openapi/jobs/unknown/classifications")
|
||||
response = client.get("/api/v1/openapi/jobs/unknown/classifications")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_classification_has_expected_fields(self, client, job_with_classifications) -> None:
|
||||
"""Each classification item has access_type and endpoint fields."""
|
||||
response = client.get(
|
||||
f"/api/openapi/jobs/{job_with_classifications}/classifications"
|
||||
f"/api/v1/openapi/jobs/{job_with_classifications}/classifications"
|
||||
)
|
||||
item = response.json()[0]
|
||||
assert "access_type" in item
|
||||
@@ -152,12 +152,12 @@ class TestGetClassificationsEndpoint:
|
||||
|
||||
|
||||
class TestUpdateClassificationEndpoint:
|
||||
"""Tests for PUT /api/openapi/jobs/{job_id}/classifications/{idx}."""
|
||||
"""Tests for PUT /api/v1/openapi/jobs/{job_id}/classifications/{idx}."""
|
||||
|
||||
def test_update_classification_succeeds(self, client, job_with_classifications) -> None:
|
||||
"""PUT /classifications/0 updates the classification."""
|
||||
response = client.put(
|
||||
f"/api/openapi/jobs/{job_with_classifications}/classifications/0",
|
||||
f"/api/v1/openapi/jobs/{job_with_classifications}/classifications/0",
|
||||
json={"access_type": "write", "needs_interrupt": True, "agent_group": "write_agent"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -165,7 +165,7 @@ class TestUpdateClassificationEndpoint:
|
||||
def test_update_unknown_job_returns_404(self, client) -> None:
|
||||
"""PUT /classifications/0 for unknown job returns 404."""
|
||||
response = client.put(
|
||||
"/api/openapi/jobs/unknown/classifications/0",
|
||||
"/api/v1/openapi/jobs/unknown/classifications/0",
|
||||
json={"access_type": "write", "needs_interrupt": True, "agent_group": "write_agent"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
@@ -173,7 +173,7 @@ class TestUpdateClassificationEndpoint:
|
||||
def test_update_invalid_access_type_returns_422(self, client, job_with_classifications) -> None:
|
||||
"""PUT /classifications/0 with invalid access_type returns 422."""
|
||||
response = client.put(
|
||||
f"/api/openapi/jobs/{job_with_classifications}/classifications/0",
|
||||
f"/api/v1/openapi/jobs/{job_with_classifications}/classifications/0",
|
||||
json={"access_type": "admin", "needs_interrupt": True, "agent_group": "x"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
@@ -181,7 +181,7 @@ class TestUpdateClassificationEndpoint:
|
||||
def test_update_invalid_agent_group_returns_422(self, client, job_with_classifications) -> None:
|
||||
"""PUT /classifications/0 with invalid agent_group returns 422."""
|
||||
response = client.put(
|
||||
f"/api/openapi/jobs/{job_with_classifications}/classifications/0",
|
||||
f"/api/v1/openapi/jobs/{job_with_classifications}/classifications/0",
|
||||
json={"access_type": "read", "needs_interrupt": False, "agent_group": "evil group!"},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
@@ -189,31 +189,31 @@ class TestUpdateClassificationEndpoint:
|
||||
def test_update_out_of_range_index_returns_404(self, client, job_with_classifications) -> None:
|
||||
"""PUT /classifications/999 returns 404 for out-of-range index."""
|
||||
response = client.put(
|
||||
f"/api/openapi/jobs/{job_with_classifications}/classifications/999",
|
||||
f"/api/v1/openapi/jobs/{job_with_classifications}/classifications/999",
|
||||
json={"access_type": "read", "needs_interrupt": False, "agent_group": "read_agent"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestApproveEndpoint:
|
||||
"""Tests for POST /api/openapi/jobs/{job_id}/approve."""
|
||||
"""Tests for POST /api/v1/openapi/jobs/{job_id}/approve."""
|
||||
|
||||
def test_approve_job_succeeds(self, client, job_with_classifications) -> None:
|
||||
"""POST /approve transitions job to approved status."""
|
||||
response = client.post(
|
||||
f"/api/openapi/jobs/{job_with_classifications}/approve"
|
||||
f"/api/v1/openapi/jobs/{job_with_classifications}/approve"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_approve_unknown_job_returns_404(self, client) -> None:
|
||||
"""POST /approve for unknown job returns 404."""
|
||||
response = client.post("/api/openapi/jobs/unknown/approve")
|
||||
response = client.post("/api/v1/openapi/jobs/unknown/approve")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_approve_returns_job_status(self, client, job_with_classifications) -> None:
|
||||
"""POST /approve returns updated job status."""
|
||||
response = client.post(
|
||||
f"/api/openapi/jobs/{job_with_classifications}/approve"
|
||||
f"/api/v1/openapi/jobs/{job_with_classifications}/approve"
|
||||
)
|
||||
data = response.json()
|
||||
assert "status" in data
|
||||
|
||||
@@ -5,9 +5,12 @@ from __future__ import annotations
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api_utils import envelope
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@@ -16,6 +19,14 @@ def _build_app() -> FastAPI:
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def _http_exc(request, exc): # type: ignore[no-untyped-def]
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=envelope(None, success=False, error=exc.detail),
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -64,7 +75,7 @@ class TestListConversations:
|
||||
app.state.pool = _make_mock_pool([], count=0)
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/conversations")
|
||||
resp = client.get("/api/v1/conversations")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
@@ -89,7 +100,7 @@ class TestListConversations:
|
||||
app.state.pool = _make_mock_pool(mock_rows, count=1)
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/conversations")
|
||||
resp = client.get("/api/v1/conversations")
|
||||
body = resp.json()
|
||||
assert resp.status_code == 200
|
||||
data = body["data"]
|
||||
@@ -102,7 +113,7 @@ class TestListConversations:
|
||||
app.state.pool = _make_mock_pool([], count=0)
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/conversations")
|
||||
resp = client.get("/api/v1/conversations")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_pagination_custom_params(self) -> None:
|
||||
@@ -110,7 +121,7 @@ class TestListConversations:
|
||||
app.state.pool = _make_mock_pool([], count=0)
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/conversations?page=2&per_page=10")
|
||||
resp = client.get("/api/v1/conversations?page=2&per_page=10")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_per_page_max_capped_at_100(self) -> None:
|
||||
@@ -118,7 +129,7 @@ class TestListConversations:
|
||||
app.state.pool = _make_mock_pool([], count=0)
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/conversations?per_page=200")
|
||||
resp = client.get("/api/v1/conversations?per_page=200")
|
||||
# FastAPI Query(le=100) rejects values > 100
|
||||
assert resp.status_code == 422
|
||||
|
||||
@@ -129,7 +140,7 @@ class TestGetReplay:
|
||||
app.state.pool = _make_mock_pool([])
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/replay/nonexistent-thread")
|
||||
resp = client.get("/api/v1/replay/nonexistent-thread")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_returns_replay_page_for_existing_thread(self) -> None:
|
||||
@@ -149,7 +160,7 @@ class TestGetReplay:
|
||||
app.state.pool = _make_mock_pool(mock_rows)
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/replay/thread-123")
|
||||
resp = client.get("/api/v1/replay/thread-123")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
@@ -174,7 +185,7 @@ class TestGetReplay:
|
||||
app.state.pool = _make_mock_pool(mock_rows)
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/replay/t1?page=1&per_page=5")
|
||||
resp = client.get("/api/v1/replay/t1?page=1&per_page=5")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_error_response_has_envelope(self) -> None:
|
||||
@@ -182,16 +193,19 @@ class TestGetReplay:
|
||||
app.state.pool = _make_mock_pool([])
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/replay/missing")
|
||||
resp = client.get("/api/v1/replay/missing")
|
||||
assert resp.status_code == 404
|
||||
assert "detail" in resp.json()
|
||||
body = resp.json()
|
||||
assert body["success"] is False
|
||||
assert body["data"] is None
|
||||
assert body["error"] is not None
|
||||
|
||||
def test_invalid_thread_id_returns_400(self) -> None:
|
||||
app = _build_app()
|
||||
app.state.pool = _make_mock_pool([])
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/replay/id%20with%20spaces")
|
||||
resp = client.get("/api/v1/replay/id%20with%20spaces")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_thread_id_special_chars_returns_400(self) -> None:
|
||||
@@ -199,5 +213,5 @@ class TestGetReplay:
|
||||
app.state.pool = _make_mock_pool([])
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/api/replay/id;DROP TABLE")
|
||||
resp = client.get("/api/v1/replay/id;DROP TABLE")
|
||||
assert resp.status_code == 400
|
||||
|
||||
142
backend/tests/unit/test_error_responses.py
Normal file
142
backend/tests/unit/test_error_responses.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""Tests for standardized error response envelope format."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api_utils import envelope
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _build_test_app() -> FastAPI:
|
||||
"""Build a minimal FastAPI app with the standard exception handlers."""
|
||||
app = FastAPI()
|
||||
|
||||
@app.exception_handler(HTTPException)
|
||||
async def http_exception_handler(request, exc): # type: ignore[no-untyped-def]
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=envelope(None, success=False, error=exc.detail),
|
||||
)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request, exc): # type: ignore[no-untyped-def]
|
||||
return JSONResponse(
|
||||
status_code=422,
|
||||
content=envelope(None, success=False, error=str(exc)),
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def general_exception_handler(request, exc): # type: ignore[no-untyped-def]
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=envelope(None, success=False, error="Internal server error"),
|
||||
)
|
||||
|
||||
class ItemRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1)
|
||||
count: int = Field(..., gt=0)
|
||||
|
||||
@app.get("/items/{item_id}")
|
||||
def get_item(item_id: int) -> dict:
|
||||
if item_id == 0:
|
||||
raise HTTPException(status_code=400, detail="Invalid item ID")
|
||||
if item_id == 999:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
if item_id == 401:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
return envelope({"id": item_id, "name": "test"})
|
||||
|
||||
@app.post("/items")
|
||||
def create_item(item: ItemRequest) -> dict:
|
||||
return envelope({"id": 1, "name": item.name})
|
||||
|
||||
@app.get("/crash")
|
||||
def crash() -> dict:
|
||||
msg = "unexpected failure"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
class TestHttpExceptionEnvelope:
|
||||
"""HTTPException responses use the standard envelope format."""
|
||||
|
||||
def test_400_returns_envelope(self) -> None:
|
||||
app = _build_test_app()
|
||||
with TestClient(app, raise_server_exceptions=False) as client:
|
||||
resp = client.get("/items/0")
|
||||
assert resp.status_code == 400
|
||||
body = resp.json()
|
||||
assert body["success"] is False
|
||||
assert body["data"] is None
|
||||
assert body["error"] == "Invalid item ID"
|
||||
|
||||
def test_404_returns_envelope(self) -> None:
|
||||
app = _build_test_app()
|
||||
with TestClient(app, raise_server_exceptions=False) as client:
|
||||
resp = client.get("/items/999")
|
||||
assert resp.status_code == 404
|
||||
body = resp.json()
|
||||
assert body["success"] is False
|
||||
assert body["data"] is None
|
||||
assert body["error"] == "Item not found"
|
||||
|
||||
def test_401_returns_envelope(self) -> None:
|
||||
app = _build_test_app()
|
||||
with TestClient(app, raise_server_exceptions=False) as client:
|
||||
resp = client.get("/items/401")
|
||||
assert resp.status_code == 401
|
||||
body = resp.json()
|
||||
assert body["success"] is False
|
||||
assert body["data"] is None
|
||||
assert body["error"] == "Not authenticated"
|
||||
|
||||
|
||||
class TestValidationErrorEnvelope:
|
||||
"""Validation errors return 422 with envelope format."""
|
||||
|
||||
def test_validation_error_returns_envelope(self) -> None:
|
||||
app = _build_test_app()
|
||||
with TestClient(app, raise_server_exceptions=False) as client:
|
||||
resp = client.post("/items", json={"name": "", "count": -1})
|
||||
assert resp.status_code == 422
|
||||
body = resp.json()
|
||||
assert body["success"] is False
|
||||
assert body["data"] is None
|
||||
assert isinstance(body["error"], str)
|
||||
assert len(body["error"]) > 0
|
||||
|
||||
|
||||
class TestGeneralExceptionEnvelope:
|
||||
"""Unhandled exceptions return 500 with safe envelope."""
|
||||
|
||||
def test_unhandled_exception_returns_500_envelope(self) -> None:
|
||||
app = _build_test_app()
|
||||
with TestClient(app, raise_server_exceptions=False) as client:
|
||||
resp = client.get("/crash")
|
||||
assert resp.status_code == 500
|
||||
body = resp.json()
|
||||
assert body["success"] is False
|
||||
assert body["data"] is None
|
||||
assert body["error"] == "Internal server error"
|
||||
|
||||
|
||||
class TestSuccessResponseUnchanged:
|
||||
"""Success responses still work normally."""
|
||||
|
||||
def test_success_returns_envelope(self) -> None:
|
||||
app = _build_test_app()
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/items/42")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
assert body["data"]["id"] == 42
|
||||
assert body["error"] is None
|
||||
86
backend/tests/unit/test_interrupt_cleanup.py
Normal file
86
backend/tests/unit/test_interrupt_cleanup.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Tests for the interrupt cleanup background loop in main.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.main import _interrupt_cleanup_loop
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_loop_calls_cleanup_expired() -> None:
|
||||
"""The loop should call cleanup_expired after each sleep interval."""
|
||||
manager = MagicMock()
|
||||
manager.cleanup_expired.return_value = ()
|
||||
|
||||
call_count = 0
|
||||
original_sleep = asyncio.sleep
|
||||
|
||||
async def _fake_sleep(seconds: float) -> None:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count >= 2:
|
||||
raise asyncio.CancelledError
|
||||
await original_sleep(0)
|
||||
|
||||
with patch("app.main.asyncio.sleep", side_effect=_fake_sleep):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await _interrupt_cleanup_loop(manager, interval=60)
|
||||
|
||||
assert manager.cleanup_expired.call_count >= 1
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_loop_survives_exceptions() -> None:
|
||||
"""The loop should not die when cleanup_expired raises an exception."""
|
||||
manager = MagicMock()
|
||||
manager.cleanup_expired.side_effect = [RuntimeError("db gone"), ()]
|
||||
|
||||
call_count = 0
|
||||
original_sleep = asyncio.sleep
|
||||
|
||||
async def _fake_sleep(seconds: float) -> None:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count >= 3:
|
||||
raise asyncio.CancelledError
|
||||
await original_sleep(0)
|
||||
|
||||
with patch("app.main.asyncio.sleep", side_effect=_fake_sleep):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await _interrupt_cleanup_loop(manager, interval=60)
|
||||
|
||||
# Should have been called twice: once raising, once returning ()
|
||||
assert manager.cleanup_expired.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_loop_logs_expired_count(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""The loop should log when expired interrupts are found."""
|
||||
fake_record = MagicMock()
|
||||
manager = MagicMock()
|
||||
manager.cleanup_expired.return_value = (fake_record, fake_record)
|
||||
|
||||
call_count = 0
|
||||
original_sleep = asyncio.sleep
|
||||
|
||||
async def _fake_sleep(seconds: float) -> None:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count >= 2:
|
||||
raise asyncio.CancelledError
|
||||
await original_sleep(0)
|
||||
|
||||
with patch("app.main.asyncio.sleep", side_effect=_fake_sleep):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await _interrupt_cleanup_loop(manager, interval=60)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "2 expired interrupt" in captured.out
|
||||
20
backend/tests/unit/test_logging_config.py
Normal file
20
backend/tests/unit/test_logging_config.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Tests for structured logging configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.logging_config import configure_logging
|
||||
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_configure_logging_console_mode() -> None:
|
||||
"""Console mode configures without error."""
|
||||
configure_logging("console")
|
||||
|
||||
|
||||
def test_configure_logging_json_mode() -> None:
|
||||
"""JSON mode configures without error."""
|
||||
configure_logging("json")
|
||||
@@ -36,7 +36,7 @@ class TestMainModule:
|
||||
|
||||
def test_health_route_registered(self) -> None:
|
||||
routes = [r.path for r in app.routes if hasattr(r, "path")]
|
||||
assert "/api/health" in routes
|
||||
assert "/api/v1/health" in routes
|
||||
|
||||
def test_app_version_is_0_5_0(self) -> None:
|
||||
assert app.version == "0.6.0"
|
||||
|
||||
Reference in New Issue
Block a user