feat: competitive intelligence & historical pattern matching layer
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
"""Unit tests for macro API endpoints and dashboard components.
|
||||
|
||||
Tests macro event list/detail endpoints, macro toggle endpoint,
|
||||
and trend projection endpoint return correct data structures.
|
||||
|
||||
Requirements: 8.1, 8.2, 11.5, 12.10
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from services.api.app import _parse_jsonb, _row_to_dict, app
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
NOW = datetime(2026, 5, 15, 14, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class FakeRecord(dict):
|
||||
"""Mimics asyncpg.Record for testing."""
|
||||
def items(self):
|
||||
return super().items()
|
||||
|
||||
|
||||
def _make_event_row(event_id: str | None = None) -> FakeRecord:
|
||||
eid = event_id or str(uuid4())
|
||||
return FakeRecord({
|
||||
"id": eid,
|
||||
"event_types": ["trade_barrier", "cost_increase"],
|
||||
"severity": "high",
|
||||
"affected_regions": ["US", "CN"],
|
||||
"affected_sectors": ["Technology"],
|
||||
"affected_commodities": ["semiconductors"],
|
||||
"summary": "US tariffs on Chinese semiconductors",
|
||||
"key_facts": json.dumps(["25% tariff", "Effective in 30 days"]),
|
||||
"estimated_duration": "medium_term",
|
||||
"confidence": 0.85,
|
||||
"source_document_id": str(uuid4()),
|
||||
"created_at": NOW,
|
||||
# Detail fields
|
||||
"model_provider": "ollama",
|
||||
"model_name": "test-model",
|
||||
"prompt_version": "event-v1",
|
||||
"schema_version": "1.0.0",
|
||||
})
|
||||
|
||||
|
||||
def _make_impact_row(event_id: str) -> FakeRecord:
|
||||
return FakeRecord({
|
||||
"id": str(uuid4()),
|
||||
"event_id": event_id,
|
||||
"company_id": str(uuid4()),
|
||||
"ticker": "AAPL",
|
||||
"macro_impact_score": 0.45,
|
||||
"impact_direction": "negative",
|
||||
"contributing_factors": json.dumps(["geographic_overlap:0.650"]),
|
||||
"confidence": 0.8,
|
||||
"computed_at": NOW,
|
||||
"legal_name": "Apple Inc.",
|
||||
"sector": "Technology",
|
||||
# For ticker endpoint
|
||||
"event_summary": "US tariffs on Chinese semiconductors",
|
||||
"event_severity": "high",
|
||||
"event_types": ["trade_barrier"],
|
||||
"affected_regions": ["US", "CN"],
|
||||
})
|
||||
|
||||
|
||||
def _make_projection_row(trend_id: str) -> FakeRecord:
|
||||
return FakeRecord({
|
||||
"id": str(uuid4()),
|
||||
"trend_window_id": trend_id,
|
||||
"projected_direction": "bearish",
|
||||
"projected_strength": 0.6,
|
||||
"projected_confidence": 0.5,
|
||||
"projection_horizon": "7d",
|
||||
"driving_factors": json.dumps(["Macro signals project bearish impact"]),
|
||||
"macro_contribution_pct": 0.3,
|
||||
"diverges_from_current": True,
|
||||
"computed_at": NOW,
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route structure tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMacroRouteStructure:
|
||||
"""Verify all macro-related routes are registered."""
|
||||
|
||||
def test_macro_event_list_route_exists(self):
|
||||
paths = [route.path for route in app.routes]
|
||||
assert "/api/macro/events" in paths
|
||||
|
||||
def test_macro_event_detail_route_exists(self):
|
||||
paths = [route.path for route in app.routes]
|
||||
assert "/api/macro/events/{event_id}" in paths
|
||||
|
||||
def test_macro_impacts_route_exists(self):
|
||||
paths = [route.path for route in app.routes]
|
||||
assert "/api/macro/impacts/{ticker}" in paths
|
||||
|
||||
def test_macro_status_route_exists(self):
|
||||
paths = [route.path for route in app.routes]
|
||||
assert "/api/admin/macro/status" in paths
|
||||
|
||||
def test_macro_toggle_route_exists(self):
|
||||
paths = [route.path for route in app.routes]
|
||||
assert "/api/admin/macro/toggle" in paths
|
||||
|
||||
def test_trend_projection_route_exists(self):
|
||||
paths = [route.path for route in app.routes]
|
||||
assert "/api/trends/{trend_id}/projection" in paths
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Macro event endpoints (Requirements: 8.1, 8.2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMacroEventEndpoints:
|
||||
"""Test macro event list and detail endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_macro_events_returns_events(self):
|
||||
"""GET /api/macro/events should return a list of events."""
|
||||
event_row = _make_event_row()
|
||||
mock_pool = AsyncMock()
|
||||
mock_pool.fetch = AsyncMock(return_value=[event_row])
|
||||
|
||||
with patch("services.api.app.pool", mock_pool):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get("/api/macro/events")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 1
|
||||
assert data[0]["severity"] == "high"
|
||||
assert data[0]["summary"] == "US tariffs on Chinese semiconductors"
|
||||
assert isinstance(data[0]["key_facts"], list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_macro_events_with_severity_filter(self):
|
||||
"""GET /api/macro/events?severity=high should filter by severity."""
|
||||
event_row = _make_event_row()
|
||||
mock_pool = AsyncMock()
|
||||
mock_pool.fetch = AsyncMock(return_value=[event_row])
|
||||
|
||||
with patch("services.api.app.pool", mock_pool):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get("/api/macro/events?severity=high")
|
||||
|
||||
assert resp.status_code == 200
|
||||
# Verify the query was called (filter applied)
|
||||
mock_pool.fetch.assert_called_once()
|
||||
call_args = mock_pool.fetch.call_args
|
||||
assert "high" in call_args.args
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_macro_event_detail(self):
|
||||
"""GET /api/macro/events/{id} should return event with affected companies."""
|
||||
event_id = str(uuid4())
|
||||
event_row = _make_event_row(event_id)
|
||||
impact_row = _make_impact_row(event_id)
|
||||
|
||||
mock_pool = AsyncMock()
|
||||
mock_pool.fetchrow = AsyncMock(return_value=event_row)
|
||||
mock_pool.fetch = AsyncMock(return_value=[impact_row])
|
||||
|
||||
with patch("services.api.app.pool", mock_pool):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get(f"/api/macro/events/{event_id}")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["id"] == event_id
|
||||
assert data["severity"] == "high"
|
||||
assert "affected_companies" in data
|
||||
assert len(data["affected_companies"]) == 1
|
||||
assert data["affected_companies"][0]["ticker"] == "AAPL"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_macro_event_not_found(self):
|
||||
"""GET /api/macro/events/{id} should return 404 for missing event."""
|
||||
mock_pool = AsyncMock()
|
||||
mock_pool.fetchrow = AsyncMock(return_value=None)
|
||||
|
||||
with patch("services.api.app.pool", mock_pool):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get(f"/api/macro/events/{uuid4()}")
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Macro toggle endpoint (Requirement: 11.5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMacroToggleEndpoint:
|
||||
"""Test macro toggle endpoint persists state and records audit event."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_macro_status_returns_default(self):
|
||||
"""GET /api/admin/macro/status should return default enabled state."""
|
||||
mock_pool = AsyncMock()
|
||||
mock_pool.fetchrow = AsyncMock(return_value=None)
|
||||
|
||||
with patch("services.api.app.pool", mock_pool):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get("/api/admin/macro/status")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["macro_enabled"] is True
|
||||
assert data["source"] == "default"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_macro_status_from_config(self):
|
||||
"""GET /api/admin/macro/status should read from risk_configs."""
|
||||
mock_pool = AsyncMock()
|
||||
mock_pool.fetchrow = AsyncMock(return_value=FakeRecord({
|
||||
"macro_enabled": "false",
|
||||
}))
|
||||
|
||||
with patch("services.api.app.pool", mock_pool):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get("/api/admin/macro/status")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["macro_enabled"] is False
|
||||
assert data["source"] == "risk_configs"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_macro_layer(self):
|
||||
"""PUT /api/admin/macro/toggle should persist state and record audit."""
|
||||
config_id = str(uuid4())
|
||||
mock_pool = AsyncMock()
|
||||
# First call: fetch current state
|
||||
mock_pool.fetchrow = AsyncMock(return_value=FakeRecord({
|
||||
"id": config_id,
|
||||
"macro_enabled": "true",
|
||||
}))
|
||||
mock_pool.execute = AsyncMock()
|
||||
|
||||
with patch("services.api.app.pool", mock_pool), \
|
||||
patch("services.api.app.record_audit_event", new_callable=AsyncMock) as mock_audit:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.put(
|
||||
"/api/admin/macro/toggle",
|
||||
json={"enabled": False, "operator": "test_user"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["macro_enabled"] is False
|
||||
assert data["previous_enabled"] is True
|
||||
assert data["toggled_by"] == "test_user"
|
||||
|
||||
# Verify audit event was recorded
|
||||
mock_audit.assert_called_once()
|
||||
audit_call = mock_audit.call_args
|
||||
assert audit_call.kwargs.get("event_type") or audit_call.args[1] == "macro.layer_toggled"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Trend projection endpoint (Requirement: 12.10)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTrendProjectionEndpoint:
|
||||
"""Test trend projection endpoint returns projection data."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_trend_projection(self):
|
||||
"""GET /api/trends/{id}/projection should return projection data."""
|
||||
trend_id = str(uuid4())
|
||||
proj_row = _make_projection_row(trend_id)
|
||||
|
||||
mock_pool = AsyncMock()
|
||||
# First call: verify trend exists
|
||||
mock_pool.fetchrow = AsyncMock(side_effect=[
|
||||
FakeRecord({"id": trend_id}), # trend exists
|
||||
proj_row, # projection data
|
||||
])
|
||||
|
||||
with patch("services.api.app.pool", mock_pool):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get(f"/api/trends/{trend_id}/projection")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["projected_direction"] == "bearish"
|
||||
assert data["projected_strength"] == 0.6
|
||||
assert data["projected_confidence"] == 0.5
|
||||
assert data["diverges_from_current"] is True
|
||||
assert isinstance(data["driving_factors"], list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_trend_projection_not_found(self):
|
||||
"""GET /api/trends/{id}/projection should return null projection for missing."""
|
||||
trend_id = str(uuid4())
|
||||
mock_pool = AsyncMock()
|
||||
mock_pool.fetchrow = AsyncMock(side_effect=[
|
||||
FakeRecord({"id": trend_id}), # trend exists
|
||||
None, # no projection
|
||||
])
|
||||
|
||||
with patch("services.api.app.pool", mock_pool):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get(f"/api/trends/{trend_id}/projection")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["projection"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_trend_projection_trend_not_found(self):
|
||||
"""GET /api/trends/{id}/projection should 404 for missing trend."""
|
||||
mock_pool = AsyncMock()
|
||||
mock_pool.fetchrow = AsyncMock(return_value=None)
|
||||
|
||||
with patch("services.api.app.pool", mock_pool):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get(f"/api/trends/{uuid4()}/projection")
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Macro impacts for ticker endpoint (Requirement: 8.2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMacroImpactsEndpoint:
|
||||
"""Test macro impacts for a specific company."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_macro_impacts_for_ticker(self):
|
||||
"""GET /api/macro/impacts/{ticker} should return impact records."""
|
||||
impact_row = _make_impact_row(str(uuid4()))
|
||||
mock_pool = AsyncMock()
|
||||
mock_pool.fetch = AsyncMock(return_value=[impact_row])
|
||||
|
||||
with patch("services.api.app.pool", mock_pool):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get("/api/macro/impacts/AAPL")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 1
|
||||
assert data[0]["ticker"] == "AAPL"
|
||||
assert data[0]["macro_impact_score"] == 0.45
|
||||
assert data[0]["impact_direction"] == "negative"
|
||||
Reference in New Issue
Block a user