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
274 lines
9.1 KiB
Python
274 lines
9.1 KiB
Python
"""Unit tests for the report data collector.
|
|
|
|
Tests the CollectedData dataclass defaults, _row_dict UUID conversion,
|
|
and collect_report_data with mocked asyncpg pool.
|
|
|
|
Requirements: 1.1, 1.2, 1.3, 1.4, 1.5
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import date
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from services.reporting.collector import CollectedData, _row_dict, collect_report_data
|
|
|
|
# ===================================================================
|
|
# _row_dict tests
|
|
# ===================================================================
|
|
|
|
|
|
class TestRowDict:
|
|
"""Tests for _row_dict UUID→str conversion."""
|
|
|
|
def test_uuid_fields_converted_to_str(self):
|
|
"""UUID values in the record are converted to strings."""
|
|
test_uuid = uuid.uuid4()
|
|
row = MagicMock()
|
|
row.__iter__ = MagicMock(return_value=iter([("id", test_uuid), ("name", "test")]))
|
|
row.keys = MagicMock(return_value=["id", "name"])
|
|
row.values = MagicMock(return_value=[test_uuid, "test"])
|
|
row.items = MagicMock(return_value=[("id", test_uuid), ("name", "test")])
|
|
# dict(row) needs to work — use a real dict-like mock
|
|
mock_dict = {"id": test_uuid, "name": "test"}
|
|
row.__iter__ = MagicMock(return_value=iter(mock_dict))
|
|
row.__getitem__ = lambda self, key: mock_dict[key]
|
|
|
|
# Simpler approach: just pass a dict-like object
|
|
class FakeRecord(dict):
|
|
pass
|
|
|
|
record = FakeRecord(id=test_uuid, name="test", count=42)
|
|
result = _row_dict(record)
|
|
|
|
assert result["id"] == str(test_uuid)
|
|
assert result["name"] == "test"
|
|
assert result["count"] == 42
|
|
|
|
def test_no_uuid_fields_unchanged(self):
|
|
"""Non-UUID values pass through unchanged."""
|
|
|
|
class FakeRecord(dict):
|
|
pass
|
|
|
|
record = FakeRecord(ticker="AAPL", price=185.50, active=True)
|
|
result = _row_dict(record)
|
|
|
|
assert result["ticker"] == "AAPL"
|
|
assert result["price"] == 185.50
|
|
assert result["active"] is True
|
|
|
|
def test_multiple_uuid_fields(self):
|
|
"""Multiple UUID fields are all converted."""
|
|
|
|
class FakeRecord(dict):
|
|
pass
|
|
|
|
id1 = uuid.uuid4()
|
|
id2 = uuid.uuid4()
|
|
record = FakeRecord(id=id1, recommendation_id=id2, ticker="MSFT")
|
|
result = _row_dict(record)
|
|
|
|
assert result["id"] == str(id1)
|
|
assert result["recommendation_id"] == str(id2)
|
|
assert result["ticker"] == "MSFT"
|
|
|
|
def test_empty_record(self):
|
|
"""Empty record returns empty dict."""
|
|
|
|
class FakeRecord(dict):
|
|
pass
|
|
|
|
record = FakeRecord()
|
|
result = _row_dict(record)
|
|
assert result == {}
|
|
|
|
|
|
# ===================================================================
|
|
# CollectedData defaults
|
|
# ===================================================================
|
|
|
|
|
|
class TestCollectedDataDefaults:
|
|
"""Tests for CollectedData dataclass default values."""
|
|
|
|
def test_default_empty_lists(self):
|
|
"""All list fields default to empty lists."""
|
|
data = CollectedData()
|
|
assert data.trading_decisions == []
|
|
assert data.orders == []
|
|
assert data.open_positions == []
|
|
assert data.closed_positions == []
|
|
assert data.recommendations == []
|
|
assert data.prediction_outcomes == []
|
|
assert data.model_metric_snapshots == []
|
|
assert data.circuit_breaker_events == []
|
|
|
|
def test_default_none_snapshots(self):
|
|
"""Snapshot fields default to None."""
|
|
data = CollectedData()
|
|
assert data.portfolio_snapshot is None
|
|
assert data.previous_portfolio_snapshot is None
|
|
|
|
def test_default_zero_balance(self):
|
|
"""Reserve pool balance defaults to 0.0."""
|
|
data = CollectedData()
|
|
assert data.reserve_pool_balance == 0.0
|
|
|
|
def test_independent_list_instances(self):
|
|
"""Each CollectedData instance has independent list instances."""
|
|
data1 = CollectedData()
|
|
data2 = CollectedData()
|
|
data1.trading_decisions.append({"id": "test"})
|
|
assert data2.trading_decisions == []
|
|
|
|
|
|
# ===================================================================
|
|
# collect_report_data with mocked pool
|
|
# ===================================================================
|
|
|
|
|
|
def _make_mock_pool():
|
|
"""Create a mock asyncpg pool with async context manager support."""
|
|
pool = MagicMock()
|
|
conn = AsyncMock()
|
|
|
|
# pool.acquire() returns a sync object that supports async context manager
|
|
ctx = MagicMock()
|
|
ctx.__aenter__ = AsyncMock(return_value=conn)
|
|
ctx.__aexit__ = AsyncMock(return_value=False)
|
|
pool.acquire.return_value = ctx
|
|
|
|
return pool, conn
|
|
|
|
|
|
class TestCollectReportData:
|
|
"""Tests for collect_report_data with mocked asyncpg."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_zero_activity_returns_empty_lists(self):
|
|
"""When no data exists, all lists are empty and snapshots are None."""
|
|
pool, conn = _make_mock_pool()
|
|
|
|
# All queries return empty results
|
|
conn.fetch.return_value = []
|
|
conn.fetchrow.return_value = None
|
|
|
|
result = await collect_report_data(
|
|
pool, date(2025, 1, 15), date(2025, 1, 15)
|
|
)
|
|
|
|
assert isinstance(result, CollectedData)
|
|
assert result.trading_decisions == []
|
|
assert result.orders == []
|
|
assert result.open_positions == []
|
|
assert result.closed_positions == []
|
|
assert result.portfolio_snapshot is None
|
|
assert result.previous_portfolio_snapshot is None
|
|
assert result.recommendations == []
|
|
assert result.prediction_outcomes == []
|
|
assert result.model_metric_snapshots == []
|
|
assert result.circuit_breaker_events == []
|
|
assert result.reserve_pool_balance == 0.0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_queries_use_correct_date_range(self):
|
|
"""Verify that queries are called with the correct period dates."""
|
|
pool, conn = _make_mock_pool()
|
|
conn.fetch.return_value = []
|
|
conn.fetchrow.return_value = None
|
|
|
|
start = date(2025, 1, 13)
|
|
end = date(2025, 1, 17)
|
|
|
|
await collect_report_data(pool, start, end)
|
|
|
|
# Verify fetch was called (trading_decisions, orders, open_positions,
|
|
# closed_positions, recommendations, prediction_outcomes,
|
|
# model_metric_snapshots, circuit_breaker_events)
|
|
assert conn.fetch.call_count == 8
|
|
|
|
# Verify fetchrow was called (portfolio_snapshot, previous_snapshot,
|
|
# reserve_pool_balance)
|
|
assert conn.fetchrow.call_count == 3
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reserve_pool_balance_from_ledger(self):
|
|
"""Reserve pool balance is read from the latest ledger entry."""
|
|
pool, conn = _make_mock_pool()
|
|
conn.fetch.return_value = []
|
|
|
|
# Mock fetchrow to return different values for different queries
|
|
balance_row = {"balance_after": 450.75}
|
|
|
|
call_count = 0
|
|
|
|
async def mock_fetchrow(query, *args):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if "reserve_pool_ledger" in query:
|
|
return balance_row
|
|
return None
|
|
|
|
conn.fetchrow.side_effect = mock_fetchrow
|
|
|
|
result = await collect_report_data(
|
|
pool, date(2025, 1, 15), date(2025, 1, 15)
|
|
)
|
|
|
|
assert result.reserve_pool_balance == 450.75
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_portfolio_snapshots_populated(self):
|
|
"""Portfolio snapshot and previous snapshot are populated when data exists."""
|
|
pool, conn = _make_mock_pool()
|
|
conn.fetch.return_value = []
|
|
|
|
current_snapshot = {
|
|
"id": uuid.uuid4(),
|
|
"snapshot_date": date(2025, 1, 15),
|
|
"portfolio_value": 10500.0,
|
|
"active_pool": 8000.0,
|
|
"reserve_pool": 2500.0,
|
|
"cumulative_return": 0.05,
|
|
}
|
|
previous_snapshot = {
|
|
"id": uuid.uuid4(),
|
|
"snapshot_date": date(2025, 1, 14),
|
|
"portfolio_value": 10000.0,
|
|
"active_pool": 7500.0,
|
|
"reserve_pool": 2500.0,
|
|
"cumulative_return": 0.0,
|
|
}
|
|
|
|
call_count = 0
|
|
|
|
async def mock_fetchrow(query, *args):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if "reserve_pool_ledger" in query:
|
|
return None
|
|
if "snapshot_date >=" in query:
|
|
# current snapshot query (snapshot_date >= $1 AND snapshot_date <= $2)
|
|
return current_snapshot
|
|
if "snapshot_date <" in query:
|
|
# previous snapshot query (snapshot_date < $1)
|
|
return previous_snapshot
|
|
return None
|
|
|
|
conn.fetchrow.side_effect = mock_fetchrow
|
|
|
|
result = await collect_report_data(
|
|
pool, date(2025, 1, 15), date(2025, 1, 15)
|
|
)
|
|
|
|
assert result.portfolio_snapshot is not None
|
|
assert result.portfolio_snapshot["portfolio_value"] == 10500.0
|
|
# UUID fields should be converted to str
|
|
assert isinstance(result.portfolio_snapshot["id"], str)
|
|
|
|
assert result.previous_portfolio_snapshot is not None
|
|
assert result.previous_portfolio_snapshot["portfolio_value"] == 10000.0
|