Files
ARR-2.0-0918/tests/test_ohip_day_capture.py

321 lines
17 KiB
Python

from dataclasses import replace
import hashlib
import json
from pathlib import Path
import stat
import tempfile
import unittest
from unittest.mock import patch
from integrations.ohip import audit_arr_capture as v1audit
from integrations.ohip import audit_arr_day as audit
from integrations.ohip import collect_arr_day as day
from integrations.ohip import collect_arr_source as source
from integrations.ohip import rate_info
from tests.test_ohip_arr_collection import DAY, HOTEL, SECRET, FakeService, envelope
class DayService:
def __init__(self, count=3):
self.base = FakeService(count)
for row in self.base.rows:
row["roomStay"]["roomRates"] = [{"rates": {"rate": [{"base": {
"currencyCode": "USD", "amountBeforeTax": 999}}]}}]
self.calls = []
self.rate_edit = lambda data, body: data
def __call__(self, method, path, raw):
self.calls.append((method, path, raw))
if path == rate_info.PATH + "/searches":
body = json.loads(raw)
data = {"detail": {"totalRateAmount": int(body["id"][2:]),
"rateSuppressed": False, "revenue": {"currencyCode": "USD"}}}
return 200, {}, source.json_bytes(envelope(rate_info.POST, self.rate_edit(data, body)))
return self.base(method, path, raw)
class DayCaptureTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.output = self.root / "capture"
self.service = DayService()
self.options = day.Options(DAY, DAY, DAY, HOTEL, page_size=2)
def capture(self, transport=None):
archive = source.Archive(self.output)
transport = transport or self.service
return day.collect(self.options, archive,
source.Reader(archive, HOTEL, transport, key=SECRET, sleep=lambda _: None),
rate_info.RateInfoReader(archive, HOTEL, transport, key=SECRET, sleep=lambda _: None))
def verify(self, result):
verified = audit.VerifiedArchive(self.output, result["manifest_sha256"])
with patch.object(source, "HTTPTransport", side_effect=AssertionError("offline only")), \
patch.object(source, "load_key", side_effect=AssertionError("offline only")):
return audit.replay(verified)
def repin(self, name, edit):
path = self.output / name
document = json.loads(path.read_bytes())
edit(document)
raw = source.json_bytes(document)
path.write_bytes(raw)
result_path = self.output / "result.json"
if name != "result.json":
result = json.loads(result_path.read_bytes())
for item in result["files"]:
if item["name"] == name:
item.update(bytes=len(raw), sha256=hashlib.sha256(raw).hexdigest())
result_path.write_bytes(source.json_bytes(result))
return {"manifest_sha256": hashlib.sha256(result_path.read_bytes()).hexdigest()}
def test_complete_day_dates_identity_zero_privacy_and_offline_replay(self):
result = self.capture()
self.assertTrue(result["candidate_capture_complete"])
self.assertTrue(result["all_rates_valid"])
self.assertEqual((result["search_records"], result["verified_details"], result["rate_responses"]), (3, 3, 3))
self.assertEqual((result["source_http_attempts"], result["rate_http_attempts"], result["http_attempts"]), (7, 3, 10))
self.assertEqual(result["explicit_zero_rates"], 1)
self.assertEqual(result["currencies"], {"USD": 3})
self.assertEqual(result["rate_issues"], {})
for field in ("finance_ready", "atomic_snapshot", "report_equivalence_verified"):
self.assertFalse(result[field])
rate_requests = [json.loads(raw) for _, path, raw in self.service.calls if path.endswith("rate-info/searches")]
self.assertEqual(rate_requests, [{"id": f"id{i}", "type": "Reservation", "summaryInfo": False,
"detailDate": DAY} for i in range(3)])
searches = [json.loads(raw) for _, path, raw in self.service.calls if path == "/api/v1/reservations/searches"]
self.assertEqual([r["offset"] for r in searches], [0, 2, 0, 2])
self.assertTrue(all(r["arrivalStartDate"] == r["arrivalEndDate"] == DAY for r in searches))
rows, details, assessments = self.verify(result)
self.assertEqual(len(rows), len(details))
self.assertEqual(assessments["records"][0]["effective_rate"], "0")
self.assertEqual(stat.S_IMODE(self.output.stat().st_mode), 0o700)
for path in self.output.iterdir():
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
self.assertNotIn(SECRET.encode(), path.read_bytes())
def test_system_dates_required_equal_valid_and_no_clock_default(self):
# A delayed retry or backfill is valid; there is no today/yesterday gate.
day.Options("2001-01-01", "2001-01-01", "2001-01-01", HOTEL).validate()
for field in ("from_date", "to_date", "rate_date"):
for value in (None, "", "2026-02-31", "20260915", "2026-09-14"):
with self.subTest(field=field, value=value), self.assertRaises(source.CollectionError):
replace(self.options, **{field: value}).validate()
def test_invalid_dates_rejected_before_any_read_or_archive_write(self):
self.options = replace(self.options, rate_date="2026-09-14")
with self.assertRaisesRegex(source.CollectionError, "report_dates_must_match"):
self.capture()
self.assertEqual(self.service.calls, [])
self.assertEqual(list(self.output.iterdir()), [])
def test_missing_rate_is_diagnostic_full_day_kept_and_replayable(self):
self.service.rate_edit = lambda data, body: {} if body["id"] == "id1" else data
result = self.capture()
self.assertTrue(result["candidate_capture_complete"])
self.assertFalse(result["all_rates_valid"])
self.assertFalse(result["finance_ready"])
self.assertEqual(result["valid_rate_candidates"], 2)
self.assertEqual(result["rate_issues"], {"empty_rate_info": 1})
rows, details, assessments = self.verify(result)
self.assertEqual(len(assessments["records"]), 3)
field = audit.analyze_fields(rows, details, assessments)["fields"]["EFFECTIVE_RATE_AMOUNT"]
self.assertEqual(field["records_with_candidate"], 2)
self.assertEqual(field["records_without_candidate"], 1)
self.assertFalse(field["report_mapping_verified"])
def test_suppressed_summary_and_invalid_amounts_are_never_zero(self):
cases = [({"detail": {"rateSuppressed": True}}, "rate_suppressed"),
({"summary": {}}, "unexpected_rate_summary"),
({"detail": {"totalRateAmount": -1}}, "missing_or_invalid_effective_rate"),
({"detail": {"totalRateAmount": "0"}}, "missing_or_invalid_effective_rate")]
for index, (data, code) in enumerate(cases):
with self.subTest(code=code):
self.output = self.root / f"case{index}"
self.service.rate_edit = lambda _, body, data=data: data
result = self.capture()
self.assertTrue(result["candidate_capture_complete"])
self.assertFalse(result["all_rates_valid"])
self.assertEqual(result["explicit_zero_rates"], 0)
self.assertEqual(result["rate_issues"], {code: 3})
self.verify(result)
def test_currency_mismatch_does_not_convert_or_use_base(self):
self.service.base.rows[0]["roomStay"]["roomRates"][0]["rates"]["rate"][0]["base"]["currencyCode"] = "THB"
result = self.capture()
self.assertEqual(result["rate_issues"], {"rate_currency_mismatch": 1})
self.assertEqual(result["valid_rate_candidates"], 2)
self.verify(result)
def test_missing_and_mixed_detail_currency_require_review(self):
self.service.base.rows[0]["roomStay"].pop("roomRates")
self.service.base.rows[1]["roomStay"]["roomRates"][0]["rates"]["rate"].append({"base": {"currencyCode": "THB"}})
result = self.capture()
self.assertEqual(result["rate_issues"], {"missing_detail_rate_currency": 1, "mixed_detail_rate_currencies": 1})
self.assertEqual(result["valid_rate_candidates"], 1)
self.verify(result)
def test_money_decimal_bytes_preserved_through_assessment_and_replay(self):
def transport(method, path, raw):
status, headers, response = self.service(method, path, raw)
if path.endswith("rate-info/searches"):
response = response.replace(b'"totalRateAmount": 0', b'"totalRateAmount": 9007199254740993.01')
return status, headers, response
result = self.capture(transport)
self.assertEqual(self.verify(result)[2]["records"][0]["effective_rate"], "9007199254740993.01")
def test_no_show_cancelled_unassigned_and_missing_codes_are_not_filtered(self):
for row, status in zip(self.service.base.rows, ["NoShow", "Cancelled", "InHouse"]):
row["reservationStatus"] = status
result = self.capture()
self.assertEqual(result["verified_details"], 3)
self.assertEqual(result["rate_responses"], 3)
def test_detail_identity_drift_or_note_loss_stops_before_its_rate(self):
for index, (change, code) in enumerate([
(lambda d: d["reservationIdList"][0].update(id="other"), "detail_identity_mismatch"),
(lambda d: d.update(reservationStatus="Cancelled"), "search_detail_state_mismatch"),
(lambda d: d["roomStay"].update(arrivalDate="2026-09-14"), "record_arrival_mismatch"),
]):
with self.subTest(code=code):
self.service = DayService()
self.output = self.root / f"bad-detail-{index}"
def edit(document, _, change=change):
if document["operation_id"] == source.DETAIL:
change(document["data"]["reservations"]["reservation"][0])
return document
self.service.base.edit = edit
result = self.capture()
self.assertEqual(result["error"], code)
self.assertEqual(result["rate_responses"], 0)
self.assertFalse(result["candidate_capture_complete"])
self.service = DayService()
self.output = self.root / "note-loss"
self.service.base.rows[0]["reservationIndicators"] = [{"indicatorName": "COMMENT", "count": 2}]
self.assertEqual(self.capture()["error"], "note_or_trace_count_mismatch")
def test_final_search_drift_refuses_capture_even_after_all_prices(self):
def edit(document, index):
if index == 6:
document["data"]["reservations"]["reservationInfo"][0]["lastModifyDateTime"] = "changed"
return document
self.service.base.edit = edit
result = self.capture()
self.assertEqual(result["error"], "source_changed_during_collection")
self.assertEqual(result["valid_rate_candidates"], 3)
self.assertFalse(result["candidate_capture_complete"])
self.assertFalse(result["all_rates_valid"])
with self.assertRaisesRegex(source.CollectionError, "incomplete_capture"):
self.verify(result)
def test_empty_day_requires_review_without_rates(self):
self.service = DayService(0)
result = self.capture()
self.assertEqual(result["error"], "empty_source_requires_review")
self.assertEqual(result["rate_responses"], 0)
def test_rate_warning_wrong_hotel_or_auth_failure_stops_whole_day(self):
for index, kind in enumerate(("warning", "hotel", "auth")):
self.output = self.root / kind
self.service = DayService()
def transport(method, path, raw, kind=kind):
status, headers, response = self.service(method, path, raw)
if path.endswith("rate-info/searches"):
document = json.loads(response)
if kind == "warning":
document["data"]["warnings"] = [{"code": "SYNTHETIC"}]
elif kind == "hotel":
document["hotel_id"] = "OTHER"
else:
status = 403
response = source.json_bytes(document)
return status, headers, response
result = self.capture(transport)
self.assertFalse(result["candidate_capture_complete"])
self.assertEqual(result["rate_http_attempts"], 1)
self.assertEqual(result["rate_issues"], {})
def test_transient_rate_and_source_retries_replay_without_network(self):
counts = {"search": 0, "rate": 0}
def transport(method, path, raw):
key = "rate" if path.endswith("rate-info/searches") else "search"
counts[key] += 1
if key == "rate" and counts[key] == 1:
return 429, {"Retry-After": "0"}, b"{}"
if key == "search" and counts[key] == 1:
raise TimeoutError()
return self.service(method, path, raw)
result = self.capture(transport)
self.assertEqual(result["http_attempts"], 12)
self.assertEqual(result["rate_http_attempts"], 4)
self.verify(result)
def test_modified_raw_and_manifest_require_original_pin(self):
result = self.capture()
path = self.output / "rate-000001.response.bin"
path.write_bytes(path.read_bytes() + b" ")
with self.assertRaisesRegex(source.CollectionError, "archive_(file_too_large|hash_mismatch)"):
self.verify(result)
self.repin("result.json", lambda d: d.update(valid_rate_candidates=99))
with self.assertRaisesRegex(source.CollectionError, "manifest_hash_mismatch"):
self.verify(result)
def test_resealed_wrong_rate_date_or_identity_fails_protocol(self):
for field, value in (("detailDate", "2026-09-14"), ("id", "other")):
self.output = self.root / field
self.capture()
result = self.repin("rate-000001.json", lambda d: d["body"].update({field: value}))
with self.assertRaisesRegex(source.CollectionError, "capture_protocol_replay_failed"):
self.verify(result)
def test_resealed_summary_and_assessment_do_not_override_raw(self):
for file, change, code in [
("result.json", lambda d: d.update(valid_rate_candidates=999), "capture_summary_mismatch"),
("rate-assessments.json", lambda d: d["records"][0].update(effective_rate="100"), "rate_assessment_mismatch"),
]:
self.output = self.root / file
self.capture()
result = self.repin(file, change)
with self.assertRaisesRegex(source.CollectionError, code):
self.verify(result)
def test_resealed_protocol_dates_operations_and_safety_claims_rejected(self):
cases = [("result.json", lambda d: d.update(rate_date="2026-09-14"), "capture_date_mismatch"),
("capture.json", lambda d: d.update(operations=[source.SEARCH, source.DETAIL]), "capture_contract_mismatch"),
("result.json", lambda d: d.update(finance_ready=True), "unsupported_capture_claim"),
("capture.json", lambda d: d["options"].update(to_date="2026-09-14"), "report_dates_must_match")]
for index, (file, change, code) in enumerate(cases):
self.output = self.root / f"contract-{index}"
self.capture()
result = self.repin(file, change)
with self.assertRaisesRegex(source.CollectionError, code):
self.verify(result)
def test_unsafe_file_permissions_and_extra_files_refused(self):
result = self.capture()
target = self.output / "rate-000001.response.bin"
target.chmod(0o644)
with self.assertRaisesRegex(source.CollectionError, "unsafe_archive_file"):
self.verify(result)
target.chmod(0o600)
(self.output / "extra.json").write_text("{}")
with self.assertRaisesRegex(source.CollectionError, "archive_file_set_mismatch"):
self.verify(result)
def test_v1_verifier_never_silently_accepts_v2(self):
result = self.capture()
with self.assertRaisesRegex(source.CollectionError, "incomplete_capture"):
v1audit.VerifiedArchive(self.output, result["manifest_sha256"])
def test_field_audit_requires_rate_identity_alignment(self):
rows, details, assessments = self.verify(self.capture())
assessments["records"].reverse()
with self.assertRaisesRegex(source.CollectionError, "rate_assessment_identity_mismatch"):
audit.analyze_fields(rows, details, assessments)
if __name__ == "__main__":
unittest.main()