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)