"""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"