195 lines
8.7 KiB
Python
195 lines
8.7 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, 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)
|