366 lines
18 KiB
Python
366 lines
18 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 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
|
|
|
|
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_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"])
|
|
get = self.app.handle("GET", "/api/arr-downloads/" + "a" * 32, self.headers)
|
|
self.assertEqual(get.status, 200)
|
|
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_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()
|