323 lines
15 KiB
Python
323 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from dataclasses import replace
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
from arr_ingestion.contracts import DeliveryEnvelope, IngestionError
|
|
from arr_ingestion.repository import (
|
|
InMemoryIngestionRepository,
|
|
JobRegistration,
|
|
)
|
|
from arr_ingestion.service import IngestionService
|
|
from arr_ingestion.validation import DeliveryValidator
|
|
from tests.test_arr_ingestion_validation import build_delivery, policy
|
|
from tests.test_arr_opera_daily_ingest import reservation, xml_document
|
|
|
|
|
|
def register(repository: InMemoryIngestionRepository, raw: bytes) -> DeliveryEnvelope:
|
|
envelope = DeliveryEnvelope.from_dict(json.loads(raw))
|
|
source = envelope.artifacts["source_xml"]
|
|
assert source is not None
|
|
repository.register_job(
|
|
JobRegistration(
|
|
job_id=envelope.job_id,
|
|
source=source,
|
|
processor_version=envelope.processor_version,
|
|
rule_set_sha256=envelope.rule_set_sha256,
|
|
attempt_no=envelope.attempt_no,
|
|
idempotency_key=hashlib.sha256(
|
|
f"{envelope.job_id}:{envelope.attempt_no}".encode("utf-8")
|
|
).hexdigest(),
|
|
)
|
|
)
|
|
return envelope
|
|
|
|
|
|
class IngestionServiceTests(unittest.TestCase):
|
|
def success_xml(self, room: str = "SYN-ROOM-1") -> str:
|
|
return xml_document(
|
|
reservation(1, room=room, res_comment="SYN-GROUP", departure="2026-07-30")
|
|
)
|
|
|
|
def test_success_commits_all_records_and_same_callback_is_idempotent(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
raw, store, _values = build_delivery(self.success_xml(), Path(temp_dir))
|
|
repository = InMemoryIngestionRepository()
|
|
register(repository, raw)
|
|
service = IngestionService(DeliveryValidator(store, policy()), repository)
|
|
|
|
first = service.ingest(raw)
|
|
second = service.ingest(raw)
|
|
self.assertEqual(first.status, "committed")
|
|
self.assertEqual(second, first)
|
|
self.assertEqual(first.version_no, 1)
|
|
self.assertEqual(repository.current_version_id(date(2026, 7, 27)), first.daily_version_id)
|
|
self.assertEqual(len(repository.version_records(first.daily_version_id or 0)), 1)
|
|
|
|
def test_same_delivery_id_with_different_envelope_bytes_conflicts(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
raw, store, _values = build_delivery(self.success_xml(), Path(temp_dir))
|
|
repository = InMemoryIngestionRepository()
|
|
register(repository, raw)
|
|
service = IngestionService(DeliveryValidator(store, policy()), repository)
|
|
service.ingest(raw)
|
|
rewritten = json.dumps(json.loads(raw), ensure_ascii=False, indent=2).encode("utf-8")
|
|
with self.assertRaisesRegex(IngestionError, "identifier conflicts") as raised:
|
|
service.ingest(rewritten)
|
|
self.assertEqual(raised.exception.code, "CALLBACK_CONFLICT")
|
|
|
|
def test_same_source_and_rule_from_new_job_reuses_committed_version(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
raw, store, _values = build_delivery(self.success_xml(), root / "first")
|
|
repository = InMemoryIngestionRepository()
|
|
register(repository, raw)
|
|
first = IngestionService(DeliveryValidator(store, policy()), repository).ingest(raw)
|
|
|
|
envelope = json.loads(raw)
|
|
envelope["delivery_id"] = "delivery-002"
|
|
envelope["job_id"] = "job-002"
|
|
second_raw = json.dumps(envelope, ensure_ascii=False).encode("utf-8")
|
|
register(repository, second_raw)
|
|
second = IngestionService(DeliveryValidator(store, policy()), repository).ingest(second_raw)
|
|
|
|
self.assertEqual(second.status, "already_committed")
|
|
self.assertEqual(second.daily_version_id, first.daily_version_id)
|
|
self.assertEqual(second.version_no, first.version_no)
|
|
|
|
def test_repository_identity_allows_same_source_under_new_rule(self):
|
|
"""A future allowlisted rule creates a new immutable version, not a no-op."""
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
raw, store, _values = build_delivery(self.success_xml(), Path(temp_dir))
|
|
repository = InMemoryIngestionRepository()
|
|
register(repository, raw)
|
|
validator = DeliveryValidator(store, policy())
|
|
verified = validator.validate(raw)
|
|
first = repository.commit_delivery(verified)
|
|
|
|
new_rule = "1" * 64
|
|
new_envelope = replace(
|
|
verified.envelope,
|
|
delivery_id="delivery-new-rule",
|
|
job_id="job-new-rule",
|
|
rule_set_sha256=new_rule,
|
|
)
|
|
source = new_envelope.artifacts["source_xml"]
|
|
assert source is not None
|
|
repository.register_job(
|
|
JobRegistration(
|
|
job_id=new_envelope.job_id,
|
|
source=source,
|
|
processor_version=new_envelope.processor_version,
|
|
rule_set_sha256=new_rule,
|
|
attempt_no=1,
|
|
idempotency_key=hashlib.sha256(b"job-new-rule:1").hexdigest(),
|
|
)
|
|
)
|
|
new_verified = replace(
|
|
verified,
|
|
envelope=new_envelope,
|
|
envelope_sha256=hashlib.sha256(b"synthetic-new-rule-envelope").hexdigest(),
|
|
)
|
|
second = repository.commit_delivery(new_verified)
|
|
|
|
self.assertEqual(second.status, "committed")
|
|
self.assertEqual(second.version_no, 2)
|
|
self.assertNotEqual(second.daily_version_id, first.daily_version_id)
|
|
self.assertEqual(repository.version_status(first.daily_version_id or 0), "superseded")
|
|
|
|
def test_same_business_date_new_source_supersedes_only_after_success(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
raw_one, store_one, _ = build_delivery(
|
|
self.success_xml("SYN-ROOM-1"), root / "one"
|
|
)
|
|
repository = InMemoryIngestionRepository()
|
|
register(repository, raw_one)
|
|
first = IngestionService(
|
|
DeliveryValidator(store_one, policy()), repository
|
|
).ingest(raw_one)
|
|
|
|
raw_two, store_two, _ = build_delivery(
|
|
self.success_xml("SYN-ROOM-2"), root / "two"
|
|
)
|
|
envelope_two = json.loads(raw_two)
|
|
envelope_two["delivery_id"] = "delivery-002"
|
|
envelope_two["job_id"] = "job-002"
|
|
raw_two = json.dumps(envelope_two, ensure_ascii=False).encode("utf-8")
|
|
register(repository, raw_two)
|
|
second = IngestionService(
|
|
DeliveryValidator(store_two, policy()), repository
|
|
).ingest(raw_two)
|
|
|
|
self.assertEqual(second.status, "committed")
|
|
self.assertEqual(second.version_no, 2)
|
|
self.assertEqual(repository.version_status(first.daily_version_id or 0), "superseded")
|
|
self.assertEqual(repository.version_status(second.daily_version_id or 0), "active")
|
|
self.assertEqual(
|
|
repository.current_version_id(date(2026, 7, 27)),
|
|
second.daily_version_id,
|
|
)
|
|
|
|
def test_business_failure_is_recorded_without_switching_current(self):
|
|
failure_xml = xml_document(
|
|
reservation(1, departure="2026-07-26", res_comment="SYN-FAIL")
|
|
)
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
raw, store, _values = build_delivery(failure_xml, Path(temp_dir))
|
|
repository = InMemoryIngestionRepository()
|
|
register(repository, raw)
|
|
outcome = IngestionService(
|
|
DeliveryValidator(store, policy()), repository
|
|
).ingest(raw)
|
|
self.assertEqual(outcome.status, "recorded_failure")
|
|
self.assertIsNone(outcome.daily_version_id)
|
|
self.assertIsNone(repository.current_version_id(date(2026, 7, 27)))
|
|
|
|
def test_review_receipt_has_no_version_then_frozen_manual_replay_commits_once(self):
|
|
source = xml_document(
|
|
reservation(1, rate_code="GRPA1", rate_amount="900"),
|
|
reservation(2, rate_code="GRPA1", rate_amount="1800"),
|
|
)
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
root = Path(temp_dir)
|
|
review_raw, review_store, _values = build_delivery(source, root / "review")
|
|
repository = InMemoryIngestionRepository()
|
|
envelope = register(repository, review_raw)
|
|
service = IngestionService(DeliveryValidator(review_store, policy()), repository)
|
|
review_outcome = service.ingest(review_raw)
|
|
|
|
self.assertEqual(review_outcome.status, "recorded_review")
|
|
self.assertIsNone(review_outcome.daily_version_id)
|
|
self.assertIsNone(repository.current_version_id(date(2026, 7, 27)))
|
|
case_id = review_outcome.review_case_id
|
|
self.assertIsNotNone(case_id)
|
|
current = repository.get_price_review(envelope.job_id, 50, 0)
|
|
self.assertEqual(current["total_items"], 1)
|
|
self.assertEqual(current["completed_items"], 0)
|
|
|
|
updated = repository.update_price_review_item(
|
|
envelope.job_id,
|
|
current["items"][0]["item_id"],
|
|
str(case_id),
|
|
current["revision"],
|
|
"0",
|
|
"operator@example.test",
|
|
)
|
|
self.assertEqual(updated["items"][0]["real_price"], "0")
|
|
plan = repository.begin_price_review_generation(
|
|
envelope.job_id,
|
|
str(case_id),
|
|
updated["revision"],
|
|
"operator@example.test",
|
|
envelope.processor_version,
|
|
envelope.rule_set_sha256,
|
|
"1" * 64,
|
|
)
|
|
self.assertEqual(plan.status, "ready")
|
|
self.assertEqual(plan.attempt_no, 2)
|
|
self.assertEqual(plan.source, envelope.artifacts["source_xml"])
|
|
self.assertIsNotNone(plan.manual_override_bytes)
|
|
self.assertIsNotNone(plan.manual_override_sha256)
|
|
with self.assertRaisesRegex(IngestionError, "已冻结"):
|
|
repository.update_price_review_item(
|
|
envelope.job_id,
|
|
current["items"][0]["item_id"],
|
|
str(case_id),
|
|
updated["revision"],
|
|
"1",
|
|
"operator@example.test",
|
|
)
|
|
|
|
manifest = json.loads((plan.manual_override_bytes or b"{}").decode("utf-8"))
|
|
self.assertEqual(manifest["overrides"][0]["real_price"], "0.00")
|
|
final_raw, final_store, _final_values = build_delivery(
|
|
source,
|
|
root / "final",
|
|
manual_override=manifest,
|
|
review_job_id=envelope.job_id,
|
|
review_case_id=str(case_id),
|
|
)
|
|
final_envelope = json.loads(final_raw)
|
|
final_envelope["delivery_id"] = "delivery-002"
|
|
final_envelope["attempt_no"] = int(plan.attempt_no or 0)
|
|
final_raw = json.dumps(final_envelope, ensure_ascii=False).encode("utf-8")
|
|
final_service = IngestionService(DeliveryValidator(final_store, policy()), repository)
|
|
final = final_service.ingest(final_raw)
|
|
duplicate = final_service.ingest(final_raw)
|
|
|
|
self.assertEqual(final.status, "committed")
|
|
self.assertEqual(duplicate, final)
|
|
self.assertEqual(final.version_no, 1)
|
|
self.assertEqual(repository.current_version_id(date(2026, 7, 27)), final.daily_version_id)
|
|
self.assertEqual(
|
|
[record["pricing_method"] for record in repository.version_records(final.daily_version_id or 0)],
|
|
["price_reference_exact", "manual_review"],
|
|
)
|
|
completed = repository.get_price_review(envelope.job_id, 50, 0)
|
|
self.assertEqual(completed["case_status"], "completed")
|
|
|
|
def test_frozen_review_generation_failure_reuses_manifest_on_retry(self):
|
|
source = xml_document(reservation(1, rate_code="GRPA1", rate_amount="1800"))
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
raw, store, _values = build_delivery(source, Path(temp_dir) / "review")
|
|
repository = InMemoryIngestionRepository()
|
|
envelope = register(repository, raw)
|
|
outcome = IngestionService(DeliveryValidator(store, policy()), repository).ingest(raw)
|
|
review = repository.get_price_review(envelope.job_id, 50, 0)
|
|
updated = repository.update_price_review_item(
|
|
envelope.job_id,
|
|
review["items"][0]["item_id"],
|
|
str(outcome.review_case_id),
|
|
review["revision"],
|
|
"12",
|
|
"operator@example.test",
|
|
)
|
|
first = repository.begin_price_review_generation(
|
|
envelope.job_id,
|
|
str(outcome.review_case_id),
|
|
updated["revision"],
|
|
"operator@example.test",
|
|
envelope.processor_version,
|
|
envelope.rule_set_sha256,
|
|
"2" * 64,
|
|
)
|
|
repository.record_price_review_generation_failure(
|
|
envelope.job_id,
|
|
str(outcome.review_case_id),
|
|
int(first.attempt_no or 0),
|
|
"OBJECT_STORE_UNAVAILABLE",
|
|
)
|
|
retry = repository.begin_price_review_generation(
|
|
envelope.job_id,
|
|
str(outcome.review_case_id),
|
|
updated["revision"],
|
|
"operator@example.test",
|
|
envelope.processor_version,
|
|
envelope.rule_set_sha256,
|
|
"3" * 64,
|
|
)
|
|
self.assertEqual(retry.status, "ready")
|
|
self.assertGreater(int(retry.attempt_no or 0), int(first.attempt_no or 0))
|
|
self.assertEqual(retry.manual_override_bytes, first.manual_override_bytes)
|
|
self.assertEqual(retry.manual_override_sha256, first.manual_override_sha256)
|
|
|
|
def test_unknown_job_is_rejected_after_artifact_validation(self):
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
raw, store, _values = build_delivery(self.success_xml(), Path(temp_dir))
|
|
service = IngestionService(
|
|
DeliveryValidator(store, policy()), InMemoryIngestionRepository()
|
|
)
|
|
with self.assertRaisesRegex(IngestionError, "not registered") as raised:
|
|
service.ingest(raw)
|
|
self.assertEqual(raised.exception.code, "JOB_NOT_FOUND")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|