"""Unit tests for report generator orchestrator. Tests the orchestration flow in services.reporting.generator with mocked dependencies (collector, section builders, validator, summarizer). Requirements validated: 5.1, 5.2, 5.3 """ from __future__ import annotations import uuid from datetime import date, datetime, timezone from unittest.mock import AsyncMock, patch import pytest from services.reporting.collector import CollectedData from services.reporting.generator import ( _in_progress_jobs, generate_report, process_report_job, store_report, ) from services.reporting.models import ( ModelQualitySection, ModelQualityWindow, PLSection, PositionPerformanceSection, RecommendationAccuracySection, ReportData, ReportType, RiskMetricsSection, ValidationStatus, ) # ── Helpers ────────────────────────────────────────────────────────────── def _make_report_data(**overrides: object) -> ReportData: """Build a minimal valid ReportData for testing.""" defaults = { "pnl": PLSection( realized_pnl=100.0, unrealized_pnl=-20.0, daily_return=0.01, cumulative_return=0.05, win_count=5, loss_count=2, win_rate=0.71, profit_factor=2.0, sharpe_ratio=1.2, summary="P&L summary", ), "recommendation_accuracy": RecommendationAccuracySection( total_evaluated=10, act_count=6, skip_count=4, acted_win_rate=0.67, avg_confidence_acted=0.75, avg_confidence_skipped=0.40, summary="Rec accuracy summary", ), "position_performance": PositionPerformanceSection( positions=[], summary="Position summary", ), "risk_metrics": RiskMetricsSection( current_risk_tier="moderate", portfolio_heat=0.12, max_drawdown=0.06, current_drawdown_pct=0.02, reserve_pool_balance=500.0, circuit_breaker_event_count=0, summary="Risk summary", ), "model_quality": ModelQualitySection( windows=[ ModelQualityWindow( lookback="7d", win_rate=0.65, directional_accuracy=0.62, information_coefficient=0.08, calibration_error=0.12, brier_score=0.22, ), ], summary="Model quality summary", ), "executive_summary": "Executive summary text", "validation_status": ValidationStatus.PASSED, "generated_at": datetime(2025, 1, 15, 21, 30, tzinfo=timezone.utc), "period_start": date(2025, 1, 15), "period_end": date(2025, 1, 15), "report_type": ReportType.DAILY, } defaults.update(overrides) return ReportData(**defaults) def _empty_collected_data() -> CollectedData: """Build a zero-activity CollectedData.""" return CollectedData() def _mock_pool() -> AsyncMock: """Create a mock asyncpg pool.""" pool = AsyncMock() return pool # Patch targets (all in the generator module namespace) _PATCH_COLLECT = "services.reporting.generator.collect_report_data" _PATCH_BUILD_PNL = "services.reporting.generator.build_pnl_section" _PATCH_BUILD_REC = "services.reporting.generator.build_recommendation_accuracy_section" _PATCH_BUILD_POS = "services.reporting.generator.build_position_performance_section" _PATCH_BUILD_RISK = "services.reporting.generator.build_risk_metrics_section" _PATCH_BUILD_MQ = "services.reporting.generator.build_model_quality_section" _PATCH_VALIDATE_REC = "services.reporting.generator.validate_recommendation_accuracy" _PATCH_VALIDATE_MQ = "services.reporting.generator.validate_model_quality" _PATCH_COMPUTE_STATUS = "services.reporting.generator.compute_validation_status" _PATCH_SUMMARIZE = "services.reporting.generator.summarize_section" _PATCH_EXEC_SUMMARY = "services.reporting.generator.generate_executive_summary" _PATCH_RESOLVER = "services.reporting.generator.AgentConfigResolver" # ═══════════════════════════════════════════════════════════════════════ # 1. generate_report — orchestration flow # Requirements validated: 5.1 # ═══════════════════════════════════════════════════════════════════════ class TestGenerateReport: """Tests for generate_report orchestration.""" @pytest.mark.asyncio async def test_orchestration_calls_all_steps(self) -> None: """generate_report calls collector, builders, validators, summarizer in order.""" pool = _mock_pool() collected = _empty_collected_data() pnl = PLSection( realized_pnl=0, unrealized_pnl=0, daily_return=0, cumulative_return=0, win_count=0, loss_count=0, win_rate=0, profit_factor=0, sharpe_ratio=0, ) rec = RecommendationAccuracySection( total_evaluated=0, act_count=0, skip_count=0, acted_win_rate=0, avg_confidence_acted=0, avg_confidence_skipped=0, ) pos = PositionPerformanceSection() risk = RiskMetricsSection( current_risk_tier="low", portfolio_heat=0, max_drawdown=0, current_drawdown_pct=0, reserve_pool_balance=0, circuit_breaker_event_count=0, ) mq = ModelQualitySection() with ( patch(_PATCH_COLLECT, new_callable=AsyncMock, return_value=collected) as mock_collect, patch(_PATCH_BUILD_PNL, return_value=pnl) as mock_pnl, patch(_PATCH_BUILD_REC, return_value=rec) as mock_rec, patch(_PATCH_BUILD_POS, return_value=pos) as mock_pos, patch(_PATCH_BUILD_RISK, return_value=risk) as mock_risk, patch(_PATCH_BUILD_MQ, return_value=mq) as mock_mq, patch(_PATCH_VALIDATE_REC, return_value=[]) as mock_val_rec, patch(_PATCH_VALIDATE_MQ, return_value=[]) as mock_val_mq, patch(_PATCH_COMPUTE_STATUS, return_value=ValidationStatus.PASSED) as mock_status, patch(_PATCH_SUMMARIZE, new_callable=AsyncMock, return_value="summary") as mock_sum, patch(_PATCH_EXEC_SUMMARY, new_callable=AsyncMock, return_value="exec summary") as mock_exec, patch(_PATCH_RESOLVER) as mock_resolver_cls, ): result = await generate_report( pool, ReportType.DAILY, date(2025, 1, 15), date(2025, 1, 15), ) # Collector called with pool and dates mock_collect.assert_awaited_once_with(pool, date(2025, 1, 15), date(2025, 1, 15)) # All section builders called with collected data mock_pnl.assert_called_once_with(collected) mock_rec.assert_called_once_with(collected) mock_pos.assert_called_once_with(collected) mock_risk.assert_called_once_with(collected) mock_mq.assert_called_once_with(collected) # Validators called mock_val_rec.assert_called_once_with(rec, collected.prediction_outcomes) mock_val_mq.assert_called_once_with(mq, collected.model_metric_snapshots) # Summarizer called 5 times (one per section) assert mock_sum.await_count == 5 # Executive summary called mock_exec.assert_awaited_once() # Validation status computed mock_status.assert_called_once() # Result is a ReportData assert isinstance(result, ReportData) assert result.report_type == ReportType.DAILY assert result.period_start == date(2025, 1, 15) assert result.period_end == date(2025, 1, 15) assert result.executive_summary == "exec summary" @pytest.mark.asyncio async def test_zero_activity_report(self) -> None: """generate_report handles zero-activity data (empty CollectedData).""" pool = _mock_pool() collected = _empty_collected_data() pnl = PLSection( realized_pnl=0, unrealized_pnl=0, daily_return=0, cumulative_return=0, win_count=0, loss_count=0, win_rate=0, profit_factor=0, sharpe_ratio=0, ) rec = RecommendationAccuracySection( total_evaluated=0, act_count=0, skip_count=0, acted_win_rate=0, avg_confidence_acted=0, avg_confidence_skipped=0, ) pos = PositionPerformanceSection() risk = RiskMetricsSection( current_risk_tier="unknown", portfolio_heat=0, max_drawdown=0, current_drawdown_pct=0, reserve_pool_balance=0, circuit_breaker_event_count=0, ) mq = ModelQualitySection() with ( patch(_PATCH_COLLECT, new_callable=AsyncMock, return_value=collected), patch(_PATCH_BUILD_PNL, return_value=pnl), patch(_PATCH_BUILD_REC, return_value=rec), patch(_PATCH_BUILD_POS, return_value=pos), patch(_PATCH_BUILD_RISK, return_value=risk), patch(_PATCH_BUILD_MQ, return_value=mq), patch(_PATCH_VALIDATE_REC, return_value=[]), patch(_PATCH_VALIDATE_MQ, return_value=[]), patch(_PATCH_COMPUTE_STATUS, return_value=ValidationStatus.PASSED), patch(_PATCH_SUMMARIZE, new_callable=AsyncMock, return_value="No activity"), patch(_PATCH_EXEC_SUMMARY, new_callable=AsyncMock, return_value="No trading activity"), patch(_PATCH_RESOLVER), ): result = await generate_report( pool, ReportType.DAILY, date(2025, 1, 15), date(2025, 1, 15), ) assert result.pnl.realized_pnl == 0.0 assert result.pnl.win_count == 0 assert result.recommendation_accuracy.total_evaluated == 0 assert result.position_performance.positions == [] assert result.risk_metrics.current_risk_tier == "unknown" assert result.validation_status == ValidationStatus.PASSED @pytest.mark.asyncio async def test_validation_warnings_attached(self) -> None: """Validation warnings from validators are attached to sections.""" pool = _mock_pool() collected = _empty_collected_data() from services.reporting.models import ValidationWarning rec_warning = ValidationWarning( field_name="acted_win_rate", computed_value=0.80, snapshot_value=0.60, pct_difference=33.33, ) pnl = PLSection( realized_pnl=0, unrealized_pnl=0, daily_return=0, cumulative_return=0, win_count=0, loss_count=0, win_rate=0, profit_factor=0, sharpe_ratio=0, ) rec = RecommendationAccuracySection( total_evaluated=5, act_count=3, skip_count=2, acted_win_rate=0.80, avg_confidence_acted=0.7, avg_confidence_skipped=0.4, ) pos = PositionPerformanceSection() risk = RiskMetricsSection( current_risk_tier="moderate", portfolio_heat=0.1, max_drawdown=0.05, current_drawdown_pct=0.02, reserve_pool_balance=100, circuit_breaker_event_count=0, ) mq = ModelQualitySection() with ( patch(_PATCH_COLLECT, new_callable=AsyncMock, return_value=collected), patch(_PATCH_BUILD_PNL, return_value=pnl), patch(_PATCH_BUILD_REC, return_value=rec), patch(_PATCH_BUILD_POS, return_value=pos), patch(_PATCH_BUILD_RISK, return_value=risk), patch(_PATCH_BUILD_MQ, return_value=mq), patch(_PATCH_VALIDATE_REC, return_value=[rec_warning]), patch(_PATCH_VALIDATE_MQ, return_value=[]), patch(_PATCH_COMPUTE_STATUS, return_value=ValidationStatus.WARNINGS), patch(_PATCH_SUMMARIZE, new_callable=AsyncMock, return_value="summary"), patch(_PATCH_EXEC_SUMMARY, new_callable=AsyncMock, return_value="exec"), patch(_PATCH_RESOLVER), ): result = await generate_report( pool, ReportType.DAILY, date(2025, 1, 15), date(2025, 1, 15), ) assert result.validation_status == ValidationStatus.WARNINGS assert len(result.recommendation_accuracy.validation_warnings) == 1 assert result.recommendation_accuracy.validation_warnings[0].field_name == "acted_win_rate" @pytest.mark.asyncio async def test_weekly_report_type(self) -> None: """generate_report correctly sets weekly report type.""" pool = _mock_pool() collected = _empty_collected_data() pnl = PLSection( realized_pnl=0, unrealized_pnl=0, daily_return=0, cumulative_return=0, win_count=0, loss_count=0, win_rate=0, profit_factor=0, sharpe_ratio=0, ) rec = RecommendationAccuracySection( total_evaluated=0, act_count=0, skip_count=0, acted_win_rate=0, avg_confidence_acted=0, avg_confidence_skipped=0, ) pos = PositionPerformanceSection() risk = RiskMetricsSection( current_risk_tier="low", portfolio_heat=0, max_drawdown=0, current_drawdown_pct=0, reserve_pool_balance=0, circuit_breaker_event_count=0, ) mq = ModelQualitySection() with ( patch(_PATCH_COLLECT, new_callable=AsyncMock, return_value=collected), patch(_PATCH_BUILD_PNL, return_value=pnl), patch(_PATCH_BUILD_REC, return_value=rec), patch(_PATCH_BUILD_POS, return_value=pos), patch(_PATCH_BUILD_RISK, return_value=risk), patch(_PATCH_BUILD_MQ, return_value=mq), patch(_PATCH_VALIDATE_REC, return_value=[]), patch(_PATCH_VALIDATE_MQ, return_value=[]), patch(_PATCH_COMPUTE_STATUS, return_value=ValidationStatus.PASSED), patch(_PATCH_SUMMARIZE, new_callable=AsyncMock, return_value="summary"), patch(_PATCH_EXEC_SUMMARY, new_callable=AsyncMock, return_value="exec"), patch(_PATCH_RESOLVER), ): result = await generate_report( pool, ReportType.WEEKLY, date(2025, 1, 13), date(2025, 1, 17), ) assert result.report_type == ReportType.WEEKLY assert result.period_start == date(2025, 1, 13) assert result.period_end == date(2025, 1, 17) # ═══════════════════════════════════════════════════════════════════════ # 2. store_report — upsert behavior # Requirements validated: 5.2, 5.3 # ═══════════════════════════════════════════════════════════════════════ class TestStoreReport: """Tests for store_report upsert behavior.""" @pytest.mark.asyncio async def test_store_calls_upsert_sql(self) -> None: """store_report calls pool.fetchrow with the upsert SQL and correct params.""" pool = _mock_pool() report_id = str(uuid.uuid4()) pool.fetchrow = AsyncMock(return_value={"id": report_id}) report = _make_report_data() result = await store_report(pool, report) assert result == report_id pool.fetchrow.assert_awaited_once() call_args = pool.fetchrow.call_args sql = call_args[0][0] assert "INSERT INTO trading_reports" in sql assert "ON CONFLICT" in sql assert "DO UPDATE" in sql # Verify the positional parameters assert call_args[0][1] == report.report_type.value assert call_args[0][2] == report.period_start assert call_args[0][3] == report.period_end # param 4 is the JSON string assert call_args[0][4] == report.model_dump_json() assert call_args[0][5] == report.validation_status.value assert call_args[0][6] == report.generated_at @pytest.mark.asyncio async def test_store_returns_uuid_string(self) -> None: """store_report returns the UUID as a string.""" pool = _mock_pool() expected_id = str(uuid.uuid4()) pool.fetchrow = AsyncMock(return_value={"id": expected_id}) report = _make_report_data() result = await store_report(pool, report) assert isinstance(result, str) assert result == expected_id @pytest.mark.asyncio async def test_store_upsert_regeneration(self) -> None: """store_report handles regeneration (upsert) for existing period.""" pool = _mock_pool() report_id = str(uuid.uuid4()) pool.fetchrow = AsyncMock(return_value={"id": report_id}) # First store report1 = _make_report_data() result1 = await store_report(pool, report1) # Second store (regeneration) — same period, different data report2 = _make_report_data( executive_summary="Updated executive summary", generated_at=datetime(2025, 1, 15, 22, 0, tzinfo=timezone.utc), ) result2 = await store_report(pool, report2) # Both calls succeed (upsert handles the conflict) assert result1 == report_id assert result2 == report_id assert pool.fetchrow.await_count == 2 # ═══════════════════════════════════════════════════════════════════════ # 3. process_report_job — job processing # Requirements validated: 5.1, 5.3 # ═══════════════════════════════════════════════════════════════════════ class TestProcessReportJob: """Tests for process_report_job.""" @pytest.mark.asyncio async def test_valid_job_calls_generate_and_store(self) -> None: """A valid job payload triggers generate_report and store_report.""" pool = _mock_pool() report = _make_report_data() with ( patch( "services.reporting.generator.generate_report", new_callable=AsyncMock, return_value=report, ) as mock_gen, patch( "services.reporting.generator.store_report", new_callable=AsyncMock, return_value=str(uuid.uuid4()), ) as mock_store, ): job = { "report_type": "daily", "period_start": "2025-01-15", "period_end": "2025-01-15", } await process_report_job(pool, job) mock_gen.assert_awaited_once_with( pool, ReportType.DAILY, date(2025, 1, 15), date(2025, 1, 15), ) mock_store.assert_awaited_once_with(pool, report) @pytest.mark.asyncio async def test_invalid_report_type_returns_early(self) -> None: """An invalid report_type in the job payload causes early return.""" pool = _mock_pool() with ( patch( "services.reporting.generator.generate_report", new_callable=AsyncMock, ) as mock_gen, ): job = { "report_type": "invalid_type", "period_start": "2025-01-15", "period_end": "2025-01-15", } await process_report_job(pool, job) mock_gen.assert_not_awaited() @pytest.mark.asyncio async def test_invalid_date_returns_early(self) -> None: """An invalid date in the job payload causes early return.""" pool = _mock_pool() with ( patch( "services.reporting.generator.generate_report", new_callable=AsyncMock, ) as mock_gen, ): job = { "report_type": "daily", "period_start": "not-a-date", "period_end": "2025-01-15", } await process_report_job(pool, job) mock_gen.assert_not_awaited() @pytest.mark.asyncio async def test_missing_fields_returns_early(self) -> None: """Missing fields in the job payload causes early return.""" pool = _mock_pool() with ( patch( "services.reporting.generator.generate_report", new_callable=AsyncMock, ) as mock_gen, ): job = {} await process_report_job(pool, job) mock_gen.assert_not_awaited() @pytest.mark.asyncio async def test_duplicate_job_rejected(self) -> None: """A duplicate in-progress job is rejected without calling generate_report.""" pool = _mock_pool() key = "daily:2025-01-20:2025-01-20" # Simulate an in-progress job _in_progress_jobs.add(key) try: with ( patch( "services.reporting.generator.generate_report", new_callable=AsyncMock, ) as mock_gen, ): job = { "report_type": "daily", "period_start": "2025-01-20", "period_end": "2025-01-20", } await process_report_job(pool, job) mock_gen.assert_not_awaited() finally: _in_progress_jobs.discard(key) @pytest.mark.asyncio async def test_job_cleans_up_in_progress_on_success(self) -> None: """After successful completion, the job key is removed from _in_progress_jobs.""" pool = _mock_pool() report = _make_report_data( period_start=date(2025, 1, 21), period_end=date(2025, 1, 21), ) key = "daily:2025-01-21:2025-01-21" with ( patch( "services.reporting.generator.generate_report", new_callable=AsyncMock, return_value=report, ), patch( "services.reporting.generator.store_report", new_callable=AsyncMock, return_value=str(uuid.uuid4()), ), ): job = { "report_type": "daily", "period_start": "2025-01-21", "period_end": "2025-01-21", } await process_report_job(pool, job) assert key not in _in_progress_jobs @pytest.mark.asyncio async def test_job_cleans_up_in_progress_on_failure(self) -> None: """After all retries fail, the job key is still removed from _in_progress_jobs.""" pool = _mock_pool() key = "daily:2025-01-22:2025-01-22" with ( patch( "services.reporting.generator.generate_report", new_callable=AsyncMock, side_effect=RuntimeError("DB down"), ), patch("asyncio.sleep", new_callable=AsyncMock), ): job = { "report_type": "daily", "period_start": "2025-01-22", "period_end": "2025-01-22", } await process_report_job(pool, job) assert key not in _in_progress_jobs @pytest.mark.asyncio async def test_retries_on_failure(self) -> None: """process_report_job retries up to 3 times on failure.""" pool = _mock_pool() report = _make_report_data( period_start=date(2025, 1, 23), period_end=date(2025, 1, 23), ) call_count = 0 async def _gen_side_effect(*args, **kwargs): nonlocal call_count call_count += 1 if call_count < 3: raise RuntimeError("Transient error") return report with ( patch( "services.reporting.generator.generate_report", new_callable=AsyncMock, side_effect=_gen_side_effect, ), patch( "services.reporting.generator.store_report", new_callable=AsyncMock, return_value=str(uuid.uuid4()), ) as mock_store, patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep, ): job = { "report_type": "daily", "period_start": "2025-01-23", "period_end": "2025-01-23", } await process_report_job(pool, job) # generate_report called 3 times (2 failures + 1 success) assert call_count == 3 # store_report called once on success mock_store.assert_awaited_once() # sleep called twice (between retries) assert mock_sleep.await_count == 2 @pytest.mark.asyncio async def test_weekly_job(self) -> None: """A weekly job payload is processed correctly.""" pool = _mock_pool() report = _make_report_data( report_type=ReportType.WEEKLY, period_start=date(2025, 1, 13), period_end=date(2025, 1, 17), ) with ( patch( "services.reporting.generator.generate_report", new_callable=AsyncMock, return_value=report, ) as mock_gen, patch( "services.reporting.generator.store_report", new_callable=AsyncMock, return_value=str(uuid.uuid4()), ), ): job = { "report_type": "weekly", "period_start": "2025-01-13", "period_end": "2025-01-17", } await process_report_job(pool, job) mock_gen.assert_awaited_once_with( pool, ReportType.WEEKLY, date(2025, 1, 13), date(2025, 1, 17), )