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:
Celes Renata
2026-04-20 02:34:19 +00:00
parent 8f67d326c9
commit 898f89926d
12 changed files with 2129 additions and 0 deletions
+14
View File
@@ -21,18 +21,25 @@ import pytest_asyncio
from tests.integration.profiler import EndpointProfiler
from tests.integration.seed_sandbox import (
SEED_AGENT_IDS,
SEED_APPROVAL_IDS,
SEED_BROKER_ACCOUNT_ID,
SEED_COMPANY_IDS,
SEED_DOCUMENT_IDS,
SEED_GLOBAL_EVENT_IDS,
SEED_INGESTION_RUN_IDS,
SEED_LOCKOUT_IDS,
SEED_NOTIFICATION_IDS,
SEED_ORDER_IDS,
SEED_PORTFOLIO_SNAPSHOT_ID,
SEED_POSITION_IDS,
SEED_RECOMMENDATION_IDS,
SEED_RISK_CONFIG_ID,
SEED_RISK_SNAPSHOT_IDS,
SEED_SAVED_QUERY_IDS,
SEED_TRADING_DECISION_ID,
SEED_TREND_IDS,
SEED_VARIANT_IDS,
SEED_WATCHLIST_IDS,
)
# ---------------------------------------------------------------------------
@@ -185,4 +192,11 @@ def seed_ids() -> dict:
"trading_decision_id": SEED_TRADING_DECISION_ID,
"portfolio_snapshot_id": SEED_PORTFOLIO_SNAPSHOT_ID,
"risk_config_id": SEED_RISK_CONFIG_ID,
"watchlists": SEED_WATCHLIST_IDS,
"approvals": SEED_APPROVAL_IDS,
"lockouts": SEED_LOCKOUT_IDS,
"notifications": SEED_NOTIFICATION_IDS,
"ingestion_runs": SEED_INGESTION_RUN_IDS,
"saved_queries": SEED_SAVED_QUERY_IDS,
"risk_snapshots": SEED_RISK_SNAPSHOT_IDS,
}
+199
View File
@@ -194,6 +194,36 @@ AUDIT_01 = UUID("00000000-0000-4000-ab00-000000000001")
AUDIT_02 = UUID("00000000-0000-4000-ab00-000000000002")
AUDIT_03 = UUID("00000000-0000-4000-ab00-000000000003")
# Watchlists
WATCHLIST_01 = UUID("00000000-0000-4000-ac00-000000000001")
WATCHLIST_02 = UUID("00000000-0000-4000-ac00-000000000002")
# Watchlist members
WL_MEMBER_01 = UUID("00000000-0000-4000-ac10-000000000001")
WL_MEMBER_02 = UUID("00000000-0000-4000-ac10-000000000002")
WL_MEMBER_03 = UUID("00000000-0000-4000-ac10-000000000003")
# Operator Approvals
APPROVAL_PENDING = UUID("00000000-0000-4000-ad00-000000000001")
APPROVAL_APPROVED = UUID("00000000-0000-4000-ad00-000000000002")
# Symbol Lockouts
LOCKOUT_ACTIVE = UUID("00000000-0000-4000-ae00-000000000001")
LOCKOUT_EXPIRED = UUID("00000000-0000-4000-ae00-000000000002")
# Notifications
NOTIFICATION_01 = UUID("00000000-0000-4000-af00-000000000001")
# Ingestion Runs
INGESTION_RUN_01 = UUID("00000000-0000-4000-b300-000000000001")
INGESTION_RUN_02 = UUID("00000000-0000-4000-b300-000000000002")
# Saved Queries
SAVED_QUERY_01 = UUID("00000000-0000-4000-b400-000000000001")
# Daily Risk Snapshot
RISK_SNAPSHOT_01 = UUID("00000000-0000-4000-b500-000000000001")
# ── Exported lookup dicts for test imports ────────────────────
SEED_COMPANY_IDS = {
@@ -259,6 +289,26 @@ SEED_TRADING_DECISION_ID = str(TRADING_DECISION_01)
SEED_PORTFOLIO_SNAPSHOT_ID = str(PORTFOLIO_SNAP_01)
SEED_RISK_CONFIG_ID = str(RISK_CONFIG_01)
SEED_WATCHLIST_IDS = {
"WL_01": str(WATCHLIST_01),
"WL_02": str(WATCHLIST_02),
}
SEED_APPROVAL_IDS = {
"PENDING": str(APPROVAL_PENDING),
"APPROVED": str(APPROVAL_APPROVED),
}
SEED_LOCKOUT_IDS = {
"ACTIVE": str(LOCKOUT_ACTIVE),
"EXPIRED": str(LOCKOUT_EXPIRED),
}
SEED_NOTIFICATION_IDS = {"NOTIF_01": str(NOTIFICATION_01)}
SEED_INGESTION_RUN_IDS = {
"RUN_01": str(INGESTION_RUN_01),
"RUN_02": str(INGESTION_RUN_02),
}
SEED_SAVED_QUERY_IDS = {"SQ_01": str(SAVED_QUERY_01)}
SEED_RISK_SNAPSHOT_IDS = {"SNAP_01": str(RISK_SNAPSHOT_01)}
# ── Seed function ─────────────────────────────────────────────
@@ -303,6 +353,13 @@ async def seed() -> None:
await _seed_agent_performance_log(conn)
await _seed_risk_configs(conn)
await _seed_audit_events(conn)
await _seed_watchlists(conn)
await _seed_operator_approvals(conn)
await _seed_symbol_lockouts(conn)
await _seed_notifications(conn)
await _seed_ingestion_runs(conn)
await _seed_saved_queries(conn)
await _seed_daily_risk_snapshots(conn)
finally:
await conn.close()
@@ -990,6 +1047,148 @@ async def _seed_audit_events(conn: asyncpg.Connection) -> None:
)
# ── Watchlists ────────────────────────────────────────────────
async def _seed_watchlists(conn: asyncpg.Connection) -> None:
watchlists = [
(WATCHLIST_01, "Tech Leaders", "Top technology companies", True, BASE_TS, BASE_TS),
(WATCHLIST_02, "Value Picks", "Undervalued large caps", True, BASE_TS, BASE_TS),
]
await conn.executemany(
"""INSERT INTO watchlists (id, name, description, active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT DO NOTHING""",
watchlists,
)
members = [
(WL_MEMBER_01, WATCHLIST_01, COMPANY_AAPL, BASE_TS),
(WL_MEMBER_02, WATCHLIST_01, COMPANY_MSFT, BASE_TS),
(WL_MEMBER_03, WATCHLIST_02, COMPANY_JPM, BASE_TS),
]
await conn.executemany(
"""INSERT INTO watchlist_members (id, watchlist_id, company_id, added_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT DO NOTHING""",
members,
)
# ── Operator Approvals ────────────────────────────────────────
async def _seed_operator_approvals(conn: asyncpg.Connection) -> None:
approvals = [
(APPROVAL_PENDING, json.dumps({"ticker": "AAPL", "side": "buy", "qty": 5}),
REC_01, "AAPL", "buy", 5, 927.50, "pending", RISK_EVAL_01,
"system", None, None,
BASE_TS + timedelta(hours=24), # expires in 24h
BASE_TS, None, BASE_TS, BASE_TS),
(APPROVAL_APPROVED, json.dumps({"ticker": "MSFT", "side": "buy", "qty": 3}),
REC_02, "MSFT", "buy", 3, 1230.00, "approved", None,
"system", "test-operator", "Approved for paper trading",
BASE_TS + timedelta(hours=24),
BASE_TS - timedelta(hours=2), BASE_TS - timedelta(hours=1),
BASE_TS, BASE_TS),
]
await conn.executemany(
"""INSERT INTO operator_approvals
(id, order_job, recommendation_id, ticker, side, quantity, estimated_value,
status, risk_evaluation_id, requested_by, reviewed_by, review_note,
expires_at, requested_at, reviewed_at, created_at, updated_at)
VALUES ($1, $2::jsonb, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
ON CONFLICT DO NOTHING""",
approvals,
)
# ── Symbol Lockouts ───────────────────────────────────────────
async def _seed_symbol_lockouts(conn: asyncpg.Connection) -> None:
lockouts = [
(LOCKOUT_ACTIVE, "AAPL", "news_shock", "Earnings volatility cooldown",
BASE_TS + timedelta(days=7), BASE_TS),
(LOCKOUT_EXPIRED, "XOM", "cooldown", "Post-trade cooldown period",
BASE_TS - timedelta(days=1), BASE_TS - timedelta(days=3)),
]
await conn.executemany(
"""INSERT INTO symbol_lockouts (id, ticker, lockout_type, reason, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT DO NOTHING""",
lockouts,
)
# ── Notifications ─────────────────────────────────────────────
async def _seed_notifications(conn: asyncpg.Connection) -> None:
await conn.execute(
"""INSERT INTO notifications
(id, channel, event_type, message, delivery_status, retry_count, error_message, created_at, delivered_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT DO NOTHING""",
NOTIFICATION_01, "email", "order.filled",
"Order filled: AAPL buy 10 shares at $185.50",
"delivered", 0, None, BASE_TS, BASE_TS + timedelta(seconds=5),
)
# ── Ingestion Runs ────────────────────────────────────────────
async def _seed_ingestion_runs(conn: asyncpg.Connection) -> None:
runs = [
(INGESTION_RUN_01, SOURCE_AAPL, COMPANY_AAPL, "news", "completed",
BASE_TS - timedelta(hours=2), BASE_TS - timedelta(hours=1, minutes=55),
15, 8, None, 0, None),
(INGESTION_RUN_02, SOURCE_JPM, COMPANY_JPM, "filing", "failed",
BASE_TS - timedelta(hours=1), None,
0, 0, "Connection timeout to SEC EDGAR", 2,
BASE_TS + timedelta(hours=1)),
]
await conn.executemany(
"""INSERT INTO ingestion_runs
(id, source_id, company_id, source_type, status, started_at, completed_at,
items_fetched, items_new, error_message, retry_count, next_retry_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT DO NOTHING""",
runs,
)
# ── Saved Queries ─────────────────────────────────────────────
async def _seed_saved_queries(conn: asyncpg.Connection) -> None:
await conn.execute(
"""INSERT INTO saved_queries (id, name, description, sql_text, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT DO NOTHING""",
SAVED_QUERY_01, "Top Recommendations",
"Shows highest confidence recommendations",
"SELECT ticker, action, confidence FROM recommendations ORDER BY confidence DESC LIMIT 10",
"operator", BASE_TS, BASE_TS,
)
# ── Daily Risk Snapshots ──────────────────────────────────────
async def _seed_daily_risk_snapshots(conn: asyncpg.Connection) -> None:
await conn.execute(
"""INSERT INTO daily_risk_snapshots
(id, account_id, snapshot_date, portfolio_value, daily_pnl,
daily_trade_count, positions_by_sector, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9)
ON CONFLICT DO NOTHING""",
RISK_SNAPSHOT_01, "PAPER-001", BASE_DATE, 12500.00, 150.25,
4, json.dumps({"Technology": 0.45, "Financial Services": 0.30, "Energy": 0.25}),
BASE_TS, BASE_TS,
)
# ── Entry point ───────────────────────────────────────────────
if __name__ == "__main__":
@@ -0,0 +1,159 @@
"""Integration tests for cross-service data consistency.
These tests validate round-trip behavior: writing data via one service
and reading it back via another (or the same service on a different path).
They catch data propagation issues and schema drift between services.
"""
import pytest
pytestmark = pytest.mark.asyncio
# ---------------------------------------------------------------------------
# Cross-Service: Company creation via Registry → read via Query API
# ---------------------------------------------------------------------------
class TestCrossServiceCompanyRoundTrip:
"""Write company via registry, verify visibility in query API."""
async def test_create_company_via_registry_read_via_query(
self, registry_client, query_client,
):
"""Create company via registry, read via query API."""
payload = {
"ticker": "XRND",
"legal_name": "Cross Round Trip Corp",
"exchange": "NYSE",
"sector": "Technology",
"industry": "Software",
"market_cap_bucket": "small",
}
create_resp = await registry_client.post("/companies", json=payload)
assert create_resp.status_code == 201
# Read via query API
query_resp = await query_client.get("/api/companies")
assert query_resp.status_code == 200
tickers = {c["ticker"] for c in query_resp.json()}
assert "XRND" in tickers
# ---------------------------------------------------------------------------
# Cross-Service: Exposure profile round-trip via Registry
# ---------------------------------------------------------------------------
class TestCrossServiceExposureRoundTrip:
"""PUT exposure via registry, GET via registry on a different path."""
async def test_exposure_round_trip(self, registry_client, seed_ids):
"""PUT exposure via registry → GET via registry."""
company_id = seed_ids["companies"]["MSFT"]
payload = {
"geographic_revenue_mix": {
"North America": 0.55,
"Europe": 0.25,
"Asia": 0.20,
},
"supply_chain_regions": ["North America", "Europe"],
"key_input_commodities": [],
"regulatory_jurisdictions": ["US", "EU"],
"market_position_tier": "global_leader",
"export_dependency_pct": 0.35,
"source": "manual",
"confidence": 0.85,
}
put_resp = await registry_client.put(
f"/companies/{company_id}/exposure", json=payload,
)
assert put_resp.status_code == 200
# Read back
get_resp = await registry_client.get(
f"/companies/{company_id}/exposure",
)
assert get_resp.status_code == 200
data = get_resp.json()
assert data["geographic_revenue_mix"]["North America"] == 0.55
assert data["market_position_tier"] == "global_leader"
assert "supply_chain_regions" in data
# ---------------------------------------------------------------------------
# Cross-Service: Competitor relationship bidirectional visibility
# ---------------------------------------------------------------------------
class TestCrossServiceCompetitorBidirectional:
"""Competitor relationships visible from both sides."""
async def test_competitor_bidirectional_visibility(
self, registry_client, seed_ids,
):
"""Competitor visible from both sides."""
aapl_id = seed_ids["companies"]["AAPL"]
msft_id = seed_ids["companies"]["MSFT"]
# Check from AAPL side
resp_a = await registry_client.get(
f"/companies/{aapl_id}/competitors",
)
assert resp_a.status_code == 200
a_partners = set()
for rel in resp_a.json():
if rel["company_a_id"] == aapl_id:
a_partners.add(rel["company_b_id"])
else:
a_partners.add(rel["company_a_id"])
assert msft_id in a_partners
# Check from MSFT side
resp_b = await registry_client.get(
f"/companies/{msft_id}/competitors",
)
assert resp_b.status_code == 200
b_partners = set()
for rel in resp_b.json():
if rel["company_a_id"] == msft_id:
b_partners.add(rel["company_b_id"])
else:
b_partners.add(rel["company_a_id"])
assert aapl_id in b_partners
# ---------------------------------------------------------------------------
# Cross-Service: Risk evaluation schema matches query API recommendations
# ---------------------------------------------------------------------------
class TestCrossServiceRiskEvaluationSchema:
"""Risk evaluation schema matches what query API returns for recommendations."""
async def test_risk_evaluation_matches_recommendation(
self, risk_client, query_client, seed_ids,
):
"""Risk evaluation schema matches what query API returns for recommendations."""
# Evaluate via risk engine
eval_resp = await risk_client.post("/evaluate", json={
"order": {
"ticker": "AAPL",
"action": "buy",
"quantity": 10,
"estimated_value": 1855.00,
"confidence": 0.85,
},
})
assert eval_resp.status_code == 200
eval_data = eval_resp.json()
assert "evaluation_id" in eval_data
assert "eligible" in eval_data
assert "checks" in eval_data
# Query recommendation with risk evaluation
rec_id = seed_ids["recommendations"]["REC_01"]
rec_resp = await query_client.get(f"/api/recommendations/{rec_id}")
assert rec_resp.status_code == 200
rec_data = rec_resp.json()
assert "risk_evaluation" in rec_data
+170
View File
@@ -0,0 +1,170 @@
"""Integration tests for error handling and empty-state behavior across all services.
Validates that all endpoints return structured JSON errors (not HTML or stack
traces) and handle empty-state gracefully, so the frontend never receives
unexpected response formats.
"""
import pytest
pytestmark = pytest.mark.asyncio
# ---------------------------------------------------------------------------
# Empty list endpoints
# ---------------------------------------------------------------------------
class TestEmptyListEndpoints:
"""List endpoints with no matching data return 200 with []."""
async def test_documents_empty_filter(self, query_client):
"""GET /api/documents?ticker=ZZZZ — no matching docs returns 200 with list."""
resp = await query_client.get("/api/documents", params={"ticker": "ZZZZ"})
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) == 0
# ---------------------------------------------------------------------------
# Not-found detail endpoints
# ---------------------------------------------------------------------------
class TestNotFoundEndpoints:
"""Detail endpoints with non-existent UUID return 404 with JSON body."""
async def test_query_api_document_not_found(self, query_client):
"""GET /api/documents/{id} — 404 with JSON body."""
fake_id = "00000000-0000-4000-ffff-000000000099"
resp = await query_client.get(f"/api/documents/{fake_id}")
assert resp.status_code == 404
data = resp.json()
assert isinstance(data, dict)
async def test_registry_company_not_found(self, registry_client):
"""GET /companies/{id} — 404 with JSON body."""
fake_id = "00000000-0000-4000-ffff-000000000099"
resp = await registry_client.get(f"/companies/{fake_id}")
assert resp.status_code == 404
data = resp.json()
assert isinstance(data, dict)
async def test_risk_approval_not_found(self, risk_client):
"""GET /approvals/{id} — 404 with JSON body."""
fake_id = "00000000-0000-4000-ffff-000000000099"
resp = await risk_client.get(f"/approvals/{fake_id}")
assert resp.status_code == 404
data = resp.json()
assert isinstance(data, dict)
# ---------------------------------------------------------------------------
# Validation errors
# ---------------------------------------------------------------------------
class TestValidationErrors:
"""Invalid JSON body returns 422 with validation details."""
async def test_invalid_company_body(self, registry_client):
"""POST /companies — missing required fields returns 422."""
resp = await registry_client.post("/companies", json={})
assert resp.status_code == 422
data = resp.json()
assert isinstance(data, dict)
assert "detail" in data
# ---------------------------------------------------------------------------
# Trend projection edge case
# ---------------------------------------------------------------------------
class TestTrendProjectionNoProjection:
"""Trend projection with no projection returns appropriate response (not 500)."""
async def test_trend_projection_nonexistent(self, query_client):
"""GET /api/trends/{id}/projection — non-existent trend returns 404, not 500."""
fake_id = "00000000-0000-4000-ffff-000000000099"
resp = await query_client.get(f"/api/trends/{fake_id}/projection")
assert resp.status_code != 500
assert resp.status_code in (200, 404)
# ---------------------------------------------------------------------------
# Macro impacts empty state
# ---------------------------------------------------------------------------
class TestMacroImpactsEmpty:
"""Macro impacts with no data returns 200 with empty impacts list."""
async def test_macro_impacts_no_data(self, query_client):
"""GET /api/macro/impacts/ZZZZ — no impacts returns 200 with empty list."""
resp = await query_client.get("/api/macro/impacts/ZZZZ")
assert resp.status_code == 200
data = resp.json()
assert "impacts" in data
assert isinstance(data["impacts"], list)
assert len(data["impacts"]) == 0
# ---------------------------------------------------------------------------
# Health endpoints across all services
# ---------------------------------------------------------------------------
class TestAllServiceHealthEndpoints:
"""All four service health endpoints return {"status": "ok"}."""
async def test_query_api_health(self, query_client):
"""GET /health — query API."""
resp = await query_client.get("/health")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
async def test_registry_health(self, registry_client):
"""GET /health — symbol registry."""
resp = await registry_client.get("/health")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
async def test_risk_health(self, risk_client):
"""GET /health — risk engine."""
resp = await risk_client.get("/health")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
async def test_trading_health(self, trading_client):
"""GET /health — trading engine."""
resp = await trading_client.get("/health")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
# ---------------------------------------------------------------------------
# Override order structured error
# ---------------------------------------------------------------------------
class TestOverrideStructuredError:
"""Override order returns structured JSON error when Redis unavailable."""
async def test_override_structured_error(self, trading_client):
"""POST /api/trading/override/order — structured error (not unhandled exception)."""
payload = {
"ticker": "AAPL",
"side": "buy",
"quantity": 1.0,
"order_type": "market",
}
resp = await trading_client.post("/api/trading/override/order", json=payload)
# Accept 202 (success) or structured error
assert resp.status_code in (200, 202, 400, 422, 503)
data = resp.json()
assert isinstance(data, dict)
# If 503, verify it's JSON not an unhandled exception
if resp.status_code == 503:
assert "detail" in data or "error" in data or "message" in data
@@ -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)
@@ -0,0 +1,253 @@
"""Integration tests for Symbol Registry write paths and edge cases.
Covers duplicate detection, alias/source creation, watchlist CRUD,
competitor relationship lifecycle, and exposure profile management.
Uses the ``registry_client`` and ``seed_ids`` fixtures from conftest.py.
"""
import pytest
pytestmark = pytest.mark.asyncio
# ---------------------------------------------------------------------------
# Task 6: Write Paths and Edge Cases
# ---------------------------------------------------------------------------
class TestRegistryDuplicateCompany:
"""POST /companies — duplicate ticker+exchange returns 409."""
async def test_duplicate_company_returns_409(self, registry_client, seed_ids):
"""POST /companies — duplicate ticker+exchange returns 409."""
payload = {
"ticker": "AAPL",
"legal_name": "Apple Inc Duplicate",
"exchange": "NASDAQ",
"sector": "Technology",
"industry": "Consumer Electronics",
"market_cap_bucket": "mega",
}
resp = await registry_client.post("/companies", json=payload)
assert resp.status_code == 409
class TestRegistryCompanyNotFound:
"""GET /companies/{id} — 404 for non-existent UUID."""
async def test_company_not_found(self, registry_client):
"""GET /companies/{id} — 404 for non-existent UUID."""
fake_id = "00000000-0000-4000-ffff-000000000099"
resp = await registry_client.get(f"/companies/{fake_id}")
assert resp.status_code == 404
class TestRegistryAliasCreate:
"""POST /companies/{id}/aliases — create alias returns 201."""
async def test_create_alias(self, registry_client, seed_ids):
"""POST /companies/{id}/aliases — create alias returns 201."""
company_id = seed_ids["companies"]["AAPL"]
payload = {"alias": "Apple Computer", "alias_type": "historical"}
resp = await registry_client.post(f"/companies/{company_id}/aliases", json=payload)
assert resp.status_code == 201
data = resp.json()
assert "id" in data
assert data["alias"] == "Apple Computer"
assert data["alias_type"] == "historical"
class TestRegistrySourceCreate:
"""POST /companies/{id}/sources — create and validate sources."""
async def test_create_source(self, registry_client, seed_ids):
"""POST /companies/{id}/sources — create source returns 201."""
company_id = seed_ids["companies"]["AAPL"]
payload = {
"source_type": "news_api",
"source_name": "Test News Source",
"config": {},
"credibility_score": 0.7,
}
resp = await registry_client.post(f"/companies/{company_id}/sources", json=payload)
assert resp.status_code == 201
data = resp.json()
assert "id" in data
assert data["source_type"] == "news_api"
assert data["source_name"] == "Test News Source"
async def test_create_source_invalid_type(self, registry_client, seed_ids):
"""POST /companies/{id}/sources — invalid source_type returns 422."""
company_id = seed_ids["companies"]["AAPL"]
payload = {
"source_type": "invalid_type",
"source_name": "Bad Source",
}
resp = await registry_client.post(f"/companies/{company_id}/sources", json=payload)
assert resp.status_code == 422
class TestRegistryWatchlistCRUD:
"""Watchlist creation, member management, and duplicate detection."""
async def test_create_watchlist(self, registry_client):
"""POST /watchlists — create watchlist returns 201."""
payload = {"name": "IntTest Watchlist", "description": "Integration test"}
resp = await registry_client.post("/watchlists", json=payload)
assert resp.status_code == 201
data = resp.json()
assert "id" in data
assert data["name"] == "IntTest Watchlist"
async def test_add_watchlist_member(self, registry_client, seed_ids):
"""POST /watchlists/{id}/members/{company_id} — add member returns 201."""
wl_id = seed_ids["watchlists"]["WL_01"]
company_id = seed_ids["companies"]["XOM"]
resp = await registry_client.post(f"/watchlists/{wl_id}/members/{company_id}")
assert resp.status_code == 201
async def test_list_watchlist_members(self, registry_client, seed_ids):
"""GET /watchlists/{id}/members — members with ticker and legal_name."""
wl_id = seed_ids["watchlists"]["WL_01"]
resp = await registry_client.get(f"/watchlists/{wl_id}/members")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) >= 2 # AAPL and MSFT from seed
for member in data:
assert "ticker" in member
assert "legal_name" in member
async def test_duplicate_watchlist_returns_409(self, registry_client, seed_ids):
"""POST /watchlists — duplicate name returns 409."""
payload = {"name": "Tech Leaders"} # Already seeded
resp = await registry_client.post("/watchlists", json=payload)
assert resp.status_code == 409
# ---------------------------------------------------------------------------
# Task 7: Competitor and Exposure Write Paths
# ---------------------------------------------------------------------------
class TestRegistryCompetitorCRUD:
"""Competitor relationship creation, update, and soft-delete."""
async def test_create_competitor(self, registry_client, seed_ids):
"""POST /companies/{id}/competitors — create returns 201."""
company_id = seed_ids["companies"]["XOM"]
competitor_id = seed_ids["companies"]["JNJ"]
payload = {
"company_b_id": competitor_id,
"relationship_type": "same_sector",
"strength": 0.4,
"bidirectional": True,
"source": "manual",
}
resp = await registry_client.post(f"/companies/{company_id}/competitors", json=payload)
assert resp.status_code == 201
data = resp.json()
assert "id" in data
assert data["relationship_type"] == "same_sector"
assert data["strength"] == 0.4
assert data["bidirectional"] is True
async def test_self_referencing_competitor_returns_400(self, registry_client, seed_ids):
"""POST /companies/{id}/competitors — self-reference returns 400."""
company_id = seed_ids["companies"]["AAPL"]
payload = {
"company_b_id": company_id,
"relationship_type": "direct_rival",
"strength": 0.5,
}
resp = await registry_client.post(f"/companies/{company_id}/competitors", json=payload)
assert resp.status_code == 400
async def test_update_competitor(self, registry_client, seed_ids):
"""PUT /companies/{id}/competitors/{rel_id} — update strength."""
company_id = seed_ids["companies"]["AAPL"]
competitor_id = seed_ids["companies"]["JNJ"]
# Create a new relationship to update
create_payload = {
"company_b_id": competitor_id,
"relationship_type": "overlapping_products",
"strength": 0.3,
}
create_resp = await registry_client.post(
f"/companies/{company_id}/competitors", json=create_payload
)
assert create_resp.status_code == 201
rel_id = create_resp.json()["id"]
# Update
update_payload = {
"company_b_id": competitor_id,
"relationship_type": "overlapping_products",
"strength": 0.7,
"bidirectional": True,
"source": "manual",
}
resp = await registry_client.put(
f"/companies/{company_id}/competitors/{rel_id}", json=update_payload
)
assert resp.status_code == 200
data = resp.json()
assert data["strength"] == 0.7
async def test_soft_delete_competitor(self, registry_client, seed_ids):
"""DELETE /companies/{id}/competitors/{rel_id} — soft-delete returns 200."""
company_id = seed_ids["companies"]["AAPL"]
competitor_id = seed_ids["companies"]["XOM"]
# Create a relationship to delete
create_payload = {
"company_b_id": competitor_id,
"relationship_type": "supply_chain_adjacent",
"strength": 0.2,
}
create_resp = await registry_client.post(
f"/companies/{company_id}/competitors", json=create_payload
)
assert create_resp.status_code == 201
rel_id = create_resp.json()["id"]
# Delete
resp = await registry_client.delete(f"/companies/{company_id}/competitors/{rel_id}")
assert resp.status_code == 200
class TestRegistryExposureCRUD:
"""Exposure profile upsert, history, and not-found handling."""
async def test_upsert_exposure_profile(self, registry_client, seed_ids):
"""PUT /companies/{id}/exposure — upsert with version increment."""
company_id = seed_ids["companies"]["MSFT"] # MSFT has no exposure profile
payload = {
"geographic_revenue_mix": {"North America": 0.50, "Europe": 0.30, "Asia": 0.20},
"supply_chain_regions": ["North America", "Europe"],
"key_input_commodities": [],
"regulatory_jurisdictions": ["US", "EU"],
"market_position_tier": "global_leader",
"export_dependency_pct": 0.40,
"source": "manual",
"confidence": 0.9,
}
resp = await registry_client.put(f"/companies/{company_id}/exposure", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["version"] >= 1
assert data["market_position_tier"] == "global_leader"
assert data["geographic_revenue_mix"]["North America"] == 0.50
async def test_exposure_history(self, registry_client, seed_ids):
"""GET /companies/{id}/exposure/history — all versions."""
company_id = seed_ids["companies"]["AAPL"] # AAPL has a seeded profile
resp = await registry_client.get(f"/companies/{company_id}/exposure/history")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) >= 1
async def test_exposure_not_found(self, registry_client, seed_ids):
"""GET /companies/{id}/exposure — 404 for company with no profile."""
company_id = seed_ids["companies"]["JNJ"] # JNJ has no exposure profile
resp = await registry_client.get(f"/companies/{company_id}/exposure")
assert resp.status_code == 404
@@ -0,0 +1,136 @@
"""Integration tests for Risk Engine — evaluation edge cases and approval lifecycle.
Validates evaluation with minimal/extreme orders, custom config overrides,
and the full approval lifecycle (list → detail → review → expire) against
the live sandbox with deterministic seed data.
Uses the ``risk_client`` and ``seed_ids`` fixtures from conftest.py.
"""
import pytest
pytestmark = pytest.mark.asyncio
# ---------------------------------------------------------------------------
# 1 Evaluation Edge Cases
# ---------------------------------------------------------------------------
class TestRiskEvaluationEdgeCases:
"""Edge-case scenarios for POST /evaluate."""
async def test_minimal_order_evaluation(self, risk_client):
"""POST /evaluate — minimal order with only ticker."""
payload = {"order": {"ticker": "AAPL"}}
resp = await risk_client.post("/evaluate", json=payload)
assert resp.status_code == 200
data = resp.json()
assert "evaluation_id" in data
assert "eligible" in data
assert "rejection_reasons" in data
async def test_order_exceeding_position_cap(self, risk_client):
"""POST /evaluate — order exceeding position cap returns eligible: false."""
payload = {
"order": {
"ticker": "AAPL",
"action": "buy",
"quantity": 100000,
"estimated_value": 18550000.00,
"confidence": 0.5,
"sector": "Technology",
},
}
resp = await risk_client.post("/evaluate", json=payload)
assert resp.status_code == 200
data = resp.json()
assert data["eligible"] is False
assert len(data["rejection_reasons"]) >= 1
async def test_evaluation_with_custom_config(self, risk_client):
"""POST /evaluate — custom config override."""
payload = {
"order": {
"ticker": "MSFT",
"action": "buy",
"quantity": 5,
"estimated_value": 2050.00,
"confidence": 0.8,
},
"config": {
"max_portfolio_heat": 0.50,
"max_single_position_pct": 0.10,
"max_sector_concentration": 0.50,
"daily_loss_limit_pct": 0.05,
},
}
resp = await risk_client.post("/evaluate", json=payload)
assert resp.status_code == 200
data = resp.json()
assert "evaluation_id" in data
assert "eligible" in data
# ---------------------------------------------------------------------------
# 2 Approval Lifecycle
# ---------------------------------------------------------------------------
class TestRiskApprovalLifecycle:
"""Full approval lifecycle: list → detail → review → expire."""
async def test_pending_approvals_list(self, risk_client, seed_ids):
"""GET /approvals/pending — list pending approvals from seed."""
resp = await risk_client.get("/approvals/pending")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) >= 1
async def test_approval_detail(self, risk_client, seed_ids):
"""GET /approvals/{id} — seeded pending approval detail."""
approval_id = seed_ids["approvals"]["PENDING"]
resp = await risk_client.get(f"/approvals/{approval_id}")
assert resp.status_code == 200
data = resp.json()
assert data["ticker"] == "AAPL"
assert data["side"] == "buy"
assert "status" in data
assert "expires_at" in data
async def test_approval_review(self, risk_client, seed_ids):
"""POST /approvals/{id}/review — approve the seeded pending approval."""
approval_id = seed_ids["approvals"]["PENDING"]
payload = {
"approved": True,
"reviewed_by": "test-operator",
"review_note": "Integration test approval",
}
resp = await risk_client.post(
f"/approvals/{approval_id}/review", json=payload,
)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "approved"
async def test_review_nonexistent_approval(self, risk_client):
"""POST /approvals/{id}/review — 404 for non-existent approval."""
fake_id = "00000000-0000-4000-ffff-000000000099"
payload = {
"approved": True,
"reviewed_by": "test-operator",
"review_note": "Should fail",
}
resp = await risk_client.post(
f"/approvals/{fake_id}/review", json=payload,
)
assert resp.status_code == 404
async def test_approval_expiry(self, risk_client):
"""POST /approvals/expire — expire stale approvals."""
resp = await risk_client.post("/approvals/expire")
assert resp.status_code == 200
data = resp.json()
assert "expired" in data
assert isinstance(data["expired"], int)
+244
View File
@@ -0,0 +1,244 @@
"""Integration tests for Trading Engine — extended coverage.
Validates configuration round-trips, pause/resume lifecycle, metrics
consistency, notification config, decision filtering, and override
order validation against the live sandbox.
Uses the ``trading_client`` fixture from conftest.py.
"""
import pytest
pytestmark = pytest.mark.asyncio
# ---------------------------------------------------------------------------
# 1 Config Round-Trip
# ---------------------------------------------------------------------------
class TestTradingConfigRoundTrip:
"""PUT config → GET status — verify risk_tier reflected."""
async def test_config_round_trip(self, trading_client):
"""PUT config → GET status — verify risk_tier reflected."""
# Set to aggressive
resp = await trading_client.put(
"/api/trading/config", json={"risk_tier": "aggressive"}
)
assert resp.status_code == 200
# Verify in status
status_resp = await trading_client.get("/api/trading/status")
assert status_resp.status_code == 200
assert status_resp.json()["risk_tier"] == "aggressive"
# Restore to moderate
await trading_client.put(
"/api/trading/config", json={"risk_tier": "moderate"}
)
# ---------------------------------------------------------------------------
# 2 Pause/Resume Round-Trip
# ---------------------------------------------------------------------------
class TestTradingPauseResumeRoundTrip:
"""POST pause → GET status → POST resume → GET status."""
async def test_pause_resume_round_trip(self, trading_client):
"""POST pause → GET status → POST resume → GET status."""
# Pause
resp = await trading_client.post("/api/trading/pause")
assert resp.status_code == 200
# Verify paused
status_resp = await trading_client.get("/api/trading/status")
assert status_resp.status_code == 200
assert status_resp.json()["paused"] is True
# Resume
resp2 = await trading_client.post("/api/trading/resume")
assert resp2.status_code == 200
# Verify resumed
status_resp2 = await trading_client.get("/api/trading/status")
assert status_resp2.status_code == 200
assert status_resp2.json()["paused"] is False
# ---------------------------------------------------------------------------
# 3 Metrics Consistency
# ---------------------------------------------------------------------------
class TestTradingMetricsConsistency:
"""GET /api/trading/metrics — total ≈ active + reserve + unrealized."""
async def test_metrics_consistency(self, trading_client):
"""GET /api/trading/metrics — total ≈ active + reserve + unrealized."""
resp = await trading_client.get("/api/trading/metrics")
assert resp.status_code == 200
data = resp.json()
total = data["total_portfolio_value"]
active = data["active_pool"]
reserve = data["reserve_pool"]
unrealized = data["unrealized_pnl"]
# Allow tolerance for rounding
assert abs(total - (active + reserve + unrealized)) < 1.0
# ---------------------------------------------------------------------------
# 4 Metrics History
# ---------------------------------------------------------------------------
class TestTradingMetricsHistoryExtended:
"""GET /api/trading/metrics/history — returns portfolio snapshots."""
async def test_metrics_history_snapshots(self, trading_client):
"""GET /api/trading/metrics/history — returns portfolio snapshots."""
resp = await trading_client.get("/api/trading/metrics/history")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
if len(data) >= 1:
snap = data[0]
assert "portfolio_value" in snap
assert "snapshot_date" in snap
# ---------------------------------------------------------------------------
# 5 Notification Config Round-Trip
# ---------------------------------------------------------------------------
class TestTradingNotificationRoundTrip:
"""PUT → GET notification config round-trip."""
async def test_notification_config_round_trip(self, trading_client):
"""PUT → GET notification config round-trip."""
# Update phone number (which drives sms_enabled)
resp = await trading_client.put(
"/api/trading/notifications/config",
json={"phone_number": "+15551234567"},
)
assert resp.status_code == 200
# Verify GET returns config structure
get_resp = await trading_client.get("/api/trading/notifications/config")
assert get_resp.status_code == 200
data = get_resp.json()
assert "sms_enabled" in data
assert "email_enabled" in data
assert isinstance(data["sms_enabled"], bool)
assert isinstance(data["email_enabled"], bool)
# ---------------------------------------------------------------------------
# 6 Notification History
# ---------------------------------------------------------------------------
class TestTradingNotificationHistory:
"""GET /api/trading/notifications/history — returns list."""
async def test_notification_history(self, trading_client):
"""GET /api/trading/notifications/history — returns list."""
resp = await trading_client.get("/api/trading/notifications/history")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
# ---------------------------------------------------------------------------
# 7 Decision Filtering
# ---------------------------------------------------------------------------
class TestTradingDecisionFiltering:
"""GET /api/trading/decisions with various filters."""
async def test_decisions_filter_by_ticker(self, trading_client):
"""GET /api/trading/decisions?ticker=AAPL — only AAPL decisions."""
resp = await trading_client.get(
"/api/trading/decisions", params={"ticker": "AAPL"}
)
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
for d in data:
assert d["ticker"] == "AAPL"
async def test_decisions_filter_by_limit(self, trading_client):
"""GET /api/trading/decisions?limit=1 — at most 1 decision."""
resp = await trading_client.get(
"/api/trading/decisions", params={"limit": "1"}
)
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) <= 1
async def test_decisions_filter_by_decision_type(self, trading_client):
"""GET /api/trading/decisions?decision=execute — only execute decisions."""
resp = await trading_client.get(
"/api/trading/decisions", params={"decision": "execute"}
)
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
for d in data:
assert d["decision"] == "execute"
# ---------------------------------------------------------------------------
# 8 Override Order Validation
# ---------------------------------------------------------------------------
class TestTradingOverrideValidation:
"""POST /api/trading/override/order — validation edge cases."""
async def test_override_invalid_ticker(self, trading_client):
"""POST /api/trading/override/order — invalid ticker returns 422."""
payload = {
"ticker": "AAPL123", # contains digits — should be rejected
"side": "buy",
"quantity": 1.0,
"order_type": "market",
}
resp = await trading_client.post(
"/api/trading/override/order", json=payload
)
assert resp.status_code == 422
async def test_override_zero_quantity(self, trading_client):
"""POST /api/trading/override/order — zero quantity returns 422."""
payload = {
"ticker": "AAPL",
"side": "buy",
"quantity": 0,
"order_type": "market",
}
resp = await trading_client.post(
"/api/trading/override/order", json=payload
)
assert resp.status_code == 422
async def test_override_valid_order(self, trading_client):
"""POST /api/trading/override/order — valid order returns 202 or structured error."""
payload = {
"ticker": "AAPL",
"side": "buy",
"quantity": 1.0,
"order_type": "market",
}
resp = await trading_client.post(
"/api/trading/override/order", json=payload
)
assert resp.status_code in (200, 202, 400, 422, 503)
data = resp.json()
if resp.status_code == 202:
assert "job_id" in data
assert data["status"] == "queued"
assert data["ticker"] == "AAPL"
assert data["side"] == "buy"
assert data["quantity"] == 1.0
else:
assert isinstance(data, dict)