fix: recognize corroborated empty Oracle associations

This commit is contained in:
Wyndham ARR committed 2026-10-08 18:58:18 +08:00
1 parent 4559d5a642
commit de9eb532f2
9 files changed
+735 -28

No files matched your search

+62
View File
@@ -1,5 +1,8 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const {harness,response} = require('./helpers/arr_ui_harness.cjs');
const requestId='a'.repeat(32);
const task={request_id:requestId,report_date:'2026-10-07',status:'needs_data_review',job_id:null,can_retry:false};
@@ -7,6 +10,65 @@ const item=(extra={})=>({item_id:'1:BLOCK_CODE',source_sequence:1,confirmation_n
const review=(items,revision=1)=>({request_id:requestId,report_date:task.report_date,status:'editing',revision,items,pending_count:items.filter(x=>!x.confirmed).length,total_count:items.length,can_finalize:items.every(x=>x.confirmed)});
const plain=value=>JSON.parse(JSON.stringify(value));
for (const fixture of [
{name:'missing room retains the original required rule', field:'DISP_ROOM_NO', can_be_empty:false,
source_state:'missing', reason_code:'calendar_missing_rooms', expected:'required_value_missing'},
{name:'missing block is optional information awaiting confirmation', field:'BLOCK_CODE', can_be_empty:true,
source_state:'missing', reason_code:'missing_reservation_block', expected:'source_unconfirmed'},
{name:'missing packages are optional information awaiting confirmation', field:'PRODUCTS', can_be_empty:true,
source_state:'missing', reason_code:'missing_reservation_packages', expected:'source_unconfirmed'},
{name:'failed source state takes precedence over a missing reason', source_state:'failed',
reason_code:'missing_reservation_block', expected:'fetch_failed'},
{name:'ambiguous source state takes precedence over an invalid reason', source_state:'ambiguous',
reason_code:'invalid_package_code', expected:'conflict_value'},
{name:'older HTTP failure snapshots remain failures', reason_code:'http_failure', expected:'fetch_failed'},
{name:'invalid response JSON is a read failure rather than a field format error',
reason_code:'invalid_json', expected:'fetch_failed'},
{name:'a block disagreement is shown as conflicting information',
reason_code:'block_code_disagreement', expected:'conflict_value'},
{name:'a changed block association is shown as conflicting information',
reason_code:'block_association_changed', expected:'conflict_value'},
{name:'malformed package code has a separate format explanation',
reason_code:'invalid_package_code', expected:'invalid_value'},
{name:'invalid nights retain a separate format explanation',
field:'DEPARTURE', can_be_empty:false, reason_code:'XML_NEGATIVE_NIGHTS', expected:'invalid_value'},
{name:'confirmed decisions show the saved confirmation instead of the original problem',
source_state:'failed', reason_code:'http_failure', confirmed:true, value:'VERIFIED', expected:'confirmed_value'},
]) {
test(`field review explanation: ${fixture.name}`,async()=>{
const source=item(fixture);
const original=plain(source);
const h=harness(()=>response(200,review([source])));
await h.acceptARRDownloadTask(task,{sync:false});
const markup=h.element('#arr-data-review-body').innerHTML;
assert.match(markup,new RegExp(`data_review\\.${fixture.expected}\\b`));
assert.match(markup,new RegExp(`data_review\\.${source.can_be_empty?'optional':'required'}_field_rule\\b`));
assert.equal(markup.includes('data_review.missing_value'),false);
assert.equal(markup.includes('data-arr-data-review-empty'),source.can_be_empty);
assert.deepEqual(plain(source),original,'rendering explanations never changes a source or confirmation');
assert.equal(h.calls.every(call=>call.method==='GET'),true);
});
}
test('review explanations have distinct Chinese, English and Thai copy for required, optional and failed reads',()=>{
const context=vm.createContext({window:{},document:{readyState:'loading',addEventListener(){}}});
vm.runInContext(fs.readFileSync(path.resolve(__dirname,'../../arr_web/static/i18n.js'),'utf8'),context);
const catalog=context.window.ARRI18n.CATALOG;
const keys=['required_field_rule','optional_field_rule','required_value_missing','source_unconfirmed',
'fetch_failed','conflict_value','invalid_value'];
for(const key of keys) {
const translations=catalog[`data_review.${key}`];
assert.equal(translations.length,3);
translations.forEach(text=>assert.equal(typeof text==='string' && text.length>0,true));
}
assert.match(catalog['data_review.required_field_rule'][0],/原处理规则必填/);
assert.match(catalog['data_review.optional_field_rule'][0],/允许为空/);
assert.match(catalog['data_review.source_unconfirmed'][0],/确认无此信息/);
assert.match(catalog['data_review.fetch_failed'][0],/不能直接认定没有此信息/);
assert.equal(catalog['data_review.fetch_failed'][0].includes('刷新'),false,
'refreshing the review must not imply a retry of Oracle acquisition');
});
test('source review opens before a job exists and another date can start its own download',async()=>{
const h=harness((url,options)=>options.method==='POST'
? response(202,{...JSON.parse(options.body),status:'queued',job_id:null,can_retry:false})
+281
View File
@@ -44,6 +44,8 @@ from tests import test_arr_download_postgres_integration as pg_helpers
PROJECT = Path(__file__).resolve().parents[1]
ACTOR = "operator@example.test"
SOURCE_MANIFEST = "c" * 64
REANALYSIS_MANIFEST = "e" * 64
REANALYSIS_POLICY = "oracle-optional-association/v1"
CONTEXT = {"hotel_id": HOTEL, "source_kind": "test_transport"}
@@ -77,6 +79,23 @@ def gap(document, field, sequence=1, state="missing"):
document["status"], document["input_complete"] = "collected_with_gaps", False
def optional_omission_source(document):
result = copy.deepcopy(document)
for row in result["records"]:
for field, reason, empty_value in (("BLOCK_CODE", "missing_reservation_block", ""),
("PRODUCTS", "missing_reservation_packages", [])):
observation = row["fields"][field]
if observation.get("state") == "missing" and observation.get("reason") == reason:
row["fields"][field] = {"state": "empty", "value": empty_value}
unresolved = any(value["state"] != "available"
and not (field in review_module.OPTIONAL and value["state"] == "empty")
for row in result["records"] for field, value in row["fields"].items())
result["input_complete"] = not unresolved
result["status"] = "collected_with_gaps" if unresolved else "collected"
result["optional_omission_policy"] = REANALYSIS_POLICY
return result
class DataFieldReviewTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
@@ -103,6 +122,247 @@ class DataFieldReviewTests(unittest.TestCase):
self.assertEqual(raised.exception.code, "ARR_DATA_REVIEW_" + code)
self.assertEqual(raised.exception.status, status)
def omission_gaps(self, document=None, sequence=1):
document = self.document if document is None else document
for field, reason in (("BLOCK_CODE", "missing_reservation_block"),
("PRODUCTS", "missing_reservation_packages")):
gap(document, field, sequence=sequence)
document["records"][sequence - 1]["fields"][field]["reason"] = reason
def reanalyze(self, review, document=None, **kwargs):
return self.service.apply_source_reanalysis(REQUEST,
raw(optional_omission_source(self.document) if document is None else document),
REANALYSIS_MANIFEST, REANALYSIS_POLICY, expected_revision=review["revision"], **kwargs)
def test_source_reanalysis_removes_only_25_optional_gaps_without_confirming_a_room(self):
template = self.document["records"][0]
self.document["records"] = []
for sequence in range(1, 19):
row = copy.deepcopy(template)
row["source_sequence"], row["reservation_id"] = sequence, f"synthetic-{sequence}"
row["fields"]["CONFIRMATION_NO"]["value"] = f"SYNTHETIC-{sequence:03d}"
row["fields"]["DISP_ROOM_NO"]["value"] = f"ROOM-{sequence:03d}"
self.document["records"].append(row)
gap(self.document, "BLOCK_CODE", sequence)
row["fields"]["BLOCK_CODE"]["reason"] = "missing_reservation_block"
if sequence <= 7:
gap(self.document, "PRODUCTS", sequence)
row["fields"]["PRODUCTS"]["reason"] = "missing_reservation_packages"
gap(self.document, "DISP_ROOM_NO")
original = raw(self.document)
review = self.prepare()
self.assertEqual(review["pending_count"], 26)
revised = self.reanalyze(review)
self.assertEqual((revised["revision"], revised["pending_count"], revised["total_count"]), (1, 1, 1))
self.assertEqual([item["field"] for item in revised["items"]], ["DISP_ROOM_NO"])
self.assertFalse(revised["can_finalize"])
self.assertIsNone(self.service.payload(REQUEST))
self.assertEqual(self.service.original(REQUEST), (original, SOURCE_MANIFEST))
directory = self.root / REQUEST
_, _, state = self.service._read(directory)
self.assertEqual(state["decisions"], {})
self.assertEqual([event["action"] for event in state["events"]], ["source_reanalysis"])
self.assertNotIn("actor", state["events"][0])
receipt = json.loads((directory / state["source_reanalysis"]["receipt_file"]).read_bytes())
self.assertEqual(len(receipt["changes"]), 25)
self.assertEqual(receipt["parent_original_sha256"], hashlib.sha256(original).hexdigest())
for path in directory.iterdir():
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600)
def test_reanalysis_preserves_staff_decisions_and_final_manifest_has_both_source_bindings(self):
self.omission_gaps()
gap(self.document, "DISP_ROOM_NO")
before = raw(self.document)
first = self.prepare()
staff = self.update(first, "BLOCK_CODE", "STAFF-VERIFIED-BLOCK")
payload = raw(optional_omission_source(self.document))
revised = self.reanalyze(staff)
confirmed = next(item for item in revised["items"] if item["field"] == "BLOCK_CODE")
self.assertEqual((confirmed["value"], confirmed["source_state"], confirmed["confirmed"]),
("STAFF-VERIFIED-BLOCK", "available", True))
self.assertEqual(revised["pending_count"], 1)
restored = DataFieldReviews(root=self.root, policy=self.policy, context=CONTEXT)
self.assertEqual(restored.get(REQUEST), revised)
revised = restored.update(REQUEST, "1:DISP_ROOM_NO", revised["revision"], "STAFF-ROOM", ACTOR)
# A lost maintenance response can be retried even after a later staff save.
self.assertEqual(restored.apply_source_reanalysis(REQUEST, payload, REANALYSIS_MANIFEST,
REANALYSIS_POLICY, expected_revision=staff["revision"]), revised)
frozen = restored.finalize(REQUEST, revised["revision"], ACTOR)
result, binding = restored.payload(REQUEST)
self.assertEqual(restored.original(REQUEST), (before, SOURCE_MANIFEST))
derived = json.loads(result)
fields = derived["records"][0]["fields"]
self.assertEqual(fields["BLOCK_CODE"]["value"], "STAFF-VERIFIED-BLOCK")
self.assertEqual(fields["PRODUCTS"], {"state": "empty", "value": []})
audit = derived["manual_data_review"]["manifest"]
self.assertEqual(audit["original_sha256"], hashlib.sha256(before).hexdigest())
self.assertEqual(audit["source_manifest_sha256"], SOURCE_MANIFEST)
self.assertEqual(audit["source_reanalysis"]["policy_id"], REANALYSIS_POLICY)
self.assertEqual(audit["source_reanalysis"]["reanalyzed_sha256"], hashlib.sha256(payload).hexdigest())
self.assertEqual(audit["source_reanalysis"]["reanalysis_manifest_sha256"], REANALYSIS_MANIFEST)
self.assertEqual([event["action"] for event in audit["events"]],
["set_field", "source_reanalysis", "set_field", "finalize"])
self.assertEqual({change["actor"] for change in audit["changes"]}, {ACTOR})
self.assertRegex(binding, r"^[0-9a-f]{64}$")
self.assertEqual(restored.finalize(REQUEST, revised["revision"], ACTOR), frozen)
self.assert_error("FROZEN", lambda: restored.apply_source_reanalysis(REQUEST, payload,
REANALYSIS_MANIFEST, REANALYSIS_POLICY, expected_revision=frozen["revision"]), 409)
def test_reanalysis_rejects_other_source_changes_and_leaves_revision_unchanged(self):
self.document = source_document(2)
self.omission_gaps()
review = self.prepare()
valid = optional_omission_source(self.document)
mutations = [
lambda data: data.update(hotel_id="OTHER"),
lambda data: data.update(report_date="2026-09-16"),
lambda data: data.update(source_kind="OTHER"),
lambda data: data.update(collection_complete=False),
lambda data: data.update(input_complete=False),
lambda data: data.update(status="collected_with_gaps"),
lambda data: data.update(optional_omission_policy="OTHER"),
lambda data: data.update(extra="not allowed"),
lambda data: data["records"].reverse(),
lambda data: data["records"][0].update(reservation_id="OTHER"),
lambda data: data["records"][0]["related"].update(extra="not allowed"),
lambda data: data["records"][0]["fields"]["FULL_NAME"].update(value="OTHER"),
lambda data: data["records"][0]["fields"]["BLOCK_CODE"].update(reason="not allowed"),
]
for change in mutations:
with self.subTest(change=change):
document = copy.deepcopy(valid)
change(document)
with self.assertRaises(CollectionError):
self.reanalyze(review, document)
self.assertEqual(self.service.get(REQUEST), review)
self.assertEqual(list((self.root / REQUEST).glob("*reanalysis*")), [])
def test_reanalysis_never_clears_failed_ambiguous_available_or_unrecognized_observations(self):
for index, (state, reason) in enumerate((("failed", "missing_reservation_packages"),
("ambiguous", "missing_reservation_packages"), ("available", "missing_reservation_packages"),
("missing", "synthetic_field_gap")), 1):
with self.subTest(state=state, reason=reason):
document = source_document()
self.omission_gaps(document)
observation = document["records"][0]["fields"]["PRODUCTS"]
observation["state"], observation["reason"] = state, reason
if state == "available":
observation["value"] = [{"package": {"packageCode": "EXISTING"}}]
request_id = f"{index:032x}"
review = self.prepare(document, request_id)
proposed = optional_omission_source(document)
proposed["records"][0]["fields"]["PRODUCTS"] = {"state": "empty", "value": []}
proposed["input_complete"], proposed["status"] = True, "collected"
with self.assertRaisesRegex(CollectionError, "data_review_reanalysis_observation_invalid"):
self.service.apply_source_reanalysis(request_id, raw(proposed), REANALYSIS_MANIFEST,
REANALYSIS_POLICY, expected_revision=review["revision"])
self.assertEqual(self.service.get(request_id), review)
def test_reanalysis_is_revision_guarded_and_rejects_other_policy_or_evidence(self):
self.omission_gaps()
review = self.prepare()
payload = raw(optional_omission_source(self.document))
staff = self.update(review, "BLOCK_CODE", "STAFF")
self.assert_error("CONFLICT", lambda: self.reanalyze(review), 409)
with self.assertRaisesRegex(CollectionError, "data_review_reanalysis_policy_invalid"):
self.service.apply_source_reanalysis(REQUEST, payload, REANALYSIS_MANIFEST, "OTHER",
expected_revision=staff["revision"])
with self.assertRaisesRegex(CollectionError, "data_review_reanalysis_manifest_invalid"):
self.service.apply_source_reanalysis(REQUEST, payload, "invalid", REANALYSIS_POLICY,
expected_revision=staff["revision"])
current = self.reanalyze(staff)
self.assertEqual(self.reanalyze(staff), current)
self.assert_error("CONFLICT", lambda: self.service.apply_source_reanalysis(REQUEST, payload,
"f" * 64, REANALYSIS_POLICY, expected_revision=current["revision"]), 409)
self.assertEqual(self.service.get(REQUEST), current)
def test_reanalysis_publication_recovers_after_crash_without_publishing_partial_source(self):
self.omission_gaps()
review = self.prepare()
real_atomic = review_module.atomic_json
def interrupted(path, value, **kwargs):
if path.name == "state.json":
raise OSError("synthetic reanalysis pointer interruption")
return real_atomic(path, value, **kwargs)
with patch.object(review_module, "atomic_json", side_effect=interrupted):
with self.assertRaisesRegex(OSError, "synthetic reanalysis pointer interruption"):
self.reanalyze(review)
self.assertEqual(self.service.get(REQUEST), review)
staff = self.update(review, "BLOCK_CODE", "PRESERVED-AFTER-INTERRUPTION")
restored = DataFieldReviews(root=self.root, policy=self.policy, context=CONTEXT)
result = restored.apply_source_reanalysis(REQUEST, raw(optional_omission_source(self.document)),
REANALYSIS_MANIFEST, REANALYSIS_POLICY, expected_revision=staff["revision"])
self.assertEqual(result["revision"], staff["revision"] + 1)
self.assertEqual(result["pending_count"], 0)
self.assertEqual(result["items"][0]["value"], "PRESERVED-AFTER-INTERRUPTION")
_, _, state = restored._read(self.root / REQUEST)
self.assertEqual([event["action"] for event in state["events"]], ["set_field", "source_reanalysis"])
def test_reanalysis_with_no_pending_items_still_requires_finalize_and_recovers_its_intent(self):
self.omission_gaps()
original = raw(self.document)
revised = self.reanalyze(self.prepare())
self.assertTrue(revised["can_finalize"])
self.assertEqual(revised["items"], [])
self.assertEqual(self.prepare(), revised, "executor cannot bypass an unfinalized reanalysis")
self.assertIsNone(self.service.payload(REQUEST))
real_atomic = review_module.atomic_json
def interrupted(path, value, **kwargs):
if path.name == "state.json":
raise OSError("synthetic finalize pointer interruption")
return real_atomic(path, value, **kwargs)
with patch.object(review_module, "atomic_json", side_effect=interrupted):
with self.assertRaisesRegex(OSError, "synthetic finalize pointer interruption"):
self.service.finalize(REQUEST, revised["revision"], ACTOR)
frozen_bytes = (self.root / REQUEST / "reviewed-source.json").read_bytes()
self.assertEqual(self.service.get(REQUEST), revised)
self.assertIsNone(self.service.payload(REQUEST))
self.assert_error("FROZEN", lambda: self.reanalyze(revised), 409)
restored = DataFieldReviews(root=self.root, policy=self.policy, context=CONTEXT)
final = restored.finalize(REQUEST, revised["revision"], "different-operator@example.test")
self.assertEqual(final["status"], "finalized")
self.assertEqual(restored.payload(REQUEST)[0], frozen_bytes)
self.assertEqual(restored.original(REQUEST), (original, SOURCE_MANIFEST))
audit = json.loads(frozen_bytes)["manual_data_review"]["manifest"]
self.assertEqual(audit["changes"], [])
self.assertEqual([event["action"] for event in audit["events"]], ["source_reanalysis", "finalize"])
self.assertEqual(audit["events"][-1]["actor"], ACTOR)
def test_reanalysis_and_staff_save_share_one_revision_winner(self):
self.omission_gaps()
gap(self.document, "DISP_ROOM_NO")
review = self.prepare()
start = threading.Barrier(2)
def mutate(reanalysis):
start.wait(timeout=3)
try:
return self.reanalyze(review) if reanalysis else self.update(review, "DISP_ROOM_NO", "STAFF-ROOM")
except PortalError as error:
return error
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(mutate, [True, False]))
winners = [result for result in results if isinstance(result, dict)]
conflicts = [result for result in results if isinstance(result, PortalError)]
self.assertEqual((len(winners), len(conflicts)), (1, 1))
self.assertEqual((conflicts[0].code, conflicts[0].status), ("ARR_DATA_REVIEW_CONFLICT", 409))
self.assertEqual(self.service.get(REQUEST), winners[0])
def test_reanalysis_payload_and_receipt_tampering_are_rejected(self):
self.omission_gaps()
revised = self.reanalyze(self.prepare())
directory = self.root / REQUEST
_, _, state = self.service._read(directory)
reference = state["source_reanalysis"]
for name in (reference["file"], reference["receipt_file"]):
with self.subTest(name=name):
path = directory / name
before = path.read_bytes()
path.write_bytes(before + b" ")
with self.assertRaisesRegex(CollectionError, "data_review_reanalysis_changed"):
self.service.get(REQUEST)
path.write_bytes(before)
self.assertEqual(self.service.get(REQUEST), revised)
def test_complete_source_and_gaps_only_on_excluded_rows_need_no_review(self):
self.assertIsNone(self.prepare())
excluded = source_document()
@@ -134,6 +394,27 @@ class DataFieldReviewTests(unittest.TestCase):
self.assertEqual({item["field"] for item in review["items"]}, set(invalid))
self.assertEqual(review["pending_count"], len(invalid))
def test_review_exposes_source_failure_separately_from_optional_absence(self):
gap(self.document, "DISP_ROOM_NO")
gap(self.document, "BLOCK_CODE")
gap(self.document, "PRODUCTS", state="failed")
gap(self.document, "RES_COMMENT", state="ambiguous")
before = raw(self.document)
review = self.prepare()
items = {item["field"]: item for item in review["items"]}
self.assertEqual({field: (item["source_state"], item["can_be_empty"])
for field, item in items.items()}, {
"DISP_ROOM_NO": ("missing", False), "BLOCK_CODE": ("missing", True),
"PRODUCTS": ("failed", True), "RES_COMMENT": ("ambiguous", True),
})
self.assertEqual(review["pending_count"], 4)
self.assertFalse(review["can_finalize"])
self.assertEqual(self.service.original(REQUEST)[0], before)
confirmed = self.update(review, "BLOCK_CODE", "")
item = next(item for item in confirmed["items"] if item["field"] == "BLOCK_CODE")
self.assertEqual((item["source_state"], item["confirmed"]), ("empty", True))
self.assertEqual(confirmed["pending_count"], 3)
def test_departure_before_arrival_is_an_editable_issue(self):
self.document["records"][0]["fields"]["DEPARTURE"]["value"] = "2026-09-14"
review = self.prepare()
+118 -4
View File
@@ -402,12 +402,13 @@ class ARRDataTests(unittest.TestCase):
self.assertEqual(result["status"], "collected_with_gaps")
self.assertEqual(field["state"], "missing")
def test_native_optional_absence_requires_review_even_with_zero_package_price(self):
def test_successful_optional_omission_is_empty_only_with_association_corroboration(self):
row = self.service.rows[0]
row.pop("reservationProfiles")
row.pop("reservationPackages")
row["roomStay"].pop("reservationBlock")
row["roomStay"]["roomRates"][0].pop("reservationBlock")
row["reservationIndicators"] = []
def edit(op, doc, _):
if op == "searchRateInfo": doc["data"]["detail"]["totalPackageAmount"] = 0
return doc
@@ -415,9 +416,122 @@ class ARRDataTests(unittest.TestCase):
result, payload = self.fetch()
self.assertEqual(result["status"], "collected_with_gaps")
self.assertTrue(result["collection_complete"])
for name in ("BLOCK_CODE", "COMPANY_NAME", "PRODUCTS"):
self.assertEqual(payload["records"][0]["fields"][name]["state"], "missing")
self.assertIsNone(payload["records"][0]["fields"][name]["value"])
observations = payload["records"][0]["fields"]
self.assertEqual(observations["BLOCK_CODE"], {"state": "empty", "value": ""})
self.assertEqual(observations["PRODUCTS"], {"state": "empty", "value": []})
# Omission of a different, unestablished structure is not accepted by
# this narrowly scoped association rule; zero package price is no proof.
self.assertEqual(observations["COMPANY_NAME"]["state"], "missing")
self.assertIsNone(observations["COMPANY_NAME"]["value"])
self.assertEqual(payload["optional_omission_policy"], data.OPTIONAL_OMISSION_POLICY)
def test_omitted_packages_without_indicator_list_remain_unresolved(self):
self.service.rows[0].pop("reservationPackages")
result, field = self.field("PRODUCTS")
self.assertEqual((result["status"], field["state"], field["reason"]),
("collected_with_gaps", "missing", "missing_reservation_packages"))
def test_omitted_packages_with_explicit_zero_indicator_are_empty(self):
row = self.service.rows[0]
row.pop("reservationPackages")
row["reservationIndicators"] = [{"indicatorName": "PACKAGEITEM", "count": 0}]
result, field = self.field("PRODUCTS")
self.assertEqual((result["status"], field), ("collected", {"state": "empty", "value": []}))
def test_invalid_indicator_names_do_not_prove_absent_packages(self):
for index, indicator in enumerate(({}, {"indicatorName": None}, {"indicatorName": 1},
{"indicatorName": " "})):
self.root = Path(self.temp.name) / f"package-invalid-indicator-{index}"
self.service = SimulatedOHIP()
row = self.service.rows[0]
row.pop("reservationPackages")
row["reservationIndicators"] = [indicator]
with self.subTest(indicator=indicator):
_, field = self.field("PRODUCTS")
self.assertEqual((field["state"], field["reason"]), ("missing", "invalid_package_indicators"))
def test_empty_block_ids_do_not_swallow_other_association_evidence(self):
cases = (("search", {"blockIdList": [], "hotelId": "OTHER"}),
("search", {"blockIdList": [], "blockName": "ASSOCIATED"}),
("day", {"blockIdList": [], "blockName": None}),
("detail", {"blockIdList": [{"type": "Block", "id": "b1"}]}),
("detail", {}), ("detail", None))
for index, (location, block) in enumerate(cases):
self.root = Path(self.temp.name) / f"block-empty-contradiction-{index}"
self.service = SimulatedOHIP()
def edit(op, doc, _, location=location, block=block):
if op == "getReservation" and location in {"day", "detail"}:
stay = doc["data"]["reservations"]["reservation"][0]["roomStay"]
target = stay["roomRates"][0] if location == "day" else stay
target["reservationBlock"] = block
elif op == base.SEARCH and location == "search":
doc["data"]["reservations"]["reservationInfo"][0]["roomStay"]["reservationBlock"] = block
return doc
self.service.edit = edit
with self.subTest(location=location, block=block):
_, field = self.field("BLOCK_CODE")
self.assertEqual(field["state"], "missing")
self.assertIsNone(field["value"])
def test_positive_or_unknown_package_indicator_never_becomes_empty(self):
indicators = [
[{"indicatorName": "PACKAGEITEM", "count": 1}],
[{"indicatorName": "PACKAGEITEM"}],
[{"indicatorName": "PACKAGEITEM", "count": True}],
[{"indicatorName": "PACKAGEITEM", "count": -1}],
[{"indicatorName": "PACKAGEITEM", "count": 0}] * 2,
]
for index, indicator in enumerate(indicators):
for present in (False, True):
self.root = Path(self.temp.name) / f"package-contradiction-{index}-{present}"
self.service = SimulatedOHIP()
row = self.service.rows[0]
if not present:
row.pop("reservationPackages")
row["reservationIndicators"] = indicator
with self.subTest(indicator=indicator, explicit_empty=present):
result, field = self.field("PRODUCTS")
self.assertEqual(result["status"], "collected_with_gaps")
self.assertEqual(field["state"], "missing")
self.assertIsNone(field["value"])
def test_malformed_packages_are_not_interpreted_as_normal_omission(self):
for index, malformed in enumerate((None, {}, "", [None])):
self.root = Path(self.temp.name) / f"package-malformed-{index}"
self.service = SimulatedOHIP()
self.service.rows[0]["reservationPackages"] = malformed
self.service.rows[0]["reservationIndicators"] = []
with self.subTest(value=malformed):
_, field = self.field("PRODUCTS")
self.assertEqual((field["state"], field["reason"]), ("missing", "invalid_reservation_packages"))
def test_other_block_evidence_prevents_automatic_empty(self):
for index, extra in enumerate((None, {}, {"blockIdList": [{"type": "Block", "id": "b1"}]})):
self.root = Path(self.temp.name) / f"block-other-evidence-{index}"
self.service = SimulatedOHIP()
stay = self.service.rows[0]["roomStay"]
stay.pop("reservationBlock")
stay["roomRates"][0].pop("reservationBlock")
# A detail-only extra association must not silently be discarded.
def edit(op, doc, _, extra=extra):
if op == "getReservation":
doc["data"]["reservations"]["reservation"][0]["roomStay"]["reservationBlock"] = extra
return doc
self.service.edit = edit
with self.subTest(value=extra):
_, field = self.field("BLOCK_CODE")
self.assertEqual(field["state"], "missing")
self.assertIsNone(field["value"])
def test_failed_block_supplement_is_not_normal_empty(self):
block = {"blockIdList": [{"type": "Block", "id": "b1"}]}
stay = self.service.rows[0]["roomStay"]
stay["reservationBlock"] = copy.deepcopy(block)
stay["roomRates"][0]["reservationBlock"] = copy.deepcopy(block)
self.service.status = lambda op, count: 503 if op == "getBlock" else 200
result, field = self.field("BLOCK_CODE")
self.assertEqual((result["status"], field["state"], field["reason"]),
("failed", "failed", "http_failure"))
def test_block_supplement_cannot_overwrite_earlier_code_or_hotel(self):
for mismatch in ("code", "hotel"):