136 lines
6.6 KiB
Python
136 lines
6.6 KiB
Python
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()
|