898f89926d
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.
408 lines
16 KiB
Python
408 lines
16 KiB
Python
"""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)
|