from dataclasses import replace import hashlib import json from pathlib import Path import tempfile import unittest from integrations.ohip import capture_day_job as jobs from integrations.ohip import capture_job as v1jobs from integrations.ohip import collect_arr_day as day from integrations.ohip import collect_arr_source as source from integrations.ohip.rate_info import RateInfoReader from tests.test_ohip_day_capture import DayService, DAY, HOTEL from tests.test_ohip_capture_jobs import assert_strict_persisted_identity class DayJobTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) self.root = Path(self.temp.name) / "jobs" self.options = day.Options(DAY, DAY, DAY, HOTEL, page_size=2) self.service = DayService() self.factory_calls = 0 def factory(self, archive, hotel): self.factory_calls += 1 return (source.Reader(archive, hotel, self.service, sleep=lambda _: None), RateInfoReader(archive, hotel, self.service, sleep=lambda _: None)) def run_job(self, options=None, batch="day-001", factory=None): return jobs.run_batch(self.root, batch, options or self.options, factory or self.factory) def hashes(self): return {str(p): hashlib.sha256(p.read_bytes()).hexdigest() for p in self.root.rglob("*") if p.is_file()} def test_complete_retry_reuses_same_dates_pin_and_never_loads_credentials(self): first = self.run_job() self.assertEqual(first["version"], "arr-api-capture-job/v2") self.assertEqual(first["valid_rate_candidates"], 3) self.assertTrue(first["all_rates_valid"]) self.assertFalse(first["finance_ready"]) before = self.hashes() second = self.run_job(factory=lambda *args: self.fail("must not load key or call network")) self.assertTrue(second["reused"]) self.assertEqual(second["manifest_sha256"], first["manifest_sha256"]) self.assertEqual((second["from_date"], second["to_date"], second["rate_date"]), (DAY, DAY, DAY)) self.assertEqual(self.hashes(), before) self.assertEqual(len(self.service.calls), 10) def test_cannot_change_dates_hotel_or_page_settings_of_existing_batch(self): self.run_job() for options in [replace(self.options, from_date="2026-09-14", to_date="2026-09-14", rate_date="2026-09-14"), replace(self.options, hotel_id="OTHER"), replace(self.options, page_size=1)]: self.assertEqual(self.run_job(options)["error"], "batch_request_conflict") self.assertEqual(self.factory_calls, 1) def test_persisted_job_identity_rejects_numeric_type_changes(self): assert_strict_persisted_identity(self, self.run_job, self.root / "day-001", ("page_size", "max_records")) self.assertEqual(self.factory_calls, 1) def test_mismatch_dates_refused_before_job_or_key_creation(self): self.assertEqual(self.run_job(replace(self.options, rate_date="2026-09-14"))["error"], "report_dates_must_match") self.assertFalse(self.root.exists()) self.assertEqual(self.factory_calls, 0) def test_v1_batch_id_conflicts_with_v2_both_directions(self): self.run_job() old = v1jobs.run_batch(self.root, "day-001", self.options.search_options(), self.factory) self.assertEqual(old["error"], "batch_request_conflict") from tests.test_ohip_arr_collection import FakeService def old_factory(archive, hotel): return source.Reader(archive, hotel, FakeService(), sleep=lambda _: None) first = v1jobs.run_batch(self.root, "old-day", self.options.search_options(), old_factory) self.assertTrue(first["candidate_capture_complete"]) self.assertEqual(self.run_job(batch="old-day")["error"], "batch_request_conflict") def test_failed_rate_attempt_is_preserved_and_retry_restarts_entire_day(self): underlying = self.service def failing(method, path, raw): if path.endswith("rate-info/searches"): return 403, {}, b"{}" return underlying(method, path, raw) self.service = failing first = self.run_job() self.assertEqual(first["status"], "capture_failed") before = self.hashes() self.service = DayService() second = self.run_job() self.assertTrue(second["candidate_capture_complete"]) self.assertEqual(second["attempt_no"], 2) self.assertEqual(len(self.service.calls), 10) self.assertEqual(json.loads(self.service.calls[0][2])["offset"], 0) after = self.hashes() self.assertTrue(all(after[name] == digest for name, digest in before.items() if "attempt-0001" in name)) def test_interrupted_attempt_without_committed_pin_is_not_promoted(self): first = self.run_job() state_path = self.root / "day-001/state.json" state = json.loads(state_path.read_bytes()) state["attempts"][0]["status"] = "running" state["attempts"][0].pop("manifest_sha256") state_path.write_bytes(source.json_bytes(state)) self.service = DayService() second = self.run_job() self.assertFalse(second["reused"]) self.assertEqual(second["attempt_no"], 2) self.assertEqual(len(self.service.calls), 10) self.assertNotEqual(first["capture_dir"], second["capture_dir"]) self.assertEqual(json.loads(state_path.read_bytes())["attempts"][0]["status"], "interrupted") def test_rate_issues_remain_diagnostic_and_are_not_auto_refetched_on_retry(self): self.service.rate_edit = lambda _, body: {} first = self.run_job() self.assertTrue(first["candidate_capture_complete"]) self.assertFalse(first["all_rates_valid"]) self.assertFalse(first["finance_ready"]) second = self.run_job(factory=lambda *args: self.fail("diagnostics must be stable")) self.assertEqual(second["rate_issues"], {"empty_rate_info": 3}) self.assertTrue(second["reused"]) self.service = DayService() fresh = self.run_job(batch="explicit-new-capture") self.assertTrue(fresh["all_rates_valid"]) self.assertFalse(fresh["reused"]) def test_modified_rate_response_refused_without_recollection(self): first = self.run_job() path = Path(first["capture_dir"]) / "rate-000001.response.bin" path.write_bytes(path.read_bytes().replace(b'"USD"', b'"THB"')) result = self.run_job(factory=lambda *args: self.fail("must not retry after corruption")) self.assertEqual(result["error"], "archive_hash_mismatch") if __name__ == "__main__": unittest.main()