192 lines
8.1 KiB
Python
192 lines
8.1 KiB
Python
from __future__ import annotations
|
|
|
|
import unittest
|
|
from datetime import date
|
|
|
|
from monthly_reports.contracts import ErrorCode
|
|
from monthly_reports.repository import DerivedMonthlyRequest
|
|
from monthly_reports.service import RunResult
|
|
from monthly_reports.worker import (
|
|
CLAIM_EVENT_SQL,
|
|
FAIL_EVENT_SQL,
|
|
PUBLISH_EVENT_SQL,
|
|
WITHDRAW_EVENT_SQL,
|
|
SCOPE_CHANGED_EVENT_TYPE,
|
|
MonthlyOutboxWorker,
|
|
OutboxEvent,
|
|
)
|
|
|
|
|
|
class FakeOutbox:
|
|
def __init__(self, event: OutboxEvent | None) -> None:
|
|
self.event = event
|
|
self.published: list[tuple[int, int]] = []
|
|
self.failed: list[tuple[OutboxEvent, str, bool]] = []
|
|
self.withdrawn: list[tuple[int, date]] = []
|
|
|
|
def claim_next(self):
|
|
value, self.event = self.event, None
|
|
return value
|
|
|
|
def mark_published(self, event_id: int, report_id: int) -> None:
|
|
self.published.append((event_id, report_id))
|
|
|
|
def mark_withdrawn(self, event_id: int, period_start: date) -> None:
|
|
self.withdrawn.append((event_id, period_start))
|
|
|
|
def mark_failed(self, event: OutboxEvent, code: str, *, retryable: bool) -> str:
|
|
self.failed.append((event, code, retryable))
|
|
return "pending" if retryable else "dead"
|
|
|
|
|
|
class FakeRequests:
|
|
def __init__(self, derived: DerivedMonthlyRequest | None) -> None:
|
|
self.derived = derived
|
|
self.calls: list[int] = []
|
|
|
|
def derive_monthly_request(self, daily_version_id: int):
|
|
self.calls.append(daily_version_id)
|
|
return self.derived
|
|
|
|
|
|
class FakeService:
|
|
def __init__(self, result_factory) -> None:
|
|
self.result_factory = result_factory
|
|
self.requests = []
|
|
|
|
def run(self, request):
|
|
self.requests.append(request)
|
|
return self.result_factory(request)
|
|
|
|
|
|
def event(payload=None, attempts: int = 1) -> OutboxEvent:
|
|
return OutboxEvent(
|
|
event_id=9,
|
|
event_key="processing-run:27:accepted",
|
|
payload=payload or {"daily_version_id": 4, "business_date": "1999-01-01"},
|
|
publish_attempts=attempts,
|
|
)
|
|
|
|
|
|
class MonthlyReportsWorkerTests(unittest.TestCase):
|
|
def test_worker_uses_derived_arrival_scope_and_publishes_only_real_report(self):
|
|
outbox = FakeOutbox(event())
|
|
requests = FakeRequests(DerivedMonthlyRequest(2026, 7, date(2026, 7, 27)))
|
|
service = FakeService(
|
|
lambda request: RunResult(
|
|
request=request,
|
|
status="success",
|
|
report_version_id=44,
|
|
version_no=1,
|
|
artifact={"sha256": "a" * 64},
|
|
)
|
|
)
|
|
|
|
outcome = MonthlyOutboxWorker(outbox, requests, service).process_next()
|
|
|
|
self.assertEqual(outcome.status, "published")
|
|
self.assertEqual(outcome.report_id, 44)
|
|
self.assertEqual(requests.calls, [4])
|
|
self.assertEqual(service.requests[0].as_of_date, date(2026, 7, 27))
|
|
self.assertEqual(outbox.published, [(9, 44)])
|
|
self.assertEqual(outbox.failed, [])
|
|
|
|
def test_empty_arrival_scope_is_dead_lettered_without_generation(self):
|
|
outbox = FakeOutbox(event())
|
|
service = FakeService(lambda request: None)
|
|
|
|
outcome = MonthlyOutboxWorker(
|
|
outbox,
|
|
FakeRequests(None),
|
|
service,
|
|
).process_next()
|
|
|
|
self.assertEqual(outcome.status, "dead")
|
|
self.assertEqual(outcome.error_code, "MONTHLY_WORKER_ARRIVAL_SCOPE_EMPTY")
|
|
self.assertEqual(service.requests, [])
|
|
self.assertFalse(outbox.failed[0][2])
|
|
|
|
def test_retryable_report_failure_returns_event_to_pending(self):
|
|
outbox = FakeOutbox(event())
|
|
requests = FakeRequests(DerivedMonthlyRequest(2026, 7, date(2026, 7, 27)))
|
|
service = FakeService(
|
|
lambda request: RunResult(
|
|
request=request,
|
|
status="failed",
|
|
error_code=ErrorCode.PUBLISH_FAILED,
|
|
error_stage="publish",
|
|
)
|
|
)
|
|
|
|
outcome = MonthlyOutboxWorker(outbox, requests, service).process_next()
|
|
|
|
self.assertEqual(outcome.status, "pending")
|
|
self.assertEqual(outcome.error_code, ErrorCode.PUBLISH_FAILED)
|
|
self.assertTrue(outbox.failed[0][2])
|
|
self.assertEqual(outbox.published, [])
|
|
|
|
def test_last_day_deletion_acknowledges_withdrawal_without_an_empty_workbook(self):
|
|
deletion = OutboxEvent(9, "retired-day:4", {
|
|
"daily_version_id": 4, "period_start": "2026-07-01", "action": "delete",
|
|
}, 1, SCOPE_CHANGED_EVENT_TYPE)
|
|
outbox = FakeOutbox(deletion)
|
|
service = FakeService(lambda request: self.fail("an empty month must not produce a workbook"))
|
|
result = MonthlyOutboxWorker(outbox, FakeRequests(DerivedMonthlyRequest(2026, 7, None)), service).process_next()
|
|
self.assertEqual(result.status, "withdrawn")
|
|
self.assertIsNone(result.report_id)
|
|
self.assertEqual(outbox.withdrawn, [(9, date(2026, 7, 1))])
|
|
self.assertEqual(outbox.failed, [])
|
|
self.assertEqual(service.requests, [])
|
|
|
|
def test_old_commit_event_for_now_deleted_month_also_acknowledges_withdrawal(self):
|
|
outbox = FakeOutbox(event())
|
|
service = FakeService(lambda request: self.fail("retired source must not be republished"))
|
|
result = MonthlyOutboxWorker(outbox, FakeRequests(DerivedMonthlyRequest(2026, 7, None)), service).process_next()
|
|
self.assertEqual(result.status, "withdrawn")
|
|
self.assertEqual(outbox.withdrawn, [(9, date(2026, 7, 1))])
|
|
|
|
def test_mid_month_deletion_rebuilds_at_remaining_fact_watermark(self):
|
|
outbox = FakeOutbox(OutboxEvent(9, "retired-day:4", {
|
|
"daily_version_id": 4, "period_start": "2026-07-01", "action": "delete",
|
|
}, 1, SCOPE_CHANGED_EVENT_TYPE))
|
|
service = FakeService(lambda request: RunResult(request=request, status="success", report_version_id=45,
|
|
artifact={"sha256": "b" * 64}))
|
|
result = MonthlyOutboxWorker(outbox, FakeRequests(DerivedMonthlyRequest(2026, 7, date(2026, 7, 2))), service).process_next()
|
|
self.assertEqual(result.status, "published")
|
|
self.assertEqual(service.requests[0].as_of_date, date(2026, 7, 2))
|
|
self.assertEqual(outbox.withdrawn, [])
|
|
|
|
def test_deletion_cannot_withdraw_a_month_other_than_its_original_fact_scope(self):
|
|
outbox = FakeOutbox(OutboxEvent(9, "retired-day:4", {
|
|
"daily_version_id": 4, "period_start": "2026-08-01", "action": "delete",
|
|
}, 1, SCOPE_CHANGED_EVENT_TYPE))
|
|
result = MonthlyOutboxWorker(outbox, FakeRequests(DerivedMonthlyRequest(2026, 7, None)), FakeService(None)).process_next()
|
|
self.assertEqual((result.status, result.error_code), ("dead", "MONTHLY_WORKER_EVENT_INVALID"))
|
|
self.assertEqual(outbox.withdrawn, [])
|
|
|
|
def test_empty_ack_failure_retries_instead_of_claiming_withdrawal(self):
|
|
from monthly_reports.worker import WorkerError
|
|
outbox = FakeOutbox(event())
|
|
def fail_ack(*_args):
|
|
raise WorkerError("MONTHLY_WORKER_WITHDRAW_ACK_FAILED", retryable=True)
|
|
outbox.mark_withdrawn = fail_ack
|
|
result = MonthlyOutboxWorker(outbox, FakeRequests(DerivedMonthlyRequest(2026, 7, None)), FakeService(None)).process_next()
|
|
self.assertEqual(result.status, "pending")
|
|
self.assertEqual(result.error_code, "MONTHLY_WORKER_WITHDRAW_ACK_FAILED")
|
|
|
|
def test_claim_and_ack_sql_are_leased_locked_and_downloadable(self):
|
|
self.assertIn("FOR UPDATE SKIP LOCKED", CLAIM_EVENT_SQL)
|
|
self.assertIn("publish_status IN ('pending', 'publishing')", CLAIM_EVENT_SQL)
|
|
self.assertIn("available_at <= now()", CLAIM_EVENT_SQL)
|
|
self.assertIn("aggregate_type = 'daily_version'", CLAIM_EVENT_SQL)
|
|
self.assertIn("finance.v_active_daily_facts", WITHDRAW_EVENT_SQL)
|
|
self.assertIn("'reserved', 'active', 'superseded'", WITHDRAW_EVENT_SQL)
|
|
self.assertIn("reporting.monthly_runs", PUBLISH_EVENT_SQL)
|
|
self.assertIn("monthly_xlsx", PUBLISH_EVENT_SQL)
|
|
self.assertIn("result_json", PUBLISH_EVENT_SQL)
|
|
self.assertIn("publish_status = %s", FAIL_EVENT_SQL)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|