214 lines
12 KiB
Python
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.4.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()
|