Files
wyndham-ARR/tests/test_company_reports_service.py
2026-07-29 16:38:05 +08:00

315 lines
11 KiB
Python

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()