mine = scope.schoolGroupIds();
+ if (requestedSchoolId != null) {
+ if (!mine.contains(requestedSchoolId)) {
+ throw new DException("暂无权限访问该组织");
+ }
+ return requestedSchoolId;
+ }
+ if (mine.size() != 1) {
+ throw new DException("请选择要管理的学校");
+ }
+ return mine.get(0);
+ }
+ requireSchoolInScope(requestedSchoolId);
+ return requestedSchoolId;
+ }
+
/**
* 目标用户的归属组必须与当前用户数据范围有交集(SUPER 不限制)。
* 用于禁用/启用等「入参只有 userId」的接口,防止跨校操作他人账号;无任何组关系的用户视为越界。
diff --git a/src/test/java/com/yunhai/userbase/service/impl/DepartmentDirectoryServiceTest.java b/src/test/java/com/yunhai/userbase/service/impl/DepartmentDirectoryServiceTest.java
new file mode 100644
index 0000000..abeda3f
--- /dev/null
+++ b/src/test/java/com/yunhai/userbase/service/impl/DepartmentDirectoryServiceTest.java
@@ -0,0 +1,436 @@
+package com.yunhai.userbase.service.impl;
+
+import com.yunhai.platform.utils.exception.DException;
+import com.yunhai.platform.utils.model.BoaUser;
+import com.yunhai.userbase.entity.SysGroup;
+import com.yunhai.userbase.entity.SysUser;
+import com.yunhai.userbase.entity.SysUserGroupRel;
+import com.yunhai.userbase.entity.UbCustomDeptCategory;
+import com.yunhai.userbase.entity.UbCustomDepartment;
+import com.yunhai.userbase.entity.UbCustomDepartmentMember;
+import com.yunhai.userbase.enums.DepartmentTypeEnum;
+import com.yunhai.userbase.enums.GroupTypeEnum;
+import com.yunhai.userbase.enums.StatusEnum;
+import com.yunhai.userbase.model.dept.DepartmentEntryVo;
+import com.yunhai.userbase.model.dept.DepartmentRefRequest;
+import com.yunhai.userbase.repository.SysGroupAppRelRepository;
+import com.yunhai.userbase.repository.SysGroupRepository;
+import com.yunhai.userbase.repository.SysUserGroupRelRepository;
+import com.yunhai.userbase.repository.SysUserRepository;
+import com.yunhai.userbase.repository.UbCustomDeptCategoryRepository;
+import com.yunhai.userbase.repository.UbCustomDepartmentMemberRepository;
+import com.yunhai.userbase.repository.UbCustomDepartmentRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.Mockito.when;
+
+/**
+ * {@code DepartmentDirectoryService}(2026-10-21 T2 通用部门目录契约)单元测试。
+ * 覆盖验收映射:A15(仅验签≠可读任意学校)、A10/A11 解析侧(停用条目不进气选目录、历史解析仍可见)、
+ * A12(软删部门只经 resolve-batch 返回)、A06/A19(同名不靠名称回填、别校引用解析不到)、
+ * 以及 SYSTEM 部门口径=教师组直接子节点(含 withChildren 与下级 parentId)。
+ *
+ * 夹具组树:5=云海小学 / 6=云海中学;5 下 7=教师组、8=学生组;
+ * 教师组下 20=校长室(启用)、22=教务处(停用);20 下 21=校办;
+ * 6 下 9=教师组、40=别校校长室。CUSTOM:分类 10 下 100 启用 / 101 停用 / 102 已软删。
+ */
+class DepartmentDirectoryServiceTest {
+
+ private static final int APP = 3;
+ private static final int SCHOOL_A = 5;
+ private static final int SCHOOL_B = 6;
+ private static final int CATEGORY = 10;
+
+ @InjectMocks
+ private DepartmentDirectoryService directoryService;
+
+ @Mock
+ private UbCustomDeptCategoryRepository categoryRepository;
+
+ @Mock
+ private UbCustomDepartmentRepository departmentRepository;
+
+ @Mock
+ private UbCustomDepartmentMemberRepository memberRepository;
+
+ @Mock
+ private SysGroupRepository sysGroupRepository;
+
+ @Mock
+ private SysUserGroupRelRepository sysUserGroupRelRepository;
+
+ @Mock
+ private SysUserRepository sysUserRepository;
+
+ @Mock
+ private SysGroupAppRelRepository sysGroupAppRelRepository;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.initMocks(this);
+ when(sysGroupRepository.findAll()).thenReturn(Arrays.asList(
+ group(SCHOOL_A, GroupTypeEnum.SCHOOL_GROUP, 0, 0),
+ group(SCHOOL_B, GroupTypeEnum.SCHOOL_GROUP, 0, 0),
+ group(7, GroupTypeEnum.TEACHER_GROUP, SCHOOL_A, 0),
+ group(8, GroupTypeEnum.STUDENT_GROUP, SCHOOL_A, 0),
+ group(20, GroupTypeEnum.NORMAL_GROUP, 7, 0),
+ group(21, GroupTypeEnum.NORMAL_GROUP, 20, 0),
+ group(22, GroupTypeEnum.NORMAL_GROUP, 7, 1),
+ group(30, GroupTypeEnum.GRADE_GROUP, 8, 0),
+ group(9, GroupTypeEnum.TEACHER_GROUP, SCHOOL_B, 0),
+ group(40, GroupTypeEnum.NORMAL_GROUP, 9, 0)
+ ));
+ when(sysGroupRepository.findById(SCHOOL_A)).thenReturn(Optional.of(group(SCHOOL_A, GroupTypeEnum.SCHOOL_GROUP, 0, 0)));
+ when(sysGroupRepository.findById(SCHOOL_B)).thenReturn(Optional.of(group(SCHOOL_B, GroupTypeEnum.SCHOOL_GROUP, 0, 0)));
+ // 只有小学开通了本应用
+ when(sysGroupAppRelRepository.existsByGroupIdAndAppId(SCHOOL_A, APP)).thenReturn(true);
+ when(sysGroupAppRelRepository.existsByGroupIdAndAppId(SCHOOL_B, APP)).thenReturn(false);
+ when(sysUserGroupRelRepository.countByGroupId(org.mockito.ArgumentMatchers.anyInt())).thenReturn(1L);
+ stubCustom();
+ }
+
+ private SysGroup group(Integer id, GroupTypeEnum type, Integer parentId, Integer status) {
+ SysGroup g = new SysGroup();
+ g.setId(id);
+ g.setType(type);
+ g.setParentId(parentId);
+ g.setName("组" + id);
+ g.setStatus(status);
+ return g;
+ }
+
+ private UbCustomDepartment custom(Integer id, Integer categoryId, String name, Integer enabled, Integer isDeleted) {
+ UbCustomDepartment d = new UbCustomDepartment();
+ d.setId(id);
+ d.setSchoolId(SCHOOL_A);
+ d.setCategoryId(categoryId);
+ d.setName(name);
+ d.setEnabled(enabled);
+ d.setIsDeleted(isDeleted);
+ d.setDelMarker(isDeleted == 1 ? id.longValue() : 0L);
+ d.setSortNo(1);
+ return d;
+ }
+
+ private UbCustomDeptCategory liveCategory() {
+ UbCustomDeptCategory c = new UbCustomDeptCategory();
+ c.setId(CATEGORY);
+ c.setSchoolId(SCHOOL_A);
+ c.setName("审核部门");
+ c.setSortNo(1);
+ c.setIsDeleted(0);
+ c.setDelMarker(0L);
+ return c;
+ }
+
+ private void stubCustom() {
+ when(categoryRepository.findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(SCHOOL_A, 0))
+ .thenReturn(Collections.singletonList(liveCategory()));
+ when(departmentRepository.findByCategoryIdAndIsDeletedOrderBySortNoAscIdAsc(CATEGORY, 0))
+ .thenReturn(Arrays.asList(custom(100, CATEGORY, "校长室", 1, 0), custom(101, CATEGORY, "教务处", 0, 0)));
+ when(departmentRepository.findById(100)).thenReturn(Optional.of(custom(100, CATEGORY, "校长室", 1, 0)));
+ when(departmentRepository.findById(101)).thenReturn(Optional.of(custom(101, CATEGORY, "教务处", 0, 0)));
+ when(departmentRepository.findById(102)).thenReturn(Optional.of(custom(102, CATEGORY, "已撤并部门", 1, 1)));
+ when(categoryRepository.findById(CATEGORY)).thenReturn(Optional.of(liveCategory()));
+ when(memberRepository.findByDepartmentIdIn(anyList())).thenReturn(Collections.emptyList());
+ when(memberRepository.countByDepartmentId(org.mockito.ArgumentMatchers.anyInt())).thenReturn(0L);
+ }
+
+ // ===================== A15:应用 × 学校 =====================
+
+ /** 仅验签不等于可读任意学校:未在该校开通的应用读目录直接拒绝 */
+ @Test
+ void listRejectsAppNotOpenedAtThatSchool() {
+ DException error = assertThrows(DException.class,
+ () -> directoryService.list(APP, SCHOOL_B, DepartmentTypeEnum.SYSTEM, null, false));
+ assertTrue(error.getMessage().contains("未在本校开通"));
+ }
+
+ @Test
+ void listRejectsUnresolvableCallerApp() {
+ DException error = assertThrows(DException.class,
+ () -> directoryService.list(null, SCHOOL_A, null, null, false));
+ assertTrue(error.getMessage().contains("无法识别调用方应用"));
+ }
+
+ @Test
+ void categoriesAndMembersAlsoCheckSchoolVisibility() {
+ assertThrows(DException.class, () -> directoryService.categories(APP, SCHOOL_B));
+ // SYSTEM 组 40 属于云海中学,本应用未开通该校
+ when(sysGroupRepository.findById(40)).thenReturn(Optional.of(group(40, GroupTypeEnum.NORMAL_GROUP, 9, 0)));
+ assertThrows(DException.class,
+ () -> directoryService.members(APP, DepartmentTypeEnum.SYSTEM, 40, false));
+ }
+
+ @Test
+ void listRejectsSchoolIdThatIsNotASchoolGroup() {
+ when(sysGroupRepository.findById(7)).thenReturn(Optional.of(group(7, GroupTypeEnum.TEACHER_GROUP, SCHOOL_A, 0)));
+ DException error = assertThrows(DException.class,
+ () -> directoryService.list(APP, 7, null, null, false));
+ assertTrue(error.getMessage().contains("不是学校组"));
+ }
+
+ // ===================== 目录过滤 =====================
+
+ /** SYSTEM 部门口径=教师组直接子节点;停用不进可选目录;学生组下的节点不算部门 */
+ @Test
+ void listSystemReturnsOnlyEnabledTeacherGroupChildren() {
+ List entries = directoryService.list(APP, SCHOOL_A, DepartmentTypeEnum.SYSTEM, null, false);
+
+ assertEquals(Collections.singletonList(20), ids(entries));
+ DepartmentEntryVo first = entries.get(0);
+ assertEquals(SCHOOL_A, first.getSchoolId());
+ assertEquals("SYSTEM", first.getDepartmentType());
+ assertEquals(Boolean.TRUE, first.getSelectable());
+ assertNull(first.getCategoryId(), "SYSTEM 条目不带分类");
+ }
+
+ @Test
+ void listSystemWithChildrenAppendsDescendantsWithParentId() {
+ List entries = directoryService.list(APP, SCHOOL_A, DepartmentTypeEnum.SYSTEM, null, true);
+
+ assertEquals(Arrays.asList(20, 21), ids(entries));
+ assertEquals(20, entries.get(1).getParentId(), "下级条目靠 parentId 还原层级");
+ }
+
+ @Test
+ void listCustomExcludesDisabledButKeepsEnabled() {
+ List entries = directoryService.list(APP, SCHOOL_A, DepartmentTypeEnum.CUSTOM, CATEGORY, false);
+
+ assertEquals(Collections.singletonList(100), ids(entries));
+ assertEquals("审核部门", entries.get(0).getCategoryName());
+ assertEquals("CUSTOM", entries.get(0).getDepartmentType());
+ }
+
+ @Test
+ void listBothTypesWhenTypeOmitted() {
+ List entries = directoryService.list(APP, SCHOOL_A, null, null, false);
+ assertEquals(Arrays.asList(20, 100), ids(entries));
+ }
+
+ @Test
+ void listRejectsCategoryIdOnSystemBranch() {
+ assertThrows(DException.class,
+ () -> directoryService.list(APP, SCHOOL_A, DepartmentTypeEnum.SYSTEM, CATEGORY, false));
+ }
+
+ @Test
+ void listCustomRejectsUnknownCategory() {
+ assertThrows(DException.class,
+ () -> directoryService.list(APP, SCHOOL_A, DepartmentTypeEnum.CUSTOM, 999, false));
+ }
+
+ // ===================== A12 / A10 历史解析 =====================
+
+ /** 软删与停用部门只能经 resolve-batch 拿到,且 selectable=false */
+ @Test
+ void resolveBatchReturnsDisabledAndSoftDeletedWithHistoryFlags() {
+ List entries = directoryService.resolveBatch(APP, Arrays.asList(
+ ref(SCHOOL_A, "CUSTOM", 101),
+ ref(SCHOOL_A, "CUSTOM", 102)));
+
+ assertEquals(Boolean.FALSE, entries.get(0).getEnabled());
+ assertEquals(Boolean.FALSE, entries.get(0).getDeleted());
+ assertEquals(Boolean.FALSE, entries.get(0).getSelectable(), "停用不可新选(A10)");
+
+ assertEquals("已撤并部门", entries.get(1).getName(), "软删行保留原名,历史展示不受影响");
+ assertEquals(Boolean.TRUE, entries.get(1).getDeleted());
+ assertEquals(Boolean.FALSE, entries.get(1).getSelectable(), "软删不可新选(A12)");
+ }
+
+ /** A13:软删部门保留原 id,旧引用不会指向同名新对象 */
+ @Test
+ void resolveBatchReturnsStaleIdWithoutRedirectingToNewObject() {
+ UbCustomDepartment fresh = custom(103, CATEGORY, "已撤并部门", 1, 0);
+ when(departmentRepository.findById(103)).thenReturn(Optional.of(fresh));
+
+ List entries = directoryService.resolveBatch(APP,
+ Collections.singletonList(ref(SCHOOL_A, "CUSTOM", 102)));
+
+ assertEquals(102, entries.get(0).getDepartmentId());
+ assertEquals(Boolean.TRUE, entries.get(0).getDeleted());
+ assertEquals(103, directoryService.resolveBatch(APP, Collections.singletonList(ref(SCHOOL_A, "CUSTOM", 103)))
+ .get(0).getDepartmentId());
+ }
+
+ /** A19:别校同名组不被当成本校历史值(按 schoolId + type + id 校验归属) */
+ @Test
+ void resolveBatchMarksCrossSchoolReferenceUnresolvable() {
+ when(sysGroupRepository.findById(40)).thenReturn(Optional.of(group(40, GroupTypeEnum.NORMAL_GROUP, 9, 0)));
+
+ List entries = directoryService.resolveBatch(APP,
+ Collections.singletonList(ref(SCHOOL_A, "SYSTEM", 40)));
+
+ assertNull(entries.get(0).getName());
+ assertEquals(Boolean.TRUE, entries.get(0).getDeleted());
+ assertEquals(Boolean.FALSE, entries.get(0).getSelectable());
+ }
+
+ @Test
+ void resolveBatchRejectsBatchContainingUnauthorizedSchool() {
+ when(sysGroupRepository.findById(40)).thenReturn(Optional.of(group(40, GroupTypeEnum.NORMAL_GROUP, 9, 0)));
+ List refs = Arrays.asList(ref(SCHOOL_A, "SYSTEM", 20), ref(SCHOOL_B, "SYSTEM", 40));
+
+ assertThrows(DException.class, () -> directoryService.resolveBatch(APP, refs));
+ }
+
+ @Test
+ void resolveBatchRejectsUnknownDepartmentType() {
+ List refs = Collections.singletonList(ref(SCHOOL_A, "FLOW", 20));
+ DException error = assertThrows(DException.class, () -> directoryService.resolveBatch(APP, refs));
+ assertTrue(error.getMessage().contains("departmentType"));
+ }
+
+ @Test
+ void resolveBatchOfEmptyRefsReturnsEmptyWithoutAuthCheck() {
+ assertTrue(directoryService.resolveBatch(APP, Collections.emptyList()).isEmpty());
+ }
+
+ /** SYSTEM 历史解析:停用组仍解析出名称,只是不可选 */
+ @Test
+ void resolveSystemReturnsDisabledGroup() {
+ when(sysGroupRepository.findById(22)).thenReturn(Optional.of(group(22, GroupTypeEnum.NORMAL_GROUP, 7, 1)));
+
+ DepartmentEntryVo entry = directoryService.resolveBatch(APP,
+ Collections.singletonList(ref(SCHOOL_A, "SYSTEM", 22))).get(0);
+
+ assertEquals(Boolean.FALSE, entry.getEnabled());
+ assertEquals(Boolean.FALSE, entry.getSelectable());
+ assertEquals(Boolean.FALSE, entry.getDeleted(), "SYSTEM 不支持删除,deleted 恒 false");
+ assertEquals(7, entry.getParentId());
+ }
+
+ // ===================== 成员解析(select / history) =====================
+
+ @Test
+ void membersSelectRejectsDisabledCustomDepartment() {
+ DException error = assertThrows(DException.class,
+ () -> directoryService.members(APP, DepartmentTypeEnum.CUSTOM, 101, true));
+ assertTrue(error.getMessage().contains("停用"));
+ }
+
+ @Test
+ void membersSelectRejectsSoftDeletedCustomDepartment() {
+ DException error = assertThrows(DException.class,
+ () -> directoryService.members(APP, DepartmentTypeEnum.CUSTOM, 102, true));
+ assertTrue(error.getMessage().contains("已删除"));
+ }
+
+ /** A17:历史解析不因停用/软删而失败,成员照常返回 */
+ @Test
+ void membersHistoryReturnsMembersOfDisabledDepartment() {
+ when(memberRepository.findByDepartmentId(101)).thenReturn(Arrays.asList(member(101, 11), member(101, 12)));
+ when(sysUserRepository.findAllById(anyList())).thenReturn(Arrays.asList(
+ user(11, "甲", StatusEnum.NORMAL), user(12, "乙", StatusEnum.DISABLED)));
+
+ List users = directoryService.members(APP, DepartmentTypeEnum.CUSTOM, 101, false);
+
+ assertEquals(2, users.size(), "禁用账号不静默剔除,靠 status 交给消费方判(与 /oapi/group/users 同口径)");
+ assertEquals(Integer.valueOf(1), users.get(0).getStatus());
+ assertEquals(Integer.valueOf(0), users.get(1).getStatus());
+ }
+
+ @Test
+ void membersHistoryResolvesSoftDeletedDepartmentMembers() {
+ when(memberRepository.findByDepartmentId(102)).thenReturn(Collections.singletonList(member(102, 11)));
+ when(sysUserRepository.findAllById(anyList()))
+ .thenReturn(Collections.singletonList(user(11, "甲", StatusEnum.NORMAL)));
+
+ assertEquals(1, directoryService.members(APP, DepartmentTypeEnum.CUSTOM, 102, false).size());
+ }
+
+ @Test
+ void membersOrphanRelationRowsAreSkipped() {
+ when(memberRepository.findByDepartmentId(100)).thenReturn(Arrays.asList(member(100, 11), member(100, 999)));
+ when(sysUserRepository.findAllById(anyList()))
+ .thenReturn(Collections.singletonList(user(11, "甲", StatusEnum.NORMAL)));
+
+ List users = directoryService.members(APP, DepartmentTypeEnum.CUSTOM, 100, true);
+ assertEquals(1, users.size());
+ assertEquals("acc11", users.get(0).getAccount());
+ }
+
+ @Test
+ void membersSelectRejectsDisabledSystemGroup() {
+ when(sysGroupRepository.findById(22)).thenReturn(Optional.of(group(22, GroupTypeEnum.NORMAL_GROUP, 7, 1)));
+ assertThrows(DException.class,
+ () -> directoryService.members(APP, DepartmentTypeEnum.SYSTEM, 22, true));
+ }
+
+ @Test
+ void membersSystemHistoryReturnsGroupUsers() {
+ when(sysGroupRepository.findById(20)).thenReturn(Optional.of(group(20, GroupTypeEnum.NORMAL_GROUP, 7, 0)));
+ when(sysUserGroupRelRepository.findByGroupId(20)).thenReturn(Collections.singletonList(rel(11, 20)));
+ when(sysUserRepository.findAllById(anyList()))
+ .thenReturn(Collections.singletonList(user(11, "甲", StatusEnum.NORMAL)));
+
+ List users = directoryService.members(APP, DepartmentTypeEnum.SYSTEM, 20, false);
+ assertEquals(1, users.size());
+ assertFalse(users.get(0).getName().isEmpty());
+ }
+
+ @Test
+ void membersRejectsMissingDepartment() {
+ assertThrows(DException.class,
+ () -> directoryService.members(APP, DepartmentTypeEnum.CUSTOM, 12345, true));
+ assertThrows(DException.class,
+ () -> directoryService.members(APP, DepartmentTypeEnum.CUSTOM, null, true));
+ }
+
+ // ===================== 辅助 =====================
+
+ private List ids(List entries) {
+ List ids = new java.util.ArrayList<>();
+ for (DepartmentEntryVo entry : entries) {
+ ids.add(entry.getDepartmentId());
+ }
+ return ids;
+ }
+
+ private DepartmentRefRequest ref(Integer schoolId, String type, Integer id) {
+ DepartmentRefRequest ref = new DepartmentRefRequest();
+ ref.setSchoolId(schoolId);
+ ref.setDepartmentType(type);
+ ref.setDepartmentId(id);
+ return ref;
+ }
+
+ private UbCustomDepartmentMember member(Integer departmentId, Integer userId) {
+ UbCustomDepartmentMember m = new UbCustomDepartmentMember();
+ m.setSchoolId(SCHOOL_A);
+ m.setDepartmentId(departmentId);
+ m.setUserId(userId);
+ return m;
+ }
+
+ private SysUserGroupRel rel(Integer userId, Integer groupId) {
+ SysUserGroupRel r = new SysUserGroupRel();
+ r.setUserId(userId);
+ r.setGroupId(groupId);
+ return r;
+ }
+
+ private SysUser user(Integer id, String name, StatusEnum disabled) {
+ SysUser u = new SysUser();
+ u.setId(id);
+ u.setAccount("acc" + id);
+ u.setRealName(name);
+ u.setDisabled(disabled);
+ return u;
+ }
+}
diff --git a/src/test/java/com/yunhai/userbase/service/impl/SysGroupServiceIncludeChildrenTest.java b/src/test/java/com/yunhai/userbase/service/impl/SysGroupServiceIncludeChildrenTest.java
new file mode 100644
index 0000000..d8caea1
--- /dev/null
+++ b/src/test/java/com/yunhai/userbase/service/impl/SysGroupServiceIncludeChildrenTest.java
@@ -0,0 +1,161 @@
+package com.yunhai.userbase.service.impl;
+
+import com.yunhai.platform.utils.exception.DException;
+import com.yunhai.userbase.entity.SysGroup;
+import com.yunhai.userbase.enums.GroupTypeEnum;
+import com.yunhai.userbase.event.GroupChangedEvent;
+import com.yunhai.userbase.repository.SysGroupRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+import org.springframework.context.ApplicationEventPublisher;
+
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * {@code SysGroupService.changeIncludeChildren}(2026-10-21 部门线)单元测试。
+ * 覆盖:① 学校组上置位/复位只走 updateIncludeChildren 单列 UPDATE(不 save 不 delete);
+ * ② 组不存在 / id 为空抛 DException 且零写入;③ updated=0 分支抛错且**不发**缓存驱逐事件;
+ * ④ 非学校组节点(教师组/普通组等)拒绝,不写库不发事件;⑤ 成功路径必须发 GroupChangedEvent。
+ */
+class SysGroupServiceIncludeChildrenTest {
+
+ @InjectMocks
+ private SysGroupService sysGroupService;
+
+ @Mock
+ private SysGroupRepository sysGroupRepository;
+
+ @Mock
+ private ApplicationEventPublisher eventPublisher;
+
+ @Mock
+ private GroupSnapshotService groupSnapshotService;
+
+ @Captor
+ private ArgumentCaptor eventCaptor;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.initMocks(this);
+ }
+
+ private SysGroup node(Integer id, GroupTypeEnum type, Integer includeChildren) {
+ SysGroup g = new SysGroup();
+ g.setId(id);
+ g.setParentId(1);
+ g.setType(type);
+ g.setIncludeChildren(includeChildren);
+ return g;
+ }
+
+ private SysGroup school(Integer id) {
+ return node(id, GroupTypeEnum.SCHOOL_GROUP, 1);
+ }
+
+ // ===================== 置位:单列 UPDATE =====================
+
+ @Test
+ void turningOffUsesSingleColumnUpdateOnly() {
+ when(sysGroupRepository.findById(5)).thenReturn(Optional.of(school(5)));
+ when(sysGroupRepository.updateIncludeChildren(5, 0)).thenReturn(1);
+
+ sysGroupService.changeIncludeChildren(5, false);
+
+ verify(sysGroupRepository).updateIncludeChildren(5, 0);
+ // 单列 UPDATE 断言:不走实体 save(会触发审计列/监听器),不碰删除
+ verify(sysGroupRepository, never()).save(any(SysGroup.class));
+ verify(sysGroupRepository, never()).deleteById(anyInt());
+ verify(sysGroupRepository, never()).delete(any(SysGroup.class));
+ }
+
+ @Test
+ void turningOnWritesOne() {
+ when(sysGroupRepository.findById(5)).thenReturn(Optional.of(node(5, GroupTypeEnum.SCHOOL_GROUP, 0)));
+ when(sysGroupRepository.updateIncludeChildren(5, 1)).thenReturn(1);
+
+ sysGroupService.changeIncludeChildren(5, true);
+
+ verify(sysGroupRepository).updateIncludeChildren(5, 1);
+ verify(sysGroupRepository, never()).save(any(SysGroup.class));
+ }
+
+ @Test
+ void successEvictsGroupSnapshotCache() {
+ when(sysGroupRepository.findById(5)).thenReturn(Optional.of(school(5)));
+ when(sysGroupRepository.updateIncludeChildren(5, 0)).thenReturn(1);
+
+ sysGroupService.changeIncludeChildren(5, false);
+
+ // 不发 GroupChangedEvent = 快照最多 30min 不刷新,GetTree / oapi 组树读到旧 includeChildren
+ verify(eventPublisher).publishEvent(eventCaptor.capture());
+ assertEquals("changeIncludeChildren", eventCaptor.getValue().getSourceOp());
+ }
+
+ // ===================== 拒绝路径:零写入、零事件 =====================
+
+ @Test
+ void missingGroupRejectedWithoutAnyWrite() {
+ when(sysGroupRepository.findById(99)).thenReturn(Optional.empty());
+
+ assertThrows(DException.class, () -> sysGroupService.changeIncludeChildren(99, false));
+
+ verify(sysGroupRepository, never()).updateIncludeChildren(anyInt(), anyInt());
+ verify(sysGroupRepository, never()).save(any(SysGroup.class));
+ verify(eventPublisher, never()).publishEvent(any(GroupChangedEvent.class));
+ }
+
+ @Test
+ void nullIdRejectedWithoutLookup() {
+ assertThrows(DException.class, () -> sysGroupService.changeIncludeChildren(null, true));
+
+ verify(sysGroupRepository, never()).findById(any());
+ verify(sysGroupRepository, never()).updateIncludeChildren(anyInt(), anyInt());
+ verify(eventPublisher, never()).publishEvent(any(GroupChangedEvent.class));
+ }
+
+ /** 类型守卫:include_children 只是学校节点属性,误调到其他层(教师组/普通组)必须拒绝 */
+ @Test
+ void nonSchoolGroupRejectedWithoutWrite() {
+ when(sysGroupRepository.findById(7)).thenReturn(Optional.of(node(7, GroupTypeEnum.TEACHER_GROUP, 1)));
+
+ assertThrows(DException.class, () -> sysGroupService.changeIncludeChildren(7, false));
+
+ verify(sysGroupRepository, never()).updateIncludeChildren(anyInt(), anyInt());
+ verify(sysGroupRepository, never()).save(any(SysGroup.class));
+ verify(eventPublisher, never()).publishEvent(any(GroupChangedEvent.class));
+ }
+
+ @Test
+ void legacyNullTypeRejectedWithoutWrite() {
+ when(sysGroupRepository.findById(8)).thenReturn(Optional.of(node(8, null, 1)));
+
+ assertThrows(DException.class, () -> sysGroupService.changeIncludeChildren(8, false));
+
+ verify(sysGroupRepository, never()).updateIncludeChildren(anyInt(), anyInt());
+ verify(eventPublisher, never()).publishEvent(any(GroupChangedEvent.class));
+ }
+
+ /** 存在性校验与 UPDATE 之间组被并发删除:updated=0 视作不存在,且不留下虚假的缓存驱逐 */
+ @Test
+ void zeroRowsUpdatedThrowsAndDoesNotPublishEvent() {
+ when(sysGroupRepository.findById(5)).thenReturn(Optional.of(school(5)));
+ when(sysGroupRepository.updateIncludeChildren(5, 0)).thenReturn(0);
+
+ assertThrows(DException.class, () -> sysGroupService.changeIncludeChildren(5, false));
+
+ verify(eventPublisher, never()).publishEvent(any(GroupChangedEvent.class));
+ }
+}
diff --git a/src/test/java/com/yunhai/userbase/service/impl/UbCustomDepartmentServiceTest.java b/src/test/java/com/yunhai/userbase/service/impl/UbCustomDepartmentServiceTest.java
new file mode 100644
index 0000000..4d408a7
--- /dev/null
+++ b/src/test/java/com/yunhai/userbase/service/impl/UbCustomDepartmentServiceTest.java
@@ -0,0 +1,601 @@
+package com.yunhai.userbase.service.impl;
+
+import com.yunhai.platform.utils.exception.DException;
+import com.yunhai.userbase.common.context.UserContext;
+import com.yunhai.userbase.entity.SysGroup;
+import com.yunhai.userbase.entity.SysUser;
+import com.yunhai.userbase.entity.SysUserGroupRel;
+import com.yunhai.userbase.entity.UbCustomDeptCategory;
+import com.yunhai.userbase.entity.UbCustomDepartment;
+import com.yunhai.userbase.entity.UbCustomDepartmentMember;
+import com.yunhai.userbase.enums.GroupTypeEnum;
+import com.yunhai.userbase.enums.StatusEnum;
+import com.yunhai.userbase.model.customdept.CustomDepartmentMemberRequest;
+import com.yunhai.userbase.model.customdept.CustomDepartmentMemberVo;
+import com.yunhai.userbase.model.customdept.CustomDepartmentMoveRequest;
+import com.yunhai.userbase.model.customdept.CustomDepartmentRequest;
+import com.yunhai.userbase.repository.SysGroupRepository;
+import com.yunhai.userbase.repository.SysUserGroupRelRepository;
+import com.yunhai.userbase.repository.SysUserRepository;
+import com.yunhai.userbase.repository.UbCustomDeptCategoryRepository;
+import com.yunhai.userbase.repository.UbCustomDepartmentMemberRepository;
+import com.yunhai.userbase.repository.UbCustomDepartmentRepository;
+import com.yunhai.userbase.service.ISysGroupService;
+import com.yunhai.userbase.service.resolve.AccessScopeService;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * {@code UbCustomDepartmentService}(2026-10-21 T1 CUSTOM 部门与成员写口)单元测试。
+ * 覆盖验收映射:A04~A09(跨校/重名/成员归属)、A10~A13(启停两态、软删保留成员与名称、
+ * 同名解锁走新 id)、A14 的部门侧前置;以及 D4「本校教职工、排除学生组」的服务端复核。
+ */
+class UbCustomDepartmentServiceTest {
+
+ private static final int SCHOOL = 5;
+ private static final int OTHER_SCHOOL = 6;
+ private static final int CATEGORY = 10;
+ private static final int DEPT = 20;
+
+ @InjectMocks
+ private UbCustomDepartmentService departmentService;
+
+ @Mock
+ private UbCustomDepartmentRepository departmentRepository;
+
+ @Mock
+ private UbCustomDeptCategoryRepository categoryRepository;
+
+ @Mock
+ private UbCustomDepartmentMemberRepository memberRepository;
+
+ @Mock
+ private SysGroupRepository sysGroupRepository;
+
+ @Mock
+ private SysUserGroupRelRepository sysUserGroupRelRepository;
+
+ @Mock
+ private SysUserRepository sysUserRepository;
+
+ @Mock
+ private ISysGroupService sysGroupService;
+
+ @Mock
+ private AccessScopeService accessScopeService;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.initMocks(this);
+ when(accessScopeService.resolveWritableSchoolId(SCHOOL)).thenReturn(SCHOOL);
+ }
+
+ @AfterEach
+ void tearDown() {
+ UserContext.clear();
+ }
+
+ // ===================== 夹具 =====================
+
+ private UbCustomDeptCategory category(Integer id, Integer schoolId) {
+ UbCustomDeptCategory c = new UbCustomDeptCategory();
+ c.setId(id);
+ c.setSchoolId(schoolId);
+ c.setName("审核部门");
+ c.setSortNo(1);
+ c.setIsDeleted(0);
+ c.setDelMarker(0L);
+ return c;
+ }
+
+ private UbCustomDepartment dept(Integer id, Integer schoolId, Integer categoryId, String name) {
+ UbCustomDepartment d = new UbCustomDepartment();
+ d.setId(id);
+ d.setSchoolId(schoolId);
+ d.setCategoryId(categoryId);
+ d.setName(name);
+ d.setSortNo(1);
+ d.setEnabled(1);
+ d.setIsDeleted(0);
+ d.setDelMarker(0L);
+ return d;
+ }
+
+ private SysUser user(Integer id, String realName) {
+ SysUser u = new SysUser();
+ u.setId(id);
+ u.setAccount("acc" + id);
+ u.setRealName(realName);
+ u.setDisabled(StatusEnum.NORMAL);
+ return u;
+ }
+
+ private SysUserGroupRel rel(Integer userId, Integer groupId) {
+ SysUserGroupRel r = new SysUserGroupRel();
+ r.setUserId(userId);
+ r.setGroupId(groupId);
+ return r;
+ }
+
+ private SysGroup group(Integer id, GroupTypeEnum type) {
+ SysGroup g = new SysGroup();
+ g.setId(id);
+ g.setParentId(SCHOOL);
+ g.setType(type);
+ return g;
+ }
+
+ private void stubLiveCategory() {
+ when(categoryRepository.findById(CATEGORY)).thenReturn(Optional.of(category(CATEGORY, SCHOOL)));
+ }
+
+ private void stubLiveDepartment() {
+ when(departmentRepository.findById(DEPT))
+ .thenReturn(Optional.of(dept(DEPT, SCHOOL, CATEGORY, "校长室")));
+ }
+
+ /**
+ * findAllById 必须按实际入参返回(桩若无视参数,学生 30 会被当成教职工,
+ * 「禁用账号不能加入」也会假通过)。
+ */
+ private void stubUserRepository(SysUser... users) {
+ final Map byId = new HashMap<>();
+ for (SysUser user : users) {
+ byId.put(user.getId(), user);
+ }
+ when(sysUserRepository.findAllById(anyList())).thenAnswer(invocation -> {
+ Iterable ids = invocation.getArgument(0);
+ List hits = new ArrayList<>();
+ for (Integer id : ids) {
+ if (byId.containsKey(id)) {
+ hits.add(byId.get(id));
+ }
+ }
+ return hits;
+ });
+ }
+
+ /**
+ * 学校子树 5:教师组 7 / 学生组 8(下挂年级组 21)。
+ * 11、12 只在教师组下(教职工);30 同时挂教师组与学生组子树(按 D4 排除)。
+ */
+ private void stubSchoolMembership() {
+ when(sysGroupService.listSelfAndDescendantIds(SCHOOL))
+ .thenReturn(Arrays.asList(SCHOOL, 7, 8, 21));
+ when(sysGroupService.listSelfAndDescendantIds(8)).thenReturn(Arrays.asList(8, 21));
+ when(sysGroupRepository.findAllById(anyList())).thenReturn(Arrays.asList(
+ group(SCHOOL, GroupTypeEnum.SCHOOL_GROUP),
+ group(7, GroupTypeEnum.TEACHER_GROUP),
+ group(8, GroupTypeEnum.STUDENT_GROUP),
+ group(21, GroupTypeEnum.GRADE_GROUP)));
+ when(sysUserGroupRelRepository.findByGroupIdIn(Arrays.asList(8, 21)))
+ .thenReturn(Collections.singletonList(rel(30, 21)));
+ when(sysUserGroupRelRepository.findByGroupIdIn(Arrays.asList(SCHOOL, 7, 8, 21)))
+ .thenReturn(Arrays.asList(rel(11, 7), rel(12, 7), rel(30, 21)));
+ stubUserRepository(user(11, "甲"), user(12, "乙"), user(30, "丙"));
+ }
+
+ // ===================== 创建 / 重名 =====================
+
+ @Test
+ void addCreatesEnabledDepartmentAtTailOfSort() {
+ stubLiveCategory();
+ when(departmentRepository.findFirstBySchoolIdAndCategoryIdAndNameAndIsDeleted(SCHOOL, CATEGORY, "校长室", 0))
+ .thenReturn(Optional.empty());
+ when(departmentRepository.findMaxSortNoByCategory(CATEGORY)).thenReturn(3);
+
+ CustomDepartmentRequest request = new CustomDepartmentRequest();
+ request.setSchoolId(SCHOOL);
+ request.setCategoryId(CATEGORY);
+ request.setName("校长室");
+
+ departmentService.add(request);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(UbCustomDepartment.class);
+ verify(departmentRepository).save(captor.capture());
+ UbCustomDepartment saved = captor.getValue();
+ assertEquals(1, saved.getEnabled(), "新建默认启用(规格书 §2.2)");
+ assertEquals(0, saved.getIsDeleted());
+ assertEquals(4, saved.getSortNo());
+ assertEquals(SCHOOL, saved.getSchoolId(), "落库学校取服务端解析值,不是前端传值");
+ }
+
+ /** A06/A05:同校同分类重名拒绝;uk 之外给友好报错,且不落库 */
+ @Test
+ void addRejectsDuplicateNameInSameCategory() {
+ stubLiveCategory();
+ when(departmentRepository.findFirstBySchoolIdAndCategoryIdAndNameAndIsDeleted(SCHOOL, CATEGORY, "校长室", 0))
+ .thenReturn(Optional.of(dept(21, SCHOOL, CATEGORY, "校长室")));
+
+ CustomDepartmentRequest request = new CustomDepartmentRequest();
+ request.setSchoolId(SCHOOL);
+ request.setCategoryId(CATEGORY);
+ request.setName("校长室");
+
+ DException error = assertThrows(DException.class, () -> departmentService.add(request));
+ assertTrue(error.getMessage().contains("同名部门"));
+ verify(departmentRepository, never()).save(any(UbCustomDepartment.class));
+ }
+
+ /** A09:分类跨校(请求校与分类校不一致)直接拒绝 */
+ @Test
+ void addRejectsCategoryOfAnotherSchool() {
+ when(categoryRepository.findById(CATEGORY)).thenReturn(Optional.of(category(CATEGORY, OTHER_SCHOOL)));
+ when(accessScopeService.resolveWritableSchoolId(SCHOOL)).thenReturn(SCHOOL);
+
+ CustomDepartmentRequest request = new CustomDepartmentRequest();
+ request.setSchoolId(SCHOOL);
+ request.setCategoryId(CATEGORY);
+ request.setName("校长室");
+
+ assertThrows(DException.class, () -> departmentService.add(request));
+ verify(departmentRepository, never()).save(any(UbCustomDepartment.class));
+ }
+
+ @Test
+ void addRejectsMissingCategoryWithoutPersist() {
+ when(categoryRepository.findById(CATEGORY)).thenReturn(Optional.empty());
+
+ CustomDepartmentRequest request = new CustomDepartmentRequest();
+ request.setSchoolId(SCHOOL);
+ request.setCategoryId(CATEGORY);
+ request.setName("校长室");
+
+ assertThrows(DException.class, () -> departmentService.add(request));
+ verify(departmentRepository, never()).save(any(UbCustomDepartment.class));
+ }
+
+ // ===================== 移动 =====================
+
+ @Test
+ void moveRejectsCrossSchoolTargetCategory() {
+ stubLiveDepartment();
+ when(categoryRepository.findById(11)).thenReturn(Optional.of(category(11, OTHER_SCHOOL)));
+
+ CustomDepartmentMoveRequest request = new CustomDepartmentMoveRequest();
+ request.setSchoolId(SCHOOL);
+ request.setCategoryId(11);
+
+ assertThrows(DException.class, () -> departmentService.move(DEPT, request));
+ verify(departmentRepository, never()).save(any(UbCustomDepartment.class));
+ }
+
+ @Test
+ void moveRejectsDuplicateNameInTargetCategory() {
+ stubLiveDepartment();
+ when(categoryRepository.findById(11)).thenReturn(Optional.of(category(11, SCHOOL)));
+ when(departmentRepository.findFirstBySchoolIdAndCategoryIdAndNameAndIsDeleted(SCHOOL, 11, "校长室", 0))
+ .thenReturn(Optional.of(dept(22, SCHOOL, 11, "校长室")));
+
+ CustomDepartmentMoveRequest request = new CustomDepartmentMoveRequest();
+ request.setSchoolId(SCHOOL);
+ request.setCategoryId(11);
+
+ assertThrows(DException.class, () -> departmentService.move(DEPT, request));
+ verify(departmentRepository, never()).save(any(UbCustomDepartment.class));
+ }
+
+ @Test
+ void moveAcceptsSameSchoolTargetCategory() {
+ stubLiveDepartment();
+ when(categoryRepository.findById(11)).thenReturn(Optional.of(category(11, SCHOOL)));
+ when(departmentRepository.findFirstBySchoolIdAndCategoryIdAndNameAndIsDeleted(SCHOOL, 11, "校长室", 0))
+ .thenReturn(Optional.empty());
+ when(departmentRepository.findMaxSortNoByCategory(11)).thenReturn(null);
+
+ CustomDepartmentMoveRequest request = new CustomDepartmentMoveRequest();
+ request.setSchoolId(SCHOOL);
+ request.setCategoryId(11);
+
+ departmentService.move(DEPT, request);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(UbCustomDepartment.class);
+ verify(departmentRepository).save(captor.capture());
+ assertEquals(11, captor.getValue().getCategoryId());
+ assertEquals(1, captor.getValue().getSortNo(), "空目标分类落到首位");
+ }
+
+ // ===================== 启停(A10/A11) =====================
+
+ /** A10:停用走单列 UPDATE,不走实体 save(避免动审计列/其他列),成员与软删列不受影响 */
+ @Test
+ void disableUsesSingleColumnUpdateOnly() {
+ stubLiveDepartment();
+ when(departmentRepository.updateEnabled(DEPT, 0)).thenReturn(1);
+
+ departmentService.changeEnabled(DEPT, false);
+
+ verify(departmentRepository).updateEnabled(DEPT, 0);
+ verify(departmentRepository, never()).save(any(UbCustomDepartment.class));
+ }
+
+ /** A11:启用同样单列翻转,恢复可选,原 id 与成员不变 */
+ @Test
+ void enableWritesOneAndKeepsIdentity() {
+ stubLiveDepartment();
+ when(departmentRepository.updateEnabled(DEPT, 1)).thenReturn(1);
+
+ departmentService.changeEnabled(DEPT, true);
+
+ verify(departmentRepository).updateEnabled(DEPT, 1);
+ verify(memberRepository, never()).deleteByDepartmentIdAndUserIds(anyInt(), anyList());
+ }
+
+ @Test
+ void changeEnabledRejectsMissingDepartmentAndZeroRowUpdate() {
+ when(departmentRepository.findById(99)).thenReturn(Optional.empty());
+ assertThrows(DException.class, () -> departmentService.changeEnabled(99, false));
+
+ stubLiveDepartment();
+ when(departmentRepository.updateEnabled(DEPT, 0)).thenReturn(0);
+ assertThrows(DException.class, () -> departmentService.changeEnabled(DEPT, false));
+ }
+
+ @Test
+ void changeEnabledRejectsSoftDeletedDepartment() {
+ UbCustomDepartment deleted = dept(DEPT, SCHOOL, CATEGORY, "已删部门");
+ deleted.setIsDeleted(1);
+ deleted.setDelMarker((long) DEPT);
+ when(departmentRepository.findById(DEPT)).thenReturn(Optional.of(deleted));
+
+ assertThrows(DException.class, () -> departmentService.changeEnabled(DEPT, true));
+ verify(departmentRepository, never()).updateEnabled(anyInt(), anyInt());
+ }
+
+ // ===================== 软删(A12/A13) =====================
+
+ /** A12:软删只打标记,成员关系保留在库(由查询侧过滤),不物理删任何东西 */
+ @Test
+ void softDeleteKeepsMemberRowsAndName() {
+ UserContext.setCurrentUser(91);
+ stubLiveDepartment();
+
+ departmentService.softDelete(DEPT);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(UbCustomDepartment.class);
+ verify(departmentRepository).save(captor.capture());
+ UbCustomDepartment deleted = captor.getValue();
+ assertEquals(1, deleted.getIsDeleted());
+ assertEquals((long) DEPT, deleted.getDelMarker().longValue());
+ assertEquals("校长室", deleted.getName(), "名称永不改写,历史解析拿到的就是原名");
+ assertEquals(Integer.valueOf(91), deleted.getDeletedBy());
+ verify(memberRepository, never()).deleteByDepartmentIdAndUserIds(anyInt(), anyList());
+ verify(memberRepository, never()).delete(any(UbCustomDepartmentMember.class));
+ }
+
+ /**
+ * A13:软删行不再占用同名校验(查询带 is_deleted=0),同名新建放行且一定是新 id
+ * (自增主键,实体保存前 id 仍为空);旧引用不会指向新对象。
+ */
+ @Test
+ void sameNameAllowedAfterSoftDeleteAndGetsNewId() {
+ stubLiveCategory();
+ when(departmentRepository
+ .findFirstBySchoolIdAndCategoryIdAndNameAndIsDeleted(SCHOOL, CATEGORY, "校长室", 0))
+ .thenReturn(Optional.empty());
+
+ CustomDepartmentRequest request = new CustomDepartmentRequest();
+ request.setSchoolId(SCHOOL);
+ request.setCategoryId(CATEGORY);
+ request.setName("校长室");
+
+ departmentService.add(request);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(UbCustomDepartment.class);
+ verify(departmentRepository).save(captor.capture());
+ assertEquals(null, captor.getValue().getId(), "新建走新 id,不复用软删行主键");
+ }
+
+ // ===================== 成员(A07/A08/D4) =====================
+
+ /** D4:学生组下的用户即使同校也不能加入;教职工放行 */
+ @Test
+ void addMembersRejectsStudentUser() {
+ stubLiveDepartment();
+ stubSchoolMembership();
+ when(memberRepository.findByDepartmentId(DEPT)).thenReturn(Collections.emptyList());
+
+ CustomDepartmentMemberRequest request = new CustomDepartmentMemberRequest();
+ request.setSchoolId(SCHOOL);
+ request.setUserIds(Collections.singletonList(30));
+
+ DException error = assertThrows(DException.class, () -> departmentService.addMembers(DEPT, request));
+ assertTrue(error.getMessage().contains("本校教职工"));
+ verify(memberRepository, never()).saveAll(anyList());
+ }
+
+ @Test
+ void addMembersRejectsUserOutsideSchoolScope() {
+ stubLiveDepartment();
+ stubSchoolMembership();
+ when(memberRepository.findByDepartmentId(DEPT)).thenReturn(Collections.emptyList());
+
+ CustomDepartmentMemberRequest request = new CustomDepartmentMemberRequest();
+ request.setSchoolId(SCHOOL);
+ request.setUserIds(Collections.singletonList(777));
+
+ assertThrows(DException.class, () -> departmentService.addMembers(DEPT, request));
+ verify(memberRepository, never()).saveAll(anyList());
+ }
+
+ /** A08:重复添加幂等——已存在的成员不再插入第二条有效关系 */
+ @Test
+ void addMembersSkipsExistingRelationsIdempotently() {
+ stubLiveDepartment();
+ stubSchoolMembership();
+ UbCustomDepartmentMember existing = new UbCustomDepartmentMember();
+ existing.setDepartmentId(DEPT);
+ existing.setUserId(11);
+ when(memberRepository.findByDepartmentId(DEPT)).thenReturn(Collections.singletonList(existing));
+
+ CustomDepartmentMemberRequest request = new CustomDepartmentMemberRequest();
+ request.setSchoolId(SCHOOL);
+ request.setUserIds(new ArrayList<>(Arrays.asList(11, 12)));
+
+ departmentService.addMembers(DEPT, request);
+
+ ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class);
+ verify(memberRepository).saveAll(captor.capture());
+ assertEquals(1, captor.getValue().size());
+ assertEquals(Integer.valueOf(12), captor.getValue().get(0).getUserId());
+ assertEquals(SCHOOL, captor.getValue().get(0).getSchoolId(), "成员行冗余学校须与部门同校");
+ }
+
+ @Test
+ void addMembersWithAllDuplicatesIsNoop() {
+ stubLiveDepartment();
+ stubSchoolMembership();
+ UbCustomDepartmentMember existing = new UbCustomDepartmentMember();
+ existing.setDepartmentId(DEPT);
+ existing.setUserId(11);
+ when(memberRepository.findByDepartmentId(DEPT)).thenReturn(Collections.singletonList(existing));
+
+ CustomDepartmentMemberRequest request = new CustomDepartmentMemberRequest();
+ request.setSchoolId(SCHOOL);
+ request.setUserIds(Arrays.asList(11, 11));
+
+ departmentService.addMembers(DEPT, request);
+
+ verify(memberRepository, never()).saveAll(anyList());
+ }
+
+ /** D4 补充:禁用账号不能作为新成员 */
+ @Test
+ void addMembersRejectsDisabledAccount() {
+ stubLiveDepartment();
+ stubSchoolMembership();
+ SysUser disabled = user(11, "甲");
+ disabled.setDisabled(StatusEnum.DISABLED);
+ stubUserRepository(disabled, user(12, "乙"), user(30, "丙"));
+ when(memberRepository.findByDepartmentId(DEPT)).thenReturn(Collections.emptyList());
+
+ CustomDepartmentMemberRequest request = new CustomDepartmentMemberRequest();
+ request.setSchoolId(SCHOOL);
+ request.setUserIds(Collections.singletonList(11));
+
+ assertThrows(DException.class, () -> departmentService.addMembers(DEPT, request));
+ verify(memberRepository, never()).saveAll(anyList());
+ }
+
+ @Test
+ void addMembersRejectsEmptyList() {
+ CustomDepartmentMemberRequest request = new CustomDepartmentMemberRequest();
+ request.setSchoolId(SCHOOL);
+ request.setUserIds(Collections.emptyList());
+
+ assertThrows(DException.class, () -> departmentService.addMembers(DEPT, request));
+ }
+
+ /** 移除成员=物理删关系行 */
+ @Test
+ void removeMembersDeletesRelationRowsPhysically() {
+ stubLiveDepartment();
+
+ CustomDepartmentMemberRequest request = new CustomDepartmentMemberRequest();
+ request.setSchoolId(SCHOOL);
+ request.setUserIds(Arrays.asList(11, 11, null));
+
+ departmentService.removeMembers(DEPT, request);
+
+ verify(memberRepository).deleteByDepartmentIdAndUserIds(DEPT, Arrays.asList(11));
+ }
+
+ @Test
+ void membersListSkipsOrphanUserRows() {
+ stubLiveDepartment();
+ UbCustomDepartmentMember live = new UbCustomDepartmentMember();
+ live.setDepartmentId(DEPT);
+ live.setUserId(11);
+ UbCustomDepartmentMember orphan = new UbCustomDepartmentMember();
+ orphan.setDepartmentId(DEPT);
+ orphan.setUserId(999);
+ when(memberRepository.findByDepartmentId(DEPT)).thenReturn(Arrays.asList(live, orphan));
+ when(sysUserRepository.findAllById(anyList())).thenReturn(Collections.singletonList(user(11, "甲")));
+
+ assertEquals(1, departmentService.members(DEPT).size());
+ }
+
+ // ===================== 跨校(A09) =====================
+
+ @Test
+ void crossSchoolDepartmentWriteIsRejected() {
+ UbCustomDepartment foreign = dept(DEPT, OTHER_SCHOOL, CATEGORY, "别校部门");
+ when(departmentRepository.findById(DEPT)).thenReturn(Optional.of(foreign));
+ org.mockito.Mockito.doThrow(new DException("暂无权限访问该组织"))
+ .when(accessScopeService).requireSchoolInScope(OTHER_SCHOOL);
+
+ assertThrows(DException.class, () -> departmentService.softDelete(DEPT));
+ assertThrows(DException.class, () -> departmentService.members(DEPT));
+ verify(departmentRepository, never()).save(any(UbCustomDepartment.class));
+ }
+
+ @Test
+ void listRejectsForeignCategory() {
+ when(categoryRepository.findById(CATEGORY)).thenReturn(Optional.of(category(CATEGORY, OTHER_SCHOOL)));
+
+ assertThrows(DException.class, () -> departmentService.list(SCHOOL, CATEGORY));
+ }
+
+ // ===================== 候选教职工(选人弹窗数据源) =====================
+
+ /** 候选口径与写口一致:学生(30)不出现,已在部门内的(11)不重复出现 */
+ @Test
+ void candidateUsersExcludeStudentsAndExistingMembers() {
+ stubLiveDepartment();
+ stubSchoolMembership();
+ UbCustomDepartmentMember existing = new UbCustomDepartmentMember();
+ existing.setDepartmentId(DEPT);
+ existing.setUserId(11);
+ when(memberRepository.findByDepartmentId(DEPT)).thenReturn(Collections.singletonList(existing));
+
+ List candidates = departmentService.candidateUsers(DEPT, null);
+
+ assertEquals(1, candidates.size());
+ assertEquals(Integer.valueOf(12), candidates.get(0).getUserId());
+ }
+
+ @Test
+ void candidateUsersFilterByKeywordIgnoringCase() {
+ stubLiveDepartment();
+ stubSchoolMembership();
+ when(memberRepository.findByDepartmentId(DEPT)).thenReturn(Collections.emptyList());
+
+ List hits = departmentService.candidateUsers(DEPT, " ACC12 ");
+
+ assertEquals(1, hits.size());
+ assertEquals(Integer.valueOf(12), hits.get(0).getUserId());
+ }
+
+ @Test
+ void candidateUsersRejectCrossSchoolDepartment() {
+ UbCustomDepartment foreign = dept(DEPT, OTHER_SCHOOL, CATEGORY, "别校部门");
+ when(departmentRepository.findById(DEPT)).thenReturn(Optional.of(foreign));
+ org.mockito.Mockito.doThrow(new DException("暂无权限访问该组织"))
+ .when(accessScopeService).requireSchoolInScope(OTHER_SCHOOL);
+
+ assertThrows(DException.class, () -> departmentService.candidateUsers(DEPT, null));
+ }
+}
diff --git a/src/test/java/com/yunhai/userbase/service/impl/UbCustomDeptCategoryServiceTest.java b/src/test/java/com/yunhai/userbase/service/impl/UbCustomDeptCategoryServiceTest.java
new file mode 100644
index 0000000..f0fd59c
--- /dev/null
+++ b/src/test/java/com/yunhai/userbase/service/impl/UbCustomDeptCategoryServiceTest.java
@@ -0,0 +1,250 @@
+package com.yunhai.userbase.service.impl;
+
+import com.yunhai.platform.utils.exception.DException;
+import com.yunhai.userbase.common.context.UserContext;
+import com.yunhai.userbase.entity.UbCustomDeptCategory;
+import com.yunhai.userbase.model.customdept.CustomDeptCategoryRequest;
+import com.yunhai.userbase.model.customdept.CustomDeptCategorySortRequest;
+import com.yunhai.userbase.repository.UbCustomDeptCategoryRepository;
+import com.yunhai.userbase.repository.UbCustomDepartmentRepository;
+import com.yunhai.userbase.service.resolve.AccessScopeService;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * {@code UbCustomDeptCategoryService}(2026-10-21 T1 CUSTOM 分类写口)单元测试。
+ * 覆盖验收映射:A09 跨校写拒绝、A14 非空分类软删明确拒绝(无级联)、
+ * A13 软删只让位唯一键(del_marker=行 id、name 不改写)、重名前置校验、排序范围校验。
+ */
+class UbCustomDeptCategoryServiceTest {
+
+ private static final int SCHOOL = 5;
+ private static final int OTHER_SCHOOL = 6;
+
+ @InjectMocks
+ private UbCustomDeptCategoryService categoryService;
+
+ @Mock
+ private UbCustomDeptCategoryRepository categoryRepository;
+
+ @Mock
+ private UbCustomDepartmentRepository departmentRepository;
+
+ @Mock
+ private AccessScopeService accessScopeService;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.initMocks(this);
+ when(accessScopeService.resolveWritableSchoolId(SCHOOL)).thenReturn(SCHOOL);
+ }
+
+ @AfterEach
+ void tearDown() {
+ UserContext.clear();
+ }
+
+ private UbCustomDeptCategory category(Integer id, Integer schoolId, String name) {
+ UbCustomDeptCategory c = new UbCustomDeptCategory();
+ c.setId(id);
+ c.setSchoolId(schoolId);
+ c.setName(name);
+ c.setSortNo(1);
+ c.setIsDeleted(0);
+ c.setDelMarker(0L);
+ return c;
+ }
+
+ private CustomDeptCategoryRequest request(Integer schoolId, String name) {
+ CustomDeptCategoryRequest request = new CustomDeptCategoryRequest();
+ request.setSchoolId(schoolId);
+ request.setName(name);
+ return request;
+ }
+
+ // ===================== 创建 =====================
+
+ @Test
+ void addPersistsNewCategoryAtTailOfSort() {
+ when(categoryRepository.existsBySchoolIdAndNameAndIsDeleted(SCHOOL, "审核部门", 0)).thenReturn(false);
+ when(categoryRepository.findMaxSortNoBySchool(SCHOOL)).thenReturn(7);
+
+ categoryService.add(request(SCHOOL, "审核部门"));
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(UbCustomDeptCategory.class);
+ verify(categoryRepository).save(captor.capture());
+ UbCustomDeptCategory saved = captor.getValue();
+ assertEquals(SCHOOL, saved.getSchoolId());
+ assertEquals("审核部门", saved.getName());
+ assertEquals(8, saved.getSortNo(), "新建分类排到最后");
+ assertEquals(0, saved.getIsDeleted());
+ assertEquals(0L, saved.getDelMarker(), "有效行 del_marker 必须为 0,否则让不出同名唯一键");
+ }
+
+ @Test
+ void addRejectsDuplicateNameWithinSchool() {
+ when(categoryRepository.existsBySchoolIdAndNameAndIsDeleted(SCHOOL, "审核部门", 0)).thenReturn(true);
+
+ DException error = assertThrows(DException.class, () -> categoryService.add(request(SCHOOL, "审核部门")));
+
+ assertTrue(error.getMessage().contains("同名分类"));
+ verify(categoryRepository, never()).save(any(UbCustomDeptCategory.class));
+ }
+
+ /** A09:请求体的 schoolId 只作核对,越权由学校维度 guard 拒绝,服务层不落库 */
+ @Test
+ void addRejectsCrossSchoolWriteWithoutPersist() {
+ when(accessScopeService.resolveWritableSchoolId(OTHER_SCHOOL))
+ .thenThrow(new DException("暂无权限访问该组织"));
+
+ assertThrows(DException.class, () -> categoryService.add(request(OTHER_SCHOOL, "审核部门")));
+
+ verify(categoryRepository, never()).save(any(UbCustomDeptCategory.class));
+ verify(categoryRepository, never()).existsBySchoolIdAndNameAndIsDeleted(anyInt(), anyString(), anyInt());
+ }
+
+ @Test
+ void addTrimsBlankNameAsError() {
+ assertThrows(DException.class, () -> categoryService.add(request(SCHOOL, " ")));
+ verify(categoryRepository, never()).save(any(UbCustomDeptCategory.class));
+ }
+
+ // ===================== 改名 =====================
+
+ @Test
+ void renameRejectsNameTakenByAnotherLiveCategory() {
+ when(categoryRepository.findById(3)).thenReturn(Optional.of(category(3, SCHOOL, "旧分类")));
+ when(categoryRepository.findFirstBySchoolIdAndNameAndIsDeleted(SCHOOL, "事项部门", 0))
+ .thenReturn(Optional.of(category(4, SCHOOL, "事项部门")));
+
+ assertThrows(DException.class, () -> categoryService.rename(3, request(SCHOOL, "事项部门")));
+
+ verify(categoryRepository, never()).save(any(UbCustomDeptCategory.class));
+ }
+
+ @Test
+ void renameRejectsCrossSchoolCategory() {
+ when(categoryRepository.findById(3)).thenReturn(Optional.of(category(3, OTHER_SCHOOL, "别校分类")));
+ org.mockito.Mockito.doThrow(new DException("暂无权限访问该组织"))
+ .when(accessScopeService).requireSchoolInScope(OTHER_SCHOOL);
+
+ assertThrows(DException.class, () -> categoryService.rename(3, request(SCHOOL, "改名")));
+
+ verify(categoryRepository, never()).save(any(UbCustomDeptCategory.class));
+ }
+
+ // ===================== 软删(A14 / A13) =====================
+
+ /** A14:分类下仍有有效部门 → 明确拒绝,不允许任何级联删除 */
+ @Test
+ void softDeleteRejectedWhenCategoryStillHasDepartments() {
+ when(categoryRepository.findById(3)).thenReturn(Optional.of(category(3, SCHOOL, "审核部门")));
+ when(departmentRepository.countByCategoryIdAndIsDeleted(3, 0)).thenReturn(2L);
+
+ DException error = assertThrows(DException.class, () -> categoryService.softDelete(3));
+
+ assertTrue(error.getMessage().contains("2"), "报错需给出剩余部门数,便于用户先处理");
+ verify(categoryRepository, never()).save(any(UbCustomDeptCategory.class));
+ }
+
+ /** A13 前提:软删只让位唯一键(del_marker=行 id),name 永不改写,旧引用仍指向原名原 id */
+ @Test
+ void softDeleteMarksDelMarkerAndKeepsName() {
+ UserContext.setCurrentUser(91);
+ when(categoryRepository.findById(3)).thenReturn(Optional.of(category(3, SCHOOL, "审核部门")));
+ when(departmentRepository.countByCategoryIdAndIsDeleted(3, 0)).thenReturn(0L);
+
+ categoryService.softDelete(3);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(UbCustomDeptCategory.class);
+ verify(categoryRepository).save(captor.capture());
+ UbCustomDeptCategory deleted = captor.getValue();
+ assertEquals(1, deleted.getIsDeleted());
+ assertEquals(3L, deleted.getDelMarker(), "del_marker 必须置为自身 id,uk 才让位");
+ assertEquals("审核部门", deleted.getName(), "软删不得改写名称");
+ assertEquals(Integer.valueOf(91), deleted.getDeletedBy());
+ assertTrue(deleted.getDeletedAt() != null);
+ }
+
+ @Test
+ void softDeleteRejectsMissingOrAlreadyDeletedCategory() {
+ UbCustomDeptCategory deleted = category(4, SCHOOL, "已删分类");
+ deleted.setIsDeleted(1);
+ deleted.setDelMarker(4L);
+ when(categoryRepository.findById(3)).thenReturn(Optional.empty());
+ when(categoryRepository.findById(4)).thenReturn(Optional.of(deleted));
+
+ assertThrows(DException.class, () -> categoryService.softDelete(3));
+ assertThrows(DException.class, () -> categoryService.softDelete(4));
+ // 空 id 走同一分支:不查库、不落库
+ assertThrows(DException.class, () -> categoryService.softDelete(null));
+
+ verify(categoryRepository, never()).save(any(UbCustomDeptCategory.class));
+ verify(departmentRepository, never()).countByCategoryIdAndIsDeleted(anyInt(), anyInt());
+ }
+
+ // ===================== 排序 =====================
+
+ @Test
+ void sortRejectsForeignCategoryIds() {
+ when(accessScopeService.resolveWritableSchoolId(SCHOOL)).thenReturn(SCHOOL);
+ when(categoryRepository.findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(SCHOOL, 0))
+ .thenReturn(Collections.singletonList(category(3, SCHOOL, "甲")));
+
+ CustomDeptCategorySortRequest request = new CustomDeptCategorySortRequest();
+ request.setSchoolId(SCHOOL);
+ request.setIds(Arrays.asList(3, 99));
+
+ assertThrows(DException.class, () -> categoryService.sort(request));
+ verify(categoryRepository, never()).saveAll(anyList());
+ }
+
+ @Test
+ void sortWritesAscendingSequenceByGivenOrder() {
+ UbCustomDeptCategory a = category(3, SCHOOL, "甲");
+ UbCustomDeptCategory b = category(4, SCHOOL, "乙");
+ when(categoryRepository.findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(SCHOOL, 0))
+ .thenReturn(Arrays.asList(a, b));
+
+ CustomDeptCategorySortRequest request = new CustomDeptCategorySortRequest();
+ request.setSchoolId(SCHOOL);
+ request.setIds(Arrays.asList(4, 3));
+
+ categoryService.sort(request);
+
+ assertEquals(1, b.getSortNo());
+ assertEquals(2, a.getSortNo());
+ verify(categoryRepository).saveAll(eq(Arrays.asList(a, b)));
+ }
+
+ @Test
+ void sortRejectsDuplicateIds() {
+ CustomDeptCategorySortRequest request = new CustomDeptCategorySortRequest();
+ request.setSchoolId(SCHOOL);
+ request.setIds(Arrays.asList(3, 3));
+
+ assertThrows(DException.class, () -> categoryService.sort(request));
+ verify(categoryRepository, never()).saveAll(anyList());
+ }
+}
diff --git a/src/test/java/com/yunhai/userbase/service/resolve/AccessScopeServiceSchoolGuardTest.java b/src/test/java/com/yunhai/userbase/service/resolve/AccessScopeServiceSchoolGuardTest.java
new file mode 100644
index 0000000..b99b45c
--- /dev/null
+++ b/src/test/java/com/yunhai/userbase/service/resolve/AccessScopeServiceSchoolGuardTest.java
@@ -0,0 +1,241 @@
+package com.yunhai.userbase.service.resolve;
+
+import com.yunhai.platform.utils.exception.DException;
+import com.yunhai.userbase.common.context.UserContext;
+import com.yunhai.userbase.entity.SysGroup;
+import com.yunhai.userbase.entity.SysUser;
+import com.yunhai.userbase.entity.SysUserCurrentRole;
+import com.yunhai.userbase.entity.SysUserGroupRel;
+import com.yunhai.userbase.entity.SysUserRoleRel;
+import com.yunhai.userbase.enums.GroupTypeEnum;
+import com.yunhai.userbase.enums.StatusEnum;
+import com.yunhai.userbase.enums.SysRoleEnum;
+import com.yunhai.userbase.enums.UserTypeEnum;
+import com.yunhai.userbase.repository.SysGroupRepository;
+import com.yunhai.userbase.repository.SysUserCurrentRoleRepository;
+import com.yunhai.userbase.repository.SysUserGroupRelRepository;
+import com.yunhai.userbase.repository.SysUserRepository;
+import com.yunhai.userbase.repository.SysUserRoleRelRepository;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.Arrays;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.when;
+
+/**
+ * 学校维度 guard 单测(2026-10-21 T1 CUSTOM 部门线新增)。
+ * CUSTOM 分类/部门只以学校作组织锚点,现有 guard 全是「组」维度,
+ * 故 {@code requireSchoolInScope} / {@code resolveWritableSchoolId} 必须独立验证:
+ * 请求体 schoolId 只作核对、不作授权(A09)。
+ */
+class AccessScopeServiceSchoolGuardTest {
+
+ private static final Integer USER_ID = 1;
+ private static final Integer SCHOOL_A = 10;
+ private static final Integer SCHOOL_B = 20;
+ private static final Integer TEACHER_GROUP_IN_A = 101;
+ /** 教师组下的部门(普通组),用于验证「组在范围内但不是学校」也按学校维度拒绝 */
+ private static final Integer DEPT_GROUP_IN_A = 102;
+
+ @InjectMocks
+ private AccessScopeService accessScopeService;
+
+ @Mock
+ private SysUserRepository sysUserRepository;
+ @Mock
+ private SysUserRoleRelRepository sysUserRoleRelRepository;
+ @Mock
+ private SysUserGroupRelRepository sysUserGroupRelRepository;
+ @Mock
+ private SysGroupRepository sysGroupRepository;
+ @Mock
+ private SysUserCurrentRoleRepository sysUserCurrentRoleRepository;
+ @Mock
+ private MainGroupResolver mainGroupResolver;
+
+ @BeforeEach
+ void setUp() {
+ MockitoAnnotations.openMocks(this);
+ AccessScopeService.clearCache();
+ UserContext.clear();
+ when(sysGroupRepository.findAll()).thenReturn(Arrays.asList(
+ group(SCHOOL_A, GroupTypeEnum.SCHOOL_GROUP, 0),
+ group(SCHOOL_B, GroupTypeEnum.SCHOOL_GROUP, 0),
+ group(TEACHER_GROUP_IN_A, GroupTypeEnum.TEACHER_GROUP, SCHOOL_A),
+ group(DEPT_GROUP_IN_A, GroupTypeEnum.NORMAL_GROUP, TEACHER_GROUP_IN_A)
+ ));
+ }
+
+ @AfterEach
+ void tearDown() {
+ AccessScopeService.clearCache();
+ UserContext.clear();
+ }
+
+ private SysGroup group(Integer id, GroupTypeEnum type, Integer parentId) {
+ SysGroup g = new SysGroup();
+ g.setId(id);
+ g.setType(type);
+ g.setParentId(parentId);
+ return g;
+ }
+
+ private SysUser user(UserTypeEnum type) {
+ SysUser u = new SysUser();
+ u.setId(USER_ID);
+ u.setType(type);
+ return u;
+ }
+
+ private SysUserRoleRel adminRel(Integer schoolId) {
+ SysUserRoleRel rel = new SysUserRoleRel();
+ rel.setUserId(USER_ID);
+ rel.setRoleId(SysRoleEnum.ADMIN.getKey());
+ rel.setSchoolId(schoolId);
+ rel.setStatus(StatusEnum.NORMAL);
+ return rel;
+ }
+
+ /** 学校管理员(单校):身份行小学 + 当前激活身份小学 */
+ private void givenSingleSchoolAdmin() {
+ UserContext.setCurrentUser(USER_ID);
+ when(sysUserRepository.findById(USER_ID)).thenReturn(Optional.of(user(UserTypeEnum.SCHOOL_ADMIN)));
+ when(sysUserRoleRelRepository.findByUserIdAndStatus(USER_ID, StatusEnum.NORMAL))
+ .thenReturn(java.util.Collections.singletonList(adminRel(SCHOOL_A)));
+ SysUserCurrentRole current = new SysUserCurrentRole();
+ current.setUserId(USER_ID);
+ current.setSchoolId(SCHOOL_A);
+ when(sysUserCurrentRoleRepository.findByUserId(USER_ID)).thenReturn(Optional.of(current));
+ }
+
+ private void givenSuper() {
+ UserContext.setCurrentUser(USER_ID);
+ when(sysUserRepository.findById(USER_ID)).thenReturn(Optional.of(user(UserTypeEnum.SUPER_ADMIN)));
+ }
+
+ /** 多校管理员且未指定学校:主组解析不出(保留历史宽口径 → 两校都在范围内) */
+ private void givenTwoSchoolAdminWithoutActiveRole() {
+ UserContext.setCurrentUser(USER_ID);
+ when(sysUserRepository.findById(USER_ID)).thenReturn(Optional.of(user(UserTypeEnum.SCHOOL_ADMIN)));
+ when(sysUserRoleRelRepository.findByUserIdAndStatus(USER_ID, StatusEnum.NORMAL))
+ .thenReturn(Arrays.asList(adminRel(SCHOOL_A), adminRel(SCHOOL_B)));
+ when(sysUserCurrentRoleRepository.findByUserId(USER_ID)).thenReturn(Optional.empty());
+ when(mainGroupResolver.resolve(anyInt())).thenReturn(Optional.empty());
+ }
+
+ // ===================== requireSchoolInScope =====================
+
+ @Test
+ void requireSchoolInScopeRejectsNull() {
+ givenSuper();
+ DException error = assertThrows(DException.class, () -> accessScopeService.requireSchoolInScope(null));
+ assertEquals("学校ID不能为空", error.getMessage());
+ }
+
+ @Test
+ void requireSchoolInScopeRejectsUnknownGroup() {
+ givenSuper();
+ when(sysGroupRepository.findById(999)).thenReturn(Optional.empty());
+ DException error = assertThrows(DException.class, () -> accessScopeService.requireSchoolInScope(999));
+ assertEquals("学校分组不存在", error.getMessage());
+ }
+
+ /** 教师组在自己的数据范围内,但不是学校 → 学校维度仍要拒绝(不能拿组维度 guard 蒙混) */
+ @Test
+ void requireSchoolInScopeRejectsNonSchoolTypeEvenInsideScope() {
+ givenSingleSchoolAdmin();
+ when(sysGroupRepository.findById(TEACHER_GROUP_IN_A))
+ .thenReturn(Optional.of(group(TEACHER_GROUP_IN_A, GroupTypeEnum.TEACHER_GROUP, SCHOOL_A)));
+
+ DException error = assertThrows(DException.class,
+ () -> accessScopeService.requireSchoolInScope(TEACHER_GROUP_IN_A));
+ assertEquals("学校ID必须为学校分组", error.getMessage());
+ }
+
+ /** A09:学校管理员只能命中本校 */
+ @Test
+ void requireSchoolInScopeRejectsAnotherSchoolForSchoolAdmin() {
+ givenSingleSchoolAdmin();
+ when(sysGroupRepository.findById(SCHOOL_B))
+ .thenReturn(Optional.of(group(SCHOOL_B, GroupTypeEnum.SCHOOL_GROUP, 0)));
+
+ DException error = assertThrows(DException.class, () -> accessScopeService.requireSchoolInScope(SCHOOL_B));
+ assertTrue(error.getMessage().contains("暂无权限"));
+ }
+
+ @Test
+ void requireSchoolInScopeAcceptsOwnSchool() {
+ givenSingleSchoolAdmin();
+ when(sysGroupRepository.findById(SCHOOL_A))
+ .thenReturn(Optional.of(group(SCHOOL_A, GroupTypeEnum.SCHOOL_GROUP, 0)));
+
+ accessScopeService.requireSchoolInScope(SCHOOL_A);
+ }
+
+ @Test
+ void requireSchoolInScopeAcceptsAnySchoolForSuper() {
+ givenSuper();
+ when(sysGroupRepository.findById(SCHOOL_B))
+ .thenReturn(Optional.of(group(SCHOOL_B, GroupTypeEnum.SCHOOL_GROUP, 0)));
+
+ accessScopeService.requireSchoolInScope(SCHOOL_B);
+ }
+
+ // ===================== resolveWritableSchoolId =====================
+
+ /** 单校管理员不传 schoolId → 取本校(写口仍落在权威学校上) */
+ @Test
+ void resolveWritableSchoolIdUsesOwnSchoolWhenNotSpecified() {
+ givenSingleSchoolAdmin();
+ assertEquals(SCHOOL_A, accessScopeService.resolveWritableSchoolId(null));
+ }
+
+ /** 单校管理员传别校 id → 拒绝,且不回落到本校(不能静默改写用户意图) */
+ @Test
+ void resolveWritableSchoolIdRejectsForeignSchool() {
+ givenSingleSchoolAdmin();
+ DException error = assertThrows(DException.class, () -> accessScopeService.resolveWritableSchoolId(SCHOOL_B));
+ assertTrue(error.getMessage().contains("暂无权限"));
+ }
+
+ @Test
+ void resolveWritableSchoolIdAcceptsMatchedOwnSchool() {
+ givenSingleSchoolAdmin();
+ assertEquals(SCHOOL_A, accessScopeService.resolveWritableSchoolId(SCHOOL_A));
+ }
+
+ /** 多校管理员未指定学校 → 要求补齐,不猜学校 */
+ @Test
+ void resolveWritableSchoolIdRequiresChoiceForMultiSchoolAdmin() {
+ givenTwoSchoolAdminWithoutActiveRole();
+ DException error = assertThrows(DException.class, () -> accessScopeService.resolveWritableSchoolId(null));
+ assertTrue(error.getMessage().contains("请选择"));
+ }
+
+ /** 未登录(UserContext 为空 → NONE)→ 暂无权限 */
+ @Test
+ void resolveWritableSchoolIdRejectsAnonymous() {
+ DException error = assertThrows(DException.class, () -> accessScopeService.resolveWritableSchoolId(SCHOOL_A));
+ assertEquals("暂无权限", error.getMessage());
+ }
+
+ /** SUPER:以请求学校为准(校维度 guard 兜范围) */
+ @Test
+ void resolveWritableSchoolIdUsesRequestedSchoolForSuper() {
+ givenSuper();
+ when(sysGroupRepository.findById(SCHOOL_B))
+ .thenReturn(Optional.of(group(SCHOOL_B, GroupTypeEnum.SCHOOL_GROUP, 0)));
+
+ assertEquals(SCHOOL_B, accessScopeService.resolveWritableSchoolId(SCHOOL_B));
+ }
+}