"""Cancellation eligibility before field review; synthetic, offline sources only.""" import copy import hashlib import json from pathlib import Path import tempfile import unittest from unittest.mock import patch from arr_processing.policy import load_processor_policy from arr_web.arr_data_review import DataFieldReviews from arr_web.contracts import PortalError from integrations.ohip.collect_arr_source import CollectionError from tests.test_arr_data_review import source_document, raw, gap, CONTEXT, DAY, REQUEST, ACTOR, SOURCE_MANIFEST class CancelledReviewTests(unittest.TestCase): def setUp(self): temporary = tempfile.TemporaryDirectory(prefix="arr-cancelled-review-") self.addCleanup(temporary.cleanup) self.root = Path(temporary.name) / "reviews" self.policy = load_processor_policy(Path(__file__).resolve().parents[1]) self.service = DataFieldReviews(root=self.root, policy=self.policy, context=CONTEXT) self.document = source_document(2) def prepare(self): return self.service.prepare(REQUEST, raw(self.document), SOURCE_MANIFEST, DAY) def evidence(self, statuses=("Cancelled", "InHouse")): return {"version": "arr-reservation-status-evidence/v1", "policy_id": "arr-exclude-cancelled/v1", "original_sha256": hashlib.sha256(raw(self.document)).hexdigest(), "source_manifest_sha256": SOURCE_MANIFEST, "hotel_id": CONTEXT["hotel_id"], "report_date": DAY, "records": [{"source_sequence": row["source_sequence"], "reservation_id": row["reservation_id"], "reservation_status": status, "sources": [{"file": "synthetic.response.bin", "sha256": "f" * 64}]} for row, status in zip(self.document["records"], statuses)]} def apply(self, revision, evidence=None): with patch("integrations.ohip.reservation_status.saved_status_evidence", return_value=evidence or self.evidence()): return self.service.apply_saved_status_evidence(REQUEST, Path("unused-synthetic-capture"), expected_revision=revision) def test_cancelled_fields_and_rate_are_not_manual_requirements_but_other_rows_still_are(self): self.document["records"][0]["reservation_status"] = "Cancelled" for field in ("DISP_ROOM_NO", "RATE_CODE", "BLOCK_CODE", "ADULTS"): gap(self.document, field) gap(self.document, "DISP_ROOM_NO", sequence=2) review = self.prepare() self.assertEqual([item["item_id"] for item in review["items"]], ["2:DISP_ROOM_NO"]) self.assertEqual(review["excluded_cancelled_count"], 1) self.assertFalse(review["can_finalize"]) def test_saved_statuses_remove_room_task_preserve_original_and_freeze_audited_source(self): gap(self.document, "DISP_ROOM_NO") before = self.prepare() original = self.service.original(REQUEST) review = self.apply(before["revision"]) self.assertEqual(review["pending_count"], 0) self.assertEqual(review["items"], []) self.assertTrue(review["can_finalize"]) self.assertEqual(self.service.original(REQUEST), original) self.assertIsNone(self.service.payload(REQUEST)) self.assertEqual(self.prepare(), review, "a saved-status overlay must not bypass explicit continuation") self.assertEqual(self.apply(before["revision"]), review, "exact retry is idempotent") final = self.service.finalize(REQUEST, review["revision"], ACTOR) restored = DataFieldReviews(root=self.root, policy=self.policy, context=CONTEXT) payload, binding = restored.payload(REQUEST) derived = json.loads(payload) self.assertEqual(derived["records"][0]["fields"], self.document["records"][0]["fields"]) self.assertEqual(derived["records"][0]["reservation_status"], "Cancelled") audit = derived["manual_data_review"]["manifest"] self.assertEqual(audit["changes"], []) self.assertEqual(audit["reservation_status_evidence"]["original_sha256"], hashlib.sha256(original[0]).hexdigest()) self.assertEqual([event["action"] for event in audit["events"]], ["reservation_status_evidence", "finalize"]) self.assertEqual(restored.get(REQUEST), final) self.assertEqual(restored.original(REQUEST), original) self.assertEqual(len(binding), 64) with self.assertRaises(PortalError): self.apply(final["revision"]) def test_prior_staff_decision_on_cancelled_row_stays_audited_but_cannot_be_edited(self): gap(self.document, "BLOCK_CODE") before = self.prepare() saved = self.service.update(REQUEST, "1:BLOCK_CODE", before["revision"], "VERIFIED", ACTOR) review = self.apply(saved["revision"]) self.assertEqual(review["items"], []) with self.assertRaises(PortalError): self.service.update(REQUEST, "1:BLOCK_CODE", review["revision"], "CHANGED", ACTOR) self.service.finalize(REQUEST, review["revision"], ACTOR) derived = json.loads(self.service.payload(REQUEST)[0]) self.assertEqual(derived["manual_data_review"]["manifest"]["changes"][0]["value"], "VERIFIED") self.assertEqual(derived["records"][0]["fields"]["BLOCK_CODE"]["value"], "VERIFIED") def test_overlay_rejects_wrong_order_context_and_revision_without_publishing(self): gap(self.document, "DISP_ROOM_NO") review = self.prepare() with self.assertRaises(PortalError): self.apply(review["revision"] + 1) for alter in (lambda e: e["records"].reverse(), lambda e: e.update(original_sha256="0" * 64), lambda e: e["records"].pop(), lambda e: e["records"][0].update(reservation_status="")): evidence = self.evidence() alter(evidence) with self.assertRaises(CollectionError): self.apply(review["revision"], evidence) self.assertEqual(self.service.get(REQUEST), review) def test_status_evidence_tamper_is_rejected(self): gap(self.document, "DISP_ROOM_NO") review = self.prepare() self.apply(review["revision"]) evidence_file = next((self.root / REQUEST).glob("reservation-status-*.json")) evidence_file.write_bytes(evidence_file.read_bytes() + b" ") with self.assertRaises(CollectionError): self.service.get(REQUEST) if __name__ == "__main__": unittest.main()