"""Offline progress observations; no credentials, Oracle, or business writes.""" import hashlib import json from pathlib import Path import tempfile import unittest from unittest.mock import Mock, patch from integrations.ohip import acquisition_progress as progress from integrations.ohip import arr_data as data from integrations.ohip import collect_arr_source as base from integrations.ohip.capture_job import job_lock from tests.test_ohip_arr_data import DAY, HOTEL, REQUEST, SimulatedOHIP class AcquisitionProgressTests(unittest.TestCase): def setUp(self): temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) self.root = Path(temporary.name) / "source" self.transport = SimulatedOHIP(6) self.source = data.ARRDataSource(self.root, HOTEL, transport_factory=lambda: self.transport, sleep=lambda _: None, page_size=2) def get(self): return self.source.get_acquisition_progress(report_date=DAY, request_id=REQUEST) def observe(self): observations = [] original = progress.publish def publish(folder, identity, phase, completed=0, total=None): original(folder, identity, phase, completed, total) observations.append((phase, completed, total, self.get())) return observations, publish def test_counts_rows_only_and_verifies_before_completion_without_changing_evidence(self): observations, publish = self.observe() with patch.object(progress, "publish", side_effect=publish): result = self.source.fetch(DAY, REQUEST) self.assertTrue(result["collection_complete"]) fetching = [(done, total) for phase, done, total, _ in observations if phase == "fetching"] self.assertEqual(fetching, [(number, 6) for number in range(7)]) verifying = next(value for phase, _, _, value in observations if phase == "verifying") self.assertEqual(verifying["completed"], 6) self.assertEqual(verifying["total"], 6) self.assertEqual(verifying["percent"], 99) completed = self.get() self.assertEqual({key: completed[key] for key in ("phase", "completed", "total", "percent")}, {"phase": "completed", "completed": 6, "total": 6, "percent": 100}) self.assertEqual(set(completed), progress.PUBLIC_FIELDS) attempt = Path(result["data_path"]).parent manifest = json.loads((attempt / "result.json").read_bytes()) self.assertNotIn("progress.json", [item["name"] for item in manifest["files"]]) self.assertNotIn("acquisition_progress", json.loads(Path(result["data_path"]).read_bytes())) self.assertEqual(manifest["version"], data.VERSION) def test_all_rows_collected_but_final_recheck_failure_never_reaches_100(self): def edit(operation, envelope, request): if operation == base.SEARCH and sum(call["operation"] == base.SEARCH for call in self.transport.calls) > 3: envelope["data"]["reservations"]["reservationInfo"][0]["lastModifyDateTime"] = "2026-09-15T11:00:00+07:00" return envelope self.transport.edit = edit result = self.source.fetch(DAY, REQUEST) self.assertEqual(result["status"], "failed") self.assertEqual(result["records"], 6) observed = self.get() self.assertEqual(observed["phase"], "failed") self.assertEqual((observed["completed"], observed["total"], observed["percent"]), (6, 6, 99)) self.assertFalse((self.root / REQUEST / "ready.json").exists()) def test_nonbusiness_observation_write_failure_does_not_fail_collection(self): with patch.object(progress, "atomic_json", side_effect=OSError("synthetic observation disk failure")): result = self.source.fetch(DAY, REQUEST) self.assertEqual(result["status"], "collected") self.assertTrue(result["collection_complete"]) self.assertFalse((self.root / REQUEST / "progress.json").exists()) self.assertEqual(self.get()["percent"], 100) def test_getter_is_unlocked_and_no_http_while_collection_is_running(self): observations = [] def status(operation, count): before = len(self.transport.calls) observations.append(self.get()) self.assertEqual(len(self.transport.calls), before) return 200 self.transport.status = status self.source.fetch(DAY, REQUEST) self.assertEqual(observations[0]["phase"], "searching") self.assertIn("fetching", [item["phase"] for item in observations]) self.assertIn("verifying", [item["phase"] for item in observations]) with job_lock(self.root / REQUEST): self.assertEqual(self.get()["phase"], "completed") def test_legacy_complete_metadata_recovers_exact_counts_read_only_without_guest_reads(self): result = self.source.fetch(DAY, REQUEST) (self.root / REQUEST / "progress.json").unlink() metadata = [self.root / REQUEST / name for name in ("request.json", "ready.json")] metadata += [Path(result["data_path"]).parent / name for name in ("result.json", "capture.json")] before = {str(path): hashlib.sha256(path.read_bytes()).hexdigest() for path in metadata} calls = len(self.transport.calls) read = data.protected_read def read_metadata(path, maximum): self.assertIn(Path(path).name, {"request.json", "ready.json", "result.json", "capture.json"}) return read(path, maximum) with patch.object(data, "protected_read", side_effect=read_metadata), patch.object(data, "job_lock", side_effect=AssertionError("getter locked")): self.assertEqual(self.get()["completed"], 6) self.assertEqual(self.get()["total"], 6) self.assertEqual(before, {str(path): hashlib.sha256(path.read_bytes()).hexdigest() for path in metadata}) self.assertEqual(len(self.transport.calls), calls) self.assertFalse((self.root / REQUEST / "progress.json").exists()) def test_completed_with_field_gaps_is_acquisition_complete(self): self.transport.rows[0]["roomStay"]["currentRoomInfo"].pop("roomId") self.transport.calendar_rooms = [] result = self.source.fetch(DAY, REQUEST) self.assertEqual(result["status"], "collected_with_gaps") self.assertEqual(self.get()["phase"], "completed") self.assertEqual(self.get()["percent"], 100) def test_failed_attempt_retry_resets_counts_and_replay_uses_no_http(self): self.transport.status = lambda operation, _: 503 if operation == "getProfiles" else 200 failed = self.source.fetch(DAY, REQUEST) self.assertEqual(failed["status"], "failed") self.assertEqual(self.get()["phase"], "failed") self.transport.status = lambda *_: 200 observations, publish = self.observe() with patch.object(progress, "publish", side_effect=publish): complete = self.source.fetch(DAY, REQUEST) self.assertEqual(complete["attempt"], 2) self.assertEqual(observations[0][0:3], ("searching", 0, None)) calls = len(self.transport.calls) self.assertEqual(self.source.fetch(DAY, REQUEST), complete) self.assertEqual(len(self.transport.calls), calls) self.assertEqual(self.get()["completed"], 6) def test_context_changed_corrupt_large_and_symlink_progress_stays_unknown(self): self.transport.status = lambda *_: 503 self.source.fetch(DAY, REQUEST) other = data.ARRDataSource(self.root, HOTEL, transport_factory=lambda: self.transport, page_size=1) self.assertIsNone(other.get_acquisition_progress(report_date=DAY, request_id=REQUEST)) self.assertIsNone(self.source.get_acquisition_progress(report_date="2026-09-16", request_id=REQUEST)) path = self.root / REQUEST / "progress.json" original = path.read_bytes() for raw in (b"{", b" " * 4097): path.write_bytes(raw) self.assertIsNone(self.get()) path.unlink() target = self.root / "unrelated.json" target.write_bytes(original) target.chmod(0o600) path.symlink_to(target) self.assertIsNone(self.get()) def test_unpinned_completed_observation_is_not_completion(self): self.transport.status = lambda *_: 503 self.source.fetch(DAY, REQUEST) _, identity = self.source._identity(DAY, REQUEST) progress.publish(self.root / REQUEST, identity, "completed", 6, 6) self.assertIsNone(self.get()) def test_changed_legacy_manifest_or_capture_does_not_claim_completion(self): result = self.source.fetch(DAY, REQUEST) attempt = Path(result["data_path"]).parent for name in ("result.json", "capture.json"): path = attempt / name original = path.read_bytes() path.write_bytes(original + b" ") self.assertIsNone(self.get()) path.write_bytes(original) def test_missing_old_source_remains_unknown_without_creating_files_or_transport(self): self.assertIsNone(self.get()) self.assertFalse(self.root.exists()) self.assertEqual(self.transport.calls, []) def test_completion_checkpoint_publication_failure_preserves_collected_count_but_not_100(self): write = data.atomic_json def publish(path, document, *, replace): if Path(path).name == "ready.json": raise OSError("synthetic completion publication interruption") return write(path, document, replace=replace) with patch.object(data, "atomic_json", side_effect=publish), self.assertRaises(OSError): self.source.fetch(DAY, REQUEST) observed = self.get() self.assertEqual((observed["phase"], observed["completed"], observed["total"], observed["percent"]), ("failed", 6, 6, 99)) def test_local_authorized_wrapper_keeps_metadata_read_local_without_access_refresh(self): from arr_web.local_ohip import AuthorizedExecutor access, executor = Mock(), Mock() expected = progress.snapshot("fetching", 3, 6) executor.get_acquisition_progress.return_value = expected wrapped = AuthorizedExecutor(executor, access) self.assertEqual(wrapped.get_acquisition_progress(request_id=REQUEST, report_date=DAY), expected) executor.get_acquisition_progress.assert_called_once_with(request_id=REQUEST, report_date=DAY) access.require_ready.assert_not_called() if __name__ == "__main__": unittest.main()