Files
Celes Renata 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
feat: trading feedback engine — periodic performance reports with AI summarization
- 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
2026-05-01 22:13:09 +00:00

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()