Files
wecom_it_smart_desk/backend/tests/test_org_tree.py
T

986 lines
36 KiB
Python
Raw Normal View History

# =============================================================================
# 组织架构树构建与过滤 — 单元测试
# =============================================================================
# 测试覆盖:
# 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