Files
ARR-2.0-0918/tests/test_arr_acquisition_progress.py
T

201 lines
10 KiB
Python

"""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()