333 lines
12 KiB
Python
333 lines
12 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 = []
|
|
self.reports = []
|
|
|
|
def build(self, report, work_dir: Path) -> BuiltWorkbook:
|
|
self.companies.append(report.company)
|
|
self.reports.append(report)
|
|
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_missing_and_unmatched_group_codes_succeed_with_blank_booking_room(self):
|
|
source_snapshot = synthetic_snapshot(include_invalid_hana=True)
|
|
repository = FakeRepository(
|
|
BatchSnapshot(
|
|
facts=source_snapshot.facts,
|
|
room_items=(),
|
|
daily_versions=source_snapshot.daily_versions,
|
|
group_parse_versions={},
|
|
)
|
|
)
|
|
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, "success")
|
|
self.assertEqual(result.exit_code, 0)
|
|
self.assertEqual([item.status for item in result.companies], ["success", "success"])
|
|
self.assertEqual(builder.companies, ["QBD", "HanaTour"])
|
|
self.assertEqual(publisher.companies, ["QBD", "HanaTour"])
|
|
qbd_report, hana_report = builder.reports
|
|
self.assertEqual(qbd_report.row_count, 1)
|
|
self.assertEqual(qbd_report.periods[0].rows[0].res_comment, "SYN-GROUP-A")
|
|
self.assertEqual(qbd_report.periods[0].rows[0].booking_room, "")
|
|
self.assertEqual(hana_report.row_count, 1)
|
|
self.assertEqual(hana_report.periods[0].rows[0].res_comment, "")
|
|
self.assertEqual(hana_report.periods[0].rows[0].booking_room, "")
|
|
self.assertEqual(result.companies[0].errors, ())
|
|
self.assertEqual(result.companies[1].errors, ())
|
|
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()
|