"""system/menu service 单元测试(async 路径,回归删除父级时禁止有子级)。 测试环境无 Redis:monkeypatch 掉 invalidate_menu_change,避免 get_redis() 抛异常。 """ import json import pytest from modules.system.menu import service as menu_service from modules.system.menu.models import Menu from modules.system.menu.repository import menu_repo from modules.system.menu.schemas import MenuForm @pytest.fixture(autouse=True) def _stub_perm_cache(monkeypatch): async def _noop(): return None monkeypatch.setattr(menu_service, "invalidate_menu_change", _noop) def _resp_body(resp): return json.loads(resp.body) def _form(**overrides): data = dict( name="系统管理", icon=None, path="/system", component=None, parentId=0, type=0, target=0, permission=None, hide=0, status=1, sort=1, note=None, ) data.update(overrides) return MenuForm(**data) @pytest.mark.asyncio async def test_delete_parent_with_child_blocked(fake_request, sqlite_db): await menu_service.add_menu(fake_request, _form()) parent = menu_repo.filter(Menu.name == "系统管理").first() await menu_service.add_menu( fake_request, _form(name="用户管理", path="/system/user", parentId=parent.id), ) child = menu_repo.filter(Menu.name == "用户管理").first() resp = await menu_service.delete_menu(str(parent.id)) body = _resp_body(resp) assert body["code"] != 0 assert "下级菜单" in body["msg"] assert menu_repo.get_by_id(parent.id).is_delete == 0 assert menu_repo.get_by_id(child.id).is_delete == 0 @pytest.mark.asyncio async def test_delete_leaf_soft_deletes(fake_request, sqlite_db): await menu_service.add_menu(fake_request, _form(name="单菜单", path="/single")) leaf = menu_repo.filter(Menu.name == "单菜单").first() resp = await menu_service.delete_menu(str(leaf.id)) assert _resp_body(resp)["code"] == 0 assert menu_repo.filter(Menu.id == leaf.id).first().is_delete == 1 # ============ 菜单树成环检测:update 的 parent_id 存在性 + 自身/下级 校验 ============ @pytest.mark.asyncio async def test_update_menu_rejects_missing_parent(fake_request, sqlite_db): await menu_service.add_menu(fake_request, _form(name="系统管理", path="/system")) menu = menu_repo.filter(Menu.name == "系统管理").first() resp = await menu_service.update_menu( fake_request, _form(name="系统管理", path="/system", id=menu.id, parentId=99999)) body = _resp_body(resp) assert body["code"] != 0 assert "上级菜单不存在" in body["msg"] @pytest.mark.asyncio async def test_update_menu_rejects_self_parent(fake_request, sqlite_db): await menu_service.add_menu(fake_request, _form(name="系统管理", path="/system")) menu = menu_repo.filter(Menu.name == "系统管理").first() resp = await menu_service.update_menu( fake_request, _form(name="系统管理", path="/system", id=menu.id, parentId=menu.id)) body = _resp_body(resp) assert body["code"] != 0 assert "自身" in body["msg"] # 原记录未被改动 assert menu_repo.get_by_id(menu.id).parent_id == 0 @pytest.mark.asyncio async def test_update_menu_rejects_descendant_parent(fake_request, sqlite_db): await menu_service.add_menu(fake_request, _form(name="系统管理", path="/system")) parent = menu_repo.filter(Menu.name == "系统管理").first() await menu_service.add_menu( fake_request, _form(name="用户管理", path="/system/user", parentId=parent.id)) child = menu_repo.filter(Menu.name == "用户管理").first() # 把父级设为自己的子级 → 成环,拒绝 resp = await menu_service.update_menu( fake_request, _form(name="系统管理", path="/system", id=parent.id, parentId=child.id)) body = _resp_body(resp) assert body["code"] != 0 assert "下级" in body["msg"] assert menu_repo.get_by_id(parent.id).parent_id == 0 # ============ 多角色重复授权:权限列表 / 菜单树 distinct 去重 ============ def _seed_user_with_two_roles_same_menu(sqlite_db): """造一个同时属于两个角色、且两角色均授予同一权限节点/菜单的用户。 返回的用户 id 必不为 1(先占位超管),否则会走超级管理员全量放行分支。 """ from modules.system.role.models import Role from modules.system.role_menu.models import RoleMenu from modules.system.user.models import User from modules.system.user_role.models import UserRole User(realname='超管', nickname='admin', avatar='', mobile='', email='', province_code='', city_code='', district_code='', street_code='', address='', username='admin', password='', status=1).save() user = User(realname='张三', nickname='zhangsan', avatar='', mobile='', email='', province_code='', city_code='', district_code='', street_code='', address='', username='zhangsan', password='', status=1) user.save() r1 = Role(name='角色一', code='R1', status=1, sort=1); r1.save() r2 = Role(name='角色二', code='R2', status=1, sort=1); r2.save() perm = Menu(name='添加用户', parent_id=0, path='/system/user/add', type=1, permission='sys:user:add', status=0, sort=1) perm.save() menu = Menu(name='用户管理', parent_id=0, path='/system/user', component='system/user/index', type=0, status=0, hide=0, sort=1) menu.save() # 两个角色授予同一权限节点与同一菜单 → 多角色 join 会产生重复行 for role in (r1, r2): RoleMenu(role_id=role.id, menu_id=perm.id).save() RoleMenu(role_id=role.id, menu_id=menu.id).save() UserRole(user_id=user.id, role_id=role.id).save() sqlite_db.commit() return user def test_permissions_list_dedup_for_multi_role(sqlite_db): user = _seed_user_with_two_roles_same_menu(sqlite_db) perms = menu_service.get_permissions_list_sync(user.id) # 同一权限节点经两个角色授权只出现一次 assert perms.count('sys:user:add') == 1 assert len(perms) == len(set(perms)) def test_menu_list_dedup_for_multi_role(sqlite_db): user = _seed_user_with_two_roles_same_menu(sqlite_db) tree = menu_service.get_permission_menu_list_sync(user.id) # 菜单树无重复节点 assert len(tree) == 1 assert tree[0]['name'] == '用户管理'