初始提交:QuickTeam 工单接口导入工具

用「WCF + 通用存储过程网关」接口替代 Selenium(D://Learning//Python//工单自动化)导入工单:
单 Sprint 12 个项目 175s 跑完(旧方案 25~35 分钟),失败可断点续跑且不重复建单。

- 分层:transport(HTTP/协议)/ api(存储过程语义)/ domain(纯规则)/
  workflow(阶段编排)/ store(SQLite 台账)/ report / CLI
- 幂等:台账驱动断点续跑;dry-run 与跳过行不污染进度;--rollback-on-fail 清理半成品
- 命令:plan / apply / report / status,真实写入需 --yes
- tools:只读探针、存储过程源码导出(sp_helptext)、导入诊断
- docs:调研与设计文档(含联调踩到的 5 个坑)+ 6 个关键存储过程源码

实测:Sprint 2026 Sprint 10 的 12 个项目全部完成建单→导入→确认→推送 TFS,
工单 225134–225145,35 个功能模块,重跑 plan 全部 skip-pushed。69 个单测通过。
This commit is contained in:
chenshoulin
2026-11-05 19:14:25 +08:00
commit 58850314cf
32 changed files with 4590 additions and 0 deletions
View File
+333
View File
@@ -0,0 +1,333 @@
"""api 层:参数构造与返回判定的单元测试(不联网)。"""
from __future__ import annotations
import hashlib
import json
import unittest
import urllib.parse
from qtimport.api import QuickTeamApi
from qtimport.errors import Category, QtError
from qtimport.models import Project, TfsTeam
class FakeTransport:
def __init__(self) -> None:
self.calls: list[tuple] = []
self.table: dict = {}
self.result: object = 0
self.import_result: object = [{"Column1": 0}]
self.tfs_result: object = []
self.upload_result = "E:\\tmp\\x.xlsx"
def exec_table(self, connection, procedure, params, *, op="", write=False):
self.calls.append(("exec_table", connection, procedure, params, write))
return self.table.get(procedure, [])
def exec_result(self, connection, procedure, params, *, op=""):
self.calls.append(("exec_result", connection, procedure, params))
return self.result
def batch_import(self, procedure, params, file_path):
self.calls.append(("batch_import", procedure, params, file_path))
return self.import_result
def upload(self, local_path, folder, *, op="FileUpload"):
self.calls.append(("upload", local_path, folder))
return self.upload_result
def tfs(self, operation, payload=None, *, op="", write=False):
self.calls.append(("tfs", operation, payload))
return self.tfs_result
PROJECT = Project("0f52", "信托 - 上海信托")
class LoginTest(unittest.TestCase):
def test_password_is_md5(self) -> None:
fake = FakeTransport()
fake.table["dbo.usp_Authentication"] = [
{"GuidId": "g-1", "RealName": "陈首林", "UserName": "chen@goldenstand.cn"}
]
api = QuickTeamApi(fake)
user = api.login("chen@goldenstand.cn", "dummy-pass")
self.assertEqual(user.user_id, "g-1")
self.assertEqual(user.real_name, "陈首林")
_, connection, procedure, params, _ = fake.calls[0]
self.assertEqual(connection, "UserCenter")
self.assertEqual(procedure, "dbo.usp_Authentication")
sent = dict((name, value) for name, value, _ in params)
self.assertEqual(sent["Password"], hashlib.md5(b"dummy-pass").hexdigest())
def test_empty_result_is_auth_error(self) -> None:
api = QuickTeamApi(FakeTransport())
with self.assertRaises(QtError) as ctx:
api.login("a", "b")
self.assertEqual(ctx.exception.category, Category.AUTH)
class FillWorkSheetTest(unittest.TestCase):
def _api(self) -> tuple[QuickTeamApi, FakeTransport]:
fake = FakeTransport()
fake.table["QuickTeam.usp_UpdateWorkSheetById"] = [{"Result": 1}]
return QuickTeamApi(fake), fake
def test_payload(self) -> None:
api, fake = self._api()
api.fill_worksheet(
123,
PROJECT,
system_name="2026 Sprint 10",
start_time="2026-10-01",
end_time="2026-10-31",
evaluator="陈首林",
technical_leader="陈首林",
customer_leader="杜衡",
belong_team="资管投资人事业部",
content="需求开发测试",
operator="陈首林",
)
_, connection, procedure, params, write = fake.calls[0]
self.assertEqual(connection, "QuickTeam")
self.assertEqual(procedure, "QuickTeam.usp_UpdateWorkSheetById")
self.assertTrue(write, "写操作必须标记 write,避免被误重试")
sent = {name: (value, dbtype) for name, value, dbtype in params}
self.assertEqual(len(sent), 17)
self.assertEqual(sent["Operation"][0], "new")
self.assertEqual(sent["DeleteFlag"][0], "0")
self.assertEqual(sent["SystemName"][0], "2026 Sprint 10")
self.assertEqual(sent["ProjectId"][0], "0f52")
self.assertEqual(sent["CustomerLeader"][0], "杜衡")
self.assertEqual(sent["WorkSheetId"], (123, "string")) # 类型转换在 transport 层做
self.assertEqual(sent["Status"][0], "00", "新建工单从「等待技术责任人确认」开始")
def test_zero_result_but_state_applied_is_success(self) -> None:
"""实测:UpdateWorkSheetById 的 Result 恒为 0,但数据已落库,不能因此判失败。"""
fake = FakeTransport()
fake.table["QuickTeam.usp_UpdateWorkSheetById"] = [{"Result": 0}]
fake.table["QuickTeam.usp_GetWorkSheetById"] = [
{"WorkSheetId": 1, "SystemName": "2026 Sprint 10", "Status": "00", "CurrentVersionId": 5}
]
api = QuickTeamApi(fake)
self.assertEqual(
api.fill_worksheet(
1, PROJECT, system_name="2026 Sprint 10", start_time="a", end_time="b", evaluator="e",
technical_leader="t", customer_leader="c", belong_team="bt", content="ct", operator="op",
),
0,
)
def test_zero_result_and_state_missing_raises(self) -> None:
fake = FakeTransport()
fake.table["QuickTeam.usp_UpdateWorkSheetById"] = [{"Result": 0}]
fake.table["QuickTeam.usp_GetWorkSheetById"] = [{"WorkSheetId": 1, "SystemName": "", "Status": ""}]
api = QuickTeamApi(fake)
with self.assertRaises(QtError):
api.fill_worksheet(
1, PROJECT, system_name="2026 Sprint 10", start_time="a", end_time="b", evaluator="e",
technical_leader="t", customer_leader="c", belong_team="bt", content="ct", operator="op",
)
class ImportExcelTest(unittest.TestCase):
def test_success(self) -> None:
fake = FakeTransport()
api = QuickTeamApi(fake)
api.import_excel(9, 260113, "E:\\tmp\\x.xlsx")
_, procedure, params, path = fake.calls[0]
self.assertEqual(procedure, "QuickTeam.usp_BatchImportFeatureFromExcel")
self.assertEqual(params, [("WorkSheetId", 9, "int"), ("VersionId", 260113, "int")])
self.assertEqual(path, "E:\\tmp\\x.xlsx")
def test_column1_nonzero_raises_excel(self) -> None:
fake = FakeTransport()
fake.import_result = [{"Column1": 3}]
api = QuickTeamApi(fake)
with self.assertRaises(QtError) as ctx:
api.import_excel(9, 1, "p")
self.assertEqual(ctx.exception.category, Category.EXCEL)
def test_unexpected_payload(self) -> None:
fake = FakeTransport()
fake.import_result = None
api = QuickTeamApi(fake)
with self.assertRaises(QtError):
api.import_excel(9, 1, "p")
class WorkSheetListTest(unittest.TestCase):
"""项目工单列表:一次查全(该 SP 一行一个版本,工单本体行 VersionId 为空)。"""
ROWS = [
{"WorkSheetId": 501, "WorkSheetCode": "W_501", "SystemName": "2026 Sprint 10",
"Status": "01", "CurrentVersionId": 260220, "VersionId": "", "VersionCode": "",
"StartTime": "/Date(1791388800000)/"},
{"WorkSheetId": 501, "WorkSheetCode": "技术责任人确认版", "SystemName": "2026 Sprint 10",
"Status": "01", "CurrentVersionId": 260220, "VersionId": 260220,
"VersionCode": "技术责任人确认版"},
{"WorkSheetId": 502, "WorkSheetCode": "客户责任人确认版", "SystemName": "2026 Sprint 9",
"Status": "01", "CurrentVersionId": 260180, "VersionId": 260180,
"VersionCode": "客户责任人确认版"},
]
def test_dedupes_by_worksheet_and_prefers_base_row(self) -> None:
fake = FakeTransport()
fake.table["QuickTeam.usp_GetWorkSheetsById"] = self.ROWS
api = QuickTeamApi(fake)
sheets = api.work_sheets("0f52")
self.assertEqual([s.worksheet_id for s in sheets], [501, 502])
first = sheets[0]
self.assertEqual(first.code, "W_501", "版本行把 WorkSheetCode 覆盖成了 VersionCode,必须取本体行")
self.assertEqual(first.system_name, "2026 Sprint 10")
self.assertEqual(first.status, "01")
self.assertEqual(first.version_id, 260220)
self.assertEqual(first.start_time, "2026-10-08")
self.assertEqual(sheets[1].system_name, "2026 Sprint 9")
class WorksheetVersionTest(unittest.TestCase):
"""版本行来自 usp_GetWorkSheetsById(列表页那个 SP,一行一个版本)。"""
ROWS = [
# union 出来的「无版本」占位行,VersionId 是空串
{"WorkSheetId": 501, "VersionId": "", "VersionCode": "", "VersionStatus": "", "NextOperator": ""},
{"WorkSheetId": 501, "VersionId": 260299, "VersionCode": "客户责任人确认版",
"VersionStatus": "Y", "NextOperator": "CustomerLeader"},
{"WorkSheetId": 501, "VersionId": 260300, "VersionCode": "技术责任人确认版",
"VersionStatus": "N", "NextOperator": "TechnicalLeader"},
{"WorkSheetId": 502, "VersionId": 260400, "VersionCode": "技术责任人确认版",
"VersionStatus": "N", "NextOperator": "TechnicalLeader"},
]
def _api(self) -> tuple[QuickTeamApi, FakeTransport]:
fake = FakeTransport()
fake.table["QuickTeam.usp_GetWorkSheetsById"] = self.ROWS
return QuickTeamApi(fake), fake
def test_filters_placeholder_and_other_worksheets(self) -> None:
api, fake = self._api()
versions = api.worksheet_versions("0f52", 501)
self.assertEqual([v.version_id for v in versions], [260299, 260300])
_, connection, procedure, params, write = fake.calls[0]
self.assertEqual(procedure, "QuickTeam.usp_GetWorkSheetsById")
self.assertFalse(write)
def test_pending_version_prefers_n(self) -> None:
"""CurrentVersionId 是 NULL,导入必须落到『待确认』那一版,否则会撞 FK。"""
api, _ = self._api()
pending = api.pending_version("0f52", 501)
self.assertIsNotNone(pending)
self.assertEqual(pending.version_id, 260300)
self.assertEqual(pending.version_code, "技术责任人确认版")
def test_pending_falls_back_to_latest_when_all_confirmed(self) -> None:
fake = FakeTransport()
fake.table["QuickTeam.usp_GetWorkSheetsById"] = [
{"WorkSheetId": 501, "VersionId": 1, "VersionCode": "技术责任人确认版", "VersionStatus": "Y"},
{"WorkSheetId": 501, "VersionId": 2, "VersionCode": "甲方确认版", "VersionStatus": "Y"},
]
api = QuickTeamApi(fake)
self.assertEqual(api.pending_version("0f52", 501).version_id, 2)
def test_no_version_returns_none(self) -> None:
api = QuickTeamApi(FakeTransport())
self.assertIsNone(api.pending_version("0f52", 501))
class VerifyTest(unittest.TestCase):
def test_success_zero(self) -> None:
fake = FakeTransport()
fake.result = 0
api = QuickTeamApi(fake)
api.verify_worksheet(1, 2, next_operator="CustomerLeader", next_version_code="客户责任人确认版",
operator="陈首林", percent=20)
_, connection, procedure, params = fake.calls[0]
self.assertEqual(procedure, "QuickTeam.usp_VerifyWorkSheetById")
sent = {name: value for name, value, _ in params}
self.assertEqual(sent["NextOperator"], "CustomerLeader")
self.assertEqual(sent["Percent"], 20)
def test_nonzero_raises(self) -> None:
fake = FakeTransport()
fake.result = 1
api = QuickTeamApi(fake)
with self.assertRaises(QtError):
api.verify_worksheet(1, 2, next_operator="x", next_version_code="y", operator="o", percent=20)
class TfsTest(unittest.TestCase):
TEAMS = [
TfsTeam("DataTechnologies", "Tools_Dev\\DataTechnologies",
[{"Name": "2026 Sprint 10", "Path": "Tools_Dev\\2026 Sprint\\2026 Sprint 10"}]),
TfsTeam("FundHub", "Tools_Dev\\FundHub",
[{"Name": "2026 Sprint 06", "Path": "Tools_Dev\\FundHub\\2026 Sprint 06"}]),
]
def test_resolve_exact(self) -> None:
area, iteration = QuickTeamApi.resolve_tfs_context(self.TEAMS, "DataTechnologies", "2026 Sprint 10")
self.assertEqual(area, "Tools_Dev\\DataTechnologies")
self.assertEqual(iteration, "Tools_Dev\\2026 Sprint\\2026 Sprint 10")
def test_resolve_leading_zero(self) -> None:
_, iteration = QuickTeamApi.resolve_tfs_context(self.TEAMS, "FundHub", "2026 Sprint 6")
self.assertTrue(iteration.endswith("2026 Sprint 06"))
def test_missing_team(self) -> None:
with self.assertRaises(QtError):
QuickTeamApi.resolve_tfs_context(self.TEAMS, "Nope", "2026 Sprint 10")
def test_missing_iteration(self) -> None:
with self.assertRaises(QtError) as ctx:
QuickTeamApi.resolve_tfs_context(self.TEAMS, "DataTechnologies", "2026 Sprint 99")
self.assertEqual(ctx.exception.category, Category.TFS)
def test_push_payload(self) -> None:
fake = FakeTransport()
api = QuickTeamApi(fake)
api.push_tfs([11, 22], "Tools_Dev", "Tools_Dev\\DataTechnologies", "Tools_Dev\\2026 Sprint\\2026 Sprint 10")
_, operation, payload = fake.calls[0]
self.assertEqual(operation, "BatchImportWorkItemsToTfs")
inner = json.loads(urllib.parse.unquote(payload["param"]))
self.assertEqual(inner["SPName"], "[QuickTeam].[usp_BatchImportWorkFeaturesByFeatureIds]")
self.assertEqual(inner["SQLParams"][0]["Value"], "11,22")
self.assertEqual(payload["areaPath"], "Tools_Dev\\DataTechnologies")
def test_push_without_features(self) -> None:
with self.assertRaises(QtError):
QuickTeamApi(FakeTransport()).push_tfs([], "p", "a", "i")
def test_push_returns_status(self) -> None:
fake = FakeTransport()
fake.tfs_result = 0
self.assertEqual(QuickTeamApi(fake).push_tfs([1, 2], "p", "a", "i"), 0)
def test_push_negative_status_is_failure(self) -> None:
"""返回值是状态码:0 = 成功,-1 = 失败(实测:项目名不存在时返回 -1)。"""
fake = FakeTransport()
fake.tfs_result = -1
with self.assertRaises(QtError) as ctx:
QuickTeamApi(fake).push_tfs([1], "p", "a", "i")
self.assertEqual(ctx.exception.category, Category.TFS)
class DryRunTest(unittest.TestCase):
def test_no_server_calls(self) -> None:
fake = FakeTransport()
api = QuickTeamApi(fake, dry_run=True)
self.assertEqual(api.create_worksheet(), 0)
api.fill_worksheet(
0, PROJECT, system_name="s", start_time="a", end_time="b", evaluator="e",
technical_leader="t", customer_leader="c", belong_team="bt", content="ct", operator="op",
)
api.import_excel(0, 0, "p")
api.verify_worksheet(0, 0, next_operator="x", next_version_code="y", operator="o", percent=20)
api.push_tfs([1], "p", "a", "i")
self.assertEqual(fake.calls, [], "dry-run 不允许发出任何服务端调用")
if __name__ == "__main__":
unittest.main()
+140
View File
@@ -0,0 +1,140 @@
"""domain 层规则测试:项目匹配、动作判定、版本推进、Excel 预检。"""
from __future__ import annotations
import os
import tempfile
import unittest
import zipfile
from qtimport.domain import Action, decide, match_project, next_transition, precheck_xlsx
from qtimport.models import Project, WorkSheet, normalize_iteration, normalize_name
def ws(worksheet_id: int = 1, status: str = "00", code: str = "W_1") -> WorkSheet:
return WorkSheet(worksheet_id=worksheet_id, code=code, status=status)
class NormalizeTest(unittest.TestCase):
def test_space_and_dash(self) -> None:
self.assertEqual(normalize_name("资管 - 华泰资管"), normalize_name("资管-华泰资管"))
self.assertEqual(normalize_name("信托 - 上海信托"), normalize_name("信托-上海信托"))
def test_iteration_leading_zero(self) -> None:
self.assertEqual(normalize_iteration("2026 Sprint 06"), normalize_iteration("2026 Sprint 6"))
self.assertNotEqual(normalize_iteration("2026 Sprint 10"), normalize_iteration("2026 Sprint 1"))
class MatchProjectTest(unittest.TestCase):
def setUp(self) -> None:
self.projects = [
Project("p1", "资管 - 华泰资管"),
Project("p2", "资管 - 华泰保险资管"),
Project("p3", "信托 - 上海信托"),
]
def test_exact_near_names_do_not_collide(self) -> None:
"""Selenium 用 contains,会把「华泰资管」误命中「华泰保险资管」;这里必须精确。"""
project, reason = match_project("资管 - 华泰资管", self.projects)
self.assertEqual(project.project_id, "p1")
self.assertEqual(reason, "")
def test_missing_gives_hint(self) -> None:
project, reason = match_project("资管 - 华泰", self.projects)
self.assertIsNone(project)
self.assertIn("相近项目", reason)
def test_ambiguous(self) -> None:
projects = [Project("a", "X - Y"), Project("b", "X-Y")]
project, reason = match_project("X - Y", projects)
self.assertIsNone(project)
self.assertIn("歧义", reason)
class DecideTest(unittest.TestCase):
def test_create_when_absent(self) -> None:
action, _, _ = decide([], ledger_stage=None, adopt=False)
self.assertEqual(action, Action.CREATE)
def test_skip_when_done(self) -> None:
action, _, _ = decide([ws(status="03")], ledger_stage=None, adopt=False)
self.assertEqual(action, Action.SKIP_DONE)
def test_skip_pushed(self) -> None:
action, _, _ = decide([ws(status="01")], ledger_stage="TFS_PUSHED", adopt=False)
self.assertEqual(action, Action.SKIP_PUSHED)
def test_resume_from_ledger(self) -> None:
action, _, sheet = decide([ws()], ledger_stage="WS_FILLED", adopt=False)
self.assertEqual(action, Action.RESUME)
self.assertEqual(sheet.worksheet_id, 1)
def test_existing_without_ledger_skips(self) -> None:
action, reason, _ = decide([ws()], ledger_stage=None, adopt=False)
self.assertEqual(action, Action.SKIP_EXISTS)
self.assertIn("未接管", reason)
def test_adopt_takes_over(self) -> None:
action, _, _ = decide([ws()], ledger_stage=None, adopt=True)
self.assertEqual(action, Action.RESUME)
def test_writes_flag(self) -> None:
self.assertTrue(Action.CREATE.writes)
self.assertTrue(Action.RESUME.writes)
for action in (Action.SKIP_DONE, Action.SKIP_EXISTS, Action.SKIP_PUSHED, Action.ERROR):
self.assertFalse(action.writes)
class VersionTest(unittest.TestCase):
def test_first_step(self) -> None:
self.assertEqual(next_transition("技术责任人确认版"), ("CustomerLeader", "客户责任人确认版", 20))
def test_draft_like_first_step(self) -> None:
self.assertEqual(next_transition(""), ("CustomerLeader", "客户责任人确认版", 20))
def test_second_step(self) -> None:
self.assertEqual(next_transition("客户责任人确认版")[1], "甲方确认版")
def test_terminal_raises(self) -> None:
with self.assertRaises(ValueError):
next_transition("甲方确认版")
def test_unknown_raises(self) -> None:
with self.assertRaises(ValueError):
next_transition("甲方确认版-v2")
class PrecheckTest(unittest.TestCase):
def _make_xlsx(self, path: str, strings: list[str]) -> None:
shared = "<sst>" + "".join(f"<si><t>{text}</t></si>" for text in strings) + "</sst>"
with zipfile.ZipFile(path, "w") as archive:
archive.writestr("xl/workbook.xml", "<workbook/>")
archive.writestr("xl/sharedStrings.xml", shared)
def test_valid_template(self) -> None:
with tempfile.TemporaryDirectory() as folder:
path = os.path.join(folder, "a.xlsx")
self._make_xlsx(path, ["工作量评估表", "服务模块", "服务内容"])
ok, why = precheck_xlsx(path)
self.assertTrue(ok, why)
def test_missing_title(self) -> None:
with tempfile.TemporaryDirectory() as folder:
path = os.path.join(folder, "b.xlsx")
self._make_xlsx(path, ["随便一个表", "服务模块"])
ok, why = precheck_xlsx(path)
self.assertFalse(ok)
self.assertIn("工作量评估表", why)
def test_not_zip(self) -> None:
with tempfile.TemporaryDirectory() as folder:
path = os.path.join(folder, "c.xlsx")
with open(path, "w", encoding="utf-8") as handle:
handle.write("not a zip")
ok, why = precheck_xlsx(path)
self.assertFalse(ok)
self.assertIn("xlsx", why)
if __name__ == "__main__":
unittest.main()
+76
View File
@@ -0,0 +1,76 @@
"""台账测试:dry-run 隔离、跨 run 续跑、字段合并。"""
from __future__ import annotations
import os
import tempfile
import unittest
from qtimport.store import Store
class StoreTest(unittest.TestCase):
def setUp(self) -> None:
self.folder = tempfile.TemporaryDirectory()
self.store = Store(os.path.join(self.folder.name, "t.db"))
def tearDown(self) -> None:
self.store.close()
self.folder.cleanup()
def _job(self, run_id: str, stage: str, worksheet_id: int | None = 1) -> None:
self.store.upsert_job(
run_id,
project_id="p1",
project_name="P",
system_name="2026 Sprint 10",
source_file="f.xlsx",
action="create",
stage=stage,
worksheet_id=worksheet_id,
)
def test_latest_stage_across_runs(self) -> None:
self.store.start_run("r1", "s", "d", dry_run=False)
self._job("r1", "WS_CREATED")
self.store.start_run("r2", "s", "d", dry_run=False)
self._job("r2", "WS_FILLED")
self.assertEqual(self.store.latest_stage("p1", "2026 Sprint 10"), "WS_FILLED")
def test_dry_run_does_not_pollute_ledger(self) -> None:
self.store.start_run("real", "s", "d", dry_run=False)
self._job("real", "WS_FILLED")
self.store.start_run("dry", "s", "d", dry_run=True)
self._job("dry", "TFS_PUSHED")
self.assertEqual(
self.store.latest_stage("p1", "2026 Sprint 10"),
"WS_FILLED",
"dry-run 不能把项目标成已推送",
)
def test_worksheet_id_survives_partial_update(self) -> None:
self.store.start_run("r1", "s", "d", dry_run=False)
self._job("r1", "WS_CREATED", worksheet_id=777)
self._job("r1", "WS_FILLED", worksheet_id=None)
job = self.store.latest_job("p1", "2026 Sprint 10")
self.assertEqual(job["worksheet_id"], 777)
self.assertEqual(job["stage"], "WS_FILLED")
def test_skipped_row_does_not_mask_progress(self) -> None:
"""跳过行不带进度:不能把「已推送」覆盖成「SKIPPED」,否则下次会重推 TFS。"""
self.store.start_run("r1", "s", "d", dry_run=False)
self._job("r1", "TFS_PUSHED")
self.store.start_run("r2", "s", "d", dry_run=False)
self._job("r2", "SKIPPED")
job = self.store.latest_job("p1", "2026 Sprint 10")
self.assertEqual(job["stage"], "TFS_PUSHED")
def test_events_ordered(self) -> None:
self.store.start_run("r1", "s", "d", dry_run=False)
self.store.add_event("r1", stage="A", action="ok", detail="1")
self.store.add_event("r1", stage="B", action="ok", detail="2")
self.assertEqual([e["stage"] for e in self.store.events("r1")], ["A", "B"])
if __name__ == "__main__":
unittest.main()
+63
View File
@@ -0,0 +1,63 @@
"""传输层与错误解析的单元测试(不联网)。"""
from __future__ import annotations
import unittest
from qtimport.errors import Category, QtError, extract_exception_message
from qtimport.transport import Transport
WCF_ERROR_PAGE = """\ufeff<?xml version="1.0" encoding="utf-8"?>
<html><body><div id="content">
<p class="heading1">Request Error</p>
<p>The server encountered an error processing the request. The exception message is
'Could not find stored procedure 'QuickTeam.usp_Nope'.'. See server logs for more details.</p>
</div></body></html>"""
class DecodeTest(unittest.TestCase):
def test_plain_json(self) -> None:
self.assertEqual(Transport.decode('[{"A":1}]'), [{"A": 1}])
def test_double_encoded(self) -> None:
raw = '"[{\\"WorkSheetId\\": 225072}]"'
self.assertEqual(Transport.decode(raw)[0]["WorkSheetId"], 225072)
def test_triple_encoded(self) -> None:
raw = '"\\"[{\\\\\\"A\\\\\\": 1}]\\""'
self.assertEqual(Transport.decode(raw), [{"A": 1}])
def test_scalar_string(self) -> None:
self.assertEqual(Transport.decode('"0"'), 0)
def test_not_json(self) -> None:
self.assertEqual(Transport.decode("<html/>"), "<html/>")
class ErrorTest(unittest.TestCase):
def test_extract_message(self) -> None:
message = extract_exception_message(WCF_ERROR_PAGE)
self.assertIn("Could not find stored procedure", message)
def test_missing_procedure_not_retryable(self) -> None:
error = QtError.from_http(400, WCF_ERROR_PAGE)
self.assertEqual(error.category, Category.SP_MISSING)
self.assertFalse(error.retryable)
def test_param_error(self) -> None:
body = "<p>The exception message is 'Procedure or function usp_X has too many arguments "
body += "specified.'. See server logs</p>"
self.assertEqual(QtError.from_http(400, body).category, Category.PARAM)
def test_server_error_retryable(self) -> None:
error = QtError.from_http(503, "<p>Service Unavailable</p>")
self.assertTrue(error.retryable)
def test_html_unescaped(self) -> None:
body = "<p>The exception message is 'Parameter &#39;UserId&#39; is missing.'. See server logs</p>"
self.assertIn("UserId", extract_exception_message(body))
if __name__ == "__main__":
unittest.main()
+211
View File
@@ -0,0 +1,211 @@
"""编排测试:阶段推进、断点续跑、失败归属与回滚(全部用 FakeApi,不联网)。"""
from __future__ import annotations
import os
import tempfile
import unittest
from qtimport.config import Config
from qtimport.domain import Action, PlanRow
from qtimport.errors import Category, QtError
from qtimport.models import Feature, Project, User, WorkSheet, WorkSheetVersion
from qtimport.store import Store
from qtimport.workflow import Importer, RunOptions
class FakeLog:
def __init__(self) -> None:
self.lines: list[str] = []
def info(self, message: str, **_) -> None:
self.lines.append(message)
def warn(self, message: str, **_) -> None:
self.lines.append("!" + message)
def event(self, *_, **__) -> None:
pass
def close(self) -> None:
pass
class FakeApi:
dry_run = False
def __init__(self) -> None:
self.calls: list[str] = []
self.deleted: list[int] = []
self.version_code = "技术责任人确认版"
self.import_error: QtError | None = None
def create_worksheet(self) -> int:
self.calls.append("create")
return 501
def fill_worksheet(self, worksheet_id, project, **kwargs) -> int:
self.calls.append("fill")
return 1
def worksheet(self, worksheet_id: int) -> WorkSheet:
self.calls.append("detail")
return WorkSheet(worksheet_id=worksheet_id, status="01", version_id=88, version_code=self.version_code)
def pending_version(self, project_id: str, worksheet_id: int) -> WorkSheetVersion:
"""只读查版本,不计入 calls —— 保持断言只看写操作序列。"""
return WorkSheetVersion(
version_id=88, worksheet_id=worksheet_id,
version_code=self.version_code, version_status="N",
)
def upload_excel(self, path: str, folder: str = "WorkSheetImport") -> str:
self.calls.append("upload")
return "E:\\tmp\\x.xlsx"
def import_excel(self, worksheet_id: int, version_id: int, remote: str) -> None:
self.calls.append("import")
if self.import_error:
raise self.import_error
def verify_worksheet(self, worksheet_id, version_id, **kwargs) -> None:
self.calls.append("verify")
def features(self, worksheet_id: int) -> list[Feature]:
self.calls.append("features")
return [Feature(1, "M1", "模块")]
def push_tfs(self, feature_ids, project_name, area_path, iteration_path):
self.calls.append("push")
return []
def delete_worksheet(self, worksheet_id: int) -> None:
self.calls.append("delete")
self.deleted.append(worksheet_id)
class WorkflowTest(unittest.TestCase):
def setUp(self) -> None:
self.folder = tempfile.TemporaryDirectory()
self.store = Store(os.path.join(self.folder.name, "t.db"))
self.store.start_run("r1", "2026 Sprint 10", "d", dry_run=False)
self.cfg = Config(
base_url="http://example.invalid",
username="u",
password="p",
sprint="2026 Sprint 10",
start_time="2026-10-01",
end_time="2026-10-31",
tfs_project="Tools_Dev",
area_path="Tools_Dev\\DataTechnologies",
iteration_path="Tools_Dev\\2026 Sprint\\2026 Sprint 10",
workers=1,
db_path=os.path.join(self.folder.name, "t.db"),
)
self.log = FakeLog()
self.api = FakeApi()
def tearDown(self) -> None:
self.store.close()
self.folder.cleanup()
def _importer(self, **options) -> Importer:
importer = Importer(self.cfg, self.store, "r1", self.log, RunOptions(**options))
importer.api = self.api
importer.user = User("g1", "陈首林", "u")
importer.team_id = "t1"
return importer
def _row(self, action: Action = Action.CREATE, worksheet: WorkSheet | None = None) -> PlanRow:
return PlanRow(
project_name="信托 - 上海信托",
file_path="f.xlsx",
action=action,
project=Project("p1", "信托 - 上海信托"),
worksheet=worksheet,
)
def test_full_pipeline(self) -> None:
progress = self._importer()._run_project(self._row())
self.assertEqual(progress.stage, "TFS_PUSHED")
self.assertEqual(
self.api.calls, ["create", "fill", "upload", "import", "detail", "verify", "features", "push"]
)
self.assertEqual(self.store.latest_stage("p1", "2026 Sprint 10"), "TFS_PUSHED")
def test_resume_from_imported(self) -> None:
"""台账停在 EXCEL_IMPORTED,只该跑 确认 + 推送,不能重复建单。"""
self.store.upsert_job(
"r1", project_id="p1", project_name="信托 - 上海信托", system_name="2026 Sprint 10",
source_file="f.xlsx", action="create", stage="EXCEL_IMPORTED", worksheet_id=501, version_id=88,
)
progress = self._importer()._run_project(self._row(Action.RESUME, WorkSheet(501)))
self.assertEqual(progress.stage, "TFS_PUSHED")
self.assertEqual(self.api.calls, ["detail", "verify", "features", "push"])
def test_skip_tfs(self) -> None:
progress = self._importer(skip_tfs=True)._run_project(self._row())
self.assertNotIn("push", self.api.calls)
self.assertEqual(progress.stage, "WS_VERIFIED")
def test_import_failure_is_attributed(self) -> None:
self.api.import_error = QtError("模板不符", Category.EXCEL)
progress = self._importer()._run_project(self._row())
self.assertEqual(progress.stage, "FAILED:EXCEL_IMPORTED")
job = self.store.latest_job("p1", "2026 Sprint 10")
self.assertIn("EXCEL", job["error"])
self.assertEqual(self.api.deleted, [], "未开 --rollback-on-fail 不应删单")
def test_rollback_on_failure(self) -> None:
self.api.import_error = QtError("模板不符", Category.EXCEL)
self._importer(rollback_on_fail=True)._run_project(self._row())
self.assertEqual(self.api.deleted, [501])
def test_no_rollback_when_not_created_here(self) -> None:
self.api.import_error = QtError("模板不符", Category.EXCEL)
row = self._row(Action.RESUME, WorkSheet(501))
self.store.upsert_job(
"r1", project_id="p1", project_name="信托 - 上海信托", system_name="2026 Sprint 10",
source_file="f.xlsx", action="resume", stage="WS_FILLED", worksheet_id=501,
)
self._importer(rollback_on_fail=True)._run_project(row)
self.assertEqual(self.api.deleted, [], "接管别人的单不能删")
def test_resume_after_failure_does_not_recreate(self) -> None:
"""上次在导入环节失败,重跑必须复用同一张工单,不能重新建。"""
self.store.upsert_job(
"r1", project_id="p1", project_name="信托 - 上海信托", system_name="2026 Sprint 10",
source_file="f.xlsx", action="create", stage="FAILED:EXCEL_IMPORTED",
worksheet_id=501, version_id=88,
)
progress = self._importer()._run_project(self._row(Action.RESUME, WorkSheet(501)))
self.assertNotIn("create", self.api.calls)
self.assertNotIn("fill", self.api.calls)
self.assertEqual(self.api.calls[0], "upload", "失败点重试,先重传再导入")
self.assertEqual(progress.stage, "TFS_PUSHED")
def test_create_ignores_stale_ledger(self) -> None:
"""回滚删单后台账仍记着旧 WorkSheetId,重新规划为 create 时必须重新建单。"""
self.store.upsert_job(
"r1", project_id="p1", project_name="信托 - 上海信托", system_name="2026 Sprint 10",
source_file="f.xlsx", action="create", stage="FAILED:WS_FILLED", worksheet_id=999,
)
progress = self._importer()._run_project(self._row(Action.CREATE))
self.assertEqual(self.api.calls[0], "create")
self.assertNotIn(999, [progress.worksheet_id])
def test_completed_index(self) -> None:
self.assertEqual(Importer._completed_index(""), -1)
self.assertEqual(Importer._completed_index("WS_FILLED"), 1)
self.assertEqual(Importer._completed_index("FAILED:WS_FILLED"), 0)
self.assertEqual(Importer._completed_index("FAILED:WS_CREATED"), -1)
self.assertEqual(Importer._completed_index("SKIPPED"), -1)
def test_terminal_version_skips_verify(self) -> None:
self.api.version_code = "甲方确认版"
self._importer()._run_project(self._row())
self.assertNotIn("verify", self.api.calls)
if __name__ == "__main__":
unittest.main()