986 lines
36 KiB
Python
986 lines
36 KiB
Python
# =============================================================================
|
||
# 组织架构树构建与过滤 — 单元测试
|
||
# =============================================================================
|
||
# 测试覆盖:
|
||
# 1. build_org_tree() — 层级树构建(dept_ 前缀、parentid 根判定、多部门员工、空部门、孤儿部门、嵌套层级)
|
||
# 2. _build_flat_tree_by_name() — 降级扁平分组
|
||
# 3. filter_user_from_tree() — 递归过滤用户 + 空部门移除
|
||
# 4. count_tree_employees() — 递归统计员工数
|
||
# 5. get_org_tree_cached() — 缓存 key 区分端点 + 用户排除(mock 依赖)
|
||
#
|
||
# 设计原则:
|
||
# - build_org_tree / filter_user_from_tree / count_tree_employees / _build_flat_tree_by_name
|
||
# 是纯函数,无外部依赖,直接测试输入输出
|
||
# - get_org_tree_cached 是 async 函数,mock get_org_directory 和 get_cached_dept_list
|
||
# =============================================================================
|
||
|
||
import json
|
||
from unittest.mock import AsyncMock, patch
|
||
|
||
import pytest
|
||
|
||
from app.services.employee_directory import (
|
||
ORG_TREE_CACHE_KEY_PREFIX,
|
||
build_org_tree,
|
||
count_tree_employees,
|
||
filter_user_from_tree,
|
||
get_org_tree_cached,
|
||
_build_flat_tree_by_name,
|
||
)
|
||
|
||
|
||
# =============================================================================
|
||
# 辅助:构造测试数据
|
||
# =============================================================================
|
||
|
||
def _emp(employee_id: str, name: str, department: str = "", dept_ids=None):
|
||
"""构造员工目录条目。"""
|
||
return {
|
||
"employee_id": employee_id,
|
||
"name": name,
|
||
"department": department,
|
||
"dept_ids": dept_ids or [],
|
||
}
|
||
|
||
|
||
def _dept(dept_id: int, name: str, parentid: int = 0):
|
||
"""构造部门列表条目。"""
|
||
return {"id": dept_id, "name": name, "parentid": parentid}
|
||
|
||
|
||
# =============================================================================
|
||
# 一、build_org_tree() 测试
|
||
# =============================================================================
|
||
|
||
class TestBuildOrgTree:
|
||
"""build_org_tree 纯函数测试。"""
|
||
|
||
# ----- 1.1 dept_ 前缀与员工 UserID 不冲突 -----
|
||
|
||
def test_dept_prefix_no_collision_with_employee_id(self):
|
||
"""部门节点 id 加 dept_ 前缀,员工节点 id 用 UserID,两者不冲突。"""
|
||
directory = [_emp("zhangsan", "张三", "研发部", [2])]
|
||
dept_list = [_dept(1, "公司", 0), _dept(2, "研发部", 1)]
|
||
|
||
tree = build_org_tree(directory, dept_list)
|
||
|
||
# 收集所有节点 id
|
||
all_ids = set()
|
||
|
||
def collect_ids(nodes):
|
||
for n in nodes:
|
||
all_ids.add(n["id"])
|
||
if "children" in n:
|
||
collect_ids(n["children"])
|
||
|
||
collect_ids(tree)
|
||
# 部门 id 有 dept_ 前缀,员工 id 无前缀
|
||
assert "dept_1" in all_ids
|
||
assert "dept_2" in all_ids
|
||
assert "zhangsan" in all_ids
|
||
# 不存在 "dept_zhangsan" 之类的冲突
|
||
assert "dept_zhangsan" not in all_ids
|
||
|
||
def test_dept_prefix_with_numeric_employee_id(self):
|
||
"""员工 UserID 为纯数字时,dept_ 前缀仍能区分。"""
|
||
directory = [_emp("1001", "员工1001", "研发部", [2])]
|
||
dept_list = [_dept(1, "公司", 0), _dept(2, "研发部", 1), _dept(1001, "数字部门", 1)]
|
||
|
||
tree = build_org_tree(directory, dept_list)
|
||
|
||
all_ids = set()
|
||
|
||
def collect_ids(nodes):
|
||
for n in nodes:
|
||
all_ids.add(n["id"])
|
||
if "children" in n:
|
||
collect_ids(n["children"])
|
||
|
||
collect_ids(tree)
|
||
# 部门 1001 的 id 是 "dept_1001",员工 1001 的 id 是 "1001"
|
||
assert "dept_1001" in all_ids
|
||
assert "1001" in all_ids
|
||
|
||
# ----- 1.2 parentid=0 根部门判定 -----
|
||
|
||
def test_parentid_0_as_root(self):
|
||
"""parentid=0 的部门作为根节点。"""
|
||
directory = [_emp("u1", "用户1", "研发", [2])]
|
||
dept_list = [
|
||
_dept(1, "公司", 0),
|
||
_dept(2, "研发部", 1),
|
||
]
|
||
|
||
tree = build_org_tree(directory, dept_list)
|
||
|
||
# 只有一个根节点(公司)
|
||
assert len(tree) == 1
|
||
assert tree[0]["id"] == "dept_1"
|
||
assert tree[0]["label"] == "公司"
|
||
# 研发部是公司的子部门
|
||
assert len(tree[0]["children"]) == 1
|
||
assert tree[0]["children"][0]["id"] == "dept_2"
|
||
|
||
def test_multiple_root_departments(self):
|
||
"""多个 parentid=0 的部门都作为根节点。"""
|
||
directory = []
|
||
dept_list = [
|
||
_dept(1, "公司A", 0),
|
||
_dept(2, "公司B", 0),
|
||
]
|
||
|
||
tree = build_org_tree(directory, dept_list)
|
||
|
||
assert len(tree) == 2
|
||
root_ids = {n["id"] for n in tree}
|
||
assert root_ids == {"dept_1", "dept_2"}
|
||
|
||
def test_parentid_1_fallback_when_no_parentid_0(self):
|
||
"""没有 parentid=0 时,parentid=1 的部门作为根(企微 dept 1 缺失场景)。"""
|
||
directory = [_emp("u1", "用户1", "研发", [2])]
|
||
# dept 1 不在列表中,dept 2 的 parentid=1
|
||
dept_list = [_dept(2, "研发部", 1)]
|
||
|
||
tree = build_org_tree(directory, dept_list)
|
||
|
||
assert len(tree) == 1
|
||
assert tree[0]["id"] == "dept_2"
|
||
|
||
# ----- 1.3 多部门员工 -----
|
||
|
||
def test_multi_dept_employee_appears_in_all_depts(self):
|
||
"""员工属于多个部门时,在每个部门下都出现。"""
|
||
directory = [_emp("zhangsan", "张三", "研发,测试", [2, 3])]
|
||
dept_list = [
|
||
_dept(1, "公司", 0),
|
||
_dept(2, "研发部", 1),
|
||
_dept(3, "测试部", 1),
|
||
]
|
||
|
||
tree = build_org_tree(directory, dept_list)
|
||
|
||
# 根节点是公司
|
||
assert len(tree) == 1
|
||
company = tree[0]
|
||
# 公司下有研发部和测试部
|
||
dept_nodes = {n["id"]: n for n in company["children"] if not n.get("isLeaf")}
|
||
assert "dept_2" in dept_nodes
|
||
assert "dept_3" in dept_nodes
|
||
|
||
# 研发部下有张三
|
||
dev_emps = [n for n in dept_nodes["dept_2"]["children"] if n.get("isLeaf")]
|
||
assert len(dev_emps) == 1
|
||
assert dev_emps[0]["id"] == "zhangsan"
|
||
|
||
# 测试部下也有张三
|
||
test_emps = [n for n in dept_nodes["dept_3"]["children"] if n.get("isLeaf")]
|
||
assert len(test_emps) == 1
|
||
assert test_emps[0]["id"] == "zhangsan"
|
||
|
||
# ----- 1.4 空部门(有部门节点但没有员工) -----
|
||
|
||
def test_empty_dept_still_appears(self):
|
||
"""有部门节点但没有员工的空部门仍出现在树中。"""
|
||
directory = [_emp("u1", "用户1", "研发", [2])]
|
||
dept_list = [
|
||
_dept(1, "公司", 0),
|
||
_dept(2, "研发部", 1),
|
||
_dept(3, "空部门", 1), # 没有员工
|
||
]
|
||
|
||
tree = build_org_tree(directory, dept_list)
|
||
|
||
company = tree[0]
|
||
dept_ids = {n["id"] for n in company["children"] if not n.get("isLeaf")}
|
||
# 空部门仍然出现
|
||
assert "dept_3" in dept_ids
|
||
|
||
def test_empty_dept_has_empty_children(self):
|
||
"""空部门的 children 为空列表。"""
|
||
directory = []
|
||
dept_list = [_dept(1, "公司", 0), _dept(2, "空部门", 1)]
|
||
|
||
tree = build_org_tree(directory, dept_list)
|
||
|
||
company = tree[0]
|
||
empty_dept = next(n for n in company["children"] if n["id"] == "dept_2")
|
||
assert empty_dept["children"] == []
|
||
|
||
# ----- 1.5 未分配部门员工 -----
|
||
|
||
def test_unassigned_employees_grouped(self):
|
||
"""没有 dept_ids 的员工归到"未分配部门"。"""
|
||
directory = [
|
||
_emp("u1", "用户1", "", []),
|
||
_emp("u2", "用户2", "某部门", []),
|
||
]
|
||
dept_list = [_dept(1, "公司", 0)]
|
||
|
||
tree = build_org_tree(directory, dept_list)
|
||
|
||
# 应该有公司 + 未分配部门
|
||
top_ids = {n["id"] for n in tree}
|
||
assert "dept_1" in top_ids
|
||
assert "dept_unassigned" in top_ids
|
||
|
||
unassigned = next(n for n in tree if n["id"] == "dept_unassigned")
|
||
assert unassigned["label"] == "未分配部门"
|
||
assert len(unassigned["children"]) == 2
|
||
|
||
# ----- 1.6 孤儿部门(parentid 不在 dept_map 中) -----
|
||
|
||
def test_orphan_dept_as_root(self):
|
||
"""parentid 指向不存在的部门时,该部门作为根节点。"""
|
||
directory = [_emp("u1", "用户1", "孤儿", [99])]
|
||
dept_list = [
|
||
_dept(1, "公司", 0),
|
||
_dept(99, "孤儿部门", 88), # parentid=88 不在 dept_map
|
||
]
|
||
|
||
tree = build_org_tree(directory, dept_list)
|
||
|
||
top_ids = {n["id"] for n in tree}
|
||
assert "dept_1" in top_ids
|
||
assert "dept_99" in top_ids # 孤儿部门作为根
|
||
|
||
# ----- 1.7 多层嵌套 -----
|
||
|
||
def test_nested_hierarchy_3_levels(self):
|
||
"""3 层嵌套:公司 → 研发部 → 前端组 → 员工。"""
|
||
directory = [_emp("u1", "前端工程师", "前端组", [3])]
|
||
dept_list = [
|
||
_dept(1, "公司", 0),
|
||
_dept(2, "研发部", 1),
|
||
_dept(3, "前端组", 2),
|
||
]
|
||
|
||
tree = build_org_tree(directory, dept_list)
|
||
|
||
assert len(tree) == 1
|
||
company = tree[0]
|
||
assert company["id"] == "dept_1"
|
||
assert len(company["children"]) == 1
|
||
|
||
dev = company["children"][0]
|
||
assert dev["id"] == "dept_2"
|
||
assert len(dev["children"]) == 1
|
||
|
||
fe = dev["children"][0]
|
||
assert fe["id"] == "dept_3"
|
||
assert len(fe["children"]) == 1
|
||
|
||
emp = fe["children"][0]
|
||
assert emp["id"] == "u1"
|
||
assert emp["isLeaf"] is True
|
||
|
||
def test_deep_nested_hierarchy_10_levels(self):
|
||
"""10 层嵌套不栈溢出。"""
|
||
directory = [_emp("u1", "深层员工", "深层", [10])]
|
||
dept_list = [_dept(i, f"部门{i}", i - 1) for i in range(1, 11)]
|
||
dept_list[0] = _dept(1, "部门1", 0) # 根
|
||
|
||
tree = build_org_tree(directory, dept_list)
|
||
|
||
# 递归到最深层
|
||
node = tree[0]
|
||
for i in range(1, 10):
|
||
assert node["id"] == f"dept_{i}"
|
||
dept_children = [c for c in node["children"] if not c.get("isLeaf")]
|
||
assert len(dept_children) == 1
|
||
node = dept_children[0]
|
||
# 第 10 层有员工
|
||
assert node["id"] == "dept_10"
|
||
emps = [c for c in node["children"] if c.get("isLeaf")]
|
||
assert len(emps) == 1
|
||
|
||
# ----- 1.8 空输入 -----
|
||
|
||
def test_empty_directory_and_empty_dept_list(self):
|
||
"""空 directory + 空 dept_list → 空树。"""
|
||
tree = build_org_tree([], [])
|
||
assert tree == []
|
||
|
||
def test_empty_directory_with_dept_list(self):
|
||
"""空 directory + 有 dept_list → 只有部门节点无员工。"""
|
||
dept_list = [_dept(1, "公司", 0)]
|
||
tree = build_org_tree([], dept_list)
|
||
assert len(tree) == 1
|
||
assert tree[0]["id"] == "dept_1"
|
||
assert tree[0]["children"] == []
|
||
|
||
def test_directory_with_empty_dept_list_falls_back_to_flat(self):
|
||
"""有 directory + 空 dept_list → 降级到 _build_flat_tree_by_name。"""
|
||
directory = [_emp("u1", "用户1", "研发部")]
|
||
tree = build_org_tree(directory, [])
|
||
# 降级路径使用 dept_name_ 前缀
|
||
assert len(tree) == 1
|
||
assert tree[0]["id"] == "dept_name_研发部"
|
||
|
||
# ----- 1.9 部门节点结构验证 -----
|
||
|
||
def test_dept_node_structure(self):
|
||
"""部门节点包含 id, label, dept_id, parentid, children 字段。"""
|
||
directory = [_emp("u1", "用户1", "研发", [2])]
|
||
dept_list = [_dept(1, "公司", 0), _dept(2, "研发部", 1)]
|
||
|
||
tree = build_org_tree(directory, dept_list)
|
||
|
||
company = tree[0]
|
||
assert company["id"] == "dept_1"
|
||
assert company["label"] == "公司"
|
||
assert company["dept_id"] == 1
|
||
assert company["parentid"] == 0
|
||
assert "children" in company
|
||
|
||
def test_employee_node_structure(self):
|
||
"""员工节点包含 id, label, isLeaf, department 字段。"""
|
||
directory = [_emp("u1", "用户1", "研发", [2])]
|
||
dept_list = [_dept(1, "公司", 0), _dept(2, "研发部", 1)]
|
||
|
||
tree = build_org_tree(directory, dept_list)
|
||
|
||
company = tree[0]
|
||
dev = company["children"][0]
|
||
emp = dev["children"][0]
|
||
assert emp["id"] == "u1"
|
||
assert emp["label"] == "用户1"
|
||
assert emp["isLeaf"] is True
|
||
assert emp["department"] == "研发"
|
||
|
||
# ----- 1.10 排序验证 -----
|
||
|
||
def test_departments_sorted_by_name(self):
|
||
"""子部门按名称排序(Python 默认 Unicode 码点序)。"""
|
||
directory = []
|
||
dept_list = [
|
||
_dept(1, "公司", 0),
|
||
_dept(4, "_Z部门", 1),
|
||
_dept(2, "A部门", 1),
|
||
_dept(3, "B部门", 1),
|
||
]
|
||
|
||
tree = build_org_tree(directory, dept_list)
|
||
|
||
company = tree[0]
|
||
dept_names = [n["label"] for n in company["children"] if not n.get("isLeaf")]
|
||
# Python 按 Unicode 码点排序:A(0x41) < B(0x42) < _(0x5F)
|
||
assert dept_names == ["A部门", "B部门", "_Z部门"]
|
||
|
||
def test_employees_sorted_by_name(self):
|
||
"""同一部门下员工按姓名排序(Python 默认 Unicode 码点序)。"""
|
||
directory = [
|
||
_emp("u3", "张三", "研发", [2]),
|
||
_emp("u1", "阿大", "研发", [2]),
|
||
_emp("u2", "李四", "研发", [2]),
|
||
]
|
||
dept_list = [_dept(1, "公司", 0), _dept(2, "研发部", 1)]
|
||
|
||
tree = build_org_tree(directory, dept_list)
|
||
|
||
company = tree[0]
|
||
dev = company["children"][0]
|
||
emp_names = [n["label"] for n in dev["children"] if n.get("isLeaf")]
|
||
# Python 按 Unicode 码点排序:张(U+5F20) < 李(U+674E) < 阿(U+963F)
|
||
assert emp_names == ["张三", "李四", "阿大"]
|
||
|
||
|
||
# =============================================================================
|
||
# 二、_build_flat_tree_by_name() 测试
|
||
# =============================================================================
|
||
|
||
class TestBuildFlatTreeByName:
|
||
"""_build_flat_tree_by_name 降级扁平分组测试。"""
|
||
|
||
def test_basic_grouping(self):
|
||
"""按部门名分组(Python 默认 Unicode 码点序排序)。"""
|
||
directory = [
|
||
_emp("u1", "张三", "研发部"),
|
||
_emp("u2", "李四", "研发部"),
|
||
_emp("u3", "王五", "测试部"),
|
||
]
|
||
tree = _build_flat_tree_by_name(directory)
|
||
|
||
assert len(tree) == 2
|
||
# Python 按 Unicode 码点排序:测(U+6D4B) < 研(U+7814)
|
||
assert tree[0]["label"] == "测试部"
|
||
assert tree[1]["label"] == "研发部"
|
||
assert len(tree[0]["children"]) == 1
|
||
assert len(tree[1]["children"]) == 2
|
||
|
||
def test_multi_dept_takes_first(self):
|
||
"""多部门员工取第一个部门名。"""
|
||
directory = [_emp("u1", "张三", "研发部,测试部")]
|
||
tree = _build_flat_tree_by_name(directory)
|
||
|
||
assert len(tree) == 1
|
||
assert tree[0]["label"] == "研发部"
|
||
|
||
def test_empty_dept_name_goes_to_unassigned(self):
|
||
"""空部门名归到"未分配部门"。"""
|
||
directory = [
|
||
_emp("u1", "张三", ""),
|
||
_emp("u2", "李四", None),
|
||
]
|
||
tree = _build_flat_tree_by_name(directory)
|
||
|
||
assert len(tree) == 1
|
||
assert tree[0]["label"] == "未分配部门"
|
||
assert len(tree[0]["children"]) == 2
|
||
|
||
def test_comma_only_dept_name_goes_to_unassigned(self):
|
||
"""部门名只有逗号时归到"未分配部门"。"""
|
||
directory = [_emp("u1", "张三", ",,")]
|
||
tree = _build_flat_tree_by_name(directory)
|
||
|
||
assert len(tree) == 1
|
||
assert tree[0]["label"] == "未分配部门"
|
||
|
||
def test_empty_directory(self):
|
||
"""空 directory → 空树。"""
|
||
tree = _build_flat_tree_by_name([])
|
||
assert tree == []
|
||
|
||
def test_flat_tree_node_structure(self):
|
||
"""扁平树节点结构正确。"""
|
||
directory = [_emp("u1", "张三", "研发部")]
|
||
tree = _build_flat_tree_by_name(directory)
|
||
|
||
dept_node = tree[0]
|
||
assert dept_node["id"] == "dept_name_研发部"
|
||
assert dept_node["label"] == "研发部"
|
||
assert dept_node["dept_id"] is None
|
||
assert dept_node["parentid"] == 0
|
||
|
||
emp_node = dept_node["children"][0]
|
||
assert emp_node["id"] == "u1"
|
||
assert emp_node["label"] == "张三"
|
||
assert emp_node["isLeaf"] is True
|
||
assert emp_node["department"] == "研发部"
|
||
|
||
|
||
# =============================================================================
|
||
# 三、filter_user_from_tree() 测试
|
||
# =============================================================================
|
||
|
||
class TestFilterUserFromTree:
|
||
"""filter_user_from_tree 递归过滤测试。"""
|
||
|
||
def _sample_tree(self):
|
||
"""构造测试用树:公司 → 研发部(张三,李四) + 测试部(王五)。"""
|
||
return [
|
||
{
|
||
"id": "dept_1",
|
||
"label": "公司",
|
||
"dept_id": 1,
|
||
"parentid": 0,
|
||
"children": [
|
||
{
|
||
"id": "dept_2",
|
||
"label": "研发部",
|
||
"dept_id": 2,
|
||
"parentid": 1,
|
||
"children": [
|
||
{"id": "zhangsan", "label": "张三", "isLeaf": True, "department": "研发部"},
|
||
{"id": "lisi", "label": "李四", "isLeaf": True, "department": "研发部"},
|
||
],
|
||
},
|
||
{
|
||
"id": "dept_3",
|
||
"label": "测试部",
|
||
"dept_id": 3,
|
||
"parentid": 1,
|
||
"children": [
|
||
{"id": "wangwu", "label": "王五", "isLeaf": True, "department": "测试部"},
|
||
],
|
||
},
|
||
],
|
||
}
|
||
]
|
||
|
||
def test_filter_single_user(self):
|
||
"""过滤单个用户,其他用户保留。"""
|
||
tree = self._sample_tree()
|
||
result = filter_user_from_tree(tree, "zhangsan")
|
||
|
||
# 收集所有员工 id
|
||
def get_emp_ids(nodes):
|
||
ids = set()
|
||
for n in nodes:
|
||
if n.get("isLeaf"):
|
||
ids.add(n["id"])
|
||
elif "children" in n:
|
||
ids.update(get_emp_ids(n["children"]))
|
||
return ids
|
||
|
||
emp_ids = get_emp_ids(result)
|
||
assert "zhangsan" not in emp_ids
|
||
assert "lisi" in emp_ids
|
||
assert "wangwu" in emp_ids
|
||
|
||
def test_filter_user_empty_dept_removed(self):
|
||
"""过滤后变空的部门节点被移除。"""
|
||
tree = [
|
||
{
|
||
"id": "dept_1",
|
||
"label": "公司",
|
||
"dept_id": 1,
|
||
"parentid": 0,
|
||
"children": [
|
||
{
|
||
"id": "dept_2",
|
||
"label": "研发部",
|
||
"dept_id": 2,
|
||
"parentid": 1,
|
||
"children": [
|
||
{"id": "zhangsan", "label": "张三", "isLeaf": True, "department": "研发部"},
|
||
],
|
||
},
|
||
{
|
||
"id": "dept_3",
|
||
"label": "测试部",
|
||
"dept_id": 3,
|
||
"parentid": 1,
|
||
"children": [
|
||
{"id": "lisi", "label": "李四", "isLeaf": True, "department": "测试部"},
|
||
{"id": "wangwu", "label": "王五", "isLeaf": True, "department": "测试部"},
|
||
],
|
||
},
|
||
],
|
||
}
|
||
]
|
||
|
||
# 过滤张三 → 研发部变空 → 研发部被移除
|
||
result = filter_user_from_tree(tree, "zhangsan")
|
||
|
||
assert len(result) == 1
|
||
company = result[0]
|
||
dept_ids = [n["id"] for n in company["children"] if not n.get("isLeaf")]
|
||
assert "dept_2" not in dept_ids # 研发部被移除
|
||
assert "dept_3" in dept_ids # 测试部保留
|
||
|
||
def test_filter_user_not_in_tree(self):
|
||
"""过滤不存在的用户,树不变。"""
|
||
tree = self._sample_tree()
|
||
result = filter_user_from_tree(tree, "nonexistent")
|
||
|
||
assert len(result) == len(tree)
|
||
# 树结构应与原始相同(所有员工都在)
|
||
def count_emps(nodes):
|
||
c = 0
|
||
for n in nodes:
|
||
if n.get("isLeaf"):
|
||
c += 1
|
||
elif "children" in n:
|
||
c += count_emps(n["children"])
|
||
return c
|
||
|
||
assert count_emps(result) == 3
|
||
|
||
def test_filter_empty_tree(self):
|
||
"""空树过滤返回空列表。"""
|
||
result = filter_user_from_tree([], "anyone")
|
||
assert result == []
|
||
|
||
def test_filter_user_in_multiple_depts(self):
|
||
"""用户出现在多个部门时,所有实例都被移除。"""
|
||
tree = [
|
||
{
|
||
"id": "dept_1",
|
||
"label": "公司",
|
||
"dept_id": 1,
|
||
"parentid": 0,
|
||
"children": [
|
||
{
|
||
"id": "dept_2",
|
||
"label": "研发部",
|
||
"dept_id": 2,
|
||
"parentid": 1,
|
||
"children": [
|
||
{"id": "zhangsan", "label": "张三", "isLeaf": True, "department": "研发"},
|
||
{"id": "lisi", "label": "李四", "isLeaf": True, "department": "研发"},
|
||
],
|
||
},
|
||
{
|
||
"id": "dept_3",
|
||
"label": "测试部",
|
||
"dept_id": 3,
|
||
"parentid": 1,
|
||
"children": [
|
||
{"id": "zhangsan", "label": "张三", "isLeaf": True, "department": "测试"},
|
||
],
|
||
},
|
||
],
|
||
}
|
||
]
|
||
|
||
result = filter_user_from_tree(tree, "zhangsan")
|
||
|
||
def get_emp_ids(nodes):
|
||
ids = set()
|
||
for n in nodes:
|
||
if n.get("isLeaf"):
|
||
ids.add(n["id"])
|
||
elif "children" in n:
|
||
ids.update(get_emp_ids(n["children"]))
|
||
return ids
|
||
|
||
emp_ids = get_emp_ids(result)
|
||
assert "zhangsan" not in emp_ids
|
||
assert "lisi" in emp_ids # 李四保留
|
||
|
||
def test_filter_all_users_empties_tree(self):
|
||
"""过滤所有用户后,整个树变空。"""
|
||
tree = self._sample_tree()
|
||
# 依次过滤所有 3 个用户
|
||
result = tree
|
||
for uid in ["zhangsan", "lisi", "wangwu"]:
|
||
result = filter_user_from_tree(result, uid)
|
||
|
||
assert result == []
|
||
|
||
def test_filter_preserves_dept_node_fields(self):
|
||
"""过滤后部门节点保留原有字段(dept_id, parentid 等)。"""
|
||
tree = self._sample_tree()
|
||
result = filter_user_from_tree(tree, "zhangsan")
|
||
|
||
company = result[0]
|
||
assert company["dept_id"] == 1
|
||
assert company["parentid"] == 0
|
||
assert company["label"] == "公司"
|
||
|
||
def test_filter_does_not_mutate_original(self):
|
||
"""过滤不修改原始树(返回新对象)。"""
|
||
tree = self._sample_tree()
|
||
original_count = count_tree_employees(tree)
|
||
filter_user_from_tree(tree, "zhangsan")
|
||
# 原始树不受影响
|
||
assert count_tree_employees(tree) == original_count
|
||
|
||
|
||
# =============================================================================
|
||
# 四、count_tree_employees() 测试
|
||
# =============================================================================
|
||
|
||
class TestCountTreeEmployees:
|
||
"""count_tree_employees 递归统计测试。"""
|
||
|
||
def test_count_flat_tree(self):
|
||
"""扁平树统计员工数。"""
|
||
tree = [
|
||
{"id": "u1", "label": "张三", "isLeaf": True, "department": ""},
|
||
{"id": "u2", "label": "李四", "isLeaf": True, "department": ""},
|
||
]
|
||
assert count_tree_employees(tree) == 2
|
||
|
||
def test_count_nested_tree(self):
|
||
"""嵌套树统计员工数。"""
|
||
tree = [
|
||
{
|
||
"id": "dept_1",
|
||
"label": "公司",
|
||
"children": [
|
||
{
|
||
"id": "dept_2",
|
||
"label": "研发部",
|
||
"children": [
|
||
{"id": "u1", "label": "张三", "isLeaf": True},
|
||
{"id": "u2", "label": "李四", "isLeaf": True},
|
||
],
|
||
},
|
||
{"id": "u3", "label": "王五", "isLeaf": True},
|
||
],
|
||
}
|
||
]
|
||
assert count_tree_employees(tree) == 3
|
||
|
||
def test_count_empty_tree(self):
|
||
"""空树员工数为 0。"""
|
||
assert count_tree_employees([]) == 0
|
||
|
||
def test_count_only_dept_nodes(self):
|
||
"""只有部门节点无员工时为 0。"""
|
||
tree = [
|
||
{
|
||
"id": "dept_1",
|
||
"label": "公司",
|
||
"children": [
|
||
{"id": "dept_2", "label": "研发部", "children": []},
|
||
],
|
||
}
|
||
]
|
||
assert count_tree_employees(tree) == 0
|
||
|
||
def test_count_mixed_nodes(self):
|
||
"""混合节点(部门+员工)正确统计。"""
|
||
tree = [
|
||
{
|
||
"id": "dept_1",
|
||
"label": "公司",
|
||
"children": [
|
||
{"id": "u1", "label": "张三", "isLeaf": True},
|
||
{
|
||
"id": "dept_2",
|
||
"label": "研发部",
|
||
"children": [
|
||
{"id": "u2", "label": "李四", "isLeaf": True},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{"id": "u3", "label": "王五", "isLeaf": True},
|
||
]
|
||
assert count_tree_employees(tree) == 3
|
||
|
||
|
||
# =============================================================================
|
||
# 五、get_org_tree_cached() 测试(mock 依赖)
|
||
# =============================================================================
|
||
|
||
class TestGetOrgTreeCached:
|
||
"""get_org_tree_cached 缓存与过滤测试。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_cache_key_per_endpoint(self):
|
||
"""agent 和 h5 端点使用不同的缓存 key。"""
|
||
from tests.conftest import MockRedis
|
||
|
||
mock_redis = MockRedis()
|
||
directory = [_emp("u1", "张三", "研发", [2])]
|
||
dept_list = [_dept(1, "公司", 0), _dept(2, "研发部", 1)]
|
||
|
||
with patch(
|
||
"app.services.employee_directory.get_org_directory",
|
||
new_callable=AsyncMock,
|
||
return_value=(directory, True),
|
||
):
|
||
with patch(
|
||
"app.services.employee_directory.get_cached_dept_list",
|
||
new_callable=AsyncMock,
|
||
return_value=dept_list,
|
||
):
|
||
# 调用 agent 端点
|
||
await get_org_tree_cached(None, mock_redis, "agent", "nobody")
|
||
# 调用 h5 端点
|
||
await get_org_tree_cached(None, mock_redis, "h5", "nobody")
|
||
|
||
# 验证两个不同的缓存 key
|
||
assert f"{ORG_TREE_CACHE_KEY_PREFIX}:agent" in mock_redis._data
|
||
assert f"{ORG_TREE_CACHE_KEY_PREFIX}:h5" in mock_redis._data
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_user_exclusion_from_cached_tree(self):
|
||
"""缓存包含全量员工,读取后过滤当前用户。"""
|
||
from tests.conftest import MockRedis
|
||
|
||
mock_redis = MockRedis()
|
||
directory = [
|
||
_emp("u1", "张三", "研发", [2]),
|
||
_emp("u2", "李四", "研发", [2]),
|
||
_emp("current_user", "当前用户", "研发", [2]),
|
||
]
|
||
dept_list = [_dept(1, "公司", 0), _dept(2, "研发部", 1)]
|
||
|
||
with patch(
|
||
"app.services.employee_directory.get_org_directory",
|
||
new_callable=AsyncMock,
|
||
return_value=(directory, True),
|
||
):
|
||
with patch(
|
||
"app.services.employee_directory.get_cached_dept_list",
|
||
new_callable=AsyncMock,
|
||
return_value=dept_list,
|
||
):
|
||
# 第一次调用:构建树并缓存
|
||
tree1 = await get_org_tree_cached(None, mock_redis, "agent", "current_user")
|
||
# 当前用户应被排除
|
||
assert count_tree_employees(tree1) == 2 # 张三 + 李四
|
||
|
||
def get_emp_ids(nodes):
|
||
ids = set()
|
||
for n in nodes:
|
||
if n.get("isLeaf"):
|
||
ids.add(n["id"])
|
||
elif "children" in n:
|
||
ids.update(get_emp_ids(n["children"]))
|
||
return ids
|
||
|
||
assert "current_user" not in get_emp_ids(tree1)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_cache_hit_skips_rebuild(self):
|
||
"""缓存命中时不重新构建树。"""
|
||
from tests.conftest import MockRedis
|
||
|
||
mock_redis = MockRedis()
|
||
# 预填充缓存
|
||
cached_tree = [
|
||
{
|
||
"id": "dept_1",
|
||
"label": "公司",
|
||
"dept_id": 1,
|
||
"parentid": 0,
|
||
"children": [
|
||
{"id": "u1", "label": "张三", "isLeaf": True, "department": ""},
|
||
{"id": "u2", "label": "李四", "isLeaf": True, "department": ""},
|
||
],
|
||
}
|
||
]
|
||
await mock_redis.setex(
|
||
f"{ORG_TREE_CACHE_KEY_PREFIX}:agent",
|
||
1800,
|
||
json.dumps(cached_tree, ensure_ascii=False),
|
||
)
|
||
|
||
# mock get_org_directory — 如果被调用说明缓存未命中
|
||
mock_get_dir = AsyncMock(return_value=([], True))
|
||
mock_get_dept = AsyncMock(return_value=[])
|
||
|
||
with patch("app.services.employee_directory.get_org_directory", mock_get_dir):
|
||
with patch("app.services.employee_directory.get_cached_dept_list", mock_get_dept):
|
||
tree = await get_org_tree_cached(None, mock_redis, "agent", "u1")
|
||
|
||
# 缓存命中,不应调用 get_org_directory
|
||
mock_get_dir.assert_not_called()
|
||
mock_get_dept.assert_not_called()
|
||
|
||
# u1 被过滤
|
||
assert count_tree_employees(tree) == 1
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_cache_miss_builds_and_caches(self):
|
||
"""缓存未命中时构建树并写入缓存。"""
|
||
from tests.conftest import MockRedis
|
||
|
||
mock_redis = MockRedis()
|
||
directory = [_emp("u1", "张三", "研发", [2])]
|
||
dept_list = [_dept(1, "公司", 0), _dept(2, "研发部", 1)]
|
||
|
||
with patch(
|
||
"app.services.employee_directory.get_org_directory",
|
||
new_callable=AsyncMock,
|
||
return_value=(directory, True),
|
||
):
|
||
with patch(
|
||
"app.services.employee_directory.get_cached_dept_list",
|
||
new_callable=AsyncMock,
|
||
return_value=dept_list,
|
||
):
|
||
tree = await get_org_tree_cached(None, mock_redis, "agent", "nobody")
|
||
|
||
# 树已构建
|
||
assert len(tree) == 1
|
||
# 缓存已写入
|
||
assert f"{ORG_TREE_CACHE_KEY_PREFIX}:agent" in mock_redis._data
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_no_redis_builds_tree_without_cache(self):
|
||
"""redis=None 时仍能构建树(不读写缓存)。"""
|
||
directory = [_emp("u1", "张三", "研发", [2])]
|
||
dept_list = [_dept(1, "公司", 0), _dept(2, "研发部", 1)]
|
||
|
||
with patch(
|
||
"app.services.employee_directory.get_org_directory",
|
||
new_callable=AsyncMock,
|
||
return_value=(directory, True),
|
||
):
|
||
with patch(
|
||
"app.services.employee_directory.get_cached_dept_list",
|
||
new_callable=AsyncMock,
|
||
return_value=dept_list,
|
||
):
|
||
tree = await get_org_tree_cached(None, None, "agent", "nobody")
|
||
|
||
assert len(tree) == 1
|
||
assert count_tree_employees(tree) == 1
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_degraded_flat_tree_when_no_dept_list(self):
|
||
"""dept_list 为空时降级到扁平树。"""
|
||
from tests.conftest import MockRedis
|
||
|
||
mock_redis = MockRedis()
|
||
directory = [_emp("u1", "张三", "研发部")]
|
||
|
||
with patch(
|
||
"app.services.employee_directory.get_org_directory",
|
||
new_callable=AsyncMock,
|
||
return_value=(directory, True),
|
||
):
|
||
with patch(
|
||
"app.services.employee_directory.get_cached_dept_list",
|
||
new_callable=AsyncMock,
|
||
return_value=[], # 空部门列表
|
||
):
|
||
tree = await get_org_tree_cached(None, mock_redis, "agent", "nobody")
|
||
|
||
# 降级路径:dept_name_ 前缀
|
||
assert len(tree) == 1
|
||
assert tree[0]["id"] == "dept_name_研发部"
|
||
|
||
|
||
# =============================================================================
|
||
# 六、兼容性测试:resolve_target 与搜索接口不受 dept_ids 影响
|
||
# =============================================================================
|
||
|
||
class TestCompatibility:
|
||
"""验证新增 dept_ids 字段不影响现有功能。"""
|
||
|
||
def test_directory_entry_has_dept_ids_field(self):
|
||
"""验证 directory 条目结构包含 dept_ids 字段(企微路径)。
|
||
|
||
这不是直接测试 get_org_directory(需要 mock 企微 API),
|
||
而是验证 build_org_tree 对 dept_ids 字段的消费方式正确。
|
||
"""
|
||
# 模拟 get_org_directory 返回的数据结构
|
||
directory = [
|
||
{
|
||
"employee_id": "u1",
|
||
"name": "张三",
|
||
"department": "研发部",
|
||
"dept_ids": [2],
|
||
}
|
||
]
|
||
dept_list = [_dept(1, "公司", 0), _dept(2, "研发部", 1)]
|
||
|
||
tree = build_org_tree(directory, dept_list)
|
||
|
||
# 员工正确出现在部门下
|
||
assert count_tree_employees(tree) == 1
|
||
|
||
def test_directory_entry_without_dept_ids(self):
|
||
"""dept_ids 字段缺失时(旧缓存兼容),build_org_tree 不报错。"""
|
||
directory = [
|
||
{
|
||
"employee_id": "u1",
|
||
"name": "张三",
|
||
"department": "研发部",
|
||
# dept_ids 缺失
|
||
}
|
||
]
|
||
dept_list = [_dept(1, "公司", 0), _dept(2, "研发部", 1)]
|
||
|
||
tree = build_org_tree(directory, dept_list)
|
||
|
||
# dept_ids 缺失 → emp.get("dept_ids") or [] → [] → 归入未分配部门
|
||
top_ids = {n["id"] for n in tree}
|
||
assert "dept_unassigned" in top_ids
|
||
|
||
def test_employee_with_unknown_dept_id(self):
|
||
"""员工 dept_id 不在 dept_list 中时的行为验证。
|
||
|
||
已知边界情况:员工 dept_id 不在 dept_map 中时,
|
||
员工会被分配到 dept_employees[unknown_id] 但不会被任何 build_dept_node 处理,
|
||
导致该员工从树中"消失"。
|
||
|
||
本测试验证此行为并记录为已知边界情况。
|
||
"""
|
||
directory = [_emp("u1", "张三", "某部门", [99])] # dept 99 不在 dept_list
|
||
dept_list = [_dept(1, "公司", 0), _dept(2, "研发部", 1)]
|
||
|
||
tree = build_org_tree(directory, dept_list)
|
||
|
||
# 已知边界情况:员工 u1 不在树中(dept 99 不在 dept_map)
|
||
# 理想行为应该是归入"未分配部门",但当前实现会丢失该员工
|
||
assert count_tree_employees(tree) == 0
|
||
# 记录此行为 — 见测试报告中的 Known Issues
|