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
679 lines
26 KiB
Python
679 lines
26 KiB
Python
"""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),
|
|
)
|