"""API integration tests for trading report endpoints. Tests GET /api/reports (list with pagination/filtering) and GET /api/reports/{report_id} (detail with full report_data). Uses httpx.AsyncClient with the FastAPI app and mocks the module-level ``pool`` variable in services.api.app. Requirements validated: 5.4, 5.5, 5.6 """ from __future__ import annotations import uuid from datetime import date, datetime, timezone from unittest.mock import AsyncMock, patch import httpx import pytest from services.api.app import app # ── Helpers ────────────────────────────────────────────────────────────── class FakeRecord(dict): """Dict subclass that behaves like an asyncpg Record for bracket access.""" def __getattr__(self, name: str): try: return self[name] except KeyError: raise AttributeError(name) def _make_list_record(**overrides) -> FakeRecord: """Build a FakeRecord matching the list-endpoint SELECT columns.""" defaults = { "id": uuid.uuid4(), "report_type": "daily", "period_start": date(2025, 1, 15), "period_end": date(2025, 1, 15), "validation_status": "passed", "generated_at": datetime(2025, 1, 15, 21, 30, tzinfo=timezone.utc), } defaults.update(overrides) return FakeRecord(**defaults) def _make_detail_record(**overrides) -> FakeRecord: """Build a FakeRecord matching the detail-endpoint SELECT columns.""" defaults = { "id": uuid.uuid4(), "report_type": "daily", "period_start": date(2025, 1, 15), "period_end": date(2025, 1, 15), "report_data": { "pnl": {"realized_pnl": 125.50, "unrealized_pnl": -30.20}, "executive_summary": "Test summary", }, "validation_status": "passed", "generated_at": datetime(2025, 1, 15, 21, 30, tzinfo=timezone.utc), "created_at": datetime(2025, 1, 15, 21, 30, 5, tzinfo=timezone.utc), } defaults.update(overrides) return FakeRecord(**defaults) _POOL_PATCH = "services.api.app.pool" # ═══════════════════════════════════════════════════════════════════════ # 1. GET /api/reports — list endpoint # Requirements validated: 5.4, 5.6 # ═══════════════════════════════════════════════════════════════════════ class TestListReports: """Tests for GET /api/reports.""" @pytest.mark.asyncio async def test_default_pagination(self) -> None: """List reports with no params returns rows using default limit/offset.""" r1 = _make_list_record() r2 = _make_list_record( report_type="weekly", period_start=date(2025, 1, 13), period_end=date(2025, 1, 17), ) mock_pool = AsyncMock() mock_pool.fetch = AsyncMock(return_value=[r1, r2]) with patch(_POOL_PATCH, mock_pool): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://test" ) as client: resp = await client.get("/api/reports") assert resp.status_code == 200 data = resp.json() assert len(data) == 2 # UUID fields are serialized as strings assert data[0]["id"] == str(r1["id"]) assert data[0]["report_type"] == "daily" assert data[0]["period_start"] == "2025-01-15" assert data[0]["period_end"] == "2025-01-15" assert data[0]["validation_status"] == "passed" assert "generated_at" in data[0] # pool.fetch called with default limit=20, offset=0 call_args = mock_pool.fetch.call_args sql = call_args[0][0] assert "LIMIT" in sql assert "OFFSET" in sql # Last two positional args are limit and offset assert call_args[0][-2] == 20 assert call_args[0][-1] == 0 @pytest.mark.asyncio async def test_filter_by_report_type(self) -> None: """Filtering by report_type=weekly passes the value to the query.""" r1 = _make_list_record(report_type="weekly") mock_pool = AsyncMock() mock_pool.fetch = AsyncMock(return_value=[r1]) with patch(_POOL_PATCH, mock_pool): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://test" ) as client: resp = await client.get("/api/reports", params={"report_type": "weekly"}) assert resp.status_code == 200 data = resp.json() assert len(data) == 1 assert data[0]["report_type"] == "weekly" # Verify the SQL includes a report_type condition call_args = mock_pool.fetch.call_args sql = call_args[0][0] assert "report_type" in sql # "weekly" should be among the positional params assert "weekly" in call_args[0] @pytest.mark.asyncio async def test_filter_by_date_range(self) -> None: """Filtering by start_date and end_date passes dates to the query.""" mock_pool = AsyncMock() mock_pool.fetch = AsyncMock(return_value=[]) with patch(_POOL_PATCH, mock_pool): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://test" ) as client: resp = await client.get( "/api/reports", params={"start_date": "2025-01-01", "end_date": "2025-01-31"}, ) assert resp.status_code == 200 call_args = mock_pool.fetch.call_args sql = call_args[0][0] assert "period_start" in sql assert "period_end" in sql # Date strings should be among the positional params assert "2025-01-01" in call_args[0] assert "2025-01-31" in call_args[0] @pytest.mark.asyncio async def test_invalid_report_type_returns_400(self) -> None: """An invalid report_type value returns HTTP 400.""" mock_pool = AsyncMock() with patch(_POOL_PATCH, mock_pool): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://test" ) as client: resp = await client.get( "/api/reports", params={"report_type": "monthly"} ) assert resp.status_code == 400 assert "daily" in resp.json()["detail"].lower() or "weekly" in resp.json()["detail"].lower() # pool.fetch should NOT have been called mock_pool.fetch.assert_not_awaited() @pytest.mark.asyncio async def test_invalid_date_format_returns_400(self) -> None: """A malformed start_date returns HTTP 400.""" mock_pool = AsyncMock() with patch(_POOL_PATCH, mock_pool): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://test" ) as client: resp = await client.get( "/api/reports", params={"start_date": "not-a-date"} ) assert resp.status_code == 400 assert "YYYY-MM-DD" in resp.json()["detail"] mock_pool.fetch.assert_not_awaited() # ═══════════════════════════════════════════════════════════════════════ # 2. GET /api/reports/{report_id} — detail endpoint # Requirements validated: 5.4, 5.5 # ═══════════════════════════════════════════════════════════════════════ class TestGetReport: """Tests for GET /api/reports/{report_id}.""" @pytest.mark.asyncio async def test_valid_id_returns_full_report(self) -> None: """A valid report_id returns the full report including report_data.""" record = _make_detail_record() mock_pool = AsyncMock() mock_pool.fetchrow = AsyncMock(return_value=record) report_id = str(record["id"]) with patch(_POOL_PATCH, mock_pool): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://test" ) as client: resp = await client.get(f"/api/reports/{report_id}") assert resp.status_code == 200 data = resp.json() assert data["id"] == report_id assert data["report_type"] == "daily" assert data["period_start"] == "2025-01-15" assert data["period_end"] == "2025-01-15" assert data["validation_status"] == "passed" assert "generated_at" in data assert "created_at" in data # report_data is included as a dict assert isinstance(data["report_data"], dict) assert data["report_data"]["pnl"]["realized_pnl"] == 125.50 assert data["report_data"]["executive_summary"] == "Test summary" @pytest.mark.asyncio async def test_nonexistent_id_returns_404(self) -> None: """A non-existent report_id returns HTTP 404.""" mock_pool = AsyncMock() mock_pool.fetchrow = AsyncMock(return_value=None) fake_id = str(uuid.uuid4()) with patch(_POOL_PATCH, mock_pool): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://test" ) as client: resp = await client.get(f"/api/reports/{fake_id}") assert resp.status_code == 404 assert "not found" in resp.json()["detail"].lower()