- 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
165 lines
5.3 KiB
Python
165 lines
5.3 KiB
Python
"""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()
|