Files
ARR-2.0-0918/tests/test_arr_cancelled_scope.py
T

214 lines
12 KiB
Python

"""Offline cancellation scope checks for both daily report input paths."""
from __future__ import annotations
import argparse
import contextlib
import copy
import io
import json
from pathlib import Path
import tempfile
import unittest
import xml.etree.ElementTree as ET
from tests.test_arr_opera_daily_ingest import core, reservation, run_processor, xml_document
from tests.test_arr_data_review import source_document
def xml_row(sequence, statuses=(), *, blank_room=False, blank_rate=False, invalid_fields=False):
node = ET.fromstring(reservation(sequence))
for tag, value in statuses:
ET.SubElement(node, tag).text = value
if blank_room:
node.find("DISP_ROOM_NO").text = ""
if blank_rate:
node.find("RATE_CODE").text = ""
if invalid_fields:
for tag, value in (("COMPANY_NAME", ""), ("FULL_NAME", ""), ("ADULTS", "-1"),
("NO_OF_ROOMS", "0"), ("TRUNC_END", "2026-07-26")):
node.find(tag).text = value
return ET.tostring(node, encoding="unicode")
class CancelledScopeTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory(prefix="arr-cancelled-scope-")
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
def classify_xml(self, *rows, apply_scope=True):
path = self.root / "source.xml"
path.write_text(xml_document(*rows))
business_date, nodes = core.read_xml(path)
return core.classify_input_records(nodes, business_date, apply_scope=apply_scope)
def read_data(self, document):
path = self.root / "source.json"
path.write_text(json.dumps(document))
return core.read_data_source(path)
def run_data(self, document, output_name="output"):
path = self.root / "source.json"
path.write_text(json.dumps(document))
output = self.root / output_name
args = argparse.Namespace(xml=None, data_json=str(path), output_dir=str(output),
result_json=str(output / "result.json"), structured_result_json=str(output / "structured-result.json"))
before = path.read_bytes()
with contextlib.redirect_stdout(io.StringIO()):
result = core.process(args)
self.assertEqual(path.read_bytes(), before)
return result, json.loads((output / "structured-result.json").read_text())
def test_xml_cancellation_precedes_rate_room_other_validation_and_keeps_lineage(self):
all_rows, eligible, rate_removed, duplicates, errors = self.classify_xml(
xml_row(1, (("RESV_STATUS", " CxL "),), blank_room=True, blank_rate=True, invalid_fields=True),
reservation(2))
self.assertEqual(errors, [])
self.assertEqual((rate_removed, duplicates), (0, 0))
self.assertEqual([row["_SOURCE_INDEX"] for row in eligible], [2])
self.assertEqual(all_rows[0]["_OUTCOME"], "excluded_cancelled")
self.assertEqual(all_rows[0]["_DECISION_CODES"], ["RESERVATION_CANCELLED_EXCLUDED"])
self.assertEqual(all_rows[0]["CONFIRMATION_NO"], "SYNTHETIC-CONF-1")
self.assertEqual(all_rows[0]["_SOURCE_LOCATION"], "reservation[1]")
def test_cancelled_with_a_room_never_occupies_duplicate_key(self):
rows = [ET.fromstring(reservation(index, room="SAME-ROOM")) for index in (1, 2, 3)]
ET.SubElement(rows[0], "SHORT_RESV_STATUS").text = "CANCELLED"
all_rows, eligible, _, duplicates, errors = self.classify_xml(
*(ET.tostring(node, encoding="unicode") for node in rows))
self.assertEqual(errors, [])
self.assertEqual([row["_SOURCE_INDEX"] for row in eligible], [2])
self.assertEqual([row["_OUTCOME"] for row in all_rows], ["excluded_cancelled", "pending", "duplicate"])
self.assertEqual(all_rows[2]["_DUPLICATE_OF_SOURCE_SEQUENCE"], 2)
self.assertEqual(duplicates, 1)
def test_only_exact_cancellation_values_in_explicit_status_fields_are_excluded(self):
for field in ("RESV_STATUS", "RESERVATION_STATUS", "SHORT_RESV_STATUS"):
for status in ("CXL", " cancelled ", "CaNcElEd"):
with self.subTest(field=field, status=status):
rows, _, _, _, errors = self.classify_xml(
xml_row(1, ((field, status),), blank_room=True), reservation(2))
self.assertEqual(errors, [])
self.assertTrue(core.is_cancelled_record(rows[0]))
for status in ("CA", "CD", "GC", "TA", "NoShow", "CKOT", "CancelledByGuest", ""):
with self.subTest(status=status):
rows, _, _, _, errors = self.classify_xml(
xml_row(1, (("SHORT_RESV_STATUS", status),), blank_room=True), reservation(2))
self.assertFalse(core.is_cancelled_record(rows[0]))
self.assertIn("XML_ROOM_MISSING", {error.code for error in errors})
for field in ("RESERVATION_TYPE", "RESV_TYPE", "BOOKING_TYPE"):
with self.subTest(type_field=field):
rows, _, _, _, errors = self.classify_xml(
xml_row(1, ((field, "CANCELLED"),), blank_room=True), reservation(2))
self.assertFalse(core.is_cancelled_record(rows[0]))
self.assertIn("XML_ROOM_MISSING", {error.code for error in errors})
def test_conflicting_xml_status_is_retained_and_cancel_aliases_agree(self):
for fields in ((("RESV_STATUS", "CXL"), ("SHORT_RESV_STATUS", "CKIN")),
(("RESERVATION_STATUS", "CANCELLED"), ("SHORT_RESV_STATUS", "CA")),
(("RESV_STATUS", "CXL"), ("RESV_STATUS", "RESERVED"))):
with self.subTest(fields=fields):
rows, _, _, _, errors = self.classify_xml(xml_row(1, fields, blank_room=True), reservation(2))
self.assertTrue(rows[0]["_RESERVATION_STATUS_CONFLICT"])
self.assertFalse(core.is_cancelled_record(rows[0]))
self.assertIn("XML_ROOM_MISSING", {error.code for error in errors})
rows, _, _, _, errors = self.classify_xml(xml_row(1,
(("RESV_STATUS", "CXL"), ("SHORT_RESV_STATUS", "CANCELED")), blank_room=True), reservation(2))
self.assertEqual(errors, [])
self.assertFalse(rows[0]["_RESERVATION_STATUS_CONFLICT"])
self.assertTrue(core.is_cancelled_record(rows[0]))
def test_data_cancellation_is_shared_and_unknown_or_absent_status_keeps_validation(self):
for status in ("Cancelled", " CANCELED ", "cxl"):
with self.subTest(status=status):
document = source_document(2)
document["records"][0]["reservation_status"] = status
for field in document["records"][0]["fields"]:
document["records"][0]["fields"][field] = {"state": "missing", "value": None}
business_date, rows = self.read_data(document)
all_rows, _, rate_removed, _, errors = core.classify_input_records(rows, business_date)
self.assertEqual(errors, [])
self.assertEqual(rate_removed, 0)
self.assertEqual(all_rows[0]["_OUTCOME"], "excluded_cancelled")
for status in (None, "NoShow", "InHouse", "CA"):
with self.subTest(status=status):
document = source_document(2)
if status is not None:
document["records"][0]["reservation_status"] = status
document["records"][0]["fields"]["DISP_ROOM_NO"] = {"state": "missing", "value": None}
business_date, rows = self.read_data(document)
_, _, _, _, errors = core.classify_input_records(rows, business_date)
self.assertIn("DATA_ROOM_MISSING", {error.code for error in errors})
def test_data_status_rejects_invalid_types_and_empty_strings(self):
for value in (None, True, 1, [], {}, "", " "):
with self.subTest(value=value):
document = source_document()
document["records"][0]["reservation_status"] = value
with self.assertRaises(core.ProcessingFailure) as raised:
self.read_data(document)
self.assertEqual(raised.exception.errors[0].code, "INPUT_DATA_INVALID")
def test_xml_success_and_independent_validation_audit_cancellation_separately(self):
result, source, _, _, payload = run_processor(xml_document(
xml_row(1, (("SHORT_RESV_STATUS", "CXL"),), blank_room=True, blank_rate=True),
reservation(2)), self.root)
self.assertEqual(result, 0, payload["errors"])
self.assertEqual((payload["source_rows"], payload["removed_by_rate_code"], payload["output_rows"]), (2, 0, 1))
self.assertEqual(payload["outcome_counts"]["excluded_cancelled"], 1)
self.assertEqual(set(payload["outcome_counts"]), core.FINAL_OUTCOMES)
self.assertEqual(payload["processor_version"], "4.3.0")
self.assertEqual([row["outcome"] for row in payload["records"]], ["excluded_cancelled", "retained"])
self.assertEqual(payload["records"][0]["decision_codes"], ["RESERVATION_CANCELLED_EXCLUDED"])
self.assertEqual(payload["records"][0]["confirmation_no"], "SYNTHETIC-CONF-1")
self.assertEqual(payload["artifacts"]["source_xml"]["sha256"], core.sha256_file(source))
for field, value in (("decision_codes", ["RATE_CODE_NOT_WHITELISTED"]),
("real_price", 900), ("channel_key", "QBD")):
with self.subTest(tampered=field):
changed = copy.deepcopy(payload)
changed["records"][0][field] = value
with self.assertRaises(core.ProcessingFailure):
core.validate_structured_completeness(changed)
for name in ("structured-result.schema.json", "data-structured-result.schema.json"):
schema = json.loads((core.SKILL_ROOT / "references" / name).read_text())
self.assertIn("excluded_cancelled", schema["properties"]["outcome_counts"]["required"])
self.assertIn("excluded_cancelled", schema["$defs"]["record"]["properties"]["outcome"]["enum"])
def test_data_success_review_and_failure_keep_cancelled_rows_outside_business_rules(self):
baseline = source_document(2)
baseline["records"][0]["reservation_status"] = "Cancelled"
for field in baseline["records"][0]["fields"]:
baseline["records"][0]["fields"][field] = {"state": "missing", "value": None}
result, payload = self.run_data(baseline)
self.assertEqual(result, 0, payload["errors"])
self.assertEqual(payload["outcome_counts"]["excluded_cancelled"], 1)
self.assertEqual(payload["removed_by_rate_code"], 0)
self.assertEqual(payload["output_rows"], 1)
review = copy.deepcopy(baseline)
review["records"][1]["fields"]["EFFECTIVE_RATE_AMOUNT"]["value"] = "8765"
result, payload = self.run_data(review, "review")
self.assertEqual(result, 0, payload["errors"])
self.assertEqual(payload["status"], "review_required", payload["errors"])
self.assertEqual(payload["outcome_counts"]["excluded_cancelled"], 1)
self.assertEqual(payload["outcome_counts"]["price_unmatched"], 1)
failed = copy.deepcopy(baseline)
failed["records"][1]["fields"]["DISP_ROOM_NO"] = {"state": "missing", "value": None}
result, payload = self.run_data(failed, "failure")
self.assertNotEqual(result, 0)
self.assertEqual(payload["status"], "failed")
self.assertEqual(payload["outcome_counts"]["excluded_cancelled"], 1)
self.assertEqual(payload["outcome_counts"]["validation_failed"], 1)
def test_legacy_v3_replay_retains_original_scope_and_original_outcome_contract(self):
rows = (xml_row(1, (("SHORT_RESV_STATUS", "CXL"),)), reservation(2))
result, _, _, _, payload = run_processor(xml_document(*rows), self.root, legacy_v3_output=True)
self.assertEqual(result, 0, payload["errors"])
self.assertEqual(payload["result_schema_version"], "3.0")
self.assertEqual(payload["output_rows"], 2)
self.assertEqual(set(payload["outcome_counts"]), core.LEGACY_DIRECT_FINAL_OUTCOMES)
self.assertEqual([row["outcome"] for row in payload["records"]], ["retained", "retained"])
if __name__ == "__main__":
unittest.main()