- 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
202 lines
6.8 KiB
Python
202 lines
6.8 KiB
Python
"""E2E tests for OpenAPI import flow (flow 5).
|
|
|
|
Flow 5: paste OpenAPI spec URL -> import job -> classify endpoints ->
|
|
review classifications -> approve -> tool generation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from starlette.testclient import TestClient
|
|
|
|
from app.openapi.models import ClassificationResult, EndpointInfo
|
|
from app.openapi.review_api import _job_store
|
|
from tests.e2e.conftest import create_e2e_app
|
|
|
|
pytestmark = pytest.mark.e2e
|
|
|
|
|
|
def _fake_endpoint(
|
|
path: str = "/orders/{id}",
|
|
method: str = "GET",
|
|
operation_id: str = "getOrder",
|
|
summary: str = "Get order details",
|
|
) -> EndpointInfo:
|
|
return EndpointInfo(
|
|
path=path,
|
|
method=method,
|
|
operation_id=operation_id,
|
|
summary=summary,
|
|
description="",
|
|
parameters=(),
|
|
request_body_schema=None,
|
|
response_schema=None,
|
|
)
|
|
|
|
|
|
def _fake_classification(
|
|
endpoint: EndpointInfo | None = None,
|
|
access_type: str = "read",
|
|
needs_interrupt: bool = False,
|
|
agent_group: str = "order_lookup",
|
|
) -> ClassificationResult:
|
|
return ClassificationResult(
|
|
endpoint=endpoint or _fake_endpoint(),
|
|
access_type=access_type,
|
|
customer_params=["order_id"],
|
|
agent_group=agent_group,
|
|
confidence=0.95,
|
|
needs_interrupt=needs_interrupt,
|
|
)
|
|
|
|
|
|
class TestFlow5OpenAPIImport:
|
|
"""Flow 5: full OpenAPI import lifecycle."""
|
|
|
|
def test_import_job_lifecycle(self) -> None:
|
|
"""Start import -> check status -> review classifications -> approve."""
|
|
app = create_e2e_app()
|
|
|
|
with TestClient(app) as client:
|
|
# Step 1: Start import job
|
|
resp = client.post(
|
|
"/api/v1/openapi/import",
|
|
json={"url": "https://api.example.com/openapi.json"},
|
|
)
|
|
assert resp.status_code == 202
|
|
body = resp.json()
|
|
assert body["status"] == "pending"
|
|
job_id = body["job_id"]
|
|
|
|
# Step 2: Check job status (still pending since background task hasn't run)
|
|
resp = client.get(f"/api/v1/openapi/jobs/{job_id}")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["job_id"] == job_id
|
|
|
|
def test_import_job_with_classifications(self) -> None:
|
|
"""Simulate completed import and review classified endpoints."""
|
|
app = create_e2e_app()
|
|
|
|
# Seed a completed job directly
|
|
ep_read = _fake_endpoint("/orders/{id}", "GET", "getOrder", "Get order")
|
|
ep_write = _fake_endpoint("/orders/{id}/cancel", "POST", "cancelOrder", "Cancel order")
|
|
|
|
clf_read = _fake_classification(ep_read, "read", False, "order_lookup")
|
|
clf_write = _fake_classification(ep_write, "write", True, "order_actions")
|
|
|
|
job_id = "test-job-001"
|
|
_job_store[job_id] = {
|
|
"job_id": job_id,
|
|
"status": "done",
|
|
"spec_url": "https://api.example.com/openapi.json",
|
|
"total_endpoints": 2,
|
|
"classified_count": 2,
|
|
"error_message": None,
|
|
"classifications": [clf_read, clf_write],
|
|
}
|
|
|
|
with TestClient(app) as client:
|
|
# Step 1: Get classifications
|
|
resp = client.get(f"/api/v1/openapi/jobs/{job_id}/classifications")
|
|
assert resp.status_code == 200
|
|
classifications = resp.json()
|
|
assert len(classifications) == 2
|
|
|
|
# Verify read endpoint
|
|
read_clf = classifications[0]
|
|
assert read_clf["access_type"] == "read"
|
|
assert read_clf["needs_interrupt"] is False
|
|
assert read_clf["endpoint"]["path"] == "/orders/{id}"
|
|
|
|
# Verify write endpoint
|
|
write_clf = classifications[1]
|
|
assert write_clf["access_type"] == "write"
|
|
assert write_clf["needs_interrupt"] is True
|
|
assert write_clf["endpoint"]["path"] == "/orders/{id}/cancel"
|
|
|
|
# Step 2: Update a classification
|
|
resp = client.put(
|
|
f"/api/v1/openapi/jobs/{job_id}/classifications/0",
|
|
json={
|
|
"access_type": "write",
|
|
"needs_interrupt": True,
|
|
"agent_group": "order_actions",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
updated = resp.json()
|
|
assert updated["access_type"] == "write"
|
|
assert updated["needs_interrupt"] is True
|
|
assert updated["agent_group"] == "order_actions"
|
|
|
|
# Step 3: Approve the job
|
|
resp = client.post(f"/api/v1/openapi/jobs/{job_id}/approve")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "approved"
|
|
|
|
def test_import_nonexistent_job_returns_404(self) -> None:
|
|
app = create_e2e_app()
|
|
|
|
with TestClient(app) as client:
|
|
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/v1/openapi/import", json={"url": "not-a-url"})
|
|
assert resp.status_code == 422
|
|
|
|
def test_classification_index_out_of_range(self) -> None:
|
|
app = create_e2e_app()
|
|
|
|
job_id = "test-job-range"
|
|
_job_store[job_id] = {
|
|
"job_id": job_id,
|
|
"status": "done",
|
|
"spec_url": "https://example.com/spec.json",
|
|
"total_endpoints": 1,
|
|
"classified_count": 1,
|
|
"error_message": None,
|
|
"classifications": [_fake_classification()],
|
|
}
|
|
|
|
with TestClient(app) as client:
|
|
resp = client.put(
|
|
f"/api/v1/openapi/jobs/{job_id}/classifications/99",
|
|
json={
|
|
"access_type": "read",
|
|
"needs_interrupt": False,
|
|
"agent_group": "order_lookup",
|
|
},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
def test_update_classification_invalid_agent_group(self) -> None:
|
|
app = create_e2e_app()
|
|
|
|
job_id = "test-job-invalid"
|
|
_job_store[job_id] = {
|
|
"job_id": job_id,
|
|
"status": "done",
|
|
"spec_url": "https://example.com/spec.json",
|
|
"total_endpoints": 1,
|
|
"classified_count": 1,
|
|
"error_message": None,
|
|
"classifications": [_fake_classification()],
|
|
}
|
|
|
|
with TestClient(app) as client:
|
|
resp = client.put(
|
|
f"/api/v1/openapi/jobs/{job_id}/classifications/0",
|
|
json={
|
|
"access_type": "read",
|
|
"needs_interrupt": False,
|
|
"agent_group": "invalid group!", # spaces and special chars
|
|
},
|
|
)
|
|
assert resp.status_code == 422
|