Automatically resume empty saved source reviews
This commit is contained in:
1 parent
cd362b3b81
commit
976a4fa7b2
11 files changed
+585
-29
No files matched your search
@@ -9,6 +9,7 @@ const task={request_id:requestId,report_date:'2026-10-07',status:'needs_data_rev
|
||||
const item=(extra={})=>({item_id:'1:BLOCK_CODE',source_sequence:1,confirmation_no:'SYNTHETIC-001',room_no:'SYNTHETIC-ROOM',company_name:'Synthetic Company',rate_code:'SYNTHETIC',field:'BLOCK_CODE',field_label:'团队代码',reason_code:'MISSING',can_be_empty:true,value:null,confirmed:false,...extra});
|
||||
const review=(items,revision=1)=>({request_id:requestId,report_date:task.report_date,status:'editing',revision,items,pending_count:items.filter(x=>!x.confirmed).length,total_count:items.length,can_finalize:items.every(x=>x.confirmed)});
|
||||
const plain=value=>JSON.parse(JSON.stringify(value));
|
||||
const activeTaskPoll=h=>h.timers.filter(timer=>!timer.cancelled&&timer.callback.name==='loadARRDownloadTask').at(-1);
|
||||
const priceTask={request_id:'c'.repeat(32),report_date:'2026-09-16',status:'needs_review',
|
||||
job_id:'fixture-price-september',can_retry:false};
|
||||
const priceReview=(price=null)=>({case_id:'fixture-price-case',case_status:'open',business_date:priceTask.report_date,revision:1,
|
||||
@@ -27,6 +28,77 @@ function pendingAPI(url,currentPrice=priceTask) {
|
||||
throw new Error('unexpected request '+url);
|
||||
}
|
||||
|
||||
test('an empty validated field review polls the same task and follows processing then price review by GET only',async()=>{
|
||||
let nextStatus='processing';
|
||||
const jobId='fixture-recovered-empty-review';
|
||||
const h=harness(url=>{
|
||||
if(url.endsWith('/data-review')) return response(200,review([]));
|
||||
if(url===`/api/arr-downloads/${requestId}`) return response(200,{...task,status:nextStatus,job_id:nextStatus==='processing'?null:jobId});
|
||||
if(url.startsWith('/api/jobs?')) return response(200,[]);
|
||||
if(url.endsWith('/trace')) return response(200,{job:{job_id:jobId,business_date:task.report_date,status:nextStatus,active:true},logs:[]});
|
||||
if(url.includes('/review?')) return response(200,{...priceReview(),business_date:task.report_date});
|
||||
throw new Error('unexpected request '+url);
|
||||
});
|
||||
await h.acceptARRDownloadTask(task,{sync:false});
|
||||
const emptyPoll=activeTaskPoll(h);
|
||||
assert.equal(emptyPoll?.delay,4000);
|
||||
await emptyPoll.callback();
|
||||
assert.equal(h.state.arrDownloadTask.status,'processing');
|
||||
assert.equal(h.state.arrDataReview,null);
|
||||
assert.equal(h.element('#arr-data-review-panel').hidden,true);
|
||||
nextStatus='needs_review';
|
||||
await activeTaskPoll(h).callback();
|
||||
assert.equal(h.state.arrDownloadTask.status,'needs_review');
|
||||
assert.equal(h.element('#arr-download-review').hidden,false);
|
||||
assert.equal(h.element('#arr-data-review-panel').hidden,true);
|
||||
assert.equal(h.state.arrDownloadPendingReviews[0].status,'needs_review');
|
||||
await h.openDailyPriceReview(jobId,false);
|
||||
assert.equal(h.element('#daily-price-review-panel').hidden,false);
|
||||
assert.equal(h.element('#manual-review-workspace').hidden,false);
|
||||
assert.equal(h.calls.every(call=>call.method==='GET'),true);
|
||||
assert.equal(h.calls.some(call=>call.url.includes('/finalize')),false);
|
||||
});
|
||||
|
||||
for(const status of ['succeeded','failed']) {
|
||||
test(`empty-review recovery follows the real ${status} task status without submitting a decision`,async()=>{
|
||||
const h=harness(url=>{
|
||||
if(url.endsWith('/data-review')) return response(200,review([]));
|
||||
assert.equal(url,`/api/arr-downloads/${requestId}`);
|
||||
return response(200,{...task,status,error_code:status==='failed'?'ARR_DATA_EXECUTION_FAILED':null});
|
||||
});
|
||||
await h.acceptARRDownloadTask(task,{sync:false});
|
||||
await activeTaskPoll(h).callback();
|
||||
assert.equal(h.state.arrDownloadTask.status,status);
|
||||
assert.equal(h.element('#arr-data-review-panel').hidden,true);
|
||||
assert.equal(h.element('#manual-review-workspace').hidden,true);
|
||||
assert.equal(h.element('#arr-download-pending').hidden,true);
|
||||
assert.match(h.element('#arr-download-status').textContent,new RegExp(`arr_download\\.${status}`));
|
||||
assert.equal(activeTaskPoll(h),undefined);
|
||||
assert.equal(h.calls.every(call=>call.method==='GET'),true);
|
||||
});
|
||||
}
|
||||
|
||||
test('filled real review items, inconsistent zero counts and unsaved drafts never trigger zero-item recovery polling',async()=>{
|
||||
for(const snapshot of [review([item({confirmed:true,value:'SAVED'})]),
|
||||
{...review([]),total_count:1}, {...review([]),pending_count:1}, {...review([]),can_finalize:false},
|
||||
{...review([]),items:[item()]}, {...review([]),request_id:'f'.repeat(32)},
|
||||
{...review([]),report_date:'2026-09-17'}]) {
|
||||
const h=harness(()=>response(200,snapshot));
|
||||
await h.acceptARRDownloadTask(task,{sync:false});
|
||||
assert.equal(activeTaskPoll(h),undefined);
|
||||
assert.equal(h.calls.length,1);
|
||||
assert.equal(h.calls.every(call=>call.method==='GET'),true);
|
||||
}
|
||||
const h=harness(()=>response(200,review([item({confirmed:true,value:'SAVED'})])));
|
||||
await h.acceptARRDownloadTask(task,{sync:false});
|
||||
h.trackARRDataReviewDraft(h.row('1:BLOCK_CODE','UNSAVED').input);
|
||||
h.state.arrDataReview=review([]);
|
||||
await h.acceptARRDownloadTask(task,{sync:false});
|
||||
assert.equal(activeTaskPoll(h),undefined);
|
||||
assert.equal(h.state.arrDataReviewDrafts['1:BLOCK_CODE'],'UNSAVED');
|
||||
assert.equal(h.calls.some(call=>call.url.includes('/finalize')),false);
|
||||
});
|
||||
|
||||
test('cross-month field and price dates restore, stay clickable after polling, and open their own pages by GET',async()=>{
|
||||
const h=harness(url=>url==='/api/arr-downloads'
|
||||
? response(200,{context_id:'production',ready:true,default_date:priceTask.report_date,
|
||||
|
||||
@@ -9,7 +9,7 @@ const boot = ' boot();\n})();';
|
||||
assert(source.endsWith(boot+'\n') || source.endsWith(boot));
|
||||
|
||||
function harness(respond) {
|
||||
const elements=new Map(), storage=new Map(), storageReads=[], calls=[];
|
||||
const elements=new Map(), storage=new Map(), storageReads=[], calls=[], timers=[];
|
||||
const element=selector => {
|
||||
if (!elements.has(selector)) {
|
||||
const attributes=new Map(),classes=new Set();
|
||||
@@ -27,7 +27,8 @@ function harness(respond) {
|
||||
const context=vm.createContext({Headers, console, Date, Intl, Uint8Array,
|
||||
document:{hidden:false,querySelector:element},
|
||||
window:{ARRI18n:{t:(key,values)=>key==='manual_review.context'?`${values.date} · ${values.type}`:key,text:value=>value,errorMessage:code=>code,formatInteger:value=>String(value),formatMonth:value=>value},crypto:webcrypto,
|
||||
setTimeout(){return 1;},clearTimeout(){},location:{replace(){}}},
|
||||
setTimeout(callback,delay){const timer={id:timers.length+1,callback,delay,cancelled:false};timers.push(timer);return timer.id;},
|
||||
clearTimeout(id){const timer=timers.find(entry=>entry.id===id);if(timer) timer.cancelled=true;},location:{replace(){}}},
|
||||
localStorage:{setItem:(k,v)=>storage.set(k,v),getItem:k=>{storageReads.push(k);return storage.get(k);},removeItem:k=>storage.delete(k)},
|
||||
fetch:async (url, options) => {calls.push({url,method:options.method||'GET',body:options.body,csrf:options.headers.get('X-ARR-CSRF')});return respond(url,options,calls);},
|
||||
});
|
||||
@@ -41,7 +42,7 @@ function harness(respond) {
|
||||
const node={dataset:{arrDataReviewItemId:itemId},querySelector:()=>input};
|
||||
return {input,button:{closest:()=>node}};
|
||||
};
|
||||
return {...subject,calls,storage,storageReads,element,row,i18n:context.window.ARRI18n,submit:()=>subject.submitARRDownload({preventDefault(){}})};
|
||||
return {...subject,calls,storage,storageReads,timers,element,row,i18n:context.window.ARRI18n,submit:()=>subject.submitARRDownload({preventDefault(){}})};
|
||||
}
|
||||
const response=(status,data,code) => ({status,ok:status<400,json:async()=>code?{ok:false,error:{code}}:{ok:true,data}});
|
||||
const scopedKey=(context='production',username='operator')=>`arr:last-download:v2:${encodeURIComponent(context)}:${encodeURIComponent(username)}`;
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
"""Saved zero-item source reviews continue locally without a human confirmation."""
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from arr_processing.policy import load_processor_policy
|
||||
from arr_web import arr_data_review as review_module
|
||||
from arr_web.arr_data_review import DataFieldReviews, EMPTY_REVIEW_ACTOR
|
||||
from arr_web.arr_downloads import DownloadOutcome, PersistentARRDownloads
|
||||
from integrations.ohip.collect_arr_source import CollectionError
|
||||
from tests import test_arr_data_review as review_fixtures
|
||||
from tests.test_arr_data_review import (
|
||||
ACTOR, CONTEXT, DAY, PROJECT, REANALYSIS_MANIFEST, REANALYSIS_POLICY,
|
||||
REQUEST, SOURCE_MANIFEST, gap, optional_omission_source,
|
||||
raw, source_document,
|
||||
)
|
||||
|
||||
|
||||
def omission_source():
|
||||
document = source_document()
|
||||
for field, reason in (("BLOCK_CODE", "missing_reservation_block"),
|
||||
("PRODUCTS", "missing_reservation_packages")):
|
||||
gap(document, field)
|
||||
document["records"][0]["fields"][field]["reason"] = reason
|
||||
return document
|
||||
|
||||
|
||||
class EmptyReviewRecoveryTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.policy = load_processor_policy(PROJECT)
|
||||
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.root = Path(temporary.name) / "reviews"
|
||||
self.service = DataFieldReviews(root=self.root, policy=self.policy, context=CONTEXT)
|
||||
self.document = omission_source()
|
||||
|
||||
def prepare(self):
|
||||
return self.service.prepare(REQUEST, raw(self.document), SOURCE_MANIFEST, DAY)
|
||||
|
||||
def reanalyze(self, review):
|
||||
return self.service.apply_source_reanalysis(REQUEST, raw(optional_omission_source(self.document)),
|
||||
REANALYSIS_MANIFEST, REANALYSIS_POLICY, expected_revision=review["revision"])
|
||||
|
||||
def test_zero_item_reanalysis_freezes_under_system_actor_without_original_or_decision_changes(self):
|
||||
review = self.reanalyze(self.prepare())
|
||||
original = self.service.original(REQUEST)
|
||||
self.assertEqual((review["total_count"], review["pending_count"]), (0, 0))
|
||||
self.assertTrue(self.service.recover_ready(REQUEST))
|
||||
payload = self.service.payload(REQUEST)
|
||||
audit = json.loads(payload[0])["manual_data_review"]["manifest"]
|
||||
self.assertEqual(audit["changes"], [])
|
||||
self.assertEqual([event["action"] for event in audit["events"]], ["source_reanalysis", "finalize"])
|
||||
self.assertEqual(audit["events"][-1]["actor"], EMPTY_REVIEW_ACTOR)
|
||||
self.assertTrue(self.service.recover_ready(REQUEST))
|
||||
self.assertEqual(self.service.payload(REQUEST), payload)
|
||||
self.assertEqual(self.service.original(REQUEST), original)
|
||||
|
||||
def test_fully_filled_manual_items_still_require_explicit_confirmation(self):
|
||||
review = self.prepare()
|
||||
saved = self.service.update(REQUEST, "1:BLOCK_CODE", review["revision"], "STAFF-VERIFIED", ACTOR)
|
||||
review = self.reanalyze(saved)
|
||||
self.assertEqual((review["total_count"], review["pending_count"]), (1, 0))
|
||||
self.assertFalse(self.service.recover_ready(REQUEST))
|
||||
self.assertIsNone(self.service.payload(REQUEST))
|
||||
self.assertEqual(self.service.get(REQUEST), review)
|
||||
self.service.finalize(REQUEST, review["revision"], ACTOR)
|
||||
self.assertTrue(self.service.recover_ready(REQUEST), "an explicit saved freeze may resume")
|
||||
audit = json.loads(self.service.payload(REQUEST)[0])["manual_data_review"]["manifest"]
|
||||
self.assertEqual(audit["events"][-1]["actor"], ACTOR)
|
||||
|
||||
def test_pending_failed_or_ambiguous_observations_do_not_auto_continue(self):
|
||||
for state in ("missing", "failed", "ambiguous"):
|
||||
with self.subTest(state=state):
|
||||
document = omission_source()
|
||||
gap(document, "DISP_ROOM_NO", state=state)
|
||||
key = {"missing": "b", "failed": "c", "ambiguous": "d"}[state] * 32
|
||||
review = self.service.prepare(key, raw(document), SOURCE_MANIFEST, DAY)
|
||||
review = self.service.apply_source_reanalysis(key, raw(optional_omission_source(document)),
|
||||
REANALYSIS_MANIFEST, REANALYSIS_POLICY, expected_revision=review["revision"])
|
||||
self.assertGreater(review["pending_count"], 0)
|
||||
self.assertFalse(self.service.recover_ready(key))
|
||||
self.assertIsNone(self.service.payload(key))
|
||||
|
||||
def test_complete_ordinary_prepare_still_needs_no_review_or_derived_source(self):
|
||||
self.document = source_document()
|
||||
self.assertIsNone(self.prepare())
|
||||
self.assertIsNone(self.service.payload(REQUEST))
|
||||
|
||||
def test_cancelled_and_pm_hidden_history_decisions_freeze_without_overlay_and_remain_audited(self):
|
||||
for kind in ("cancelled", "pm"):
|
||||
with self.subTest(kind=kind):
|
||||
document = source_document(2)
|
||||
gap(document, "BLOCK_CODE")
|
||||
if kind == "cancelled":
|
||||
document["records"][0]["reservation_status"] = "Cancelled"
|
||||
else:
|
||||
document["records"][0]["fields"]["ROOM_CATEGORY_LABEL"] = {"state": "available", "value": "PM"}
|
||||
key = ("b" if kind == "cancelled" else "c") * 32
|
||||
old_rule = "is_cancelled_record" if kind == "cancelled" else "is_pm_record"
|
||||
# Represent an audited legacy decision saved before the approved
|
||||
# exclusion; the original source and rule identity stay pinned.
|
||||
with patch.object(self.service.rules, old_rule, return_value=False):
|
||||
review = self.service.prepare(key, raw(document), SOURCE_MANIFEST, DAY)
|
||||
self.service.update(key, "1:BLOCK_CODE", review["revision"], "PRESERVED-" + kind, ACTOR)
|
||||
original = self.service.original(key)
|
||||
review = self.service.prepare(key, raw(document), SOURCE_MANIFEST, DAY)
|
||||
self.assertEqual((review["total_count"], review["pending_count"]), (0, 0))
|
||||
self.assertTrue(self.service.recover_ready(key))
|
||||
audit = json.loads(self.service.payload(key)[0])["manual_data_review"]["manifest"]
|
||||
self.assertEqual(audit["changes"][0]["value"], "PRESERVED-" + kind)
|
||||
self.assertEqual(audit["changes"][0]["actor"], ACTOR)
|
||||
self.assertEqual(audit["events"][-1]["actor"], EMPTY_REVIEW_ACTOR)
|
||||
self.assertNotIn("source_reanalysis", audit)
|
||||
self.assertNotIn("reservation_status_evidence", audit)
|
||||
self.assertEqual(self.service.original(key), original)
|
||||
|
||||
def test_failed_original_source_is_not_automatically_frozen(self):
|
||||
self.document["status"] = "failed"
|
||||
review = self.reanalyze(self.prepare())
|
||||
self.assertEqual(review["total_count"], 0)
|
||||
self.assertFalse(self.service.recover_ready(REQUEST))
|
||||
self.assertIsNone(self.service.payload(REQUEST))
|
||||
|
||||
def test_invalid_receipt_blocks_freeze_without_state_change(self):
|
||||
review = self.reanalyze(self.prepare())
|
||||
pointer = (self.root / REQUEST / "state.json").read_bytes()
|
||||
reference = next((self.root / REQUEST).glob("source-reanalysis-*.json"))
|
||||
reference.write_bytes(reference.read_bytes() + b" ")
|
||||
with self.assertRaises(CollectionError):
|
||||
self.service.recover_ready(REQUEST)
|
||||
self.assertEqual((self.root / REQUEST / "state.json").read_bytes(), pointer)
|
||||
self.assertFalse((self.root / REQUEST / "finalize-intent.json").exists())
|
||||
|
||||
def test_original_hash_or_context_conflict_never_auto_freezes(self):
|
||||
self.reanalyze(self.prepare())
|
||||
path = self.root / REQUEST / "original.json"
|
||||
original = path.read_bytes()
|
||||
path.write_bytes(original + b" ")
|
||||
with self.assertRaises(CollectionError):
|
||||
self.service.recover_ready(REQUEST)
|
||||
path.write_bytes(original)
|
||||
changed = DataFieldReviews(root=self.root, policy=self.policy, context={**CONTEXT, "hotel_id": "OTHER"})
|
||||
with self.assertRaises(CollectionError):
|
||||
changed.recover_ready(REQUEST)
|
||||
self.assertFalse((self.root / REQUEST / "finalize-intent.json").exists())
|
||||
|
||||
def test_interrupted_freeze_replays_same_bytes_and_one_system_event(self):
|
||||
review = self.reanalyze(self.prepare())
|
||||
publish = review_module.atomic_json
|
||||
def interrupt(path, document, **kwargs):
|
||||
if path.name == "state.json":
|
||||
raise OSError("synthetic freeze pointer interruption")
|
||||
return publish(path, document, **kwargs)
|
||||
with patch.object(review_module, "atomic_json", side_effect=interrupt), self.assertRaises(OSError):
|
||||
self.service.recover_ready(REQUEST)
|
||||
frozen = (self.root / REQUEST / "reviewed-source.json").read_bytes()
|
||||
self.assertEqual(self.service.get(REQUEST), review)
|
||||
restored = DataFieldReviews(root=self.root, policy=self.policy, context=CONTEXT)
|
||||
self.assertTrue(restored.recover_ready(REQUEST))
|
||||
self.assertEqual(restored.payload(REQUEST)[0], frozen)
|
||||
audit = json.loads(frozen)["manual_data_review"]["manifest"]
|
||||
self.assertEqual(sum(event["action"] == "finalize" for event in audit["events"]), 1)
|
||||
self.assertEqual(audit["events"][-1]["actor"], EMPTY_REVIEW_ACTOR)
|
||||
|
||||
def test_saved_status_zero_items_preserves_prior_decisions_in_audit(self):
|
||||
self.document = source_document(2)
|
||||
gap(self.document, "BLOCK_CODE")
|
||||
review = self.prepare()
|
||||
review = self.service.update(REQUEST, "1:BLOCK_CODE", review["revision"], "PRESERVED", ACTOR)
|
||||
evidence = {"version": "arr-reservation-status-evidence/v1", "policy_id": review_module.STATUS_POLICY,
|
||||
"original_sha256": hashlib.sha256(raw(self.document)).hexdigest(), "source_manifest_sha256": SOURCE_MANIFEST,
|
||||
"hotel_id": CONTEXT["hotel_id"], "report_date": DAY,
|
||||
"records": [{"source_sequence": row["source_sequence"], "reservation_id": row["reservation_id"],
|
||||
"reservation_status": status, "sources": [{"file": "synthetic.response.bin", "sha256": "f" * 64}]}
|
||||
for row, status in zip(self.document["records"], ["Cancelled", "InHouse"])]}
|
||||
with patch("integrations.ohip.reservation_status.saved_status_evidence", return_value=evidence):
|
||||
review = self.service.apply_saved_status_evidence(REQUEST, Path("synthetic-unused"),
|
||||
expected_revision=review["revision"])
|
||||
self.assertEqual((review["total_count"], review["pending_count"]), (0, 0))
|
||||
original = self.service.original(REQUEST)
|
||||
self.assertTrue(self.service.recover_ready(REQUEST))
|
||||
audit = json.loads(self.service.payload(REQUEST)[0])["manual_data_review"]["manifest"]
|
||||
self.assertEqual(audit["changes"][0]["value"], "PRESERVED")
|
||||
self.assertEqual(audit["changes"][0]["actor"], ACTOR)
|
||||
self.assertEqual(audit["events"][-1]["actor"], EMPTY_REVIEW_ACTOR)
|
||||
self.assertEqual(self.service.original(REQUEST), original)
|
||||
|
||||
|
||||
class EmptyReviewExecutorTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.policy = load_processor_policy(PROJECT)
|
||||
|
||||
setUp = review_fixtures.DirectFieldReviewTests.setUp
|
||||
configure_executor = review_fixtures.DirectFieldReviewTests.configure_executor
|
||||
execute = review_fixtures.DirectFieldReviewTests.execute
|
||||
wait_task = review_fixtures.DirectFieldReviewTests.wait_task
|
||||
|
||||
def fixture(self):
|
||||
document = omission_source()
|
||||
payload = raw(document)
|
||||
path = self.files / "synthetic-collected.json"
|
||||
path.write_bytes(payload)
|
||||
path.chmod(0o600)
|
||||
fetched = {"collection_complete": True, "data_path": str(path),
|
||||
"data_sha256": hashlib.sha256(payload).hexdigest(), "manifest_sha256": SOURCE_MANIFEST}
|
||||
return document, fetched
|
||||
|
||||
def reanalyze(self, document):
|
||||
review = self.executor.get_data_review(REQUEST)
|
||||
return self.executor.data_reviews.apply_source_reanalysis(REQUEST, raw(optional_omission_source(document)),
|
||||
REANALYSIS_MANIFEST, REANALYSIS_POLICY, expected_revision=review["revision"])
|
||||
|
||||
def test_executor_zero_item_saved_review_continues_same_source_with_one_finance_version(self):
|
||||
document, fetched = self.fixture()
|
||||
with patch.object(self.source, "fetch", return_value=fetched) as fetch:
|
||||
self.assertEqual(self.execute().status, "needs_data_review")
|
||||
self.reanalyze(document)
|
||||
original = self.executor.data_reviews.original(REQUEST)
|
||||
self.assertEqual(self.execute().status, "succeeded")
|
||||
self.assertEqual(self.execute().status, "succeeded")
|
||||
self.assertEqual(fetch.call_count, 1)
|
||||
self.assertEqual(len(self.repository._versions), 1)
|
||||
self.assertEqual(self.executor.data_reviews.original(REQUEST), original)
|
||||
|
||||
def test_missing_or_changed_saved_checkpoint_never_auto_freezes_or_refetches(self):
|
||||
document, fetched = self.fixture()
|
||||
with patch.object(self.source, "fetch", return_value=fetched):
|
||||
self.assertEqual(self.execute().status, "needs_data_review")
|
||||
self.reanalyze(document)
|
||||
path = self.files / "executor" / "requests" / REQUEST / "collected.json"
|
||||
saved = path.read_bytes()
|
||||
with patch.object(self.source, "fetch", side_effect=AssertionError("must not refetch")) as fetch:
|
||||
path.unlink()
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
self.executor.recover_data_review(REQUEST)
|
||||
path.write_bytes(saved)
|
||||
path.chmod(0o600)
|
||||
changed = json.loads(saved)
|
||||
changed["data_sha256"] = "0" * 64
|
||||
path.write_bytes(raw(changed))
|
||||
with self.assertRaises(CollectionError):
|
||||
self.executor.recover_data_review(REQUEST)
|
||||
fetch.assert_not_called()
|
||||
self.assertEqual(self.executor.get_data_review(REQUEST)["status"], "editing")
|
||||
self.assertIsNone(self.executor.data_reviews.payload(REQUEST))
|
||||
self.assertEqual(len(self.repository._versions), 0)
|
||||
|
||||
def test_startup_recovers_without_browser_get_or_another_fetch_and_restart_is_idempotent(self):
|
||||
document, fetched = self.fixture()
|
||||
with patch.object(self.source, "fetch", return_value=fetched):
|
||||
queue = PersistentARRDownloads(self.files / "queue", self.executor)
|
||||
try:
|
||||
queue.create(DAY, REQUEST)
|
||||
self.assertEqual(self.wait_task(queue)["status"], "needs_data_review")
|
||||
finally:
|
||||
queue.close(wait=True)
|
||||
self.reanalyze(document)
|
||||
completed = threading.Event()
|
||||
execute = self.executor.execute
|
||||
def run(**kwargs):
|
||||
try:
|
||||
return execute(**kwargs)
|
||||
finally:
|
||||
completed.set()
|
||||
with patch.object(self.source, "fetch", side_effect=AssertionError("must replay saved checkpoint")), \
|
||||
patch.object(self.executor, "execute", side_effect=run):
|
||||
queue = PersistentARRDownloads(self.files / "queue", self.executor)
|
||||
try:
|
||||
self.assertTrue(completed.wait(30), "startup must recover without any browser read")
|
||||
self.assertEqual(self.wait_task(queue)["status"], "succeeded")
|
||||
finally:
|
||||
queue.close(wait=True)
|
||||
queue = PersistentARRDownloads(self.files / "queue", self.executor)
|
||||
try:
|
||||
self.assertEqual(queue.get(REQUEST)["status"], "succeeded")
|
||||
finally:
|
||||
queue.close(wait=True)
|
||||
self.assertEqual(len(self.repository._versions), 1)
|
||||
|
||||
def test_successful_freeze_before_lost_queue_update_recovers_on_next_startup(self):
|
||||
document, fetched = self.fixture()
|
||||
with patch.object(self.source, "fetch", return_value=fetched):
|
||||
queue = PersistentARRDownloads(self.files / "queue", self.executor)
|
||||
try:
|
||||
queue.create(DAY, REQUEST)
|
||||
self.assertEqual(self.wait_task(queue)["status"], "needs_data_review")
|
||||
finally:
|
||||
queue.close(wait=True)
|
||||
self.reanalyze(document)
|
||||
recover = self.executor.recover_data_review
|
||||
recovered = threading.Event()
|
||||
def lost_update(request_id):
|
||||
recover(request_id)
|
||||
recovered.set()
|
||||
raise OSError("synthetic loss before queue update")
|
||||
with patch.object(self.executor, "recover_data_review", side_effect=lost_update):
|
||||
queue = PersistentARRDownloads(self.files / "queue", self.executor)
|
||||
try:
|
||||
self.assertTrue(recovered.wait(10))
|
||||
with sqlite3.connect(self.files / "queue" / "arr-downloads.sqlite3") as db:
|
||||
self.assertEqual(db.execute("SELECT status FROM downloads WHERE request_id=?", (REQUEST,)).fetchone()[0],
|
||||
"needs_data_review")
|
||||
self.assertTrue(queue.ready)
|
||||
finally:
|
||||
queue.close(wait=True)
|
||||
frozen = self.executor.data_reviews.payload(REQUEST)
|
||||
with patch.object(self.source, "fetch", side_effect=AssertionError("must replay saved checkpoint")):
|
||||
queue = PersistentARRDownloads(self.files / "queue", self.executor)
|
||||
try:
|
||||
self.assertEqual(self.wait_task(queue)["status"], "succeeded")
|
||||
finally:
|
||||
queue.close(wait=True)
|
||||
self.assertEqual(self.executor.data_reviews.payload(REQUEST), frozen)
|
||||
self.assertEqual(len(self.repository._versions), 1)
|
||||
|
||||
def test_authorized_wrapper_recovery_is_local_without_access_refresh(self):
|
||||
from arr_web.local_ohip import AuthorizedExecutor
|
||||
executor, access = Mock(), Mock()
|
||||
executor.recover_data_review.return_value = True
|
||||
self.assertTrue(AuthorizedExecutor(executor, access).recover_data_review(REQUEST))
|
||||
executor.recover_data_review.assert_called_once_with(REQUEST)
|
||||
access.require_ready.assert_not_called()
|
||||
|
||||
|
||||
class EmptyReviewQueueRecoveryTests(unittest.TestCase):
|
||||
def test_one_bad_review_cannot_kill_worker_and_get_never_runs_new_recovery(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
root = Path(temporary.name)
|
||||
entered = threading.Event()
|
||||
executor = Mock()
|
||||
executor.source_identity.return_value = {"kind": "synthetic-empty-review-recovery"}
|
||||
executor.get_acquisition_progress.return_value = None
|
||||
executor.get_data_review.return_value = {"status": "editing"}
|
||||
executor.recover_data_review.return_value = False
|
||||
queue = PersistentARRDownloads(root, executor)
|
||||
queue.close(wait=True)
|
||||
now = "2026-10-08T00:00:00+00:00"
|
||||
with sqlite3.connect(root / "arr-downloads.sqlite3") as db:
|
||||
db.executemany("INSERT INTO downloads(request_id,report_date,status,created_at,updated_at) VALUES(?,?,?,?,?)",
|
||||
[("a" * 32, DAY, "needs_data_review", now, now),
|
||||
("b" * 32, "2026-09-16", "needs_data_review", now, now),
|
||||
("c" * 32, "2026-09-17", "needs_data_review", now, now)])
|
||||
def recover(request_id):
|
||||
if request_id == "a" * 32:
|
||||
raise CollectionError("synthetic conflicting evidence")
|
||||
return request_id == "c" * 32
|
||||
def execute(**kwargs):
|
||||
entered.set()
|
||||
return DownloadOutcome("succeeded", "arrjob-synthetic-recovered")
|
||||
executor.recover_data_review.side_effect = recover
|
||||
executor.execute.side_effect = execute
|
||||
queue = PersistentARRDownloads(root, executor)
|
||||
try:
|
||||
self.assertTrue(entered.wait(5), "startup resumes the good task without a browser read")
|
||||
queue.close(wait=True)
|
||||
self.assertEqual(queue.get("c" * 32)["status"], "succeeded")
|
||||
before = executor.recover_data_review.call_count
|
||||
self.assertEqual(queue.get("a" * 32)["status"], "needs_data_review")
|
||||
self.assertEqual(queue.get("b" * 32)["status"], "needs_data_review")
|
||||
queue.latest()
|
||||
queue.pending_reviews()
|
||||
self.assertEqual(executor.recover_data_review.call_count, before)
|
||||
self.assertEqual(executor.execute.call_count, 1)
|
||||
finally:
|
||||
queue.close(wait=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user