Files
ARR-2.0-0918/tests/test_ohip_named_day_jobs.py

117 lines
6.0 KiB
Python

from dataclasses import replace
import hashlib
import json
from pathlib import Path
import tempfile
import unittest
from integrations.ohip import capture_named_day_job as jobs, capture_day_job as v2jobs
from integrations.ohip import collect_arr_named_day as named, collect_arr_day as day, collect_arr_source as source
from integrations.ohip.profile_reader import ProfileSummaryReader
from integrations.ohip.rate_info import RateInfoReader
from tests.test_ohip_named_day import NamedService, DAY, HOTEL
from tests.test_ohip_capture_jobs import assert_strict_persisted_identity
class NamedDayJobTests(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name) / "jobs"
self.options = named.Options(DAY, DAY, DAY, HOTEL, page_size=2, max_profiles=3)
self.service = NamedService()
self.factory_count = 0
def factory(self, archive, hotel):
self.factory_count += 1
return (source.Reader(archive, hotel, self.service, sleep=lambda _: None),
RateInfoReader(archive, hotel, self.service, sleep=lambda _: None),
ProfileSummaryReader(archive, hotel, self.service, max_profiles=self.options.max_profiles, sleep=lambda _: None))
def run_job(self, options=None, factory=None):
return jobs.run_batch(self.root, "named-day", options or self.options, factory or self.factory)
def hashes(self, root=None):
return {str(p): hashlib.sha256(p.read_bytes()).hexdigest() for p in (root or self.root).rglob("*") if p.is_file()}
def test_successful_batch_reuses_all_names_without_loading_key_or_network(self):
first = self.run_job()
before = self.hashes()
second = self.run_job(factory=lambda *_: self.fail("must not load key or recollect"))
self.assertEqual(first["version"], "arr-api-capture-job/v3")
self.assertTrue(second["reused"])
self.assertTrue(second["all_names_valid"])
self.assertEqual((second["unique_profiles"], second["valid_name_candidates"]), (3, 3))
self.assertEqual(first["manifest_sha256"], second["manifest_sha256"])
self.assertEqual(before, self.hashes())
self.assertFalse(second["finance_ready"])
self.assertEqual(self.factory_count, 1)
def test_name_gaps_remain_stable_diagnostics_on_reuse(self):
self.service.profiles["p0"].pop("fullName")
self.run_job()
second = self.run_job(factory=lambda *_: self.fail("must not refresh name gaps"))
self.assertTrue(second["reused"])
self.assertFalse(second["all_names_valid"])
self.assertEqual(second["profile_issues"], {"profile_full_name_unavailable": 1})
def test_persisted_job_identity_rejects_numeric_type_changes(self):
assert_strict_persisted_identity(self, self.run_job, self.root / "named-day", ("page_size", "max_profiles"))
self.assertEqual(self.factory_count, 1)
def test_budget_dates_hotel_and_v2_v3_batch_identity_conflicts(self):
self.run_job()
for options in [replace(self.options, max_profiles=2), replace(self.options, hotel_id="OTHER"),
replace(self.options, from_date="2026-09-14", to_date="2026-09-14", rate_date="2026-09-14")]:
with self.subTest(options=options):
self.assertEqual(self.run_job(options)["error"], "batch_request_conflict")
old_options = day.Options(DAY, DAY, DAY, HOTEL, page_size=2)
result = v2jobs.run_batch(self.root, "named-day", old_options, lambda *_: self.fail("no network"))
self.assertEqual(result["error"], "batch_request_conflict")
self.assertEqual(v2jobs.run_batch(self.root, "unused", self.options, self.factory)["error"], "invalid_day_options")
self.assertEqual(self.factory_count, 1)
def test_v2_existing_batch_cannot_be_silently_upgraded(self):
def old_factory(archive, hotel):
return (source.Reader(archive, hotel, self.service, sleep=lambda _: None),
RateInfoReader(archive, hotel, self.service, sleep=lambda _: None))
first = v2jobs.run_batch(self.root, "named-day", day.Options(DAY, DAY, DAY, HOTEL, page_size=2), old_factory)
self.assertTrue(first["candidate_capture_complete"])
self.assertEqual(self.run_job()["error"], "batch_request_conflict")
self.assertEqual(self.factory_count, 0)
def test_failed_profile_capture_retry_preserves_first_attempt(self):
original = self.service
self.service = lambda method, path, body: (403, {}, b"{}") if path.endswith("profiles/searches") else original(method, path, body)
first = self.run_job()
self.assertEqual(first["error"], "profile_http_failure")
attempt = self.root / "named-day/attempt-0001"
before = self.hashes(attempt)
self.service = NamedService()
second = self.run_job()
self.assertEqual(second["attempt_no"], 2)
self.assertTrue(second["all_names_valid"])
self.assertEqual(self.hashes(attempt), before)
self.assertEqual(json.loads(self.service.calls[0][2])["offset"], 0)
def test_changed_profile_bytes_refused_without_new_attempt(self):
first = self.run_job()
path = Path(first["capture_dir"]) / "profile-000001.response.bin"
original = path.read_bytes()
changed = original.replace(b"Surname0", b"Changed0")
self.assertNotEqual(changed, original)
self.assertEqual(len(changed), len(original))
path.write_bytes(changed)
second = self.run_job(factory=lambda *_: self.fail("must not hide corruption by recollecting"))
self.assertEqual(second["error"], "archive_hash_mismatch")
self.assertFalse((self.root / "named-day/attempt-0002").exists())
def test_invalid_budget_refused_before_directory_or_factory(self):
self.assertEqual(self.run_job(replace(self.options, max_profiles=0))["error"], "invalid_profile_limit")
self.assertFalse(self.root.exists())
self.assertEqual(self.factory_count, 0)
if __name__ == "__main__":
unittest.main()