feat: trading feedback engine — periodic performance reports with AI summarization
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
This commit is contained in:
Celes Renata
2026-05-01 22:13:09 +00:00
parent 376fcb4bb4
commit bc077bfcc8
28 changed files with 6771 additions and 1 deletions
+678
View File
@@ -0,0 +1,678 @@
"""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),
)