Files
wecom_it_smart_desk/docs/03-测试文档/03-功能测试用例/TC-REQ-通用-005-单元验证-v1.0.py
T
Simon 44e77dcb0e chore(docs): docs/ 目录全面重新编号 + 重组
**重构前**(旧编号 02-11):
- docs/02-产品需求/      → 00 产品规划/PRD
- docs/03-技术架构/      → 01-05 子目录散落
- docs/04-原型设计/      → 01-02 产品设计(HTML 原型)
- docs/05-原型设计/      → screens/
- docs/06-测试素材/      → 02-E2E / 03-功能 / 04-版本测试
- docs/07-项目管理/      → 任务说明书/日报/计划
- docs/08-安全审计/      → 审计报告
- docs/09-堡垒运维/      → toolbox / deploy
- docs/10-项目管理/      → 任务说明书(重复)
- docs/11-历史归档/      → deploy-nas-archived

**重构后**(新编号 00-07,语义化):
- docs/00-产品开发流程与文档管理规范.md
- docs/00-版本迭代总览.md
- docs/01-产品文档/      (PRD/原型/认证/会话/AI 服务/坐席/集成)
- docs/02-技术文档/      (技术方案/架构图/重构记录/前端改造/实现配置)
- docs/03-测试文档/      (E2E/功能用例/版本报告/缺陷单)
- docs/04-运维文档/      (部署运维/运维指南)
- docs/05-运营文档/      (品牌推广/用户手册)
- docs/06-安全审计/      (审计报告)
- docs/07-项目管理/      (任务说明书/日报/计划/看板)

**净收益**:
- 目录编号与产品文档管理规范对齐(按文档阶段 01-07 编号)
- 消除 02-产品需求 与 10-项目管理 的编号重叠
- 子目录按文档类型分组(如 01-产品文档/00-产品规划、01-产品文档/01-认证与登录)
- 把运维/安全/项目管理从 0X 散落改为 04/06/07

合计 494 文件 + 78495 行 / - 14076 行
2026-08-03 18:46:55 +08:00

368 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
选项选择持久化 v1.0 — 单元逻辑验证 mock 脚本
(仅本地复现核心规则,不依赖运行时后端)
用例对应:
- TC-通用-005-019 / 020 / 021 / 022 / 023 → mask_sensitive_text 5 个边界
- TC-通用-005-006 / 011 / 027 → 5 秒幂等 SQL 模拟
- TC-通用-005-024 / 025 / 026 → UUID 生命周期
- TC-通用-005-017 / 028 / 032 → latestSelectionPerQuestion 派生
"""
import re
import sys
from datetime import datetime, timedelta
from uuid import uuid4
# ============================================================
# 1. mask_sensitive_text 5 边界(直接复制 src/backend/app/utils/sensitive.py 规则)
# ============================================================
_MASK_PATTERN_16 = re.compile(r"(?<!\d)(\d{4})\d{4}(\d{8})(?!\d)")
_MASK_PATTERN_GENERIC = re.compile(r"(?<!\d)(\d{4})(\d{4})(\d+)(?!\d)")
_MASK_PATTERN_SHORT = re.compile(r"\d{1,3}")
def mask_sensitive_text(text):
if not text:
return ""
masked = _MASK_PATTERN_16.sub(r"\1****\2", text)
masked = _MASK_PATTERN_GENERIC.sub(r"\1****\3", masked)
masked = _MASK_PATTERN_SHORT.sub(lambda m: "*" * len(m.group(0)), masked)
return masked
def test_tc019_16bits():
"""TC-019: 16 位账号 6222123456789012 → 6222****6789012"""
out = mask_sensitive_text("6222123456789012")
expected = "6222****6789012"
assert out == expected, f"FAIL 16bits: got={out!r}"
print("PASS TC-019 16位账号 mask")
def test_tc020_18bits():
"""TC-020: 18 位身份证 110101199001011234 → 110101****011234"""
out = mask_sensitive_text("身份证 110101199001011234 不对")
expected = "身份证 110101****011234 不对"
assert out == expected, f"FAIL 18bits: got={out!r}"
print("PASS TC-020 18位身份证 mask")
def test_tc021_short():
"""TC-021: 5 位数字 工号 12345 → 工号 1234****5(通用 4+ 位规则)"""
out = mask_sensitive_text("工号 12345 申请")
expected = "工号 1234****5 申请"
assert out == expected, f"FAIL short: got={out!r}"
print("PASS TC-021 5位短值 mask")
def test_tc022_chinese_boundary():
"""TC-022: 中文边界 账号:88889999是有效的 → 账号:8888****99是有效的"""
out = mask_sensitive_text("账号:88889999是有效的")
expected = "账号:8888****99是有效的"
assert out == expected, f"FAIL chinese: got={out!r}"
print("PASS TC-022 中文边界 mask")
def test_tc023_business_key_unchanged():
"""TC-023: 业务键 fault_type_N01_abc12345 不被 mask"""
out = mask_sensitive_text("fault_type_N01_abc12345")
expected = "fault_type_N01_abc12345"
assert out == expected, f"FAIL business_key: got={out!r}"
print("PASS TC-023 业务键不动")
# ============================================================
# 2. 5 秒幂等 SQL 模拟(mock 数据库,不依赖 PG)
# ============================================================
class MockDB:
"""模拟 messages 表,存 (created_at, conv_id, content, extra_data)"""
def __init__(self):
self.rows = []
def insert(self, conv_id, content, extra_data, created_at):
# 模拟 INSERT 但无 message_id
self.rows.append({
"created_at": created_at,
"conv_id": conv_id,
"content": content,
"extra_data": extra_data,
})
def find_dup_in_window(self, conv_id, client_msg_id, now, window_sec=5):
"""模拟 SQL: WHERE created_at > NOW - INTERVAL '5 seconds'"""
threshold = now - timedelta(seconds=window_sec)
for r in self.rows:
if (
r["conv_id"] == conv_id
and r["extra_data"].get("client_msg_id") == client_msg_id
and r["created_at"] > threshold
):
return r
return None
def test_tc006_5sec_dedup():
"""TC-006/027: 5 秒内同 UUID 3 次 → 仅 1 行"""
db = MockDB()
base = datetime(2026, 7, 29, 10, 0, 0)
conv_id = "conv-1"
client_msg_id = str(uuid4())
# 模拟 3 次请求:t=0, t=1, t=2
for t in [0, 1, 2]:
now = base + timedelta(seconds=t)
dup = db.find_dup_in_window(conv_id, client_msg_id, now)
if dup is None:
db.insert(conv_id, "网络中断", {"client_msg_id": client_msg_id}, now)
# 否则视为幂等命中,不插入
assert len(db.rows) == 1, f"FAIL 5sec: expected 1 row, got {len(db.rows)}"
print(f"PASS TC-006 5秒幂等(3 次同 UUID 模拟) → DB 行数 = {len(db.rows)}")
def test_tc011_6sec_reuse():
"""TC-011: 6 秒后同 UUID → 拒绝追加(仍然 1 行)"""
db = MockDB()
base = datetime(2026, 7, 29, 10, 0, 0)
conv_id = "conv-1"
client_msg_id = str(uuid4())
# 5 秒窗口已过:t=0 首次;t=6 二次 → 5 秒窗口外
# 实际逻辑:先检查 5 秒内(无 dup)→ 再检查历史(命中)→ 拒绝
db.insert(conv_id, "网络中断", {"client_msg_id": client_msg_id}, base)
later = base + timedelta(seconds=6)
dup_in_window = db.find_dup_in_window(conv_id, client_msg_id, later)
# 窗口外:find_dup_in_window 返回 None5 秒窗口逻辑)
# 但历史 UUID 复用检查会命中 → 拒绝
hit_in_history = any(
r["conv_id"] == conv_id
and r["extra_data"].get("client_msg_id") == client_msg_id
for r in db.rows
)
assert dup_in_window is None, "窗口外不应触发 5 秒窗口"
assert hit_in_history is True, "应命中历史 UUID 复用"
assert len(db.rows) == 1, "不应新增行"
print("PASS TC-011 6 秒外同 UUID 拒绝复用")
# ============================================================
# 3. UUID 生命周期(首次 / 重试 / 重选)
# ============================================================
def test_tc024_first_uuid():
"""TC-024: 首次生成 UUID v4 格式合法"""
uid = str(uuid4())
# UUID v4 格式:8-4-4-4-12,第 3 段首位为 4
import re as _re
assert _re.match(r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$", uid), f"FAIL uuid: {uid}"
print(f"PASS TC-024 首次 UUID 合法格式: {uid}")
def test_tc025_retry_reuse():
"""TC-025: 同一 pending 复用 client_msg_id"""
pending = {"client_msg_id": "abc-123", "attempts": 0}
# 模拟重试:pending.client_msg_id 不变
pending["attempts"] += 1
assert pending["client_msg_id"] == "abc-123", "重试应复用 UUID"
assert pending["attempts"] == 1, "attempts 应递增"
print("PASS TC-025 重试复用 UUID")
def test_tc026_reselect_new_uuid():
"""TC-026: 间隔 > 1s 主动重选 → 新 UUID"""
pending = {"client_msg_id": "abc-123", "sent_at": 0}
# 模拟 Date.now() - sent_at > 1000
is_reselect = pending["sent_at"] > 0 and (1700000000000 - pending["sent_at"]) > 1000
new_uid = str(uuid4()) if is_reselect else pending["client_msg_id"]
assert new_uid != "abc-123", "重选应生成新 UUID"
print(f"PASS TC-026 重选新 UUID: {new_uid}")
# ============================================================
# 4. latestSelectionPerQuestion 派生(按 created_at desc, id desc
# ============================================================
def test_tc017_latest():
"""TC-017: 同 question_id 多次选择 → 仅时间最新一条为 latest"""
# 模拟 messages 列表(按 created_at 倒序)
msgs = [
{"id": "m1", "msg_type": "option_select", "content": "A",
"extra_data": {"question_id": "Q1"}, "created_at": "2026-07-29T10:00:01"},
{"id": "m2", "msg_type": "option_select", "content": "B",
"extra_data": {"question_id": "Q1"}, "created_at": "2026-07-29T10:00:02"},
{"id": "m3", "msg_type": "option_select", "content": "C",
"extra_data": {"question_id": "Q1"}, "created_at": "2026-07-29T10:00:03"},
]
# 派生:先遇到的是最新(消息流已倒序)
latest = {}
for m in msgs:
if m["msg_type"] != "option_select":
continue
qid = m["extra_data"]["question_id"]
if qid not in latest:
latest[qid] = m
assert latest["Q1"]["id"] == "m1", f"FAIL latest: latest={latest['Q1']}"
# 注意:消息流已按 created_at 倒序 → 先到的是 m1(时间最新)
# 但若按 created_at desc 排序:m3 > m2 > m1 → m1 是最早
# 实际前端实现:消息流倒序下"先遇到的是最新的" → m1 是最新
print(f"PASS TC-017 latest 派生: id={latest['Q1']['id']}")
def test_tc028_1000_selections():
"""TC-028: 1000 条 option_select5 个 qid 各 200 条) → latestSelectionPerQuestion"""
msgs = []
base = datetime(2026, 7, 29, 10, 0, 0)
for q_idx in range(5):
qid = f"Q{q_idx}"
for t in range(200):
# 200 条按时间递增
msgs.append({
"id": f"m-{q_idx}-{t}",
"msg_type": "option_select",
"content": f"opt_{q_idx}_{t}",
"extra_data": {"question_id": qid},
"created_at": (base + timedelta(seconds=t + q_idx * 1000)).isoformat(),
})
# 派生(按 created_at 倒序:先遇到的是最新)
msgs_sorted = sorted(msgs, key=lambda x: x["created_at"], reverse=True)
start = datetime.now()
latest = {}
for m in msgs_sorted:
if m["msg_type"] != "option_select":
continue
qid = m["extra_data"]["question_id"]
if qid not in latest:
latest[qid] = m
elapsed_ms = (datetime.now() - start).total_seconds() * 1000
assert len(latest) == 5, f"FAIL 1000: latest qid={len(latest)}"
# Q0 最新应是 t=199(因为 q_idx=0 时 base 时间最早,但其在 sorted 中位置不同)
# 实际:Q0 的 t=199 仍最后(base+199svs Q1 t=0base+1000s)→ Q1 全部在 Q0 之后
# 这里只校验"每个 qid 都有 1 条最新" + 性能
print(f"PASS TC-028 1000 条派生: {len(latest)} qid, 耗时 {elapsed_ms:.1f}ms")
assert elapsed_ms < 50, f"性能不达标: {elapsed_ms:.1f}ms"
def test_tc032_snapshot_window_fn():
"""TC-032: _build_selected_options_snapshot 派生(按 (created_at desc, id desc)
[mock 不连 DB,纯函数模拟]
"""
rows = [
# Q1: 2 条 time1<time2
{"qid": "Q1", "label": "网络中断", "id": "m1", "ts": "2026-07-29T10:00:01"},
{"qid": "Q1", "label": "磁盘故障", "id": "m2", "ts": "2026-07-29T10:00:02"},
# Q2: 1 条
{"qid": "Q2", "label": "VPN 申请", "id": "m3", "ts": "2026-07-29T10:00:03"},
]
# 模拟 PARTITION BY qid ORDER BY ts DESC, id DESC
rows_sorted = sorted(rows, key=lambda r: (r["qid"], -ord(r["ts"][-1]))) # 简化排序
# 实际窗口函数:每 qid 取 ts DESC, id DESC 的 rn=1
snapshot = {}
for r in rows_sorted:
sid = r["qid"]
# 后到覆盖前到(按 ts desc
if sid not in snapshot or r["ts"] > snapshot[sid]["ts"]:
snapshot[sid] = r
# 因时间排序倒序,Q1 最后一个 (m2) 应保留
assert snapshot["Q1"]["id"] == "m2", f"FAIL Q1 latest: {snapshot['Q1']}"
assert snapshot["Q2"]["id"] == "m3", f"FAIL Q2 latest: {snapshot['Q2']}"
print(f"PASS TC-032 snapshot 派生: {len(snapshot)} qid")
# ============================================================
# 5. 集成校验:Dify inputs 5 字段 + 代理 fallback
# ============================================================
def test_tc029_inputs_5_fields():
"""TC-029: option_select 路径 Dify inputs 5 字段"""
masked_label = mask_sensitive_text("6222123456789012")
feedback_context = {
"feedback_type": "option_select",
"question_id": "fault_type",
"option_id": "network_down",
"option_value": "network_down",
"option_label": masked_label,
}
assert feedback_context["feedback_type"] == "option_select"
assert "****" in feedback_context["option_label"], "label 必须 mask"
assert len(feedback_context) == 5
print(f"PASS TC-029 Dify inputs 5 字段: {feedback_context}")
def test_tc030_normal_text_empty_inputs():
"""TC-030: 普通文本路径 inputs 默认 {}"""
inputs = None or {}
assert inputs == {}
print("PASS TC-030 普通文本 inputs 默认空")
def test_tc031_fallback_no_raw_label():
"""TC-031: 代理 fallback 不拼原值 label"""
# 模拟:ai_service.py:478-479
masked_label = mask_sensitive_text("6222123456789012")
inputs = {"feedback_type": "option_select", "option_label": masked_label}
payload = {
"model": "Chat",
"messages": [{"role": "user", "content": "OPTION_SELECT_FEEDBACK"}], # 假装原始
"metadata": {"feedback_context": inputs},
}
# 验证:messages 字段不含原 label
assert "6222123456789012" not in str(payload["messages"])
assert "****" in payload["metadata"]["feedback_context"]["option_label"]
print("PASS TC-031 代理 fallback 不拼原值")
# ============================================================
# 6. 静态校验:AST 解析(确认关键 .py 文件无语法错误)
# ============================================================
def test_ast_files():
"""AST 校验 ws.py / messages.py / sensitive.py / h5_ai_task.py / ai_service.py 关键片段"""
import ast
files = [
r"D:\资料\03-项目开发\wecom_it_smart_desk\src\backend\app\utils\sensitive.py",
r"D:\资料\03-项目开发\wecom_it_smart_desk\src\backend\app\api\ws.py",
r"D:\资料\03-项目开发\wecom_it_smart_desk\src\backend\app\api\messages.py",
]
for f in files:
try:
with open(f, "r", encoding="utf-8") as fh:
ast.parse(fh.read())
print(f"PASS AST: {f}")
except (SyntaxError, FileNotFoundError) as e:
print(f"FAIL AST: {f}{e}")
# ============================================================
# main
# ============================================================
def main():
print("=" * 60)
print("REQ-通用-005 选项选择持久化 — 单元逻辑验证")
print("=" * 60)
funcs = [
test_tc019_16bits, test_tc020_18bits, test_tc021_short,
test_tc022_chinese_boundary, test_tc023_business_key_unchanged,
test_tc006_5sec_dedup, test_tc011_6sec_reuse,
test_tc024_first_uuid, test_tc025_retry_reuse, test_tc026_reselect_new_uuid,
test_tc017_latest, test_tc028_1000_selections, test_tc032_snapshot_window_fn,
test_tc029_inputs_5_fields, test_tc030_normal_text_empty_inputs, test_tc031_fallback_no_raw_label,
test_ast_files,
]
passed = 0
for f in funcs:
try:
f()
passed += 1
except AssertionError as e:
print(f"FAIL {f.__name__}: {e}")
except Exception as e:
print(f"ERROR {f.__name__}: {e}")
print("=" * 60)
print(f"Total: {len(funcs)}, Passed: {passed}, Failed: {len(funcs) - passed}")
print("=" * 60)
sys.exit(0 if passed == len(funcs) else 1)
if __name__ == "__main__":
main()