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

456 lines
24 KiB
Python

from __future__ import annotations
import json
import sqlite3
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from concurrent.futures import ThreadPoolExecutor
from datetime import date, datetime
from pathlib import Path
from unittest.mock import patch
from zoneinfo import ZoneInfo
from arr_web.app import PortalApplication, RuntimeHealth
from arr_web.arr_downloads import DownloadOutcome, PersistentARRDownloads, default_report_date, validate_report_date
from arr_web.contracts import PortalError
from integrations.ohip.acquisition_progress import snapshot as acquisition_snapshot
from tests.test_arr_web import TEST_CREDENTIALS, FakeRepository, login
class Executor:
def __init__(self, outcome=None):
self.calls = []
self.release = threading.Event()
self.entered = threading.Event()
self.outcome = outcome or DownloadOutcome("succeeded", "arrjob-download-test")
self.raise_once = False
self.progress = None
def get_acquisition_progress(self, *, request_id, report_date):
return self.progress
def execute(self, **kwargs):
self.calls.append(kwargs)
self.entered.set()
if not self.release.wait(3):
raise RuntimeError("test timed out")
kwargs["report_stage"]("processing")
if self.raise_once:
self.raise_once = False
raise RuntimeError("private credential or guest must never be exposed")
return self.outcome
class DownloadTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
self.executor = Executor()
self.service = PersistentARRDownloads(self.root, self.executor)
def tearDown(self):
self.executor.release.set()
self.service.close()
self.temp.cleanup()
def finished(self, key="a" * 32):
deadline = time.monotonic() + 4
while time.monotonic() < deadline:
result = self.service.get(key)
if result["status"] not in {"queued", "downloading", "processing"}:
return result
time.sleep(.01)
self.fail("worker did not settle")
def test_one_date_passed_as_both_bounds_and_repeat_is_same_job(self):
task = self.service.create("2026-09-15", "a" * 32)
self.assertTrue(self.executor.entered.wait(1))
repeat = self.service.create("2026-09-15", "a" * 32)
self.assertEqual(task["request_id"], repeat["request_id"])
self.assertEqual(self.executor.calls[0]["from_date"], date(2026, 9, 15))
self.assertEqual(self.executor.calls[0]["to_date"], date(2026, 9, 15))
self.executor.release.set()
final = self.finished()
self.assertEqual(final["status"], "succeeded")
self.assertEqual(self.service.create("2026-09-15", "a" * 32), final)
self.assertEqual(len(self.executor.calls), 1)
def test_same_day_double_click_and_tabs_coalesce(self):
with ThreadPoolExecutor(max_workers=8) as pool:
ids = list(pool.map(lambda n: self.service.create("2026-09-15", f"{n:032x}")["request_id"], range(8)))
self.assertEqual(len(set(ids)), 1)
self.assertTrue(self.executor.entered.wait(1))
self.assertEqual(len(self.executor.calls), 1)
def test_same_id_cannot_change_date(self):
self.service.create("2026-09-15", "a" * 32)
with self.assertRaises(PortalError) as raised:
self.service.create("2026-09-16", "a" * 32)
self.assertEqual(raised.exception.status, 409)
def test_unknown_outcome_retries_exact_identity_without_leaking_exception(self):
self.executor.raise_once = True
self.executor.release.set()
self.service.create("2026-09-15", "a" * 32)
failed = self.finished()
self.assertEqual(failed["status"], "interrupted")
self.assertNotIn("private", json.dumps(failed))
self.assertEqual(self.service.create("2026-09-15", "b" * 32)["request_id"], "a" * 32)
self.service.retry("a" * 32)
self.assertEqual(self.finished()["status"], "succeeded")
self.assertEqual([c["request_id"] for c in self.executor.calls], ["a" * 32] * 2)
self.assertEqual([c["from_date"] for c in self.executor.calls], [date(2026, 9, 15)] * 2)
def test_review_is_not_success_and_does_not_rerun_on_retry(self):
self.executor.outcome = DownloadOutcome("needs_review", "arrjob-price-review")
self.executor.release.set()
self.service.create("2026-09-15", "a" * 32)
result = self.finished()
self.assertEqual(result["status"], "needs_review")
self.assertFalse(result["can_retry"])
self.assertEqual(self.service.retry("a" * 32), result)
self.assertEqual(len(self.executor.calls), 1)
def test_pending_source_reviews_coexist_for_different_dates_and_coalesce_per_day(self):
self.executor.outcome = DownloadOutcome("needs_data_review")
self.executor.release.set()
self.service.create("2026-10-07", "a" * 32)
october = self.finished()
self.service.create("2026-09-17", "b" * 32)
september = self.finished("b" * 32)
self.assertEqual(october["status"], "needs_data_review")
self.assertEqual(september["status"], "needs_data_review")
self.assertEqual(self.service.pending_data_reviews(), [october, september])
self.assertEqual(self.service.latest(), september)
self.assertEqual(self.service.create("2026-10-07", "c" * 32), october)
self.assertEqual(self.service.create("2026-09-17", "d" * 32), september)
self.assertEqual([call["from_date"] for call in self.executor.calls],
[date(2026, 10, 7), date(2026, 9, 17)])
def test_pending_source_review_list_survives_restart_without_reexecuting(self):
self.executor.outcome = DownloadOutcome("needs_data_review")
self.executor.release.set()
self.service.create("2026-09-17", "a" * 32)
self.finished()
self.service.create("2026-10-07", "b" * 32)
self.finished("b" * 32)
expected = self.service.pending_data_reviews()
self.service.close(wait=True)
self.service = PersistentARRDownloads(self.root, self.executor)
self.assertEqual(self.service.pending_data_reviews(), expected)
self.assertEqual(self.service.pending_data_reviews(), expected)
self.assertEqual([task["report_date"] for task in expected], ["2026-10-07", "2026-09-17"])
self.assertEqual(len(self.executor.calls), 2)
def test_mixed_pending_reviews_survive_month_switch_and_restart_without_acquisition(self):
self.executor.release.set()
self.executor.outcome = DownloadOutcome("needs_review", "arrjob-october-prices")
self.service.create("2026-10-07", "a" * 32)
october = self.finished()
self.executor.outcome = DownloadOutcome("needs_data_review")
self.service.create("2026-09-17", "b" * 32)
september = self.finished("b" * 32)
self.executor.outcome = DownloadOutcome("succeeded", "arrjob-finished")
self.service.create("2026-09-16", "c" * 32)
self.finished("c" * 32)
self.assertEqual(self.service.pending_reviews(), [october, september])
self.assertEqual(self.service.pending_data_reviews(), [september])
self.service.close(wait=True)
self.service = PersistentARRDownloads(self.root, self.executor)
self.assertEqual(self.service.pending_reviews(), [october, september])
self.assertEqual(len(self.executor.calls), 3)
def test_pending_source_review_list_excludes_other_outcomes(self):
self.assertEqual(self.service.pending_data_reviews(), [])
self.executor.release.set()
self.service.create("2026-09-17", "a" * 32)
self.finished()
self.executor.outcome = DownloadOutcome("needs_review", "arrjob-price-review")
self.service.create("2026-09-18", "b" * 32)
self.finished("b" * 32)
self.executor.outcome = DownloadOutcome("failed")
self.service.create("2026-09-19", "c" * 32)
self.finished("c" * 32)
self.assertEqual(self.service.pending_data_reviews(), [])
def test_capture_only_success_without_processing_job_is_not_accepted(self):
self.executor.outcome = DownloadOutcome("succeeded")
self.executor.release.set()
self.service.create("2026-09-15", "a" * 32)
self.assertEqual(self.finished()["status"], "interrupted")
def test_restart_preserves_success_without_reexecuting(self):
self.executor.release.set()
self.service.create("2026-09-15", "a" * 32)
final = self.finished()
self.service.close()
self.service = PersistentARRDownloads(self.root, self.executor)
self.assertEqual(self.service.latest(), final)
self.assertEqual(self.service.create("2026-09-15", "a" * 32), final)
self.assertEqual(len(self.executor.calls), 1)
def test_restart_marks_unknown_commit_interrupted_instead_of_failed(self):
self.executor.release.set()
self.service.create("2026-09-15", "a" * 32)
self.finished()
self.service.close()
with sqlite3.connect(self.root / "arr-downloads.sqlite3") as db:
db.execute("UPDATE downloads SET status='processing'")
self.service = PersistentARRDownloads(self.root, self.executor)
self.assertEqual(self.service.get("a" * 32)["status"], "interrupted")
self.assertTrue(self.service.get("a" * 32)["can_retry"])
self.assertEqual(len(self.executor.calls), 1)
def test_acquisition_snapshot_survives_get_latest_pending_and_restart(self):
self.executor.progress = acquisition_snapshot("completed", 6, 6)
self.executor.outcome = DownloadOutcome("needs_data_review")
self.executor.release.set()
self.service.create("2026-09-15", "a" * 32)
task = self.finished()
self.assertEqual(task["acquisition_progress"], self.executor.progress)
self.assertEqual(self.service.latest()["acquisition_progress"], self.executor.progress)
self.assertEqual(self.service.pending_data_reviews()[0]["acquisition_progress"], self.executor.progress)
self.assertEqual(self.service.pending_reviews()[0]["acquisition_progress"], self.executor.progress)
self.service.close(wait=True)
self.service = PersistentARRDownloads(self.root, self.executor)
self.assertEqual(self.service.get("a" * 32)["acquisition_progress"], self.executor.progress)
self.assertEqual(len(self.executor.calls), 1)
def test_restart_interrupted_state_overrides_unfinished_source_observation(self):
self.executor.progress = acquisition_snapshot("fetching", 5, 6)
self.executor.release.set()
self.service.create("2026-09-15", "a" * 32)
self.finished()
self.service.close(wait=True)
with sqlite3.connect(self.root / "arr-downloads.sqlite3") as db:
db.execute("UPDATE downloads SET status='downloading'")
self.service = PersistentARRDownloads(self.root, self.executor)
observed = self.service.get("a" * 32)["acquisition_progress"]
self.assertEqual((observed["phase"], observed["completed"], observed["total"]), ("interrupted", 5, 6))
self.assertLess(observed["percent"], 100)
self.assertEqual(len(self.executor.calls), 1)
def test_failed_acquisition_never_turns_full_row_count_into_completion(self):
self.executor.progress = acquisition_snapshot("verifying", 6, 6)
self.executor.outcome = DownloadOutcome("failed", retryable=True)
self.executor.release.set()
self.service.create("2026-09-15", "a" * 32)
task = self.finished()
self.assertEqual(task["acquisition_progress"]["phase"], "failed")
self.assertEqual(task["acquisition_progress"]["percent"], 99)
def test_later_report_failure_preserves_authoritative_acquisition_completion(self):
self.executor.progress = acquisition_snapshot("completed", 6, 6)
self.executor.outcome = DownloadOutcome("failed", "arrjob-report-failed", retryable=True)
self.executor.release.set()
self.service.create("2026-09-15", "a" * 32)
self.assertEqual(self.finished()["acquisition_progress"]["phase"], "completed")
def test_retry_hides_stale_failed_attempt_until_new_source_observation(self):
self.executor.progress = acquisition_snapshot("failed", 5, 6)
self.executor.raise_once = True
self.executor.release.set()
self.service.create("2026-09-15", "a" * 32)
self.assertEqual(self.finished()["status"], "interrupted")
self.executor.release.clear()
self.executor.entered.clear()
self.service.retry("a" * 32)
self.assertTrue(self.executor.entered.wait(1))
observed = self.service.get("a" * 32)["acquisition_progress"]
self.assertEqual(observed["phase"], "searching")
self.assertEqual(observed["completed"], 0)
self.assertIsNone(observed["total"])
def test_bad_or_unavailable_observations_never_break_task_or_expose_extra_fields(self):
self.executor.release.set()
self.service.create("2026-09-15", "a" * 32)
self.finished()
for value in ({"guest": "private guest"}, {**acquisition_snapshot("completed", 6, 6), "guest": "private guest"}):
self.executor.progress = value
task = self.service.get("a" * 32)
self.assertIsNone(task["acquisition_progress"])
self.assertNotIn("private guest", json.dumps(task))
with patch.object(self.executor, "get_acquisition_progress", side_effect=OSError("private file")):
self.assertEqual(self.service.get("a" * 32)["status"], "succeeded")
def test_second_owner_cannot_start_same_queue(self):
with self.assertRaises(BlockingIOError):
PersistentARRDownloads(self.root, self.executor)
def test_another_process_cannot_dispatch_from_owned_queue(self):
script = """from pathlib import Path
import sys
from arr_web.arr_downloads import PersistentARRDownloads
try:
PersistentARRDownloads(Path(sys.argv[1]), object())
except BlockingIOError:
sys.exit(0)
sys.exit(1)
"""
result = subprocess.run([sys.executable, "-c", script, str(self.root)], capture_output=True, timeout=5)
self.assertEqual(result.returncode, 0, result.stderr.decode())
def test_retry_cannot_jump_over_another_unresolved_task_for_same_day(self):
self.executor.outcome = DownloadOutcome("failed", retryable=True)
self.executor.release.set()
self.service.create("2026-09-15", "a" * 32)
self.finished()
self.executor.release.clear()
self.service.create("2026-09-15", "b" * 32)
with self.assertRaises(PortalError) as raised:
self.service.retry("a" * 32)
self.assertEqual(raised.exception.code, "ARR_DOWNLOAD_OTHER_ACTIVE")
def test_explicit_new_request_after_success_can_refresh_same_day(self):
self.executor.release.set()
self.service.create("2026-09-15", "a" * 32)
self.finished()
self.service.create("2026-09-15", "b" * 32)
self.finished("b" * 32)
self.assertEqual(len(self.executor.calls), 2)
class DownloadRoutesTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.executor = Executor()
self.service = PersistentARRDownloads(Path(self.temp.name), self.executor)
self.repo = FakeRepository()
self.app = PortalApplication(repository=self.repo, arr_downloads=self.service,
credentials=TEST_CREDENTIALS, health=RuntimeHealth(True, True, False))
_, self.headers = login(self.app)
def tearDown(self):
self.executor.release.set()
self.service.close()
self.temp.cleanup()
def post(self, payload, headers=None):
return self.app.handle("POST", "/api/arr-downloads", self.headers if headers is None else headers, json.dumps(payload).encode())
def test_auth_and_csrf_before_mutation(self):
self.assertEqual(self.app.handle("GET", "/api/arr-downloads", {}).status, 401)
request = {"report_date": "2026-09-15", "request_id": "a" * 32}
self.assertEqual(self.post(request, {}).status, 401)
self.assertEqual(self.post(request, {"Cookie": self.headers["Cookie"]}).status, 403)
self.assertIsNone(self.service.latest())
def test_reject_range_timestamps_invalid_calendar_days_and_extra_fields(self):
for value in ("2026-9-15", "2026-02-29", "2026-09-15T00:00:00Z", "0000-01-01", "2026-09-15,2026-09-16", None, 42):
with self.subTest(value=value):
self.assertEqual(self.post({"report_date": value, "request_id": "a" * 32}).status, 400)
self.assertEqual(self.post({"report_date": "2026-09-15", "request_id": "a" * 32, "to_date": "2026-09-16"}).status, 400)
self.assertIsNone(self.service.latest())
self.assertEqual(validate_report_date("2024-02-29"), "2024-02-29")
def test_create_poll_and_retry_have_stable_public_contract(self):
response = self.post({"report_date": "2026-09-15", "request_id": "a" * 32})
self.assertEqual(response.status, 202)
result = json.loads(response.body)["data"]
self.assertEqual(result["from_date"], result["to_date"])
self.assertIn("acquisition_progress", result)
get = self.app.handle("GET", "/api/arr-downloads/" + "a" * 32, self.headers)
self.assertEqual(get.status, 200)
self.assertIn("acquisition_progress", json.loads(get.body)["data"])
retry_path = "/api/arr-downloads/" + "a" * 32 + "/retry"
self.assertEqual(self.app.handle("POST", retry_path, {}, b"{}").status, 401)
self.assertEqual(self.app.handle("POST", retry_path, self.headers, b'{"report_date":"2026-09-16"}').status, 400)
self.assertEqual(self.app.handle("POST", retry_path, self.headers, b"{}").status, 202)
def test_progress_is_retained_by_public_get_and_configuration_without_new_execution(self):
self.post({"report_date": "2026-09-15", "request_id": "a" * 32})
self.assertTrue(self.executor.entered.wait(1))
self.executor.progress = acquisition_snapshot("fetching", 3, 6)
task = json.loads(self.app.handle("GET", "/api/arr-downloads/" + "a" * 32, self.headers).body)["data"]
config = json.loads(self.app.handle("GET", "/api/arr-downloads", self.headers).body)["data"]
self.assertEqual(task["acquisition_progress"], self.executor.progress)
self.assertEqual(config["latest_task"]["acquisition_progress"], self.executor.progress)
self.assertEqual(set(task["acquisition_progress"]), {"phase", "completed", "total", "percent", "updated_at"})
self.assertEqual(len(self.executor.calls), 1)
def test_default_runtime_is_explicitly_unavailable(self):
app = PortalApplication(credentials=TEST_CREDENTIALS)
_, headers = login(app)
config = json.loads(app.handle("GET", "/api/arr-downloads", headers).body)["data"]
self.assertFalse(config["ready"])
self.assertIsNone(config["latest_task"])
self.assertEqual(config["pending_data_reviews"], [])
self.assertEqual(config["pending_reviews"], [])
response = app.handle("POST", "/api/arr-downloads", headers,
json.dumps({"report_date": "2026-09-15", "request_id": "a" * 32}).encode())
self.assertEqual(response.status, 503)
def test_configuration_lists_pending_dates_without_changing_latest_task(self):
self.executor.outcome = DownloadOutcome("needs_data_review")
self.executor.release.set()
for day, request_id in (("2026-10-07", "a" * 32), ("2026-09-17", "b" * 32)):
self.assertEqual(self.post({"report_date": day, "request_id": request_id}).status, 202)
deadline = time.monotonic() + 4
while time.monotonic() < deadline:
if self.service.get(request_id)["status"] == "needs_data_review":
break
time.sleep(.01)
else:
self.fail("worker did not reach source review")
snapshot = self.app._arr_download_snapshot
with patch.object(self.app, "_arr_download_snapshot", wraps=snapshot) as snapshot_call:
response = self.app.handle("GET", "/api/arr-downloads", self.headers)
self.assertEqual(response.status, 200)
config = json.loads(response.body)["data"]
self.assertEqual(config["latest_task"]["request_id"], "b" * 32)
self.assertEqual([task["report_date"] for task in config["pending_data_reviews"]],
["2026-10-07", "2026-09-17"])
self.assertEqual(config["pending_data_reviews"], self.service.pending_data_reviews())
self.assertEqual(snapshot_call.call_count, 3)
self.assertEqual(self.app.handle("GET", "/api/arr-downloads", {}).status, 401)
for task in config["pending_data_reviews"]:
self.assertIsNone(task["job_id"])
self.assertNotIn("attempts", task)
self.assertNotIn("records", task)
def test_default_day_uses_bangkok_calendar_at_year_boundary(self):
with patch("arr_web.arr_downloads.datetime") as clock:
clock.now.return_value = datetime(2026, 1, 1, 0, 1, tzinfo=ZoneInfo("Asia/Bangkok"))
self.assertEqual(default_report_date(), "2025-12-31")
clock.now.assert_called_once_with(ZoneInfo("Asia/Bangkok"))
def test_review_snapshot_follows_authoritative_finance_status(self):
task = {"status": "needs_review", "job_id": "arrjob-reviewed", "can_retry": False}
with patch.object(self.repo, "get_job_trace", return_value={"job": {"status": "accepted"}}):
self.assertEqual(self.app._arr_download_snapshot(task)["status"], "succeeded")
def test_configuration_mixed_pending_reviews_filters_completed_and_cancelled_price_jobs(self):
tasks = [
{"request_id": "a" * 32, "report_date": "2026-10-07", "status": "needs_review", "job_id": "arrjob-open"},
{"request_id": "b" * 32, "report_date": "2026-09-17", "status": "needs_data_review", "job_id": None},
{"request_id": "c" * 32, "report_date": "2026-09-16", "status": "needs_review", "job_id": "arrjob-completed"},
{"request_id": "d" * 32, "report_date": "2026-09-16", "status": "needs_review", "job_id": "arrjob-cancelled"},
]
states = {"arrjob-open": "review_required", "arrjob-completed": "succeeded", "arrjob-cancelled": "cancelled"}
with patch.object(self.service, "pending_reviews", return_value=tasks), \
patch.object(self.repo, "get_job_trace", side_effect=lambda job: {"job": {"status": states[job]}}):
response = self.app.handle("GET", "/api/arr-downloads", self.headers)
self.assertEqual(response.status, 200)
config = json.loads(response.body)["data"]
self.assertEqual(config["pending_reviews"], tasks[:2])
self.assertEqual(config["pending_data_reviews"], [tasks[1]])
self.assertEqual(self.executor.calls, [])
self.assertIsNone(self.service.latest())
def test_pending_price_review_is_not_lost_when_job_lookup_is_unavailable(self):
from arr_web.repository import PortalDataError
task = {"request_id": "a" * 32, "report_date": "2026-10-07", "status": "needs_review", "job_id": "arrjob-open"}
with patch.object(self.service, "pending_reviews", return_value=[task]), \
patch.object(self.repo, "get_job_trace", side_effect=PortalDataError("UNAVAILABLE", "not available")):
response = self.app.handle("GET", "/api/arr-downloads", self.headers)
self.assertEqual(response.status, 200)
self.assertEqual(json.loads(response.body)["data"]["pending_reviews"], [task])
if __name__ == "__main__":
unittest.main()