Files
wecom_it_smart_desk/backend/tests/test_org_tree.py
T

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