fix: recognize corroborated empty Oracle associations
This commit is contained in:
1 parent
4559d5a642
commit
de9eb532f2
9 files changed
+735
-28
No files matched your search
@@ -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})
|
||||
|
||||
@@ -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
@@ -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"):
|
||||
|
||||
Reference in new issue
Block a user