test_api.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844
  1. from io import BytesIO
  2. import json
  3. import shutil
  4. import subprocess
  5. import tempfile
  6. import unittest
  7. from pathlib import Path
  8. from unittest.mock import patch
  9. from docx import Document
  10. from fastapi.testclient import TestClient
  11. import pymupdf
  12. import api_worker
  13. import http_api
  14. def _write_docx(path: Path, text: str = "ok") -> None:
  15. path.parent.mkdir(parents=True, exist_ok=True)
  16. doc = Document()
  17. doc.add_paragraph(text)
  18. doc.save(path)
  19. def _docx_bytes(text: str = "document") -> bytes:
  20. stream = BytesIO()
  21. doc = Document()
  22. doc.add_paragraph(text)
  23. doc.save(stream)
  24. return stream.getvalue()
  25. def _pdf_bytes(text: str = "tender text") -> bytes:
  26. document = pymupdf.open()
  27. page = document.new_page()
  28. if text:
  29. page.insert_text((72, 72), text)
  30. content = document.tobytes()
  31. document.close()
  32. return content
  33. def _valid_files(tender_name: str = "招标文件.pdf") -> dict:
  34. return {
  35. "TENDER_FILE": (tender_name, _pdf_bytes(), "application/pdf"),
  36. "PROCUREMENT_FILE": (
  37. "采购需求.docx",
  38. _docx_bytes("procurement"),
  39. "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  40. ),
  41. "REFERENCE_BID": (
  42. "参考投书.docx",
  43. _docx_bytes("reference"),
  44. "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  45. ),
  46. }
  47. def _write_json_input_files(root: Path):
  48. tender = root / "招标文件.pdf"
  49. procurement = root / "采购需求.docx"
  50. reference = root / "参考投书.docx"
  51. tender.write_bytes(_pdf_bytes())
  52. procurement.write_bytes(_docx_bytes("procurement"))
  53. reference.write_bytes(_docx_bytes("reference"))
  54. return tender, procurement, reference
  55. class RecordingExecutor:
  56. def __init__(self):
  57. self.submitted = []
  58. def submit(self, function, *args):
  59. self.submitted.append((function, args))
  60. return object()
  61. class ApiWorkerTests(unittest.TestCase):
  62. def test_worker_runs_test_step1_through_test_step6_with_fixed_local_assets(self):
  63. with tempfile.TemporaryDirectory() as temp_dir:
  64. root = Path(temp_dir)
  65. tender = root / "tender.pdf"
  66. procurement = root / "procurement.docx"
  67. reference = root / "reference.docx"
  68. step6 = root / "step6.docx"
  69. for path in (tender, procurement, reference):
  70. path.write_bytes(b"input")
  71. calls = []
  72. def fake_run(command, **kwargs):
  73. calls.append((command, kwargs))
  74. script_name = Path(command[1]).name
  75. if script_name == "test_step6.py":
  76. _write_docx(step6, "step6")
  77. return subprocess.CompletedProcess(command, 0)
  78. with patch("api_worker.subprocess.run", side_effect=fake_run):
  79. result = api_worker.run_test_pipeline(
  80. tender, procurement, reference, step6
  81. )
  82. self.assertEqual(result, step6)
  83. self.assertEqual(
  84. [Path(call[0][1]).name for call in calls],
  85. [
  86. "test_step1.py",
  87. "test_step2.py",
  88. "test_step3.py",
  89. "test_step4.py",
  90. "test_step5.py",
  91. "test_step6.py",
  92. ],
  93. )
  94. env = calls[0][1]["env"]
  95. self.assertEqual(
  96. Path(env["PROPOSA_COMPANY_INFO_DIR"]),
  97. api_worker.COMPANY_INFO_DIR.resolve(),
  98. )
  99. self.assertEqual(
  100. Path(env["PROPOSA_TEMPLATE_PATH"]),
  101. api_worker.TEMPLATE_PATH.resolve(),
  102. )
  103. self.assertEqual(
  104. Path(env["PROPOSA_STEP1_ITEMS_FILE"]),
  105. (step6.parent / "step1_extracted_items.pkl").resolve(),
  106. )
  107. progress_path = step6.parent / api_worker.PROGRESS_FILE_NAME
  108. self.assertTrue(progress_path.is_file())
  109. progress = json.loads(progress_path.read_text(encoding="utf-8"))
  110. self.assertEqual(progress["current_step"], 6)
  111. self.assertEqual(progress["status"], "completed")
  112. self.assertEqual(progress["completed_steps"], [1, 2, 3, 4, 5, 6])
  113. class FinalReviewApiTests(unittest.TestCase):
  114. def setUp(self):
  115. with http_api._JOBS_LOCK:
  116. http_api._JOBS.clear()
  117. def _post(
  118. self,
  119. files=None,
  120. callback_url="127.0.0.1:9999/callback",
  121. txb_id="dms-tender-001",
  122. ):
  123. return TestClient(http_api.app).post(
  124. "/api/v1/final-review",
  125. data={"CALLBACK_URL": callback_url, "txbId": txb_id},
  126. files=files or _valid_files(),
  127. )
  128. def test_valid_request_returns_202_before_pipeline_runs(self):
  129. with tempfile.TemporaryDirectory() as temp_dir:
  130. root = Path(temp_dir)
  131. work_root = root / "jobs"
  132. executor = RecordingExecutor()
  133. with patch.object(http_api, "API_WORK_ROOT", work_root), patch.object(
  134. http_api, "API_WORK_SETTING", "output/api_jobs"
  135. ), patch.object(http_api, "_JOB_EXECUTOR", executor), patch.object(
  136. http_api, "_run_pipeline_worker"
  137. ) as worker:
  138. response = self._post(
  139. _valid_files("上海市群众艺术馆物业管理服务采购项目招标文件.pdf")
  140. )
  141. self.assertEqual(response.status_code, 202)
  142. body = response.json()
  143. self.assertEqual(body["status"], "processing")
  144. self.assertEqual(body["txbId"], "dms-tender-001")
  145. self.assertEqual(body["message"], "三份文件校验通过,已开始处理")
  146. self.assertEqual(
  147. body["resultPath"],
  148. f"output/api_jobs/{body['request_id']}/"
  149. "上海市群众艺术馆物业管理服务采购项目招标文件.docx",
  150. )
  151. self.assertEqual(
  152. body["output_path"],
  153. f"output/api_jobs/{body['request_id']}/"
  154. "上海市群众艺术馆物业管理服务采购项目招标文件.docx",
  155. )
  156. self.assertEqual(len(executor.submitted), 1)
  157. worker.assert_not_called()
  158. shutil.rmtree(work_root, ignore_errors=True)
  159. def test_background_success_publishes_file_and_sends_callback(self):
  160. with tempfile.TemporaryDirectory() as temp_dir:
  161. root = Path(temp_dir)
  162. executor = RecordingExecutor()
  163. callback_payloads = []
  164. def fake_worker(_t, _p, _r, step6, _log):
  165. _write_docx(step6, "final review")
  166. with patch.object(http_api, "API_WORK_ROOT", root / "jobs"), patch.object(
  167. http_api, "_JOB_EXECUTOR", executor
  168. ), patch.object(
  169. http_api, "_run_pipeline_worker", side_effect=fake_worker
  170. ), patch.object(
  171. http_api,
  172. "_send_callback",
  173. side_effect=lambda _url, payload: callback_payloads.append(payload),
  174. ):
  175. response = self._post(_valid_files("项目A.pdf"))
  176. function, args = executor.submitted[0]
  177. job = args[0]
  178. function(*args)
  179. request_id = response.json()["request_id"]
  180. status = TestClient(http_api.app).get(
  181. f"/api/v1/jobs/{request_id}"
  182. )
  183. download = TestClient(http_api.app).get(
  184. f"/api/v1/jobs/{request_id}/file"
  185. )
  186. self.assertEqual(status.status_code, 200)
  187. self.assertEqual(status.json()["status"], "completed")
  188. self.assertTrue(status.json()["callback_delivered"])
  189. self.assertEqual(callback_payloads[0]["status"], "completed")
  190. self.assertEqual(callback_payloads[0]["txbId"], "dms-tender-001")
  191. self.assertEqual(
  192. callback_payloads[0]["files"]["TENDER_FILE"]["filename"],
  193. "项目A.pdf",
  194. )
  195. self.assertTrue(
  196. callback_payloads[0]["resultPath"].endswith(
  197. "项目A.docx"
  198. )
  199. )
  200. self.assertEqual(job["final_output"].parent.name, request_id)
  201. self.assertNotIn("final_review_path", callback_payloads[0])
  202. self.assertEqual(download.status_code, 200)
  203. self.assertTrue(download.content.startswith(b"PK"))
  204. def test_progress_endpoint_returns_step_progress(self):
  205. with tempfile.TemporaryDirectory() as temp_dir:
  206. root = Path(temp_dir)
  207. executor = RecordingExecutor()
  208. def fake_worker(_t, _p, _r, step6, _log):
  209. _write_docx(step6, "final review")
  210. progress_path = step6.parent / api_worker.PROGRESS_FILE_NAME
  211. progress_path.write_text(
  212. json.dumps(
  213. {
  214. "current_step": 6,
  215. "total_steps": 6,
  216. "status": "completed",
  217. "message": "全部步骤处理完成",
  218. "completed_steps": [1, 2, 3, 4, 5, 6],
  219. },
  220. ensure_ascii=False,
  221. ),
  222. encoding="utf-8",
  223. )
  224. with patch.object(http_api, "API_WORK_ROOT", root / "jobs"), patch.object(
  225. http_api, "_JOB_EXECUTOR", executor
  226. ), patch.object(
  227. http_api, "_run_pipeline_worker", side_effect=fake_worker
  228. ), patch.object(
  229. http_api,
  230. "_send_callback",
  231. side_effect=lambda _url, payload: None,
  232. ):
  233. response = self._post(_valid_files("项目A.pdf"))
  234. request_id = response.json()["request_id"]
  235. function, args = executor.submitted[0]
  236. function(*args)
  237. progress_response = TestClient(http_api.app).get(
  238. f"/api/v1/jobs/{request_id}/progress"
  239. )
  240. self.assertEqual(response.status_code, 202)
  241. self.assertEqual(progress_response.status_code, 200)
  242. self.assertEqual(progress_response.json()["current_step"], 6)
  243. self.assertEqual(progress_response.json()["status"], "completed")
  244. shutil.rmtree(root / "jobs", ignore_errors=True)
  245. def test_optional_cla_file_is_recorded_and_does_not_affect_worker_submission(self):
  246. with tempfile.TemporaryDirectory() as temp_dir:
  247. root = Path(temp_dir)
  248. executor = RecordingExecutor()
  249. files = _valid_files()
  250. files["CLA_FILE"] = (
  251. "澄清公告.docx",
  252. _docx_bytes("clarification"),
  253. "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  254. )
  255. with patch.object(http_api, "API_WORK_ROOT", root / "jobs"), patch.object(
  256. http_api, "_JOB_EXECUTOR", executor
  257. ), patch.object(
  258. http_api, "_run_pipeline_worker"
  259. ) as worker:
  260. response = self._post(files=files)
  261. function, args = executor.submitted[0]
  262. job = args[0]
  263. self.assertEqual(response.status_code, 202)
  264. self.assertEqual(job["files"]["CLA_FILE"]["filename"], "澄清公告.docx")
  265. self.assertGreater(job["files"]["CLA_FILE"]["size"], 0)
  266. worker.assert_not_called()
  267. shutil.rmtree(root / "jobs", ignore_errors=True)
  268. def test_json_body_accepts_server_local_paths_and_records_files(self):
  269. with tempfile.TemporaryDirectory() as temp_dir:
  270. root = Path(temp_dir)
  271. tender, procurement, reference = _write_json_input_files(root)
  272. executor = RecordingExecutor()
  273. payload = {
  274. "CALLBACK_URL": "http://127.0.0.1:9999/callback",
  275. "txbId": 1,
  276. "TENDER_FILE": str(tender),
  277. "PROCUREMENT_FILE": str(procurement),
  278. "TPC_IS_ABSOLUTE": True,
  279. "REFERENCE_BID": str(reference),
  280. "REFERENCE_IS_ABSOLUTE": True,
  281. "CLA_FILE": "",
  282. }
  283. with patch.object(http_api, "API_WORK_ROOT", root / "jobs"), patch.object(
  284. http_api, "_JOB_EXECUTOR", executor
  285. ), patch.object(
  286. http_api, "_run_pipeline_worker"
  287. ) as worker:
  288. response = TestClient(http_api.app).post(
  289. "/api/v1/final-review",
  290. json=payload,
  291. )
  292. function, args = executor.submitted[0]
  293. job = args[0]
  294. self.assertEqual(response.status_code, 202)
  295. self.assertEqual(job["txbId"], "1")
  296. self.assertEqual(job["files"]["TENDER_FILE"]["filename"], "招标文件.pdf")
  297. self.assertEqual(job["files"]["PROCUREMENT_FILE"]["filename"], "采购需求.docx")
  298. self.assertEqual(job["files"]["REFERENCE_BID"]["filename"], "参考投书.docx")
  299. self.assertNotIn("CLA_FILE", job["files"])
  300. worker.assert_not_called()
  301. shutil.rmtree(root / "jobs", ignore_errors=True)
  302. def test_json_relative_reference_bid_joins_reference_dir(self):
  303. with tempfile.TemporaryDirectory() as temp_dir:
  304. root = Path(temp_dir)
  305. reference_dir = root / "reference_root"
  306. reference_dir.mkdir()
  307. reference_file = reference_dir / "参考投书.docx"
  308. reference_file.write_bytes(_docx_bytes("reference"))
  309. tender = root / "招标文件.pdf"
  310. procurement = root / "采购需求.docx"
  311. tender.write_bytes(_pdf_bytes())
  312. procurement.write_bytes(_docx_bytes("procurement"))
  313. executor = RecordingExecutor()
  314. payload = {
  315. "CALLBACK_URL": "http://127.0.0.1:9999/callback",
  316. "txbId": 1,
  317. "TENDER_FILE": str(tender),
  318. "PROCUREMENT_FILE": str(procurement),
  319. "TPC_IS_ABSOLUTE": True,
  320. "REFERENCE_BID": "/参考投书.docx",
  321. "REFERENCE_IS_ABSOLUTE": False,
  322. "CLA_FILE": "",
  323. }
  324. with patch.object(http_api, "REFERENCE_DIR", str(reference_dir)), patch.object(
  325. http_api, "API_WORK_ROOT", root / "jobs"
  326. ), patch.object(http_api, "_JOB_EXECUTOR", executor), patch.object(
  327. http_api, "_run_pipeline_worker"
  328. ) as worker:
  329. response = TestClient(http_api.app).post(
  330. "/api/v1/final-review",
  331. json=payload,
  332. )
  333. function, args = executor.submitted[0]
  334. job = args[0]
  335. self.assertEqual(response.status_code, 202)
  336. self.assertEqual(job["files"]["REFERENCE_BID"]["filename"], "参考投书.docx")
  337. worker.assert_not_called()
  338. shutil.rmtree(root / "jobs", ignore_errors=True)
  339. def test_json_relative_tpc_files_join_tpc_dir(self):
  340. with tempfile.TemporaryDirectory() as temp_dir:
  341. root = Path(temp_dir)
  342. tpc_dir = root / "tpc_root"
  343. tpc_dir.mkdir()
  344. tender = tpc_dir / "招标文件.pdf"
  345. procurement = tpc_dir / "采购需求.docx"
  346. clarification = tpc_dir / "澄清公告.docx"
  347. reference = root / "参考投书.docx"
  348. tender.write_bytes(_pdf_bytes())
  349. procurement.write_bytes(_docx_bytes("procurement"))
  350. clarification.write_bytes(_docx_bytes("clarification"))
  351. reference.write_bytes(_docx_bytes("reference"))
  352. executor = RecordingExecutor()
  353. payload = {
  354. "CALLBACK_URL": "http://127.0.0.1:9999/callback",
  355. "txbId": 1,
  356. "TENDER_FILE": f"/{tender.name}",
  357. "PROCUREMENT_FILE": f"/{procurement.name}",
  358. "REFERENCE_BID": str(reference),
  359. "REFERENCE_IS_ABSOLUTE": True,
  360. "CLA_FILE": f"/{clarification.name}",
  361. "TPC_IS_ABSOLUTE": False,
  362. }
  363. with patch.object(http_api, "TPC_DIR", str(tpc_dir)), patch.object(
  364. http_api, "API_WORK_ROOT", root / "jobs"
  365. ), patch.object(http_api, "_JOB_EXECUTOR", executor), patch.object(
  366. http_api, "_run_pipeline_worker"
  367. ) as worker:
  368. response = TestClient(http_api.app).post(
  369. "/api/v1/final-review",
  370. json=payload,
  371. )
  372. function, args = executor.submitted[0]
  373. job = args[0]
  374. self.assertEqual(response.status_code, 202)
  375. self.assertEqual(job["files"]["TENDER_FILE"]["filename"], tender.name)
  376. self.assertEqual(
  377. job["files"]["PROCUREMENT_FILE"]["filename"], procurement.name
  378. )
  379. self.assertEqual(
  380. job["files"]["CLA_FILE"]["filename"], clarification.name
  381. )
  382. worker.assert_not_called()
  383. shutil.rmtree(root / "jobs", ignore_errors=True)
  384. def test_relative_reference_bid_prefers_reference_dir_when_both_exist(self):
  385. with tempfile.TemporaryDirectory() as temp_dir:
  386. root = Path(temp_dir)
  387. reference_dir = root / "reference_root"
  388. reference_dir1 = root / "dms_upload"
  389. reference_dir.mkdir()
  390. reference_dir1.mkdir()
  391. primary = reference_dir / "reference.docx"
  392. secondary = reference_dir1 / "reference.docx"
  393. primary.write_bytes(_docx_bytes("primary"))
  394. secondary.write_bytes(_docx_bytes("secondary"))
  395. with patch.object(http_api, "REFERENCE_DIR", str(reference_dir)), patch.object(
  396. http_api, "REFERENCE_DIR1", str(reference_dir1)
  397. ):
  398. resolved = http_api._resolve_reference_path(
  399. "/reference.docx", is_absolute=False
  400. )
  401. self.assertEqual(Path(resolved), primary)
  402. def test_json_relative_reference_bid_falls_back_to_reference_dir1(self):
  403. with tempfile.TemporaryDirectory() as temp_dir:
  404. root = Path(temp_dir)
  405. reference_dir = root / "reference_root"
  406. reference_dir1 = root / "dms_upload"
  407. reference_dir.mkdir()
  408. reference_dir1.mkdir()
  409. reference_file = reference_dir1 / "参考投书.docx"
  410. reference_file.write_bytes(_docx_bytes("reference-dir1"))
  411. tender = root / "招标文件.pdf"
  412. procurement = root / "采购需求.docx"
  413. tender.write_bytes(_pdf_bytes())
  414. procurement.write_bytes(_docx_bytes("procurement"))
  415. executor = RecordingExecutor()
  416. payload = {
  417. "CALLBACK_URL": "http://127.0.0.1:9999/callback",
  418. "txbId": 1,
  419. "TENDER_FILE": str(tender),
  420. "PROCUREMENT_FILE": str(procurement),
  421. "TPC_IS_ABSOLUTE": True,
  422. "REFERENCE_BID": "/参考投书.docx",
  423. "REFERENCE_IS_ABSOLUTE": False,
  424. "CLA_FILE": "",
  425. }
  426. with patch.object(http_api, "REFERENCE_DIR", str(reference_dir)), patch.object(
  427. http_api, "REFERENCE_DIR1", str(reference_dir1)
  428. ), patch.object(http_api, "API_WORK_ROOT", root / "jobs"), patch.object(
  429. http_api, "_JOB_EXECUTOR", executor
  430. ), patch.object(http_api, "_run_pipeline_worker") as worker:
  431. response = TestClient(http_api.app).post(
  432. "/api/v1/final-review",
  433. json=payload,
  434. )
  435. function, args = executor.submitted[0]
  436. job = args[0]
  437. self.assertEqual(response.status_code, 202)
  438. self.assertEqual(
  439. job["files"]["REFERENCE_BID"]["filename"], reference_file.name
  440. )
  441. worker.assert_not_called()
  442. shutil.rmtree(root / "jobs", ignore_errors=True)
  443. def test_json_relative_reference_bid_rejects_when_both_candidates_missing(self):
  444. with tempfile.TemporaryDirectory() as temp_dir:
  445. root = Path(temp_dir)
  446. reference_dir = root / "reference_root"
  447. reference_dir1 = root / "dms_upload"
  448. reference_dir.mkdir()
  449. reference_dir1.mkdir()
  450. with patch.object(http_api, "REFERENCE_DIR", str(reference_dir)), patch.object(
  451. http_api, "REFERENCE_DIR1", str(reference_dir1)
  452. ), patch.object(http_api, "_JOB_EXECUTOR") as executor:
  453. response = TestClient(http_api.app).post(
  454. "/api/v1/final-review",
  455. json={
  456. "CALLBACK_URL": "http://127.0.0.1:9999/callback",
  457. "txbId": "missing-reference",
  458. "TENDER_FILE": "tender.pdf",
  459. "PROCUREMENT_FILE": "procurement.docx",
  460. "TPC_IS_ABSOLUTE": True,
  461. "REFERENCE_BID": "/missing.docx",
  462. "REFERENCE_IS_ABSOLUTE": False,
  463. },
  464. )
  465. self.assertEqual(response.status_code, 400)
  466. self.assertEqual(
  467. response.json()["detail"],
  468. "REFERENCE_BID 在 REFERENCE_DIR 和 REFERENCE_DIR1 下均不存在: missing.docx",
  469. )
  470. executor.submit.assert_not_called()
  471. def test_invalid_tpc_is_absolute_value_is_rejected(self):
  472. with patch.object(http_api, "_JOB_EXECUTOR") as executor:
  473. response = TestClient(http_api.app).post(
  474. "/api/v1/final-review",
  475. json={
  476. "CALLBACK_URL": "http://127.0.0.1:9999/callback",
  477. "txbId": "bad-tpc",
  478. "TENDER_FILE": "tender.pdf",
  479. "PROCUREMENT_FILE": "procurement.docx",
  480. "REFERENCE_BID": "reference.docx",
  481. "TPC_IS_ABSOLUTE": "false",
  482. "REFERENCE_IS_ABSOLUTE": True,
  483. },
  484. )
  485. self.assertEqual(response.status_code, 400)
  486. self.assertEqual(
  487. response.json()["detail"],
  488. "TPC_IS_ABSOLUTE 必须是布尔值 true/false",
  489. )
  490. executor.submit.assert_not_called()
  491. def test_relative_tpc_files_require_tpc_dir(self):
  492. with patch.object(http_api, "TPC_DIR", ""), patch.object(
  493. http_api, "_JOB_EXECUTOR"
  494. ) as executor:
  495. response = TestClient(http_api.app).post(
  496. "/api/v1/final-review",
  497. json={
  498. "CALLBACK_URL": "http://127.0.0.1:9999/callback",
  499. "txbId": "missing-tpc-dir",
  500. "TENDER_FILE": "tender.pdf",
  501. "PROCUREMENT_FILE": "procurement.docx",
  502. "REFERENCE_BID": "reference.docx",
  503. "TPC_IS_ABSOLUTE": False,
  504. "REFERENCE_IS_ABSOLUTE": True,
  505. },
  506. )
  507. self.assertEqual(response.status_code, 400)
  508. self.assertEqual(
  509. response.json()["detail"],
  510. "TENDER_FILE 为相对路径,但未配置 TPC_DIR",
  511. )
  512. executor.submit.assert_not_called()
  513. def test_invalid_reference_is_absolute_value_is_rejected(self):
  514. with patch.object(http_api, "_JOB_EXECUTOR") as executor:
  515. response = TestClient(http_api.app).post(
  516. "/api/v1/final-review",
  517. json={
  518. "CALLBACK_URL": "http://127.0.0.1:9999/callback",
  519. "txbId": "bad-ref",
  520. "TENDER_FILE": "tender.pdf",
  521. "PROCUREMENT_FILE": "procurement.docx",
  522. "TPC_IS_ABSOLUTE": True,
  523. "REFERENCE_BID": "reference.docx",
  524. "REFERENCE_IS_ABSOLUTE": "maybe",
  525. },
  526. )
  527. self.assertEqual(response.status_code, 400)
  528. executor.submit.assert_not_called()
  529. def test_missing_absolute_flags_are_rejected(self):
  530. base_payload = {
  531. "CALLBACK_URL": "http://127.0.0.1:9999/callback",
  532. "txbId": "missing-flags",
  533. "TENDER_FILE": "tender.pdf",
  534. "PROCUREMENT_FILE": "procurement.docx",
  535. "REFERENCE_BID": "reference.docx",
  536. }
  537. with patch.object(http_api, "_JOB_EXECUTOR") as executor:
  538. missing_tpc = TestClient(http_api.app).post(
  539. "/api/v1/final-review", json=base_payload
  540. )
  541. missing_reference = TestClient(http_api.app).post(
  542. "/api/v1/final-review",
  543. json={**base_payload, "TPC_IS_ABSOLUTE": True},
  544. )
  545. self.assertEqual(missing_tpc.status_code, 400)
  546. self.assertEqual(
  547. missing_tpc.json()["detail"],
  548. "TPC_IS_ABSOLUTE 必须是布尔值 true/false",
  549. )
  550. self.assertEqual(missing_reference.status_code, 400)
  551. self.assertEqual(
  552. missing_reference.json()["detail"],
  553. "REFERENCE_IS_ABSOLUTE 必须是布尔值 true/false",
  554. )
  555. executor.submit.assert_not_called()
  556. def test_multipart_accepts_reference_is_absolute_without_path_join(self):
  557. with tempfile.TemporaryDirectory() as temp_dir, patch.object(
  558. http_api, "API_WORK_ROOT", Path(temp_dir)
  559. ), patch.object(http_api, "_JOB_EXECUTOR") as executor, patch.object(
  560. http_api, "_run_pipeline_worker"
  561. ) as worker:
  562. response = TestClient(http_api.app).post(
  563. "/api/v1/final-review",
  564. data={
  565. "CALLBACK_URL": "http://127.0.0.1:9999/callback",
  566. "txbId": "multipart-ref",
  567. "REFERENCE_IS_ABSOLUTE": "false",
  568. },
  569. files=_valid_files(),
  570. )
  571. self.assertEqual(response.status_code, 202)
  572. executor.submit.assert_called_once()
  573. worker.assert_not_called()
  574. shutil.rmtree(Path(temp_dir), ignore_errors=True)
  575. def test_clean_intermediate_false_keeps_job_directory(self):
  576. with tempfile.TemporaryDirectory() as temp_dir:
  577. root = Path(temp_dir)
  578. executor = RecordingExecutor()
  579. def fake_worker(_t, _p, _r, step6, _log):
  580. _write_docx(step6, "final review")
  581. with patch.object(http_api, "API_WORK_ROOT", root / "jobs"), patch.object(
  582. http_api, "_JOB_EXECUTOR", executor
  583. ), patch.object(
  584. http_api, "_run_pipeline_worker", side_effect=fake_worker
  585. ), patch.object(
  586. http_api,
  587. "_send_callback",
  588. side_effect=lambda _url, payload: None,
  589. ):
  590. response = TestClient(http_api.app).post(
  591. "/api/v1/final-review",
  592. data={
  593. "CALLBACK_URL": "http://127.0.0.1:9999/callback",
  594. "txbId": "keep-001",
  595. "CLEAN_INTERMEDIATE": "false",
  596. },
  597. files=_valid_files("项目A.pdf"),
  598. )
  599. function, args = executor.submitted[0]
  600. job = args[0]
  601. function(*args)
  602. self.assertEqual(response.status_code, 202)
  603. self.assertIn("intermediate_dir", response.json())
  604. self.assertTrue(Path(job["job_dir"]).exists())
  605. status = http_api._job_snapshot(response.json()["request_id"])
  606. self.assertEqual(status["intermediate_dir"], str(job["job_dir"]))
  607. shutil.rmtree(root / "jobs", ignore_errors=True)
  608. def test_invalid_clean_intermediate_value_is_rejected(self):
  609. with patch.object(http_api, "_JOB_EXECUTOR") as executor:
  610. response = TestClient(http_api.app).post(
  611. "/api/v1/final-review",
  612. data={
  613. "CALLBACK_URL": "http://127.0.0.1:9999/callback",
  614. "txbId": "bad-001",
  615. "CLEAN_INTERMEDIATE": "maybe",
  616. },
  617. files=_valid_files(),
  618. )
  619. self.assertEqual(response.status_code, 400)
  620. executor.submit.assert_not_called()
  621. def test_background_failure_sends_error_callback(self):
  622. with tempfile.TemporaryDirectory() as temp_dir:
  623. root = Path(temp_dir)
  624. executor = RecordingExecutor()
  625. callback_payloads = []
  626. with patch.object(http_api, "API_WORK_ROOT", root / "jobs"), patch.object(
  627. http_api, "_JOB_EXECUTOR", executor
  628. ), patch.object(
  629. http_api,
  630. "_run_pipeline_worker",
  631. side_effect=subprocess.CalledProcessError(7, ["worker"]),
  632. ), patch.object(
  633. http_api,
  634. "_send_callback",
  635. side_effect=lambda _url, payload: callback_payloads.append(payload),
  636. ):
  637. response = self._post()
  638. function, args = executor.submitted[0]
  639. function(*args)
  640. request_id = response.json()["request_id"]
  641. status = http_api._job_snapshot(request_id)
  642. self.assertEqual(status["status"], "failed")
  643. self.assertTrue(status["callback_delivered"])
  644. self.assertEqual(callback_payloads[0]["status"], "failed")
  645. self.assertEqual(callback_payloads[0]["txbId"], "dms-tender-001")
  646. self.assertEqual(
  647. callback_payloads[0]["resultPath"],
  648. "error: Step1-6 处理进程异常退出(退出码 7)",
  649. )
  650. self.assertIn("退出码 7", callback_payloads[0]["error"])
  651. def test_callback_ip_without_scheme_defaults_to_http(self):
  652. self.assertEqual(
  653. http_api._normalize_callback_url("127.0.0.1:9000/callback"),
  654. "http://127.0.0.1:9000/callback",
  655. )
  656. def test_callback_url_with_scheme_but_missing_slashes_is_normalized(self):
  657. self.assertEqual(
  658. http_api._normalize_callback_url(
  659. "http:121.43.55.7:10026/shenqin/tender/callback"
  660. ),
  661. "http://121.43.55.7:10026/shenqin/tender/callback",
  662. )
  663. def test_callback_posts_json_without_authorization_token(self):
  664. captured = {}
  665. class Response:
  666. status = 200
  667. def __enter__(self):
  668. return self
  669. def __exit__(self, *_args):
  670. return False
  671. def fake_urlopen(request, timeout):
  672. captured["request"] = request
  673. captured["timeout"] = timeout
  674. return Response()
  675. payload = {
  676. "txbId": "dms-tender-001",
  677. "resultPath": "output/result.docx",
  678. }
  679. with patch("http_api.urlopen", side_effect=fake_urlopen):
  680. http_api._send_callback(
  681. "http://121.43.55.7:10026/shenqin/tender/callback", payload
  682. )
  683. request = captured["request"]
  684. self.assertEqual(json.loads(request.data.decode("utf-8")), payload)
  685. self.assertNotIn(
  686. "authorization", {key.lower() for key, _value in request.header_items()}
  687. )
  688. def test_txb_id_is_required_and_cannot_be_blank(self):
  689. client = TestClient(http_api.app)
  690. missing = client.post(
  691. "/api/v1/final-review",
  692. data={"CALLBACK_URL": "127.0.0.1:9999/callback"},
  693. files=_valid_files(),
  694. )
  695. blank = self._post(txb_id=" ")
  696. self.assertEqual(missing.status_code, 422)
  697. self.assertEqual(blank.status_code, 400)
  698. self.assertEqual(blank.json()["detail"], "txbId 不能为空")
  699. def test_invalid_callback_is_rejected_before_job_submission(self):
  700. with patch.object(http_api, "_JOB_EXECUTOR") as executor:
  701. response = self._post(callback_url="ftp://127.0.0.1/callback")
  702. self.assertEqual(response.status_code, 400)
  703. executor.submit.assert_not_called()
  704. def test_missing_required_upload_is_rejected(self):
  705. response = self._post(
  706. files={
  707. "TENDER_FILE": ("tender.pdf", _pdf_bytes()),
  708. "REFERENCE_BID": ("reference.docx", _docx_bytes()),
  709. }
  710. )
  711. self.assertEqual(response.status_code, 422)
  712. def test_invalid_extension_is_rejected_before_job_submission(self):
  713. files = _valid_files()
  714. files["TENDER_FILE"] = ("tender.exe", b"bad")
  715. with patch.object(http_api, "_JOB_EXECUTOR") as executor:
  716. response = self._post(files)
  717. self.assertEqual(response.status_code, 400)
  718. executor.submit.assert_not_called()
  719. def test_procurement_and_reference_bid_must_be_docx(self):
  720. for field_name in ("PROCUREMENT_FILE", "REFERENCE_BID"):
  721. files = _valid_files()
  722. files[field_name] = ("wrong.pdf", _pdf_bytes())
  723. with self.subTest(field_name=field_name), patch.object(
  724. http_api, "_JOB_EXECUTOR"
  725. ) as executor:
  726. response = self._post(files)
  727. self.assertEqual(response.status_code, 400)
  728. self.assertIn("只能上传 .DOCX", response.json()["detail"])
  729. executor.submit.assert_not_called()
  730. def test_empty_tender_is_rejected(self):
  731. with tempfile.TemporaryDirectory() as temp_dir, patch.object(
  732. http_api, "API_WORK_ROOT", Path(temp_dir)
  733. ), patch.object(http_api, "_JOB_EXECUTOR") as executor:
  734. files = _valid_files()
  735. files["TENDER_FILE"] = ("tender.pdf", b"")
  736. response = self._post(files)
  737. self.assertEqual(response.status_code, 400)
  738. self.assertEqual(response.json()["detail"], "TENDER_FILE 不能为空")
  739. executor.submit.assert_not_called()
  740. self.assertEqual(list(Path(temp_dir).iterdir()), [])
  741. def test_scanned_pdf_without_extractable_text_is_rejected(self):
  742. with tempfile.TemporaryDirectory() as temp_dir, patch.object(
  743. http_api, "API_WORK_ROOT", Path(temp_dir)
  744. ), patch.object(http_api, "_JOB_EXECUTOR") as executor:
  745. files = _valid_files()
  746. files["TENDER_FILE"] = ("tender.pdf", _pdf_bytes(text=""))
  747. response = self._post(files)
  748. self.assertEqual(response.status_code, 400)
  749. self.assertIn("文字类 PDF", response.json()["detail"])
  750. executor.submit.assert_not_called()
  751. def test_docx_extension_with_invalid_content_is_rejected(self):
  752. with tempfile.TemporaryDirectory() as temp_dir, patch.object(
  753. http_api, "API_WORK_ROOT", Path(temp_dir)
  754. ), patch.object(http_api, "_JOB_EXECUTOR") as executor:
  755. files = _valid_files()
  756. files["PROCUREMENT_FILE"] = ("procurement.docx", b"not a docx")
  757. response = self._post(files)
  758. self.assertEqual(response.status_code, 400)
  759. self.assertIn("不是有效的 DOCX", response.json()["detail"])
  760. executor.submit.assert_not_called()
  761. if __name__ == "__main__":
  762. unittest.main()