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, rate_code="GRPA4", rate_amount="1800", 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_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)