bc077bfcc8
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/build-2 Pipeline was successful
ci/woodpecker/push/build-3 Pipeline was successful
ci/woodpecker/push/build-1 Pipeline was successful
ci/woodpecker/push/finalize Pipeline was successful
Build and Push / lint-and-test (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.adapters.broker_adapter name:broker-adapter]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.aggregation.worker name:aggregation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.extractor.worker name:extractor]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.ingestion.worker name:ingestion]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.lake_publisher.worker name:lake-publisher]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.parser.worker name:parser]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.recommendation.worker name:recommendation]) (push) Has been cancelled
Build and Push / build-services (map[cmd:python -m services.scheduler.app name:scheduler]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.api.app:app --host 0.0.0.0 --port 8000 name:query-api]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.risk.app:app --host 0.0.0.0 --port 8000 name:risk]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.symbol_registry.app:app --host 0.0.0.0 --port 8000 name:symbol-registry]) (push) Has been cancelled
Build and Push / build-services (map[cmd:uvicorn services.trading.app:app --host 0.0.0.0 --port 8000 name:trading-engine]) (push) Has been cancelled
Build and Push / build-dashboard (push) Has been cancelled
Build and Push / build-superset (push) Has been cancelled
Build and Push / integration-test (push) Has been cancelled
Build and Push / beta-gate (push) Has been cancelled
- Migration 038: trading_reports table + report-summarizer agent seed
- 6 reporting modules: models, collector, sections, validator, summarizer, generator
- API endpoints: GET /api/reports (paginated, filterable), GET /api/reports/{id}
- Frontend hooks: useReports, useReport with TanStack Query
- Scheduler: daily (after 16:30 ET) and weekly (Saturday) report triggers
- Redis queue consumer for async report generation with retry/dedup
- 5 property-based tests (chunking, serialization, validation, accuracy, deltas)
- 109 unit/integration tests across all modules
- 6 frontend hook tests with MSW mocks
257 lines
9.8 KiB
Python
257 lines
9.8 KiB
Python
"""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()
|