用「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 个单测通过。
334 lines
15 KiB
Python
334 lines
15 KiB
Python
"""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()
|