feat: beta API integration test suite — 85 new tests across 6 modules
Extends integration test coverage from 108 to 193 tests for the beta gate. New test modules: - test_query_api_extended.py (33 tests): documents, evidence, macro/competitive, ops/admin, agents, analytics - test_registry_write_paths.py (16 tests): write paths, validation, duplicates, competitor/exposure CRUD - test_risk_approval_lifecycle.py (8 tests): evaluation edge cases, full approval lifecycle - test_trading_extended.py (12 tests): config round-trips, decision filtering, override validation - test_cross_service_roundtrip.py (4 tests): cross-service data consistency - test_error_handling.py (12 tests): 404s, 422s, empty states, health checks Seed script extended with watchlists, approvals, lockouts, notifications, ingestion runs, saved queries, and daily risk snapshots.
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
"""Extended integration tests for the Query API — documents, evidence, macro,
|
||||
competitive, operational, admin, agents, and analytics endpoints.
|
||||
|
||||
Validates endpoints not covered by test_query_api.py against the live sandbox
|
||||
with deterministic seed data. Uses the ``query_client`` and ``seed_ids``
|
||||
fixtures from conftest.py.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 2: Documents and Evidence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestQueryAPIDocumentFiltering:
|
||||
"""Endpoints: /api/documents with ticker and document_type filters."""
|
||||
|
||||
async def test_filter_documents_by_ticker(self, query_client, seed_ids):
|
||||
"""GET /api/documents?ticker=AAPL — only AAPL documents."""
|
||||
resp = await query_client.get("/api/documents", params={"ticker": "AAPL"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) >= 1
|
||||
# All returned docs should have an id field
|
||||
for doc in data:
|
||||
assert "id" in doc
|
||||
|
||||
async def test_filter_documents_by_doc_type(self, query_client, seed_ids):
|
||||
"""GET /api/documents?document_type=filing — only filing documents."""
|
||||
resp = await query_client.get(
|
||||
"/api/documents", params={"document_type": "filing"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) >= 1
|
||||
for doc in data:
|
||||
assert doc["document_type"] == "filing"
|
||||
|
||||
|
||||
class TestQueryAPIDocumentDetail:
|
||||
"""Endpoint: /api/documents/{id} — 404 for non-existent UUID."""
|
||||
|
||||
async def test_document_not_found(self, query_client):
|
||||
"""GET /api/documents/{id} — 404 for non-existent UUID."""
|
||||
fake_id = "00000000-0000-4000-ffff-000000000099"
|
||||
resp = await query_client.get(f"/api/documents/{fake_id}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
class TestQueryAPIRecommendationEvidence:
|
||||
"""Endpoint: /api/recommendations/{id}/evidence — evidence drill-down."""
|
||||
|
||||
async def test_recommendation_evidence_drilldown(self, query_client, seed_ids):
|
||||
"""GET /api/recommendations/{id}/evidence — evidence drill-down."""
|
||||
rec_id = seed_ids["recommendations"]["REC_01"]
|
||||
resp = await query_client.get(f"/api/recommendations/{rec_id}/evidence")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "recommendation" in data
|
||||
assert "evidence" in data
|
||||
|
||||
|
||||
class TestQueryAPITrendEvidence:
|
||||
"""Endpoint: /api/trends/{id}/evidence — trend evidence drill-down."""
|
||||
|
||||
async def test_trend_evidence_drilldown(self, query_client, seed_ids):
|
||||
"""GET /api/trends/{id}/evidence — trend evidence drill-down."""
|
||||
trend_id = seed_ids["trends"]["TREND_01"]
|
||||
resp = await query_client.get(f"/api/trends/{trend_id}/evidence")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, dict)
|
||||
assert "trend" in data or "id" in data
|
||||
|
||||
|
||||
class TestQueryAPITrendProjection:
|
||||
"""Endpoint: /api/trends/{id}/projection — trend projection."""
|
||||
|
||||
async def test_trend_projection(self, query_client, seed_ids):
|
||||
"""GET /api/trends/{id}/projection — trend with projection."""
|
||||
trend_id = seed_ids["trends"]["TREND_01"]
|
||||
resp = await query_client.get(f"/api/trends/{trend_id}/projection")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "projected_direction" in data
|
||||
assert "projected_strength" in data
|
||||
assert "projected_confidence" in data
|
||||
assert "macro_contribution_pct" in data
|
||||
|
||||
async def test_trend_projection_not_found(self, query_client):
|
||||
"""GET /api/trends/{id}/projection — no projection returns 404 for non-existent trend."""
|
||||
fake_id = "00000000-0000-4000-ffff-000000000099"
|
||||
resp = await query_client.get(f"/api/trends/{fake_id}/projection")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 3: Macro and Competitive Layer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestQueryAPIMacro:
|
||||
"""Endpoints: /api/admin/macro/status, /api/macro/events, /api/macro/impacts."""
|
||||
|
||||
async def test_macro_status(self, query_client):
|
||||
"""GET /api/admin/macro/status — macro layer toggle status."""
|
||||
resp = await query_client.get("/api/admin/macro/status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "macro_enabled" in data
|
||||
|
||||
async def test_list_macro_events(self, query_client, seed_ids):
|
||||
"""GET /api/macro/events — at least 2 seeded events."""
|
||||
resp = await query_client.get("/api/macro/events")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) >= 2
|
||||
for evt in data:
|
||||
assert "id" in evt
|
||||
assert "severity" in evt
|
||||
assert "summary" in evt
|
||||
|
||||
async def test_get_macro_event_detail(self, query_client, seed_ids):
|
||||
"""GET /api/macro/events/{id} — event detail with impacts."""
|
||||
event_id = seed_ids["global_events"]["EVT_01"]
|
||||
resp = await query_client.get(f"/api/macro/events/{event_id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == event_id
|
||||
assert "impacts" in data
|
||||
|
||||
async def test_macro_impacts_by_ticker(self, query_client):
|
||||
"""GET /api/macro/impacts/AAPL — at least 1 impact record."""
|
||||
resp = await query_client.get("/api/macro/impacts/AAPL")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "impacts" in data
|
||||
assert isinstance(data["impacts"], list)
|
||||
assert len(data["impacts"]) >= 1
|
||||
for impact in data["impacts"]:
|
||||
assert "macro_impact_score" in impact
|
||||
assert "impact_direction" in impact
|
||||
|
||||
|
||||
class TestQueryAPICompetitive:
|
||||
"""Endpoints: /api/admin/competitive/status, /api/patterns/{ticker}/competitive-signals, /api/patterns/{ticker}."""
|
||||
|
||||
async def test_competitive_status(self, query_client):
|
||||
"""GET /api/admin/competitive/status — competitive layer toggle."""
|
||||
resp = await query_client.get("/api/admin/competitive/status")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "competitive_enabled" in data
|
||||
|
||||
async def test_competitive_signals(self, query_client):
|
||||
"""GET /api/patterns/AAPL/competitive-signals — competitive signals."""
|
||||
resp = await query_client.get("/api/patterns/AAPL/competitive-signals")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "competitive_signals" in data
|
||||
assert isinstance(data["competitive_signals"], list)
|
||||
|
||||
async def test_patterns_for_ticker(self, query_client):
|
||||
"""GET /api/patterns/AAPL — patterns data."""
|
||||
resp = await query_client.get("/api/patterns/AAPL")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "ticker" in data
|
||||
assert "patterns" in data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 4: Operational and Admin Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestQueryAPIOpsExtended:
|
||||
"""Endpoints: /api/ops/pipeline/health, /api/ops/ingestion/summary, /api/ops/sources/coverage-gaps."""
|
||||
|
||||
async def test_pipeline_health_fields(self, query_client):
|
||||
"""GET /api/ops/pipeline/health — verify all required fields."""
|
||||
resp = await query_client.get("/api/ops/pipeline/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "document_stages" in data
|
||||
assert "parsing" in data
|
||||
assert "extraction" in data
|
||||
assert "aggregation" in data
|
||||
assert "queue_depths" in data
|
||||
|
||||
async def test_ingestion_summary_fields(self, query_client):
|
||||
"""GET /api/ops/ingestion/summary — verify total_runs and by_source_type."""
|
||||
resp = await query_client.get("/api/ops/ingestion/summary")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "total_runs" in data
|
||||
assert "by_source_type" in data
|
||||
|
||||
async def test_coverage_gaps_fields(self, query_client):
|
||||
"""GET /api/ops/sources/coverage-gaps — verify fields."""
|
||||
resp = await query_client.get("/api/ops/sources/coverage-gaps")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "missing_source_types" in data
|
||||
assert "stale_sources" in data
|
||||
|
||||
|
||||
class TestQueryAPISourceToggle:
|
||||
"""Endpoint: PUT /api/admin/sources/{id}/toggle."""
|
||||
|
||||
async def test_toggle_source(self, query_client):
|
||||
"""PUT /api/admin/sources/{id}/toggle?active=false — toggle source off."""
|
||||
source_id = "00000000-0000-4000-b000-000000000001" # SOURCE_AAPL
|
||||
resp = await query_client.put(
|
||||
f"/api/admin/sources/{source_id}/toggle", params={"active": "false"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# Toggle back on
|
||||
resp2 = await query_client.put(
|
||||
f"/api/admin/sources/{source_id}/toggle", params={"active": "true"}
|
||||
)
|
||||
assert resp2.status_code == 200
|
||||
|
||||
|
||||
class TestQueryAPITradingAdmin:
|
||||
"""Endpoints: /api/admin/trading/config, approvals, lockouts."""
|
||||
|
||||
async def test_trading_config(self, query_client):
|
||||
"""GET /api/admin/trading/config — trading config."""
|
||||
resp = await query_client.get("/api/admin/trading/config")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "trading_mode" in data or "config" in data or "name" in data
|
||||
|
||||
async def test_pending_approvals(self, query_client, seed_ids):
|
||||
"""GET /api/admin/trading/approvals — pending approvals list."""
|
||||
resp = await query_client.get("/api/admin/trading/approvals")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
# Should have at least the seeded pending approval
|
||||
assert len(data) >= 1
|
||||
|
||||
async def test_active_lockouts(self, query_client, seed_ids):
|
||||
"""GET /api/admin/trading/lockouts — active lockouts list."""
|
||||
resp = await query_client.get("/api/admin/trading/lockouts")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) >= 1
|
||||
|
||||
async def test_lockout_create_and_delete(self, query_client):
|
||||
"""POST then DELETE /api/admin/trading/lockouts — lifecycle."""
|
||||
# Create
|
||||
body = {
|
||||
"ticker": "TSLA",
|
||||
"lockout_type": "manual",
|
||||
"reason": "Integration test lockout",
|
||||
"duration_minutes": 1440,
|
||||
}
|
||||
resp = await query_client.post("/api/admin/trading/lockouts", json=body)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
lockout_id = data["id"]
|
||||
# Delete
|
||||
resp2 = await query_client.delete(
|
||||
f"/api/admin/trading/lockouts/{lockout_id}"
|
||||
)
|
||||
assert resp2.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 5: Agents and Analytics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestQueryAPIAgentsExtended:
|
||||
"""Endpoints: /api/agents CRUD, variants, performance."""
|
||||
|
||||
async def test_agent_detail(self, query_client, seed_ids):
|
||||
"""GET /api/agents/{id} — agent detail with system_prompt, temperature, max_tokens."""
|
||||
agent_id = seed_ids["agents"]["extractor"]
|
||||
resp = await query_client.get(f"/api/agents/{agent_id}")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "system_prompt" in data
|
||||
assert "temperature" in data
|
||||
assert "max_tokens" in data
|
||||
|
||||
async def test_create_agent(self, query_client):
|
||||
"""POST /api/agents — create agent with slug generation."""
|
||||
body = {
|
||||
"name": "Integration Test Agent",
|
||||
"purpose": "Testing agent creation",
|
||||
"model_provider": "ollama",
|
||||
"model_name": "qwen3.5:9b",
|
||||
"system_prompt": "You are a test agent.",
|
||||
"prompt_version": "test-v1",
|
||||
"schema_version": "1.0.0",
|
||||
}
|
||||
resp = await query_client.post("/api/agents", json=body)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert "id" in data
|
||||
assert "slug" in data
|
||||
assert data["name"] == "Integration Test Agent"
|
||||
|
||||
async def test_update_agent(self, query_client, seed_ids):
|
||||
"""PUT /api/agents/{id} — update agent."""
|
||||
agent_id = seed_ids["agents"]["thesis"]
|
||||
body = {"purpose": "Updated purpose for integration test"}
|
||||
resp = await query_client.put(f"/api/agents/{agent_id}", json=body)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["purpose"] == "Updated purpose for integration test"
|
||||
|
||||
async def test_create_variant(self, query_client, seed_ids):
|
||||
"""POST /api/agents/{id}/variants — create variant."""
|
||||
agent_id = seed_ids["agents"]["extractor"]
|
||||
body = {
|
||||
"variant_name": "Test Variant",
|
||||
"description": "Integration test variant",
|
||||
"model_provider": "ollama",
|
||||
"model_name": "qwen3.5:9b",
|
||||
"system_prompt": "Test variant prompt",
|
||||
"prompt_version": "test-variant-v1",
|
||||
}
|
||||
resp = await query_client.post(f"/api/agents/{agent_id}/variants", json=body)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert "id" in data
|
||||
assert data["variant_name"] == "Test Variant"
|
||||
|
||||
async def test_activate_variant(self, query_client, seed_ids):
|
||||
"""POST /api/agents/{id}/variants/{vid}/activate — activate variant."""
|
||||
agent_id = seed_ids["agents"]["extractor"]
|
||||
variant_id = seed_ids["variants"]["extractor"]
|
||||
resp = await query_client.post(
|
||||
f"/api/agents/{agent_id}/variants/{variant_id}/activate"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
async def test_agent_performance(self, query_client, seed_ids):
|
||||
"""GET /api/agents/{id}/performance — agent performance metrics."""
|
||||
agent_id = seed_ids["agents"]["extractor"]
|
||||
resp = await query_client.get(f"/api/agents/{agent_id}/performance")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, dict)
|
||||
|
||||
async def test_variant_performance(self, query_client, seed_ids):
|
||||
"""GET /api/agents/{id}/variants/{vid}/performance — variant performance."""
|
||||
agent_id = seed_ids["agents"]["extractor"]
|
||||
variant_id = seed_ids["variants"]["extractor"]
|
||||
resp = await query_client.get(
|
||||
f"/api/agents/{agent_id}/variants/{variant_id}/performance"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, dict)
|
||||
|
||||
|
||||
class TestQueryAPIAnalytics:
|
||||
"""Endpoints: /api/analytics/pg-schema, saved-queries, pg-query."""
|
||||
|
||||
async def test_pg_schema(self, query_client):
|
||||
"""GET /api/analytics/pg-schema — PG schema info."""
|
||||
resp = await query_client.get("/api/analytics/pg-schema")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, (list, dict))
|
||||
|
||||
async def test_list_saved_queries(self, query_client, seed_ids):
|
||||
"""GET /api/analytics/saved-queries — at least 1 from seed."""
|
||||
resp = await query_client.get("/api/analytics/saved-queries")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) >= 1
|
||||
|
||||
async def test_saved_query_create_and_delete(self, query_client):
|
||||
"""POST then DELETE /api/analytics/saved-queries — lifecycle."""
|
||||
body = {
|
||||
"name": "IntTest Query",
|
||||
"description": "Integration test saved query",
|
||||
"sql_text": "SELECT 1 AS test",
|
||||
}
|
||||
resp = await query_client.post("/api/analytics/saved-queries", json=body)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
query_id = data["id"]
|
||||
# Delete
|
||||
resp2 = await query_client.delete(f"/api/analytics/saved-queries/{query_id}")
|
||||
assert resp2.status_code == 200
|
||||
|
||||
async def test_pg_query(self, query_client):
|
||||
"""POST /api/analytics/pg-query — valid read-only SQL."""
|
||||
body = {"sql": "SELECT ticker, legal_name FROM companies LIMIT 5"}
|
||||
resp = await query_client.post("/api/analytics/pg-query", json=body)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "columns" in data or "rows" in data or isinstance(data, list)
|
||||
Reference in New Issue
Block a user