- 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
220 lines
8.4 KiB
Python
220 lines
8.4 KiB
Python
"""Tests for OpenAPI review API endpoints.
|
|
|
|
RED phase: written before implementation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
_SAMPLE_URL = "https://example.com/api/spec.json"
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
"""Create TestClient for the review API app."""
|
|
from fastapi import FastAPI
|
|
|
|
from app.openapi.review_api import router
|
|
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
return TestClient(app)
|
|
|
|
|
|
@pytest.fixture
|
|
def job_id(client):
|
|
"""Create a job and return its ID."""
|
|
response = client.post("/api/v1/openapi/import", json={"url": _SAMPLE_URL})
|
|
assert response.status_code == 202
|
|
return response.json()["job_id"]
|
|
|
|
|
|
@pytest.fixture
|
|
def job_with_classifications(client, job_id):
|
|
"""Return job_id for a job that has mock classifications injected."""
|
|
from app.openapi.models import ClassificationResult, EndpointInfo
|
|
from app.openapi.review_api import _job_store
|
|
|
|
ep = EndpointInfo(
|
|
path="/orders",
|
|
method="GET",
|
|
operation_id="list_orders",
|
|
summary="List orders",
|
|
description="",
|
|
)
|
|
clf = ClassificationResult(
|
|
endpoint=ep,
|
|
access_type="read",
|
|
customer_params=(),
|
|
agent_group="read_agent",
|
|
confidence=0.9,
|
|
needs_interrupt=False,
|
|
)
|
|
# Inject classifications directly into the store
|
|
job = _job_store[job_id]
|
|
_job_store[job_id] = {**job, "classifications": [clf]}
|
|
return job_id
|
|
|
|
|
|
class TestImportEndpoint:
|
|
"""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/v1/openapi/import", json={"url": _SAMPLE_URL})
|
|
assert response.status_code == 202
|
|
data = response.json()
|
|
assert "job_id" in data
|
|
assert len(data["job_id"]) > 0
|
|
|
|
def test_post_import_empty_url_returns_422(self, client) -> None:
|
|
"""POST /import with empty URL returns 422 validation error."""
|
|
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/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/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/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/v1/openapi/import", json={"url": _SAMPLE_URL})
|
|
data = response.json()
|
|
assert data["spec_url"] == _SAMPLE_URL
|
|
|
|
|
|
class TestGetJobEndpoint:
|
|
"""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/v1/openapi/jobs/{job_id}")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "status" in data
|
|
assert "job_id" in data
|
|
|
|
def test_get_unknown_job_returns_404(self, client) -> None:
|
|
"""GET /jobs/nonexistent returns 404."""
|
|
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/v1/openapi/jobs/{job_id}")
|
|
data = response.json()
|
|
assert data["spec_url"] == _SAMPLE_URL
|
|
|
|
|
|
class TestGetClassificationsEndpoint:
|
|
"""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/v1/openapi/jobs/{job_with_classifications}/classifications"
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert isinstance(data, list)
|
|
assert len(data) == 1
|
|
|
|
def test_get_classifications_unknown_job_returns_404(self, client) -> None:
|
|
"""GET /classifications for unknown job returns 404."""
|
|
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/v1/openapi/jobs/{job_with_classifications}/classifications"
|
|
)
|
|
item = response.json()[0]
|
|
assert "access_type" in item
|
|
assert "endpoint" in item
|
|
assert "needs_interrupt" in item
|
|
|
|
|
|
class TestUpdateClassificationEndpoint:
|
|
"""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/v1/openapi/jobs/{job_with_classifications}/classifications/0",
|
|
json={"access_type": "write", "needs_interrupt": True, "agent_group": "write_agent"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
def test_update_unknown_job_returns_404(self, client) -> None:
|
|
"""PUT /classifications/0 for unknown job returns 404."""
|
|
response = client.put(
|
|
"/api/v1/openapi/jobs/unknown/classifications/0",
|
|
json={"access_type": "write", "needs_interrupt": True, "agent_group": "write_agent"},
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
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/v1/openapi/jobs/{job_with_classifications}/classifications/0",
|
|
json={"access_type": "admin", "needs_interrupt": True, "agent_group": "x"},
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
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/v1/openapi/jobs/{job_with_classifications}/classifications/0",
|
|
json={"access_type": "read", "needs_interrupt": False, "agent_group": "evil group!"},
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
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/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/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/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/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/v1/openapi/jobs/{job_with_classifications}/approve"
|
|
)
|
|
data = response.json()
|
|
assert "status" in data
|