|
@@ -142,6 +142,7 @@ class FinalReviewApiTests(unittest.TestCase):
|
|
|
self.addCleanup(log_patch.stop)
|
|
self.addCleanup(log_patch.stop)
|
|
|
with http_api._JOBS_LOCK:
|
|
with http_api._JOBS_LOCK:
|
|
|
http_api._JOBS.clear()
|
|
http_api._JOBS.clear()
|
|
|
|
|
+ http_api._LATEST_REQUEST_BY_TXB_ID.clear()
|
|
|
|
|
|
|
|
def _post(
|
|
def _post(
|
|
|
self,
|
|
self,
|
|
@@ -188,6 +189,126 @@ class FinalReviewApiTests(unittest.TestCase):
|
|
|
worker.assert_not_called()
|
|
worker.assert_not_called()
|
|
|
shutil.rmtree(work_root, ignore_errors=True)
|
|
shutil.rmtree(work_root, ignore_errors=True)
|
|
|
|
|
|
|
|
|
|
+ def test_txb_id_queries_latest_submission_and_preserves_history(self):
|
|
|
|
|
+ """修复提示:只在提交时更新映射,不能在 worker 完成时覆盖它。"""
|
|
|
|
|
+ with tempfile.TemporaryDirectory() as temp_dir:
|
|
|
|
|
+ executor = RecordingExecutor()
|
|
|
|
|
+ client = TestClient(http_api.app)
|
|
|
|
|
+ txb_id = "TEST-TXB-001"
|
|
|
|
|
+ alias = f"/api/v1/jobs/{txb_id}"
|
|
|
|
|
+
|
|
|
|
|
+ def fake_worker(_t, _p, _r, step6, _log):
|
|
|
|
|
+ _write_docx(step6, str(step6))
|
|
|
|
|
+ api_worker._write_progress(
|
|
|
|
|
+ step6.parent, 6, "completed", "完成", [1, 2, 3, 4, 5, 6]
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ with patch.object(http_api, "API_WORK_ROOT", Path(temp_dir)), patch.object(
|
|
|
|
|
+ http_api, "_JOB_EXECUTOR", executor
|
|
|
|
|
+ ), patch.object(http_api, "_run_pipeline_worker", side_effect=fake_worker), patch.object(
|
|
|
|
|
+ http_api, "_send_callback"
|
|
|
|
|
+ ):
|
|
|
|
|
+ first = self._post(txb_id=txb_id).json()["request_id"]
|
|
|
|
|
+ second_response = self._post(txb_id=txb_id)
|
|
|
|
|
+ self.assertEqual(second_response.status_code, 202)
|
|
|
|
|
+ second = second_response.json()["request_id"]
|
|
|
|
|
+ self.assertNotEqual(first, second)
|
|
|
|
|
+ self.assertNotEqual(first, txb_id)
|
|
|
|
|
+ self.assertEqual(client.get(alias).json()["request_id"], second)
|
|
|
|
|
+ self.assertEqual(client.get(alias + "/progress").json()["status"], "queued")
|
|
|
|
|
+ self.assertEqual(client.get(alias + "/file").status_code, 409)
|
|
|
|
|
+
|
|
|
|
|
+ # 最新任务先完成,旧任务后完成;映射仍然指向最近提交者。
|
|
|
|
|
+ for function, args in reversed(executor.submitted):
|
|
|
|
|
+ function(*args)
|
|
|
|
|
+ for suffix in ("", "/progress", "/file"):
|
|
|
|
|
+ by_txb = client.get(alias + suffix)
|
|
|
|
|
+ by_request = client.get(f"/api/v1/jobs/{second}{suffix}")
|
|
|
|
|
+ self.assertEqual(by_txb.status_code, 200)
|
|
|
|
|
+ self.assertEqual(by_txb.content, by_request.content)
|
|
|
|
|
+ self.assertEqual(client.get(alias).json()["request_id"], second)
|
|
|
|
|
+ self.assertEqual(client.get(f"/api/v1/jobs/{first}").json()["status"], "completed")
|
|
|
|
|
+ old_file = client.get(f"/api/v1/jobs/{first}/file")
|
|
|
|
|
+ self.assertEqual(old_file.status_code, 200)
|
|
|
|
|
+ self.assertNotEqual(old_file.content, client.get(alias + "/file").content)
|
|
|
|
|
+ self.assertFalse(executor.submitted[1][1][0]["job_dir"].exists())
|
|
|
|
|
+
|
|
|
|
|
+ def test_latest_failed_task_does_not_fall_back_to_old_success(self):
|
|
|
|
|
+ with tempfile.TemporaryDirectory() as temp_dir:
|
|
|
|
|
+ executor = RecordingExecutor()
|
|
|
|
|
+ client = TestClient(http_api.app)
|
|
|
|
|
+ alias = "/api/v1/jobs/dms-tender-001"
|
|
|
|
|
+
|
|
|
|
|
+ def fake_worker(_t, _p, _r, step6, _log):
|
|
|
|
|
+ _write_docx(step6, "old successful file")
|
|
|
|
|
+
|
|
|
|
|
+ with patch.object(http_api, "API_WORK_ROOT", Path(temp_dir)), patch.object(
|
|
|
|
|
+ http_api, "_JOB_EXECUTOR", executor
|
|
|
|
|
+ ), patch.object(http_api, "_send_callback"), patch.object(
|
|
|
|
|
+ http_api, "_run_pipeline_worker", side_effect=fake_worker
|
|
|
|
|
+ ):
|
|
|
|
|
+ first = self._post().json()["request_id"]
|
|
|
|
|
+ function, args = executor.submitted[0]
|
|
|
|
|
+ function(*args)
|
|
|
|
|
+ second = self._post().json()["request_id"]
|
|
|
|
|
+ self.assertEqual(client.get(alias + "/file").status_code, 409)
|
|
|
|
|
+ function, args = executor.submitted[1]
|
|
|
|
|
+ api_worker._write_progress(args[0]["job_dir"], 4, "running", "处理中", [1, 2, 3])
|
|
|
|
|
+ with patch.object(http_api, "_run_pipeline_worker", side_effect=RuntimeError("TEST failure")):
|
|
|
|
|
+ function(*args)
|
|
|
|
|
+ status = client.get(alias).json()
|
|
|
|
|
+ self.assertEqual((status["request_id"], status["status"]), (second, "failed"))
|
|
|
|
|
+ self.assertEqual(status["error"], "TEST failure")
|
|
|
|
|
+ self.assertEqual(client.get(alias + "/progress").json()["status"], "running")
|
|
|
|
|
+ self.assertEqual(client.get(alias + "/file").status_code, 409)
|
|
|
|
|
+ self.assertEqual(client.get(f"/api/v1/jobs/{first}/file").status_code, 200)
|
|
|
|
|
+
|
|
|
|
|
+ def test_rejected_submission_restores_mapping_and_invalid_input_does_not_change_it(self):
|
|
|
|
|
+ with tempfile.TemporaryDirectory() as temp_dir, patch.object(
|
|
|
|
|
+ http_api, "API_WORK_ROOT", Path(temp_dir)
|
|
|
|
|
+ ), patch.object(http_api, "_JOB_EXECUTOR", RecordingExecutor()) as executor:
|
|
|
|
|
+ first = self._post().json()["request_id"]
|
|
|
|
|
+ with patch.object(executor, "submit", side_effect=RuntimeError("TEST rejected")):
|
|
|
|
|
+ self.assertEqual(self._post().status_code, 500)
|
|
|
|
|
+ self.assertEqual(self._post(txb_id="TEST-NEW").status_code, 500)
|
|
|
|
|
+ self.assertEqual(http_api._resolve_job("dms-tender-001")["request_id"], first)
|
|
|
|
|
+ self.assertNotIn("TEST-NEW", http_api._LATEST_REQUEST_BY_TXB_ID)
|
|
|
|
|
+ files = _valid_files()
|
|
|
|
|
+ files["TENDER_FILE"] = ("invalid.txt", b"invalid", "text/plain")
|
|
|
|
|
+ self.assertEqual(self._post(files=files).status_code, 400)
|
|
|
|
|
+ self.assertEqual(http_api._resolve_job("dms-tender-001")["request_id"], first)
|
|
|
|
|
+ self.assertEqual(list(http_api._JOBS), [first])
|
|
|
|
|
+ self.assertEqual([p.name for p in Path(temp_dir).iterdir()], [first])
|
|
|
|
|
+
|
|
|
|
|
+ def test_submission_rollback_does_not_restore_rejected_or_overwrite_newer_task(self):
|
|
|
|
|
+ # 模拟重叠提交:旧提交被拒绝时,新提交已注册;新提交随后也被拒绝。
|
|
|
|
|
+ http_api._JOBS.update({
|
|
|
|
|
+ key: {"request_id": key, "txbId": "TEST-TXB"}
|
|
|
|
|
+ for key in ("accepted", "rejected-old", "rejected-new")
|
|
|
|
|
+ })
|
|
|
|
|
+ http_api._LATEST_REQUEST_BY_TXB_ID["TEST-TXB"] = "rejected-new"
|
|
|
|
|
+ http_api._discard_unsubmitted_job("rejected-old")
|
|
|
|
|
+ self.assertEqual(http_api._resolve_job("TEST-TXB")["request_id"], "rejected-new")
|
|
|
|
|
+ http_api._discard_unsubmitted_job("rejected-new")
|
|
|
|
|
+ self.assertEqual(http_api._resolve_job("TEST-TXB")["request_id"], "accepted")
|
|
|
|
|
+
|
|
|
|
|
+ def test_shared_lookup_unknown_ids_and_request_id_collision(self):
|
|
|
|
|
+ client = TestClient(http_api.app)
|
|
|
|
|
+ for suffix in ("", "/progress", "/file"):
|
|
|
|
|
+ response = client.get("/api/v1/jobs/unknown" + suffix)
|
|
|
|
|
+ self.assertEqual(response.status_code, 404)
|
|
|
|
|
+ self.assertEqual(response.json(), {"detail": "任务不存在"})
|
|
|
|
|
+ http_api._JOBS.update({
|
|
|
|
|
+ "collision": {"request_id": "collision", "status": "queued"},
|
|
|
|
|
+ "other": {"request_id": "other", "status": "failed"},
|
|
|
|
|
+ })
|
|
|
|
|
+ http_api._LATEST_REQUEST_BY_TXB_ID["collision"] = "other"
|
|
|
|
|
+ with patch.object(http_api, "_resolve_job", wraps=http_api._resolve_job) as resolver:
|
|
|
|
|
+ self.assertEqual(client.get("/api/v1/jobs/collision").json()["request_id"], "collision")
|
|
|
|
|
+ self.assertEqual(client.get("/api/v1/jobs/collision/progress").json()["status"], "queued")
|
|
|
|
|
+ self.assertEqual(client.get("/api/v1/jobs/collision/file").status_code, 409)
|
|
|
|
|
+ self.assertEqual(resolver.call_count, 3)
|
|
|
|
|
+
|
|
|
def test_background_success_publishes_file_and_sends_callback(self):
|
|
def test_background_success_publishes_file_and_sends_callback(self):
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
|
root = Path(temp_dir)
|
|
root = Path(temp_dir)
|
|
@@ -337,6 +458,7 @@ class FinalReviewApiTests(unittest.TestCase):
|
|
|
|
|
|
|
|
self.assertEqual(response.status_code, 202)
|
|
self.assertEqual(response.status_code, 202)
|
|
|
self.assertEqual(job["txbId"], "1")
|
|
self.assertEqual(job["txbId"], "1")
|
|
|
|
|
+ self.assertEqual(http_api._resolve_job("1")["request_id"], response.json()["request_id"])
|
|
|
self.assertEqual(job["files"]["TENDER_FILE"]["filename"], "招标文件.pdf")
|
|
self.assertEqual(job["files"]["TENDER_FILE"]["filename"], "招标文件.pdf")
|
|
|
self.assertEqual(job["files"]["PROCUREMENT_FILE"]["filename"], "采购需求.docx")
|
|
self.assertEqual(job["files"]["PROCUREMENT_FILE"]["filename"], "采购需求.docx")
|
|
|
self.assertEqual(job["files"]["REFERENCE_BID"]["filename"], "参考投书.docx")
|
|
self.assertEqual(job["files"]["REFERENCE_BID"]["filename"], "参考投书.docx")
|