201 lines
10 KiB
Python
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()
|