from __future__ import annotations import io import json import tempfile import unittest from contextlib import redirect_stdout from datetime import date from decimal import Decimal from pathlib import Path from typing import Dict, Optional from unittest.mock import patch from company_reports import cli from company_reports.contracts import ( BatchSnapshot, BookingRoomItem, DailyVersionPin, ErrorCode, FinanceFact, ) from company_reports.publishing import ( BuildError, BuiltWorkbook, PublicationOutcome, ) from company_reports.repository import ( FileMetadata, RepositoryError, ReservedReport, ) from company_reports.service import ( CompanyReportService, RunRequest, write_batch_result, ) def synthetic_snapshot(include_invalid_hana: bool = False) -> BatchSnapshot: facts = [ FinanceFact( daily_record_id=1, daily_version_id=100, business_date=date(2026, 7, 8), channel_key="QBD", company_key="QBD", company_name="SYN-COMPANY", block_code="SYN-BLOCK-A", group_code_key="SYN-GROUP-A", res_comment="SYN-GROUP-A", room_category_label="SYN-RM2", arrival=date(2026, 7, 8), departure=date(2026, 7, 10), nights=2, total_price=Decimal("900"), booking_source_match_status="matched", ) ] if include_invalid_hana: facts.append( FinanceFact( daily_record_id=2, daily_version_id=100, business_date=date(2026, 7, 8), channel_key="HANA TOUR", company_key="HANA TOUR", company_name="SYN-COMPANY", block_code="SYN-BLOCK-H", group_code_key=None, res_comment=None, room_category_label="SYN-RM2", arrival=date(2026, 7, 8), departure=date(2026, 7, 10), nights=2, total_price=Decimal("900"), booking_source_match_status="missing_group_code", ) ) item = BookingRoomItem( group_code_key="SYN-GROUP-A", parse_version_id=700, segment_no=1, arrival=date(2026, 7, 8), departure=date(2026, 7, 10), nights=2, item_no=1, room_type_raw="SYN-SUP-TWN", quantity=1, ) return BatchSnapshot( facts=tuple(facts), room_items=(item,), daily_versions=(DailyVersionPin(date(2026, 7, 8), 100),), group_parse_versions={"SYN-GROUP-A": 700}, ) class FakeRepository: def __init__( self, snapshot: Optional[BatchSnapshot] = None, load_error: Optional[RepositoryError] = None, ): self.snapshot = snapshot or BatchSnapshot((), (), (), {}) self.load_error = load_error self.next_version = 1 self.reserved = [] self.activated = [] self.failed = [] def load_snapshot(self, _year: int, _month: int, _as_of: date) -> BatchSnapshot: if self.load_error: raise self.load_error return self.snapshot def reserve_report(self, report): reservation = ReservedReport( 1000 + self.next_version, self.next_version, report.company, ) self.next_version += 1 self.reserved.append(reservation) return reservation 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, failing_company: Optional[str] = None): self.failing_company = failing_company self.companies = [] def build(self, report, work_dir: Path) -> BuiltWorkbook: self.companies.append(report.company) if report.company == self.failing_company: raise BuildError( ErrorCode.OUTPUT_VALIDATION_FAILED, "synthetic builder rejected output", ) path = work_dir / report.filename path.write_bytes(("synthetic-workbook-" + report.company).encode("utf-8")) return BuiltWorkbook(path, "a" * 64, path.stat().st_size, {}) class FakePublisher: def __init__(self): self.companies = [] def publish(self, report, reservation, built, repository, work_dir): self.companies.append(report.company) artifact = FileMetadata( "company_ten_day_xlsx", report.filename, f"outputs/company_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", f"{report.company}.result.json", f"outputs/company_reports/2026/07/archive/v{reservation.version_no:04d}/{report.company}.result.json", "b" * 64, 1, "application/json", ) repository.activate_report(reservation, artifact, result_json) return PublicationOutcome( current_path=work_dir / report.filename, archive_path=work_dir / report.filename, result_path=work_dir / "result.json", artifact=artifact, result_json=result_json, ) class CompanyReportServiceTests(unittest.TestCase): def test_invalid_company_is_isolated_while_valid_company_succeeds(self): repository = FakeRepository(synthetic_snapshot(include_invalid_hana=True)) builder = FakeBuilder() publisher = FakePublisher() with tempfile.TemporaryDirectory(prefix="company-report-service-test-") as temp_dir: service = CompanyReportService( repository, builder, publisher, Path(temp_dir) / "staging", ) result = service.run( RunRequest( 2026, 7, date(2026, 7, 10), ("QBD", "HanaTour"), ) ) self.assertEqual(result.status, "partial_failure") self.assertEqual(result.exit_code, 2) self.assertEqual([item.status for item in result.companies], ["success", "failed"]) self.assertEqual(builder.companies, ["QBD"]) self.assertEqual(publisher.companies, ["QBD"]) self.assertEqual(result.companies[1].errors[0]["code"], ErrorCode.GROUP_CODE_MISSING) serialized = json.dumps(result.to_dict(), ensure_ascii=False) self.assertNotIn("SYN-GROUP-A", serialized) self.assertNotIn("SYN-BLOCK", serialized) def test_builder_failure_marks_only_its_reserved_company_failed(self): repository = FakeRepository(synthetic_snapshot()) builder = FakeBuilder(failing_company="QBD") publisher = FakePublisher() with tempfile.TemporaryDirectory(prefix="company-report-service-test-") as temp_dir: service = CompanyReportService( repository, builder, publisher, Path(temp_dir) / "staging", ) result = service.run( RunRequest( 2026, 7, date(2026, 7, 10), ("QBD", "HanaTour"), ) ) self.assertEqual(result.status, "partial_failure") self.assertEqual([item.status for item in result.companies], ["failed", "success"]) self.assertEqual(result.companies[0].errors[0]["code"], ErrorCode.OUTPUT_VALIDATION_FAILED) self.assertEqual(repository.failed[0][1], ErrorCode.OUTPUT_VALIDATION_FAILED) self.assertEqual(publisher.companies, ["HanaTour"]) def test_source_failure_returns_all_company_failures_with_contract_exit_code(self): cases = ( (ErrorCode.SOURCE_VERSION_MISSING, 2), (ErrorCode.INTERNAL_ERROR, 4), ) for error_code, expected_exit in cases: with self.subTest(error_code=error_code): repository = FakeRepository( load_error=RepositoryError(error_code, "synthetic source failure") ) with tempfile.TemporaryDirectory( prefix="company-report-service-test-" ) as temp_dir: service = CompanyReportService( repository, FakeBuilder(), FakePublisher(), Path(temp_dir) / "staging", ) result = service.run( RunRequest( 2026, 7, date(2026, 7, 10), ("QBD", "HanaTour"), ) ) self.assertEqual(result.status, "failed") self.assertEqual(result.exit_code, expected_exit) self.assertTrue( all(item.errors[0]["code"] == error_code for item in result.companies) ) def test_batch_result_is_private_atomic_and_contains_no_source_values(self): repository = FakeRepository(synthetic_snapshot()) with tempfile.TemporaryDirectory(prefix="company-report-service-test-") as temp_dir: root = Path(temp_dir) service = CompanyReportService( repository, FakeBuilder(), FakePublisher(), root / "staging", ) result = service.run( RunRequest(2026, 7, date(2026, 7, 10), ("QBD",)) ) result_path = root / "results" / "batch.result.json" write_batch_result(result_path, result) text = result_path.read_text(encoding="utf-8") self.assertEqual(result_path.stat().st_mode & 0o777, 0o600) self.assertEqual(json.loads(text)["status"], "success") self.assertNotIn("SYN-GROUP-A", text) self.assertNotIn("SYN-BLOCK", text) def test_cli_request_and_missing_configuration_use_stable_exit_three(self): cases = ( ["generate", "--month", "bad", "--as-of", "2026-07-10"], ["generate", "--month", "2026-07", "--as-of", "2026-07-09"], ["generate", "--month", "2026-07", "--as-of", "2026-07-10"], ) for arguments in cases: with self.subTest(arguments=arguments), patch.dict("os.environ", {}, clear=True): output = io.StringIO() with redirect_stdout(output): exit_code = cli.main(arguments) payload = json.loads(output.getvalue()) self.assertEqual(exit_code, 3) self.assertEqual(payload["status"], "failed") self.assertEqual( payload["errors"][0]["code"], ErrorCode.REQUEST_INVALID ) if __name__ == "__main__": unittest.main()