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