from __future__ import annotations import json import tempfile import unittest from datetime import date from decimal import Decimal from pathlib import Path from monthly_reports.contracts import ( ChannelObservation, DailyVersionPin, ErrorCode, MonthlyFact, MonthlySnapshot, ) from monthly_reports.publishing import BuildError, BuiltWorkbook, PublicationOutcome from monthly_reports.repository import FileMetadata, RepositoryError, ReservedReport from monthly_reports.service import MonthlyReportService, RunRequest, write_run_result def synthetic_snapshot() -> MonthlySnapshot: source = MonthlyFact( daily_record_id=1, daily_version_id=100, business_date=date(2026, 7, 8), channel_key="QBD", arrival=date(2026, 7, 8), departure=date(2026, 7, 10), nights=2, adults=2, children=0, block_code="SYN-BLOCK-A", no_of_rooms=1, company_name="SYN-COMPANY", confirmation_no="SYN-CONFIRMATION", disp_room_no="SYN-ROOM", effective_rate_amount=Decimal("450"), full_name="SYN-GUEST", res_comment="SYN-COMMENT", trace_text="", products="", rate_code="SYN-RATE", room_category_label="SYN-TYPE", real_price=Decimal("450"), total_price=Decimal("900"), kb_amount=None, ) return MonthlySnapshot( (source,), (DailyVersionPin(date(2026, 7, 8), 100),), (ChannelObservation(date(2026, 7, 8), 100, "QBD", 1, 1),), ) class FakeRepository: def __init__(self, load_error=None): self.load_error = load_error self.failed = [] self.activated = [] def load_snapshot(self, _year, _month, _as_of): if self.load_error: raise self.load_error return synthetic_snapshot() def reserve_report(self, _report): return ReservedReport(44, 2) def activate_report(self, reservation, artifact, result_json): self.activated.append((reservation, artifact, result_json)) def mark_failed(self, reservation, code, safe_message): self.failed.append((reservation, code, safe_message)) class FakeBuilder: def __init__(self, fail: bool = False): self.fail = fail def build(self, report, work_dir: Path): if self.fail: raise BuildError( ErrorCode.OUTPUT_VALIDATION_FAILED, "synthetic builder failure", ) output = work_dir / report.filename output.write_bytes(b"synthetic-workbook") return BuiltWorkbook( output, "a" * 64, output.stat().st_size, {"semantic_sha256": "c" * 64}, ) class FakePublisher: def publish(self, report, reservation, built, repository, work_dir): artifact = FileMetadata( "monthly_xlsx", report.filename, f"outputs/monthly_reports/2026/07/archive/v{reservation.version_no:04d}/{report.filename}", built.sha256, built.byte_size, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) result_json = FileMetadata( "result_json", "result.json", f"outputs/monthly_reports/2026/07/archive/v{reservation.version_no:04d}/result.json", "b" * 64, 1, "application/json", ) repository.activate_report(reservation, artifact, result_json) return PublicationOutcome( latest_path=work_dir / "latest.xlsx", latest_result_path=work_dir / "latest.result.json", archive_path=work_dir / report.filename, result_path=work_dir / "result.json", artifact=artifact, result_json=result_json, ) class MonthlyReportsServiceTests(unittest.TestCase): def test_success_is_privacy_minimized_and_versioned(self): repository = FakeRepository() with tempfile.TemporaryDirectory(prefix="monthly-report-service-") as temp_dir: root = Path(temp_dir) service = MonthlyReportService( repository, FakeBuilder(), FakePublisher(), root / "staging", ) result = service.run(RunRequest(2026, 7, date(2026, 7, 26))) result_path = root / "run.result.json" write_run_result(result_path, result) serialized = result_path.read_text(encoding="utf-8") self.assertEqual(result.status, "success") self.assertEqual(result.exit_code, 0) self.assertEqual(result.row_count, 1) self.assertEqual(result.report_version_id, 44) self.assertEqual(json.loads(serialized)["channel_manifest"][2]["worksheet"], "QBD") self.assertNotIn("SYN-GUEST", serialized) self.assertNotIn("SYN-CONFIRMATION", serialized) self.assertNotIn("SYN-BLOCK", serialized) self.assertEqual(len(repository.activated), 1) def test_builder_failure_marks_reserved_report_failed(self): repository = FakeRepository() with tempfile.TemporaryDirectory(prefix="monthly-report-service-") as temp_dir: result = MonthlyReportService( repository, FakeBuilder(fail=True), FakePublisher(), Path(temp_dir) / "staging", ).run(RunRequest(2026, 7, date(2026, 7, 26))) self.assertEqual(result.status, "failed") self.assertEqual(result.error_code, ErrorCode.OUTPUT_VALIDATION_FAILED) self.assertEqual(repository.failed[0][0], ReservedReport(44, 2)) def test_source_failure_uses_business_or_system_exit_code(self): cases = ( (ErrorCode.SOURCE_SNAPSHOT_STALE, 2), (ErrorCode.DATABASE_FAILED, 4), ) for code, exit_code in cases: with self.subTest(code=code), tempfile.TemporaryDirectory() as temp_dir: repository = FakeRepository(RepositoryError(code, "synthetic source failure")) result = MonthlyReportService( repository, FakeBuilder(), FakePublisher(), Path(temp_dir) / "staging", ).run(RunRequest(2026, 7, date(2026, 7, 26))) self.assertEqual(result.exit_code, exit_code) self.assertEqual(result.error_stage, "source") if __name__ == "__main__": unittest.main()