# 用户与组织架构管理 - 测试场景文档

> **版本**: v2.4  
> **架构**: 独立 Organization 表 + 组织级权限隔离  
> **最后更新**: 2026-05-19  
> **维护者**: FFOA 测试团队

---

## 📝 文档变更记录

| 版本    | 日期       | 修改人    | 修改内容                                                               |
| ------- | ---------- | --------- | ---------------------------------------------------------------------- |
| v2.4    | 2026-05-19 | FFOA Team | 新增 Entra ID SSO 登录 L1 集成测试场景（5.3.x）：start/callback 端点、JIT 建账号、白名单、绑定冲突、审计落库（关联 #334） |
| v2.1.38 | 2026-03-13 | FFOA Team | 新增钉钉年假计划参数编辑测试场景，并明确两个天数字段的差异化校验       |
| v2.1.37 | 2026-03-11 | FFOA Team | 年假释放计划页新增分页、顶部横向滚动条和左侧固定列                     |
| v2.1.35 | 2026-03-11 | FFOA Team | 年假释放计划页改为按员工展示第 N 天释放日期矩阵                        |
| v2.1.34 | 2026-03-11 | FFOA Team | 新增年假释放计划手动重算测试场景                                       |
| v2.1.33 | 2026-03-11 | FFOA Team | 年假释放中间表切换到本地数据库，补充计划重算与页面读取本地计划测试场景 |
| v2.1.32 | 2026-03-11 | FFOA Team | 新增钉钉假期余额详情测试场景，覆盖释放记录与使用记录展示               |
| v2.1.31 | 2026-03-11 | FFOA Team | 新增钉钉假期余额快照刷新与页面读取快照测试场景                         |
| v2.1.30 | 2026-03-11 | FFOA Team | 新增钉钉考勤类定时任务固定半小时整点窗口测试场景                       |
| v2.1.29 | 2026-03-10 | FFOA Team | 新增钉钉员工信息新表工时字段同步测试场景                               |
| v2.1.28 | 2026-03-10 | FFOA Team | 新增钉钉审批撤销修复测试场景                                           |
| v2.1.27 | 2026-02-25 | FFOA Team | 新增未登录访问受保护页后登录回跳测试场景                               |
| v2.1.26 | 2026-01-25 | FFOA Team | 预定义角色 code 对齐 Administrator/Employee                            |
| v1.0    | 2024-11-01 | FFOA Team | 初始版本,基础测试场景                                                  |
| v2.0    | 2025-12-20 | FFOA Team | 新增组织管理测试场景，更新部门管理测试                                 |
| v2.1    | 2025-12-26 | FFOA Team | 新增组织级权限隔离测试场景，完善多组织权限测试                         |
| v2.1.1  | 2025-12-26 | FFOA Team | 新增身份源管理、登录安全、密码修改限制测试场景                         |
| v2.1.2  | 2025-12-27 | FFOA Team | 新增 E2E 测试选择器指南                                                |
| v2.1.25 | 2026-01-05 | FFOA Team | 同步 PRD v2.1.25：简化登录测试、移除锁定测试                           |

---

## 🚨 E2E 执行方式更新

- E2E 由 **Agent + Playwright MCP** 执行
- 用例以 `10-e2e-test-spec.md` 为执行来源
- 文中 Playwright 测试代码仅作历史参考，禁止新增

## 🎯 E2E 测试选择器指南 ⭐ v2.1.2 新增

### 基础配置

```typescript
const BASE_URL = process.env.E2E_BASE_URL || "http://localhost:6010";
```

### 1. 选择器优先级

在编写 E2E 用例文档时，应按以下优先级选择元素：

1. **`data-testid` 属性**（最优先）- 专为测试设计的标识符
2. **`data-field` 属性**（推荐）- 用于标识数据字段
3. **语义化选择器** - 如 `button[type="submit"]`、`input[name="username"]`
4. **CSS 类名** - 仅当以上方式不可用时使用
5. **结构选择器**（最后）- 如 `.nth(0)`、`:first-child`

### 2. 用户管理页面选择器

#### 用户列表页面 (`/organization/members`)

```typescript
// ✅ 正确的选择器
const userTable = page.locator('[data-testid="user-table"]');
const userRows = page.locator('[data-testid="user-row"]');
const searchInput = page.locator('input[data-testid="user-search-input"]');

// ✅ 读取表格单元格内容
const firstRow = userRows.first();
const username = await firstRow.locator("td").nth(1).textContent(); // 第2列是 username
const displayName = await firstRow.locator("td").nth(2).textContent(); // 第3列是 displayName
const email = await firstRow.locator("td").nth(3).textContent(); // 第4列是 email

// 表格列顺序（从左到右，从0开始）：
// 0: Checkbox
// 1: 用户名 (username)
// 2: 显示名称 (displayName)
// 3: 邮箱 (email)
// 4: 部门 (department)
// 5: 状态 (status)
// 6: 操作按钮 (actions)

// ❌ 错误的选择器（TableCell 没有 data-column 属性）
const username = await firstRow
  .locator('[data-column="username"]')
  .textContent();
```

#### 用户详情页面 (`/organization/members/:id`)

```typescript
// ✅ 正确的选择器 - 使用 data-field 属性
await expect(page.locator('[data-field="username"]')).toHaveText("admin");
await expect(page.locator('[data-field="displayName"]')).toHaveText("管理员");
await expect(page.locator('[data-field="email"]')).toBeVisible();
await expect(page.locator('[data-field="status"]')).toBeVisible();

// ✅ 标签页
await page.click('[data-tab="basic"]');
await page.click('[data-tab="departments"]');
await page.click('[data-tab="roles"]');

// ✅ 状态徽章
const statusBadge = page.locator('[data-testid^="status-badge-"]');
```

#### 用户编辑页面 (`/organization/members/:id/edit`)

```typescript
// ✅ 表单字段
await page.fill('input[name="displayName"]', "新名称");
await page.fill('input[name="email"]', "new@example.com");
await page.selectOption('select[name="status"]', "ACTIVE");
```

### 3. 常见陷阱和解决方案

#### 陷阱 1: 混淆 TableHead 和 TableCell 的属性

```typescript
// ❌ 错误：TableCell 没有 data-column 属性
const cell = row.locator('[data-column="username"]');

// ✅ 正确：使用结构选择器或直接读取内容
const cell = row.locator("td").nth(1); // 按位置
const text = await row.locator("td").nth(1).textContent(); // 读取文本
```

#### 陷阱 2: 防抖搜索未等待

```typescript
// ❌ 错误：搜索后立即验证结果
await searchInput.fill("admin");
const rows = page.locator('[data-testid="user-row"]');
expect(await rows.count()).toBeGreaterThan(0); // 可能失败

// ✅ 正确：等待防抖延迟
await searchInput.fill("admin");
const rows = page.locator('[data-testid="user-row"]');
await expect.poll(async () => await rows.count()).toBeGreaterThan(0);
```

#### 陷阱 3: 动态加载未等待

```typescript
// ❌ 错误：未等待数据加载
await page.goto("/organization/members");
const rows = page.locator('[data-testid="user-row"]');
expect(await rows.count()).toBeGreaterThan(0); // 可能失败

// ✅ 正确：等待元素可见
await page.goto("/organization/members");
await page.waitForSelector('[data-testid="user-table"]');
const rows = page.locator('[data-testid="user-row"]');
expect(await rows.count()).toBeGreaterThan(0);
```

### 4. 最佳实践

1. **优先使用 `data-testid`**：所有需要测试的元素都应添加 `data-testid` 属性
2. **避免依赖文本内容**：文本可能会因国际化而改变
3. **使用明确的等待**：使用 `waitForSelector` 而不是 `waitForTimeout`
4. **封装通用操作**：如登录、导航等应封装为辅助函数
5. **使用 Page Object 模式**：将页面元素和操作封装到类中

### 5. 示例：正确的用户搜索测试

```typescript
test("应该能够搜索用户", async ({ page }) => {
  // 1. 导航到页面并等待加载
  await page.goto(`${BASE_URL}/organization/members`);
  await page.waitForSelector('[data-testid="user-table"]', {
    timeout: TIMEOUT,
  });

  // 2. 输入搜索关键词
  const searchInput = page.locator('input[data-testid="user-search-input"]');
  await searchInput.fill("admin");

  // 3. 等待列表刷新（前端使用了防抖）
  const rows = page.locator('[data-testid="user-row"]');
  await expect.poll(async () => await rows.count()).toBeGreaterThan(0);

  // 4. 验证搜索结果
  const count = await rows.count();
  expect(count).toBeGreaterThan(0);

  // 5. 验证第一行包含搜索关键词
  // TableCell 顺序：[checkbox(0), username(1), displayName(2), email(3), ...]
  const firstRow = rows.first();
  const username = await firstRow.locator("td").nth(1).textContent();
  expect(username?.toLowerCase()).toContain("admin");
});
```

---

## 📋 概述

本文档定义组织架构模块的完整测试场景，包括单元测试、集成测试和E2E测试。

### 测试策略

- **测试金字塔**: 60% 单元测试 + 30% 集成测试 + 10% E2E测试
- **覆盖率目标**: 代码覆盖率 ≥ 80%
- **测试框架**: Jest (后端) + Playwright MCP (E2E 执行器)
- **测试数据**: 使用专用测试数据库

---

## 钉钉模块测试场景

钉钉相关测试场景已迁移至独立模块文档维护，见 [docs/modules/dingtalk/09-test-scenarios.md](../dingtalk/09-test-scenarios.md)。

---

## 🔤 Service 方法命名约定

本文档中的测试代码使用 **Service 层方法名**，这些方法是后端内部实现，与对外 API 端点可能略有不同。

### Service vs API 映射关系

| Service 方法                                         | API 端点                                         | 说明                        |
| ---------------------------------------------------- | ------------------------------------------------ | --------------------------- |
| **OrganizationsService**                             |
| `organizationsService.findOne(id)`                   | `GET /organizations/:id`                         | 获取组织详情                |
| `organizationsService.getStats(id)`                  | `GET /organizations/:id/stats`                   | 获取组织统计                |
| `organizationsService.addOrganizationRegion(...)`    | `POST /organizations/:id/regions`                | 添加区域关联                |
| `organizationsService.removeOrganizationRegion(...)` | `DELETE /organizations/:id/regions/:regionId`    | 移除区域关联                |
| **UserDepartmentsService**                           |
| `userDepartmentsService.addUserDepartment(...)`      | `POST /users/:id/departments`                    | 添加部门归属                |
| `userDepartmentsService.setPrimaryDepartment(...)`   | `PUT /users/:userId/departments/:deptId/primary` | 设置主部门                  |
| `userDepartmentsService.removeUserDepartment(...)`   | `DELETE /users/:userId/departments/:deptId`      | 移除部门归属                |
| `userDepartmentsService.updateUserDepartment(...)`   | `PATCH /users/:userId/departments/:deptId`       | 更新部门归属                |
| **RolesService**                                     |
| `rolesService.assignPermissions(roleId, dto)`        | `PUT /roles/:id/permissions`                     | 批量分配权限（覆盖式）      |
| **UsersService**                                     |
| `usersService.assignRoles(userId, dto)`              | `POST /users/:id/roles`                          | 分配角色（v2.1 支持组织级） |

### 特殊说明

#### 1. 权限操作（RolesService）

测试中如果使用 `addPermission/removePermission` 方法，这是 **Service 层便利方法**，底层实际调用批量 API：

```typescript
// 便利方法（如果 Service 层实现）
await rolesService.addPermission(roleId, permissionId);

// 实际 API 调用（批量覆盖式）
const existingPerms = await rolesService.getPermissions(roleId);
await rolesService.assignPermissions(roleId, {
  permissionIds: [...existingPerms.map((p) => p.id), permissionId],
});
```

#### 2. 用户角色分配

角色分配通过 **UsersService** 完成，不是 RolesService：

```typescript
// ✅ 正确：通过 UsersService 分配角色
await usersService.assignRoles(user.id, {
  assignments: [
    {
      roleId: role.id,
      organizationId: orgId, // v2.1: 支持组织级角色
    },
  ],
});

// ❌ 错误：RolesService 不提供用户分配接口
await rolesService.assignToUser(user.id, role.id); // 此方法不存在
```

#### 3. 组织区域关联

使用明确的方法名以区分添加和移除操作：

```typescript
// 添加区域关联
await organizationsService.addOrganizationRegion(orgId, {
  regionId: region.id,
  isDefault: true,
});

// 移除区域关联
await organizationsService.removeOrganizationRegion(orgId, regionId);
```

---

## 🧪 单元测试场景

### 1. 用户服务 (UsersService)

#### 1.1 创建用户

##### 测试场景 1.1.1: 成功创建本地用户

**测试代码**:

```typescript
describe("UsersService.create", () => {
  it("should create a local user successfully", async () => {
    const dto = {
      username: "testuser",
      email: "test@example.com",
      displayName: "测试用户",
      passwordHash: "hashed_password",
      region: "CN",
      status: "ACTIVE",
      source: "LOCAL",
    };

    const result = await usersService.create(dto);

    expect(result.id).toBeDefined();
    expect(result.username).toBe("testuser");
    expect(result.email).toBe("test@example.com");
    expect(result.status).toBe("ACTIVE");
    expect(result.source).toBe("LOCAL");
  });
});
```

**断言**:

- ✅ 返回用户对象包含UUID
- ✅ 用户名、邮箱正确
- ✅ 状态默认为 ACTIVE
- ✅ 来源为 LOCAL

---

##### 测试场景 1.1.2: 成功创建 LDAP 用户（v2.1.1 新增）⭐

**测试代码**:

```typescript
it("should create an LDAP user successfully", async () => {
  const dto = {
    username: "ldapuser",
    email: "ldap@example.com",
    displayName: "LDAP用户",
    source: "LDAP",
    ldapDn: "CN=LDAP User,OU=Users,DC=company,DC=com",
    region: "CN",
    status: "ACTIVE",
  };

  const result = await usersService.create(dto);

  expect(result.source).toBe("LDAP");
  expect(result.ldapDn).toBe("CN=LDAP User,OU=Users,DC=company,DC=com");
  expect(result.passwordHash).toBeNull(); // LDAP用户无密码
});
```

**断言**:

- ✅ 来源为 LDAP
- ✅ ldapDn 正确设置
- ✅ passwordHash 为空

---

##### 测试场景 1.1.3: 成功创建 LDAP 用户（Entra 同步）⭐

**测试代码**:

```typescript
it("should create a LDAP user synced from Entra successfully", async () => {
  const dto = {
    username: "entrauser",
    email: "entra@example.com",
    displayName: "Entra同步用户",
    source: "LDAP",
    externalId: "entra-id-12345",
    externalSource: "ENTRA_ID",
    region: "CN",
    status: "ACTIVE",
  };

  const result = await usersService.create(dto);

  expect(result.source).toBe("LDAP");
  expect(result.externalId).toBe("entra-id-12345");
  expect(result.externalSource).toBe("ENTRA_ID");
  expect(result.passwordHash).toBeNull(); // LDAP用户无密码
});
```

**断言**:

- ✅ 来源为 LDAP
- ✅ externalId 正确设置
- ✅ passwordHash 为空

---

##### 测试场景 1.1.4: 用户名重复应抛出异常

**测试代码**:

```typescript
it("should throw conflict exception when username exists", async () => {
  await usersService.create({
    username: "duplicate",
    email: "user1@example.com",
    displayName: "用户1",
  });

  await expect(
    usersService.create({
      username: "duplicate",
      email: "user2@example.com",
      displayName: "用户2",
    }),
  ).rejects.toThrow("IAM_USERNAME_EXISTS");
});
```

**断言**:

- ✅ 抛出 ConflictException
- ✅ 错误码为 IAM_USERNAME_EXISTS
- ✅ 错误消息包含用户名

---

##### 测试场景 1.1.5: 邮箱重复应抛出异常

**测试代码**:

```typescript
it("should throw conflict exception when email exists", async () => {
  await usersService.create({
    username: "user1",
    email: "same@example.com",
    displayName: "用户1",
  });

  await expect(
    usersService.create({
      username: "user2",
      email: "same@example.com",
      displayName: "用户2",
    }),
  ).rejects.toThrow("IAM_USER_EMAIL_EXISTS");
});
```

---

##### 测试场景 1.1.6: LDAP 用户不能有密码（v2.1.1 新增）⭐

**测试代码**:

```typescript
it("should reject LDAP user with password", async () => {
  await expect(
    usersService.create({
      username: "ldapuser",
      email: "ldap@example.com",
      displayName: "LDAP用户",
      source: "LDAP",
      passwordHash: "some-hash", // LDAP用户不应该有密码
      ldapDn: "CN=User,OU=Users,DC=company,DC=com",
    }),
  ).rejects.toThrow();
});
```

**断言**:

- ✅ 抛出错误
- ✅ 抛出错误
- ✅ 错误消息说明身份源不支持该操作

---

#### 1.2 更新用户状态

##### 测试场景 1.2.1: ACTIVE → INACTIVE

**测试代码**:

```typescript
it("should deactivate active user", async () => {
  const user = await createTestUser({ status: "ACTIVE" });

  const result = await usersService.updateStatus(user.id, {
    status: "INACTIVE",
    reason: "长期休假",
  });

  expect(result.status).toBe("INACTIVE");
  expect(result.updatedAt).not.toBe(user.updatedAt);
});
```

---

##### 测试场景 1.2.2: 无法停用最后一个管理员

**测试代码**:

```typescript
it("should prevent deactivating last admin", async () => {
  const admin = await createTestUser({ roles: ["Administrator"] });

  await expect(
    usersService.updateStatus(admin.id, { status: "INACTIVE" }),
  ).rejects.toThrow();
});
```

---

#### 1.3 删除用户

##### 测试场景 1.3.1: 软删除用户

**测试代码**:

```typescript
it("should soft delete user", async () => {
  const user = await createTestUser();

  await usersService.remove(user.id);

  const deleted = await usersService.findById(user.id);
  expect(deleted).toBeNull();

  const withDeleted = await usersService.findById(user.id, {
    includeDeleted: true,
  });
  expect(withDeleted).toBeDefined();
  expect(withDeleted.deletedAt).toBeDefined();
});
```

---

##### 测试场景 1.3.2: 有下属时无法删除

**测试代码**:

```typescript
it("should prevent deleting user with subordinates", async () => {
  const manager = await createTestUser();
  const subordinate = await createTestUser({ managerId: manager.id });

  await expect(usersService.remove(manager.id)).rejects.toThrow();
});
```

**断言**:

- ✅ 抛出错误
- ✅ 错误详情包含下属数量
- ✅ 错误详情包含下属列表

---

### 2. 组织服务 (OrganizationsService) - v2.0 新增 ⭐

#### 2.1 创建组织

##### 测试场景 2.1.1: 成功创建组织并自动创建根部门（v2.1.16 ⭐）

**测试代码**:

```typescript
describe("OrganizationsService.create", () => {
  it("[测试场景 2.1.1] should create organization and auto-create root department", async () => {
    const dto = {
      name: "FF China",
      code: "FF-CN",
      legalName: "Flying Fox China Co., Ltd.",
      taxId: "91110000MA001234XX",
      address: "北京市朝阳区",
      status: "ACTIVE",
    };

    const result = await organizationsService.create(dto);

    // 验证组织创建成功
    expect(result.id).toBeDefined();
    expect(result.name).toBe("FF China");
    expect(result.code).toBe("FF-CN");
    expect(result.legalName).toBe("Flying Fox China Co., Ltd.");
    expect(result.status).toBe("ACTIVE");
    expect(result.createdAt).toBeDefined();

    // 验证根部门自动创建 (v2.1.16)
    expect(result.departments).toBeDefined();
    expect(result.departments.length).toBeGreaterThan(0);

    const rootDepartment = result.departments[0];
    expect(rootDepartment.name).toBe("FF China"); // 与组织同名
    expect(rootDepartment.code).toBe("FF-CN"); // 与组织同码
    expect(rootDepartment.parentId).toBeNull(); // 顶级部门
    expect(rootDepartment.organizationId).toBe(result.id);
  });
});
```

**断言**:

- ✅ 返回组织对象包含 UUID
- ✅ 组织名称、代码、法人名称正确
- ✅ 税号、地址正确
- ✅ 状态默认为 ACTIVE
- ✅ 创建时间已设置
- ✅ **自动创建根部门（v2.1.16 新增）**
- ✅ **根部门与组织同名同码**
- ✅ **根部门的 parentId 为 null**

---

##### 测试场景 2.1.2: 组织名称重复应抛出异常

**测试代码**:

```typescript
it("should throw conflict exception when organization name exists", async () => {
  await organizationsService.create({
    name: "FF China",
    shortName: "FF-CN",
    legalName: "Flying Fox China Co., Ltd.",
  });

  await expect(
    organizationsService.create({
      name: "FF China",
      shortName: "FF-CN2",
      legalName: "Another Legal Name",
    }),
  ).rejects.toThrow("IAM_ORGANIZATION_NAME_EXISTS");
});
```

**断言**:

- ✅ 抛出 ConflictException
- ✅ 错误码为 IAM_ORGANIZATION_NAME_EXISTS
- ✅ 错误消息包含组织名称

---

##### 测试场景 2.1.3: 税号重复应抛出异常

**测试代码**:

```typescript
it("should throw conflict exception when tax ID exists", async () => {
  await organizationsService.create({
    name: "FF China",
    shortName: "FF-CN",
    taxId: "91110000MA001234XX",
  });

  await expect(
    organizationsService.create({
      name: "FF China 2",
      shortName: "FF-CN2",
      taxId: "91110000MA001234XX",
    }),
  ).rejects.toThrow();
});
```

---

#### 2.2 更新组织

##### 测试场景 2.2.1: 成功更新组织信息

**测试代码**:

```typescript
it("should update organization information", async () => {
  const org = await createTestOrganization({
    name: "Old Name",
    status: "ACTIVE",
  });

  const result = await organizationsService.update(org.id, {
    name: "New Name",
    address: "新地址",
  });

  expect(result.name).toBe("New Name");
  expect(result.address).toBe("新地址");
  expect(result.updatedAt).not.toBe(org.updatedAt);
});
```

---

##### 测试场景 2.2.2: 更新不存在的组织应抛出异常

**测试代码**:

```typescript
it("should throw error when organization not found", async () => {
  await expect(
    organizationsService.update("non-existent-id", {
      name: "New Name",
    }),
  ).rejects.toThrow();
});
```

---

#### 2.3 删除组织

##### 测试场景 2.3.1: 成功软删除组织

**测试代码**:

```typescript
it("should soft delete organization", async () => {
  const org = await createTestOrganization();

  await organizationsService.remove(org.id);

  const deleted = await organizationsService.findOne(org.id);
  expect(deleted).toBeNull();

  const withDeleted = await organizationsService.findOne(org.id, {
    includeDeleted: true,
  });
  expect(withDeleted).toBeDefined();
  expect(withDeleted.deletedAt).toBeDefined();
});
```

---

##### 测试场景 2.3.2: 有部门时无法删除组织

**测试代码**:

```typescript
it("should prevent deleting organization with departments", async () => {
  const org = await createTestOrganization();
  await createTestDepartment({ organizationId: org.id });

  await expect(organizationsService.remove(org.id)).rejects.toThrow();
});
```

**断言**:

- ✅ 抛出错误
- ✅ 错误详情包含部门数量
- ✅ 建议先转移或删除部门

---

##### 测试场景 2.3.3: 有用户时无法删除组织

**测试代码**:

```typescript
it("should prevent deleting organization with users", async () => {
  const org = await createTestOrganization();
  const dept = await createTestDepartment({ organizationId: org.id });
  await createTestUser({ departmentId: dept.id });

  await expect(organizationsService.remove(org.id)).rejects.toThrow();
});
```

---

#### 2.4 组织区域关联

##### 测试场景 2.4.1: 添加区域到组织

**测试代码**:

```typescript
it("should add region to organization", async () => {
  const org = await createTestOrganization();
  const region = await createTestRegion({ code: "CN" });

  // 使用 POST /organizations/:id/regions API
  await organizationsService.addOrganizationRegion(org.id, {
    regionId: region.id,
    isDefault: true,
  });

  const orgWithRegions = await organizationsService.findOne(org.id);
  expect(orgWithRegions.organizationRegions).toHaveLength(1);
  expect(orgWithRegions.organizationRegions[0].regionId).toBe(region.id);
  expect(orgWithRegions.organizationRegions[0].isDefault).toBe(true);
});
```

> **说明**: API 是 `POST /organizations/:id/regions`，用于添加单个区域关联。

---

##### 测试场景 2.4.2: 移除组织区域

**测试代码**:

```typescript
it("should remove region from organization", async () => {
  const org = await createTestOrganization();
  const region = await createTestRegion({ code: "CN" });

  // 添加区域
  await organizationsService.addOrganizationRegion(org.id, {
    regionId: region.id,
    isDefault: false,
  });

  // 移除区域 (DELETE /organizations/:id/regions/:regionId)
  await organizationsService.removeOrganizationRegion(org.id, region.id);

  const orgWithRegions = await organizationsService.findOne(org.id);
  expect(orgWithRegions.organizationRegions).toHaveLength(0);
});
```

---

##### 测试场景 2.4.3: 重复添加区域应抛出异常

**测试代码**:

```typescript
it("should prevent duplicate region association", async () => {
  const org = await createTestOrganization();
  const region = await createTestRegion({ code: "CN" });

  // 第一次添加
  await organizationsService.addOrganizationRegion(org.id, {
    regionId: region.id,
    isDefault: false,
  });

  // 第二次添加相同区域（应该抛出异常）
  await expect(
    organizationsService.addOrganizationRegion(org.id, {
      regionId: region.id,
      isDefault: false,
    }),
  ).rejects.toThrow();
});
```

---

#### 2.5 组织统计

##### 测试场景 2.5.1: 获取组织统计信息

**测试代码**:

```typescript
it("should get organization statistics", async () => {
  const org = await createTestOrganization();
  const dept1 = await createTestDepartment({ organizationId: org.id });
  const dept2 = await createTestDepartment({ organizationId: org.id });
  const user1 = await createTestUser({ departmentId: dept1.id });
  const user2 = await createTestUser({ departmentId: dept2.id });

  const stats = await organizationsService.getStats(org.id);

  expect(stats.departmentCount).toBe(2);
  expect(stats.userCount).toBe(2);
  expect(stats.activeUserCount).toBe(2);
});
```

---

### 3. 部门服务 (DepartmentsService)

#### 2.1 创建部门

##### 测试场景 2.1.1: 禁止手动创建顶级部门（v2.1.16 ⭐）

**测试代码**:

```typescript
it("[测试场景 2.1.1] should prevent creating top-level department manually", async () => {
  const dto = {
    organizationId: "org-001",
    name: "Manual Root",
    code: "ROOT",
    parentId: null, // 尝试创建顶级部门
  };

  await expect(departmentsService.create(dto)).rejects.toThrow(
    BadRequestException,
  );

  await expect(departmentsService.create(dto)).rejects.toThrow(
    "Cannot create top-level department manually",
  );
});
```

**断言**:

- ✅ **抛出 BadRequestException（v2.1.16 新增）**
- ✅ **错误信息明确说明不能手动创建顶级部门**
- ✅ **根部门只能由系统在创建组织时自动创建**

---

##### 测试场景 2.1.2: 创建子部门

**测试代码**:

```typescript
it("[测试场景 2.1.2] should create child department successfully", async () => {
  const organization = await createTestOrganization(); // 自动创建根部门
  const rootDept = await departmentsService.findOne({
    organizationId: organization.id,
    parentId: null,
  });

  const child = await departmentsService.create({
    organizationId: organization.id,
    name: "技术部",
    code: "TECH",
    parentId: rootDept.id, // 必须指定父部门
  });

  expect(child.parentId).toBe(rootDept.id);
  expect(child.name).toBe("技术部");
});
```

---

##### 测试场景 2.1.3: 父部门不存在应抛出异常

**测试代码**:

```typescript
it("should throw error when parent not found", async () => {
  await expect(
    departmentsService.create({
      name: "子部门",
      code: "CHILD",
      parentId: "non-existent-id",
    }),
  ).rejects.toThrow();
});
```

---

#### 2.2 更新部门层级

##### 测试场景 2.2.1: 检测循环引用

**测试代码**:

```typescript
it("should detect circular reference", async () => {
  const deptA = await createTestDepartment({ code: "A" });
  const deptB = await createTestDepartment({ code: "B", parentId: deptA.id });
  const deptC = await createTestDepartment({ code: "C", parentId: deptB.id });

  // 尝试让 A 成为 C 的子部门（形成循环）
  await expect(
    departmentsService.update(deptA.id, { parentId: deptC.id }),
  ).rejects.toThrow();
});
```

---

#### 2.3 删除部门

##### 测试场景 2.3.1: 无法删除有子部门的部门

**测试代码**:

```typescript
it("should prevent deleting department with children", async () => {
  const parent = await createTestDepartment({ code: "PARENT" });
  await createTestDepartment({ code: "CHILD", parentId: parent.id });

  await expect(departmentsService.remove(parent.id)).rejects.toThrow(
    "IAM_DEPARTMENT_HAS_CHILDREN",
  );
});
```

---

##### 测试场景 2.3.2: 无法删除有员工的部门

**测试代码**:

```typescript
it("should prevent deleting department with members", async () => {
  const dept = await createTestDepartment();
  await createTestUser({ departmentId: dept.id });

  await expect(departmentsService.remove(dept.id)).rejects.toThrow(
    "IAM_DEPARTMENT_HAS_USERS",
  );
});
```

---

##### 测试场景 2.3.3: 禁止删除根部门（v2.1.16 ⭐）

**测试代码**:

```typescript
it("[测试场景 2.3.3] should prevent deleting root department", async () => {
  const organization = await createTestOrganization(); // 自动创建根部门

  // 查找根部门
  const rootDept = await departmentsService.findOne({
    organizationId: organization.id,
    parentId: null,
  });

  await expect(departmentsService.remove(rootDept.id)).rejects.toThrow(
    BadRequestException,
  );

  await expect(departmentsService.remove(rootDept.id)).rejects.toThrow(
    "Cannot delete root department",
  );
});
```

**断言**:

- ✅ **抛出 BadRequestException（v2.1.16 新增）**
- ✅ **错误信息说明不能删除根部门**
- ✅ **根部门只能通过删除组织来删除**

---

### 3. 用户部门关联服务 (UserDepartmentsService)

#### 3.1 添加部门归属

##### 测试场景 3.1.1: 添加用户到部门

**测试代码**:

```typescript
it("should add user to department", async () => {
  const user = await createTestUser();
  const dept = await createTestDepartment();
  const position = await createTestPosition();
  const manager = await createTestUser({ departmentId: dept.id });

  const result = await userDepartmentsService.addUserDepartment(user.id, {
    departmentId: dept.id,
    positionId: position.id,
    managerId: manager.id,
    isPrimary: true,
  });

  expect(result.userId).toBe(user.id);
  expect(result.departmentId).toBe(dept.id);
  expect(result.isPrimary).toBe(true);
  expect(result.leftAt).toBeNull();
});
```

---

##### 测试场景 3.1.2: 上级必须在同一部门

**测试代码**:

```typescript
it("should reject manager from different department", async () => {
  const user = await createTestUser();
  const dept1 = await createTestDepartment({ code: "DEPT1" });
  const dept2 = await createTestDepartment({ code: "DEPT2" });
  const manager = await createTestUser({ departmentId: dept2.id });

  await expect(
    userDepartmentsService.addUserDepartment(user.id, {
      departmentId: dept1.id,
      managerId: manager.id,
    }),
  ).rejects.toThrow("IAM_MANAGER_NOT_IN_DEPARTMENT");
});
```

---

##### 测试场景 3.1.3: 用户已属于该部门

**测试代码**:

```typescript
it("should reject duplicate department membership", async () => {
  const user = await createTestUser();
  const dept = await createTestDepartment();

  await userDepartmentsService.addUserDepartment(user.id, {
    departmentId: dept.id,
  });

  await expect(
    userDepartmentsService.addUserDepartment(user.id, {
      departmentId: dept.id,
    }),
  ).rejects.toThrow("IAM_USER_ALREADY_IN_DEPARTMENT");
});
```

---

#### 3.2 主部门管理

##### 测试场景 3.2.1: 切换主部门

**测试代码**:

```typescript
it("should switch primary department", async () => {
  const user = await createTestUser();
  const dept1 = await createTestDepartment({ code: "DEPT1" });
  const dept2 = await createTestDepartment({ code: "DEPT2" });

  const ud1 = await userDepartmentsService.addUserDepartment(user.id, {
    departmentId: dept1.id,
    isPrimary: true,
  });

  const ud2 = await userDepartmentsService.addUserDepartment(user.id, {
    departmentId: dept2.id,
    isPrimary: false,
  });

  // 切换主部门
  await userDepartmentsService.setPrimaryDepartment(user.id, dept2.id);

  const updated1 = await userDepartmentsService.findOne(ud1.id);
  const updated2 = await userDepartmentsService.findOne(ud2.id);

  expect(updated1.isPrimary).toBe(false);
  expect(updated2.isPrimary).toBe(true);
});
```

---

##### 测试场景 3.2.2: 允许删除主部门（自动切换）⭐ v2.1.1 更新

> **业务逻辑优化**: 原限制已移除，允许删除主部门并自动调整。详见 [99-changelog.md#主部门删除逻辑优化](./99-changelog.md)

**测试代码**:

```typescript
it("should allow removing primary department with auto-adjustment", async () => {
  const user = await createTestUser();
  const dept1 = await createTestDepartment({ code: "DEPT1" });
  const dept2 = await createTestDepartment({ code: "DEPT2" });

  // 添加主部门
  await userDepartmentsService.addUserDepartment(user.id, {
    departmentId: dept1.id,
    isPrimary: true,
  });

  // 添加第二个部门
  await userDepartmentsService.addUserDepartment(user.id, {
    departmentId: dept2.id,
    isPrimary: false,
  });

  // 删除主部门（应该成功，并自动将dept2设为主部门）
  const result = await userDepartmentsService.removeUserDepartment(
    user.id,
    dept1.id,
  );

  expect(result.message).toBeDefined();
  expect(result.warning).toContain("Primary department removed");
  expect(result.warning).toContain("automatically set as primary");

  // 验证dept2已成为主部门
  const memberships = await userDepartmentsService.getUserDepartments(user.id);
  expect(memberships).toHaveLength(1);
  expect(memberships[0].departmentId).toBe(dept2.id);
  expect(memberships[0].isPrimary).toBe(true);
});
```

---

### 4. 角色服务 (RolesService)

#### 4.1 角色权限分配

##### 测试场景 4.1.1: 添加权限到角色

**测试代码**:

```typescript
it("should add permission to role", async () => {
  const role = await createTestRole();
  const permission = await createTestPermission({
    resource: "user",
    action: "read",
  });

  // 使用批量覆盖式 API（添加权限）
  const existingPerms = await rolesService.getPermissions(role.id);
  await rolesService.assignPermissions(role.id, {
    permissionIds: [...existingPerms.map((p) => p.id), permission.id],
  });

  const roleWithPerms = await rolesService.findByIdWithPermissions(role.id);
  expect(roleWithPerms.permissions).toHaveLength(1);
  expect(roleWithPerms.permissions[0].permissionId).toBe(permission.id);
});
```

> **说明**: API 使用批量覆盖式权限分配 (`PUT /roles/:id/permissions`)。如果 Service 层提供 `addPermission()` 便利方法，可以直接使用。

---

##### 测试场景 4.1.2: 移除角色权限

**测试代码**:

```typescript
it("should remove permission from role", async () => {
  const role = await createTestRole();
  const permission = await createTestPermission();

  // 先添加权限
  await rolesService.assignPermissions(role.id, {
    permissionIds: [permission.id],
  });

  // 移除权限（使用批量覆盖式 API，传入空数组）
  await rolesService.assignPermissions(role.id, {
    permissionIds: [],
  });

  const roleWithPerms = await rolesService.findByIdWithPermissions(role.id);
  expect(roleWithPerms.permissions).toHaveLength(0);
});
```

> **说明**: API 使用批量覆盖式权限分配。如果 Service 层提供 `removePermission()` 便利方法，可以直接使用。

---

#### 4.2 用户角色分配

##### 测试场景 4.2.1: 分配角色给用户

**测试代码**:

```typescript
it("should assign role to user", async () => {
  const user = await createTestUser();
  const role = await createTestRole();

  // 使用 UsersService 的角色分配 API (v2.1 格式)
  await usersService.assignRoles(user.id, {
    assignments: [
      {
        roleId: role.id,
        organizationId: null, // 全局角色
      },
    ],
  });

  // 获取用户角色
  const userWithRoles = await usersService.findById(user.id, {
    include: ["roles"],
  });
  expect(userWithRoles.roles).toHaveLength(1);
  expect(userWithRoles.roles[0].roleId).toBe(role.id);
});
```

> **说明**: 角色分配通过 `POST /users/:id/roles` API 完成，由 UsersService 处理（不是 RolesService）。

---

### 5. 认证服务 (AuthService)

#### 5.1 登录

##### 测试场景 5.1.1: 本地用户成功登录

**测试代码**:

```typescript
it("should login with valid credentials", async () => {
  const password = "test123";
  const user = await createTestUser({
    username: "testuser",
    source: "LOCAL",
    passwordHash: await hashPassword(password),
  });

  const result = await authService.login({
    username: "testuser",
    password: password,
  });

  expect(result.accessToken).toBeDefined();
  expect(result.refreshToken).toBeDefined();
  expect(result.user.id).toBe(user.id);
});
```

---

##### 测试场景 5.1.2: LDAP 用户成功登录（v2.1.1 新增）⭐

**测试代码**:

```typescript
it("should login LDAP user via LDAP authentication", async () => {
  const user = await createTestUser({
    username: "ldapuser",
    source: "LDAP",
    ldapDn: "CN=LDAP User,OU=Users,DC=company,DC=com",
  });

  // Mock LDAP 认证
  mockLdapAuth("ldapuser", "ldappass", true);

  const result = await authService.login({
    username: "ldapuser",
    password: "ldappass",
  });

  expect(result.accessToken).toBeDefined();
  expect(result.user.source).toBe("LDAP");
});
```

**断言**:

- ✅ LDAP 认证成功
- ✅ 返回有效 Token
- ✅ 用户身份源为 LDAP

---

##### 测试场景 5.1.3: LDAP 用户通过 LDAP 登录（Entra 同步）⭐

**测试代码**:

```typescript
it("should allow LDAP user login via LDAP (Entra synced)", async () => {
  await createTestUser({
    username: "entrauser",
    source: "LDAP",
    externalId: "entra-id-12345",
  });

  // 需模拟 LDAP 认证通过
  const result = await authService.login({
    username: "entrauser",
    password: "ldappass",
  });

  expect(result.accessToken).toBeDefined();
  expect(result.user.source).toBe("LDAP");
});
```

**断言**:

- ✅ LDAP 认证成功
- ✅ 返回有效 Token
- ✅ 用户身份源为 LDAP

---

##### 测试场景 5.1.4: 密码错误

**测试代码**:

```typescript
it("should reject invalid password", async () => {
  await createTestUser({
    username: "testuser",
    source: "LOCAL",
    passwordHash: await hashPassword("correct"),
  });

  await expect(
    authService.login({
      username: "testuser",
      password: "wrong",
    }),
  ).rejects.toThrow("IAM_INVALID_CREDENTIALS");
});
```

---

##### 测试场景 5.1.5: 停用用户无法登录

**测试代码**:

```typescript
it("should reject inactive user", async () => {
  const password = "test123";
  await createTestUser({
    username: "testuser",
    passwordHash: await hashPassword(password),
    status: "INACTIVE",
  });

  await expect(
    authService.login({
      username: "testuser",
      password: password,
    }),
  ).rejects.toThrow("IAM_USER_SUSPENDED");
});
```

---

#### 5.2 密码修改（v2.1.1 新增）⭐

##### 测试场景 5.2.1: LOCAL 用户成功修改密码

**测试代码**:

```typescript
describe("AuthService.changePassword", () => {
  it("should allow LOCAL user to change password", async () => {
    const oldPassword = "old123";
    const newPassword = "new123";

    const user = await createTestUser({
      username: "localuser",
      source: "LOCAL",
      passwordHash: await hashPassword(oldPassword),
    });

    await authService.changePassword(user.id, {
      oldPassword,
      newPassword,
    });

    // 验证可以用新密码登录
    const result = await authService.login({
      username: "localuser",
      password: newPassword,
    });

    expect(result.accessToken).toBeDefined();
  });
});
```

---

##### 测试场景 5.2.2: LDAP 用户不能修改密码

**测试代码**:

```typescript
it("should reject LDAP user password change", async () => {
  const user = await createTestUser({
    username: "ldapuser",
    source: "LDAP",
    ldapDn: "CN=LDAP User,OU=Users,DC=company,DC=com",
  });

  await expect(
    authService.changePassword(user.id, {
      oldPassword: "old",
      newPassword: "new",
    }),
  ).rejects.toThrow();
});
```

**断言**:

- ✅ 抛出错误
- ✅ 错误码为 `CANNOT_CHANGE_PASSWORD`
- ✅ 错误消息说明 LDAP 用户不能修改密码

---

##### 测试场景 5.2.3: LDAP 用户不能修改密码（Entra 同步）

**测试代码**:

```typescript
it("should reject LDAP user password change (Entra synced)", async () => {
  const user = await createTestUser({
    username: "entrauser",
    source: "LDAP",
    externalId: "entra-id-12345",
  });

  await expect(
    authService.changePassword(user.id, {
      oldPassword: "old",
      newPassword: "new",
    }),
  ).rejects.toThrow();
});
```

**断言**:

- ✅ 抛出错误
- ✅ 错误码为 `CANNOT_CHANGE_PASSWORD`

---

##### 测试场景 5.2.4: 旧密码错误

**测试代码**:

```typescript
it("should reject incorrect old password", async () => {
  const user = await createTestUser({
    username: "localuser",
    source: "LOCAL",
    passwordHash: await hashPassword("correct"),
  });

  await expect(
    authService.changePassword(user.id, {
      oldPassword: "wrong",
      newPassword: "new123",
    }),
  ).rejects.toThrow();
});
```

**断言**:

- ✅ 抛出错误
- ✅ 错误消息提示旧密码不正确

---

##### 测试场景 5.2.5: 新密码不符合复杂度要求

**测试代码**:

```typescript
it("should reject weak new password", async () => {
  const user = await createTestUser({
    username: "localuser",
    source: "LOCAL",
    passwordHash: await hashPassword("old123"),
  });

  await expect(
    authService.changePassword(user.id, {
      oldPassword: "old123",
      newPassword: "123", // 太短
    }),
  ).rejects.toThrow();
});
```

---

#### 5.3 Entra ID SSO 登录集成测试（v2.4 新增）⭐

> **测试目标**：覆盖 `GET /api/v1/auth/sso/start` 与 `GET /api/v1/auth/sso/callback` 两个新增端点，验证 OIDC 授权码流程在已存在用户绑定、JIT 建账号、白名单、错误路径、双通道并存、审计落库各分支上的行为。
>
> **关联工单**：#334；**关联文档**：`01-prd.md` v2.4、`07-api.md` v2.4、`08-error-codes.md` v2.4、`03-architecture.md` v2.4。

##### 测试策略与边界

- **mock Entra OIDC**：用 `nock` / `msw` 拦截 Entra 的 OIDC discovery (`https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration`)、authorize、token endpoint，不连真 Entra（遵循 CLAUDE.md「集成测试使用本地通道」约定）。
- **不测真 Microsoft 登录页跳转**：跳转目标的 URL 拼装由本系统决定，可断言；Microsoft 端的登录界面、MFA 交互留给 L2 E2E。
- **沿用现有 testing/backend 框架**：与 LDAP 集成测试（场景 5.1.x）同一套 fixture 加载机制；mock Entra 响应放 `testing/backend/fixtures/sso/`，包括正常 id_token / 错误签名 token / 缺 email 的 token / 5xx 错误等固定 fixture。
- **不测 amr/acr claim**：本期不校验 MFA 等级（PRD v2.4 决策）。

##### 测试数据约定（遵循 CLAUDE.md「测试数据隔离」）

- 测试用户 email 命名前缀 `t_`，形如 `` `t_${Date.now()}_${random}@ff.com` ``。
- cleanup 用前缀过滤 `WHERE email LIKE 't_%'`，schema 增删表零维护负担。
- mock Entra 的 `oid` claim 命名前缀 `t_oid_`，避免和真实 oid 撞库。

##### 测试场景 5.3.1: SSO start 成功跳转

**测试代码**:

```typescript
it("should redirect to Entra authorize endpoint with state/nonce cookies", async () => {
  const res = await request(app)
    .get("/api/v1/auth/sso/start?redirect=/home")
    .expect(302);

  expect(res.headers.location).toMatch(
    /^https:\/\/login\.microsoftonline\.com\/[^/]+\/oauth2\/v2\.0\/authorize\?/,
  );
  expect(res.headers["set-cookie"].join(";")).toMatch(/sso_state=/);
  expect(res.headers["set-cookie"].join(";")).toMatch(/sso_nonce=/);
  expect(res.headers["set-cookie"].join(";")).toMatch(/sso_redirect=/);
});
```

**断言**:

- ✅ 302 location 形如 `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize?...`，且 query 含 `client_id`、`response_type=code`、`code_challenge`、`state`、`nonce`、`scope=openid profile email`
- ✅ 响应 Set-Cookie 含 `sso_state` / `sso_nonce` / `sso_redirect` 三项，均 HttpOnly + Secure + SameSite=Lax
- ✅ `sso_redirect` cookie 值为 `/home`

---

##### 测试场景 5.3.2: SSO start redirect 参数走白名单

**测试代码**:

```typescript
it("should reject or sanitize non-allowlisted redirect targets", async () => {
  const res = await request(app)
    .get("/api/v1/auth/sso/start?redirect=http://evil.com/x")
    .expect((r) => [302, 403].includes(r.status));

  if (res.status === 302) {
    // 实现可能改写为默认根路径
    expect(res.headers.location).not.toContain("evil.com");
  }
  const cookies = (res.headers["set-cookie"] || []).join(";");
  expect(cookies).not.toContain("evil.com");
});
```

**断言**:

- ✅ 不会原样把 `http://evil.com/x` 写进 `sso_redirect` cookie
- ✅ 实现两种合规做法之一：返回 403，或忽略改用默认 `/`

---

##### 测试场景 5.3.3: callback 成功 - 已存在用户回填 externalId

**测试代码**:

```typescript
it("should bind externalId and externalSource on existing LOCAL user without changing source", async () => {
  const email = `t_${Date.now()}_existing@ff.com`;
  const user = await createTestUser({
    email,
    source: "LOCAL",
    passwordHash: await hashPassword("local-pwd"),
  });

  mockEntraTokenEndpoint({
    idToken: {
      email,
      oid: `t_oid_${Date.now()}_abc`,
      iss: ENTRA_ISSUER,
      aud: ENTRA_CLIENT_ID,
    },
  });

  const res = await request(app)
    .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
    .set("Cookie", buildSsoCookies({ state, nonce }))
    .expect((r) => [200, 302].includes(r.status));

  const dbUser = await prisma.user.findUnique({ where: { id: user.id } });
  expect(dbUser?.source).toBe("LOCAL");
  expect(dbUser?.externalId).toMatch(/^t_oid_/);
  expect(dbUser?.externalSource).toBe("entra");
  expect(dbUser?.passwordHash).not.toBeNull();
});
```

**断言**:

- ✅ 响应是 302，Location 含 `#accessToken=...&refreshToken=...`（URL fragment 注入），**不**返 JSON body
- ✅ `User.externalId` = mock 中的 `oid`
- ✅ `User.externalSource` = `'entra'`
- ✅ `User.source` 保持 `LOCAL` 不变
- ✅ `User.passwordHash` 未被清空
- ✅ AuditLog 含 `SSO_BINDING_FILLED`（metadata: `userId` / `email` / `externalId` / `previousExternalId: null` / `entraTid`） 和 `SSO_LOGIN_SUCCESS`（metadata: `path: binding_filled` / `userId` / `email` / `externalId` / `entraTid`） 两条事件
- ✅ 响应 Set-Cookie 含 4 个清除指令（`sso_state=; Max-Age=0` / `sso_nonce=; Max-Age=0` / `sso_redirect=; Max-Age=0` / `sso_code_verifier=; Max-Age=0`）

---

##### 测试场景 5.3.4: callback 成功 - JIT 建账号

**测试代码**:

```typescript
it("should JIT-create user when email is unknown and domain is allowlisted", async () => {
  const email = `t_${Date.now()}_new@ff.com`;
  process.env.SSO_ALLOWED_DOMAINS = "ff.com";

  mockEntraTokenEndpoint({
    idToken: { email, oid: `t_oid_${Date.now()}_new` },
  });

  await request(app)
    .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
    .set("Cookie", buildSsoCookies({ state, nonce }))
    .expect((r) => [200, 302].includes(r.status));

  const dbUser = await prisma.user.findUnique({ where: { email } });
  expect(dbUser?.source).toBe("ENTRA");
  expect(dbUser?.externalSource).toBe("entra");
  expect(dbUser?.externalId).toMatch(/^t_oid_/);
  expect(dbUser?.passwordHash).toBeNull();
});
```

**断言**:

- ✅ DB 新增 `User`，`source=ENTRA`、`username = lower(email)`、`externalSource='entra'`、`externalId` 为 mock `oid`、`passwordHash=null`
- ✅ 新用户 `region` 继承自 `SSO_JIT_DEFAULT_ORG_ID` 对应 `Organization.region`（默认 `'CN'`）
- ✅ 新用户绑定默认 `Employee` 角色（建 1 行 `UserRole { roleId=Employee.id, organizationId=SSO_JIT_DEFAULT_ORG_ID }`）
- ✅ **不**建 `UserDepartment` 行（断言 `prisma.userDepartment.findMany({where:{userId}}).length === 0`）
- ✅ AuditLog 含 `SSO_JIT_CREATED`（metadata: `userId` / `email` / `externalId` / `defaultOrgId` / `entraTid`） 与 `SSO_LOGIN_SUCCESS`（metadata: `path: jit` / `entraTid`） 事件

---

##### 测试场景 5.3.5: callback JIT 域名白名单拒绝

**测试代码**:

```typescript
it("should reject JIT creation when email domain is not allowlisted", async () => {
  process.env.SSO_ALLOWED_DOMAINS = "ff.com";
  const email = `t_${Date.now()}_bad@guest.com`;

  mockEntraTokenEndpoint({
    idToken: { email, oid: `t_oid_${Date.now()}_bad` },
  });

  await request(app)
    .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
    .set("Cookie", buildSsoCookies({ state, nonce }))
    .expect(403)
    .expect((r) => expect(r.body.code).toBe("SSO_DOMAIN_NOT_ALLOWED"));

  expect(await prisma.user.findUnique({ where: { email } })).toBeNull();
});
```

**断言**:

- ✅ 返回 403，错误码 `SSO_DOMAIN_NOT_ALLOWED`（response body 或 302 Location `/login?ssoError=SSO_DOMAIN_NOT_ALLOWED`）
- ✅ DB 不新增任何 User
- ✅ AuditLog 含一条 `status='FAILED'` / `why='SSO_DOMAIN_NOT_ALLOWED'` / actor=email 的记录

---

##### 测试场景 5.3.6: callback state 不匹配

**测试代码**:

```typescript
it("should reject when cookie state and query state mismatch", async () => {
  await request(app)
    .get(`/api/v1/auth/sso/callback?code=fake&state=B`)
    .set("Cookie", buildSsoCookies({ state: "A", nonce }))
    .expect(401)
    .expect((r) => expect(r.body.code).toBe("SSO_TOKEN_INVALID"));
});
```

**断言**:

- ✅ 返回 401，错误码 `SSO_TOKEN_INVALID`

---

##### 测试场景 5.3.7: callback id_token 签名失败

**测试代码**:

```typescript
it("should reject when id_token signature verification fails", async () => {
  mockEntraTokenEndpoint({ idToken: { email: "x@ff.com" }, sign: "invalid" });

  await request(app)
    .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
    .set("Cookie", buildSsoCookies({ state, nonce }))
    .expect(401)
    .expect((r) => expect(r.body.code).toBe("SSO_TOKEN_INVALID"));
});
```

**断言**:

- ✅ 返回 401，错误码 `SSO_TOKEN_INVALID`

---

##### 测试场景 5.3.8: callback id_token 缺 email claim

**测试代码**:

```typescript
it("should reject when id_token lacks email claim", async () => {
  mockEntraTokenEndpoint({ idToken: { oid: `t_oid_${Date.now()}_noemail` } });

  await request(app)
    .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
    .set("Cookie", buildSsoCookies({ state, nonce }))
    .expect(400)
    .expect((r) => expect(r.body.code).toBe("SSO_EMAIL_MISSING"));
});
```

**断言**:

- ✅ 返回 400，错误码 `SSO_EMAIL_MISSING`

---

##### 测试场景 5.3.9: callback 绑定冲突两个子场景（真冲突 vs LDAP 升级）

**子场景 5.3.9a: Entra 来源真冲突（拒绝 + 不覆盖）**

```typescript
it("should reject and preserve original externalId when entra oid conflicts", async () => {
  const email = `t_${Date.now()}_conflict@ff.com`;
  const oldOid = `t_oid_${Date.now()}_old`;
  await createTestUser({
    email,
    source: "LOCAL",
    externalId: oldOid,
    externalSource: "entra", // 真冲突源
  });

  mockEntraTokenEndpoint({
    idToken: { email, oid: `t_oid_${Date.now()}_new` },
  });

  await request(app)
    .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
    .set("Cookie", buildSsoCookies({ state, nonce }))
    .expect(409)
    .expect((r) => expect(r.body.code).toBe("SSO_BINDING_CONFLICT"));

  const dbUser = await prisma.user.findUnique({ where: { email } });
  expect(dbUser?.externalId).toBe(oldOid);
  expect(dbUser?.externalSource).toBe("entra"); // 未覆盖
});
```

**子场景 5.3.9b: LDAP 来源自动升级（覆盖 + 专属 audit）**

```typescript
it("should upgrade LDAP-bound user to entra and preserve audit trail", async () => {
  const email = `t_${Date.now()}_ldap@ff.com`;
  const ldapDn = `CN=${email},OU=Users,DC=corp,DC=ff,DC=com`;
  const newOid = `t_oid_${Date.now()}_entra`;

  await createTestUser({
    email,
    source: "LDAP",
    externalId: ldapDn,
    externalSource: "ldap", // 升级源
  });

  mockEntraTokenEndpoint({ idToken: { email, oid: newOid, tid: TEST_TENANT_ID } });

  await request(app)
    .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
    .set("Cookie", buildSsoCookies({ state, nonce, codeVerifier: cv }))
    .expect((r) => [200, 302].includes(r.status));

  const dbUser = await prisma.user.findUnique({ where: { email } });
  expect(dbUser?.externalId).toBe(newOid); // 已覆盖
  expect(dbUser?.externalSource).toBe("entra"); // 已升级
  expect(dbUser?.source).toBe("LDAP"); // source 字段不动

  const audit = await prisma.auditLog.findFirst({
    where: { action: "SSO_BINDING_UPGRADED_FROM_LDAP", userId: dbUser!.id },
  });
  expect(audit).toBeTruthy();
  expect(audit!.newValue).toMatchObject({
    previousExternalId: ldapDn,
    newExternalId: newOid,
    entraTid: TEST_TENANT_ID,
  });
});
```

**断言**:

- ✅ 5.3.9a：返回 409 `SSO_BINDING_CONFLICT`；DB 中 `User.externalId` 保持原值；AuditLog 含 `SSO_BINDING_CONFLICT`（metadata: `email` / `existingExternalId` / `attemptedExternalId` / `entraTid`）
- ✅ 5.3.9b：返回 302 成功；DB `externalId` 覆盖为新 oid，`externalSource='entra'`，`source` 仍为 `LDAP`；AuditLog 含 `SSO_BINDING_UPGRADED_FROM_LDAP` + `SSO_LOGIN_SUCCESS`（`path: ldap_upgraded`）

---

##### 测试场景 5.3.10: callback Entra 不可达

**测试代码**:

```typescript
it("should return SSO_PROVIDER_UNAVAILABLE when token endpoint is unreachable", async () => {
  mockEntraTokenEndpoint({ status: 503 }); // 或 mockEntraTokenEndpoint({ timeout: 6000 })

  await request(app)
    .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
    .set("Cookie", buildSsoCookies({ state, nonce }))
    .expect(503)
    .expect((r) => expect(r.body.code).toBe("SSO_PROVIDER_UNAVAILABLE"));
});
```

**断言**:

- ✅ 返回 503，错误码 `SSO_PROVIDER_UNAVAILABLE`
- ✅ Token endpoint 超时阈值 ≤ 5s（避免 callback 端长时间阻塞）

---

##### 测试场景 5.3.11: 双通道并存 - SSO 后密码登录仍可用

**测试代码**:

```typescript
it("should keep local password channel working after SSO binding", async () => {
  const email = `t_${Date.now()}_dual@ff.com`;
  const password = "local-pwd-123";
  const user = await createTestUser({
    email,
    username: email,
    source: "LOCAL",
    passwordHash: await hashPassword(password),
  });

  // 先经 SSO 回填 externalId
  mockEntraTokenEndpoint({ idToken: { email, oid: `t_oid_${Date.now()}` } });
  await request(app)
    .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
    .set("Cookie", buildSsoCookies({ state, nonce }))
    .expect((r) => [200, 302].includes(r.status));

  // 然后再走密码通道
  const res = await request(app)
    .post("/api/v1/auth/login")
    .send({ username: email, password })
    .expect(200);

  expect(res.body.accessToken).toBeDefined();
});
```

**断言**:

- ✅ SSO 回填后再用 `POST /api/v1/auth/login` 走密码通道仍能拿到 JWT
- ✅ `User.passwordHash` 未被清空

---

##### 测试场景 5.3.12: 审计字段完整性（5 类事件）

**测试代码**:

```typescript
it("should write complete audit metadata for each SSO event", async () => {
  // ...触发 SSO_LOGIN_SUCCESS / SSO_JIT_CREATED / SSO_BINDING_FILLED / SSO_BINDING_UPGRADED_FROM_LDAP / SSO_BINDING_CONFLICT 各一次

  const logs = await prisma.auditLog.findMany({
    where: {
      action: {
        in: [
          "SSO_LOGIN_SUCCESS",
          "SSO_JIT_CREATED",
          "SSO_BINDING_FILLED",
          "SSO_BINDING_UPGRADED_FROM_LDAP",
          "SSO_BINDING_CONFLICT",
        ],
      },
    },
  });

  expect(logs.length).toBe(5); // 每类至少一条

  for (const log of logs) {
    expect(log.action).toBeDefined();
    expect(log.newValue).toBeDefined();
    expect(log.newValue).toMatchObject({ entraTid: expect.any(String) });

    switch (log.action) {
      case "SSO_LOGIN_SUCCESS":
        expect(log.newValue).toMatchObject({
          userId: expect.any(String),
          email: expect.any(String),
          path: expect.stringMatching(/^(existing|jit|binding_filled|ldap_upgraded)$/),
        });
        break;
      case "SSO_JIT_CREATED":
        expect(log.newValue).toMatchObject({
          userId: expect.any(String),
          defaultOrgId: expect.any(String),
        });
        break;
      case "SSO_BINDING_FILLED":
        expect(log.newValue).toMatchObject({ previousExternalId: null });
        break;
      case "SSO_BINDING_UPGRADED_FROM_LDAP":
        expect(log.newValue).toMatchObject({
          previousExternalId: expect.any(String), // LDAP DN
          newExternalId: expect.any(String),
        });
        break;
      case "SSO_BINDING_CONFLICT":
        expect(log.newValue).toMatchObject({
          email: expect.any(String),
          existingExternalId: expect.any(String),
          attemptedExternalId: expect.any(String),
        });
        break;
    }
  }
});
```

**断言**:

- ✅ **5 类事件**均落 `AuditLog`：`SSO_LOGIN_SUCCESS` / `SSO_JIT_CREATED` / `SSO_BINDING_FILLED` / `SSO_BINDING_UPGRADED_FROM_LDAP` / `SSO_BINDING_CONFLICT`
- ✅ 每条记录 metadata 按 PRD §817-820 + 第 5 行约定的字段逐项 expect（含 `entraTid` / `path` 等）
- ✅ `SSO_LOGIN_SUCCESS.path` 取值在 `existing|jit|binding_filled|ldap_upgraded` 之一

---

##### 测试场景 5.3.13: 并发 callback 回填同 email（CAS UPDATE）

**测试代码**:

```typescript
it("should handle concurrent fill-in via CAS without dirty write", async () => {
  const email = `t_${Date.now()}_concurrent@ff.com`;
  const oid = `t_oid_${Date.now()}`;
  await createTestUser({ email, source: "LOCAL", externalId: null });

  mockEntraTokenEndpoint({ idToken: { email, oid } });

  // 并发 2 个 callback（同 oid，同 email）
  const [res1, res2] = await Promise.all([
    request(app).get(`/api/v1/auth/sso/callback?code=c1&state=${state}`).set("Cookie", buildSsoCookies({ state, nonce, codeVerifier: cv })),
    request(app).get(`/api/v1/auth/sso/callback?code=c2&state=${state}`).set("Cookie", buildSsoCookies({ state, nonce, codeVerifier: cv })),
  ]);

  // 两个请求都应成功（302）或一个 302、另一个被 invalid_grant 截断；二者皆不应 5xx
  expect([res1.status, res2.status].every((s) => [302, 401].includes(s))).toBe(true);

  const dbUser = await prisma.user.findUnique({ where: { email } });
  expect(dbUser?.externalId).toBe(oid); // 最终一致

  const fillEvents = await prisma.auditLog.findMany({
    where: { action: "SSO_BINDING_FILLED", entityId: dbUser!.id },
  });
  expect(fillEvents.length).toBeLessThanOrEqual(1); // CAS 保证最多 1 次写入
});
```

**断言**:

- ✅ DB `externalId` 最终一致为新 oid
- ✅ `SSO_BINDING_FILLED` audit **最多** 1 条（CAS `WHERE externalId IS NULL` 保证）
- ✅ 无 5xx 错误（事务隔离 + 重查 fallback 兜底）

---

##### 测试场景 5.3.14: 并发 JIT 同 email（upsert + P2002 fallback）

**测试代码**:

```typescript
it("should JIT-create only once when two callbacks race on same email", async () => {
  const email = `t_${Date.now()}_jit_race@ff.com`;
  const oid = `t_oid_${Date.now()}`;
  process.env.SSO_ALLOWED_DOMAINS = "ff.com";
  mockEntraTokenEndpoint({ idToken: { email, oid } });

  const [res1, res2] = await Promise.all([
    request(app).get(`/api/v1/auth/sso/callback?code=c1&state=${state}`).set("Cookie", buildSsoCookies({ state, nonce, codeVerifier: cv })),
    request(app).get(`/api/v1/auth/sso/callback?code=c2&state=${state}`).set("Cookie", buildSsoCookies({ state, nonce, codeVerifier: cv })),
  ]);

  expect([res1.status, res2.status].every((s) => [302, 401].includes(s))).toBe(true);

  const users = await prisma.user.findMany({ where: { email } });
  expect(users.length).toBe(1); // unique email + P2002 fallback

  const jitEvents = await prisma.auditLog.findMany({ where: { action: "SSO_JIT_CREATED" } });
  expect(jitEvents.filter((e) => (e.newValue as any)?.email === email).length).toBeLessThanOrEqual(1);
});
```

**断言**:

- ✅ DB 中 email 唯一（unique 约束 + P2002 捕获 + 重查走已存在分支）
- ✅ `SSO_JIT_CREATED` audit 最多 1 条

---

##### 测试场景 5.3.15: mixed-case email 归一化

**测试代码**:

```typescript
it("should match user regardless of email case", async () => {
  const lower = `t_${Date.now()}_case@ff.com`;
  const mixed = lower.replace("@", "@").replace(/^t_/, "T_").toUpperCase().replace(/@FF\.COM$/, "@ff.com");

  await createTestUser({ email: lower, source: "LOCAL", externalId: null });

  mockEntraTokenEndpoint({ idToken: { email: mixed, oid: `t_oid_${Date.now()}` } });

  await request(app)
    .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
    .set("Cookie", buildSsoCookies({ state, nonce, codeVerifier: cv }))
    .expect((r) => [200, 302].includes(r.status));

  const matched = await prisma.user.findUnique({ where: { email: lower } });
  expect(matched?.externalId).toBeTruthy();
});
```

**断言**:

- ✅ Entra 大小写混杂的 email 命中本地 lower-case email 用户
- ✅ DB 写入的 email 始终 lower-case

---

##### 测试场景 5.3.16: SSO 路径下 INACTIVE 用户被拒（IAM_USER_SUSPENDED）

**测试代码**:

```typescript
it("should reject INACTIVE user via SSO with IAM_USER_SUSPENDED", async () => {
  const email = `t_${Date.now()}_inactive@ff.com`;
  await createTestUser({
    email,
    source: "ENTRA",
    externalId: `t_oid_${Date.now()}`,
    externalSource: "entra",
    status: "INACTIVE", // 非 ACTIVE
  });

  mockEntraTokenEndpoint({ idToken: { email, oid: `t_oid_${Date.now()}` } });

  const res = await request(app)
    .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
    .set("Cookie", buildSsoCookies({ state, nonce, codeVerifier: cv }));

  // 失败 302 → /login?ssoError=IAM_USER_SUSPENDED；或 403 body
  expect(res.status === 403 || (res.status === 302 && /IAM_USER_SUSPENDED/.test(res.headers.location ?? ""))).toBe(true);

  // 同时验证 SUSPENDED / TERMINATED
});
```

**断言**:

- ✅ 错误码为 **`IAM_USER_SUSPENDED`**（复用，不为 `AUTH_USER_DISABLED`）
- ✅ INACTIVE / SUSPENDED / TERMINATED 三个状态都被拒（参数化测试）
- ✅ 不签发 JWT

---

##### 测试场景 5.3.17: Entra error query 三种处理

**测试代码**:

```typescript
it.each([
  ["access_denied", 403, "SSO_USER_CANCELLED"],
  ["consent_required", 403, "SSO_CONSENT_REQUIRED"],
  ["interaction_required", 403, "SSO_CONSENT_REQUIRED"],
  ["invalid_request", 502, "SSO_PROVIDER_REJECTED"],
  ["server_error", 502, "SSO_PROVIDER_REJECTED"],
])("should map Entra error %s to %d %s", async (err, status, code) => {
  const res = await request(app)
    .get(`/api/v1/auth/sso/callback?error=${err}&error_description=test`)
    .set("Cookie", buildSsoCookies({ state, nonce, codeVerifier: cv }));

  if (res.status === 302) {
    expect(res.headers.location).toContain(`ssoError=${code}`);
  } else {
    expect(res.status).toBe(status);
    expect(res.body.code).toBe(code);
  }
});
```

**断言**:

- ✅ `access_denied` → `SSO_USER_CANCELLED` (403)
- ✅ `consent_required` / `interaction_required` → `SSO_CONSENT_REQUIRED` (403)
- ✅ 其它 → `SSO_PROVIDER_REJECTED` (502)
- ✅ 步骤 0 早返，**不**读 cookie / 不查 DB / 不调 token endpoint

---

##### 测试场景 5.3.18: issuer tid 替换比对（multi-tenant 防护）

**测试代码**:

```typescript
it("should reject id_token with mismatched tid claim", async () => {
  // discovery metadata.issuer = "https://login.microsoftonline.com/{tenantid}/v2.0"
  // env AZURE_TENANT_ID = "11111111-1111-1111-1111-111111111111"
  mockEntraTokenEndpoint({
    idToken: { email: "x@ff.com", tid: "22222222-2222-2222-2222-222222222222" }, // 不一致租户
  });

  await request(app)
    .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
    .set("Cookie", buildSsoCookies({ state, nonce, codeVerifier: cv }))
    .expect((r) => {
      if (r.status === 302) expect(r.headers.location).toMatch(/SSO_TOKEN_INVALID/);
      else expect(r.status === 401 && r.body.code === "SSO_TOKEN_INVALID").toBe(true);
    });
});
```

**断言**:

- ✅ ID token `tid` 不等于 env `AZURE_TENANT_ID` → 401 `SSO_TOKEN_INVALID`
- ✅ issuer 校验用 discovery 模板 `{tenantid}` 替换后字符串比对，**不**硬编码

---

##### 测试场景 5.3.19: 启动期 fail-fast（SsoConfigValidator）

**测试代码**:

```typescript
it("should exit process on bootstrap when SSO_JIT_DEFAULT_ORG_ID points to deleted org", async () => {
  const exitSpy = jest.spyOn(process, "exit").mockImplementation(((code?: number) => {
    throw new Error(`process.exit(${code})`);
  }) as never);

  // 准备：默认 org 已软删
  process.env.SSO_ALLOWED_DOMAINS = "ff.com";
  process.env.SSO_JIT_DEFAULT_ORG_ID = "11111111-1111-1111-1111-111111111111";
  await prisma.organization.update({ where: { id: process.env.SSO_JIT_DEFAULT_ORG_ID }, data: { deletedAt: new Date() } });

  // 重启 NestApplication 触发 OnApplicationBootstrap
  await expect(bootstrapApp()).rejects.toThrow(/process\.exit\(1\)/);
  expect(exitSpy).toHaveBeenCalledWith(1);
});

it("should exit process on bootstrap when AZURE_TENANT_ID is not GUID", async () => {
  process.env.AZURE_TENANT_ID = "common"; // 非 GUID
  await expect(bootstrapApp()).rejects.toThrow(/process\.exit\(1\)/);
});
```

**断言**:

- ✅ `SSO_ALLOWED_DOMAINS` 非空 + `SSO_JIT_DEFAULT_ORG_ID` 对应 org 不存在 / 已软删 → `process.exit(1)`
- ✅ `AZURE_TENANT_ID` 非 GUID（`common` / `organizations` / `consumers` / 任意非法字符）→ `process.exit(1)`
- ✅ 三项校验任一失败立即 exit non-zero，**不**留启动成功但运行时 JIT 全失败的悬空配置

---

##### 测试场景 5.3.20: 运行时默认 org 缺失 → 503

**测试代码**:

```typescript
it("should return SSO_PROVIDER_UNAVAILABLE when default org is deleted at runtime", async () => {
  process.env.SSO_ALLOWED_DOMAINS = "ff.com";
  // 启动期通过；运行时软删默认 org
  await prisma.organization.update({ where: { id: process.env.SSO_JIT_DEFAULT_ORG_ID }, data: { deletedAt: new Date() } });

  mockEntraTokenEndpoint({ idToken: { email: `t_${Date.now()}@ff.com`, oid: "t_oid" } });

  const res = await request(app)
    .get(`/api/v1/auth/sso/callback?code=fake&state=${state}`)
    .set("Cookie", buildSsoCookies({ state, nonce, codeVerifier: cv }));

  expect(res.status === 503 || (res.status === 302 && /SSO_PROVIDER_UNAVAILABLE/.test(res.headers.location ?? ""))).toBe(true);
});
```

**断言**:

- ✅ 运行时默认 org 缺失 → 503 `SSO_PROVIDER_UNAVAILABLE`（**不**引入新错误码）
- ✅ DB 无新建 User（事务回滚）

---

### 6. 流程角色服务 (WorkflowRolesService)

#### 6.1 创建流程角色

##### 测试场景 6.1.1: 成功创建组织关系类型流程角色

**测试代码**:

```typescript
it("[测试场景 6.1.1] 应该成功创建组织关系类型流程角色", async () => {
  const createDto: CreateWorkflowRoleDto = {
    name: "直属上级",
    code: "WF_DIRECT_MANAGER",
    description: "解析发起人的直属上级",
    ruleType: WorkflowRuleType.ORGANIZATION_RELATION,
    ruleConfig: {
      relation: "manager",
      fallbackType: "UP_CHAIN",
      fallbackConfig: { maxLevel: 2 },
    },
  };

  const result = await workflowRolesService.create(createDto);

  expect(result.id).toBeDefined();
  expect(result.code).toBe("WF_DIRECT_MANAGER");
  expect(result.ruleType).toBe("ORGANIZATION_RELATION");
  expect(result.ruleConfig).toMatchObject({
    relation: "manager",
    fallbackType: "UP_CHAIN",
  });
});
```

**断言**:

- ✅ 创建成功
- ✅ 返回流程角色 ID
- ✅ code 使用 WF\_ 前缀
- ✅ ruleConfig 正确保存

---

##### 测试场景 6.1.2: 成功创建固定用户类型流程角色

**测试代码**:

```typescript
it("[测试场景 6.1.2] 应该成功创建固定用户类型流程角色", async () => {
  const user1 = await createTestUser({ displayName: "审批人1" });
  const user2 = await createTestUser({ displayName: "审批人2" });

  const createDto: CreateWorkflowRoleDto = {
    name: "财务审批人",
    code: "WF_FINANCE_APPROVER",
    description: "财务部门固定审批人",
    ruleType: WorkflowRuleType.FIXED_USERS,
    ruleConfig: {},
  };

  const result = await workflowRolesService.create(createDto);
  expect(result.id).toBeDefined();
  expect(result.ruleType).toBe("FIXED_USERS");
});
```

**断言**:

- ✅ 创建成功
- ✅ ruleType 为 FIXED_USERS

---

##### 测试场景 6.1.3: code 重复应抛出异常

**测试代码**:

```typescript
it("[测试场景 6.1.3] code 重复应抛出异常", async () => {
  const createDto: CreateWorkflowRoleDto = {
    name: "直属上级",
    code: "WF_DIRECT_MANAGER",
    ruleType: WorkflowRuleType.ORGANIZATION_RELATION,
    ruleConfig: { relation: "manager" },
  };

  await workflowRolesService.create(createDto);

  await expect(
    workflowRolesService.create({
      ...createDto,
      name: "直属上级2",
    }),
  ).rejects.toThrow(ConflictException);
  await expect(
    workflowRolesService.create({
      ...createDto,
      name: "直属上级2",
    }),
  ).rejects.toThrow("code 'WF_DIRECT_MANAGER' already exists");
});
```

**断言**:

- ✅ 抛出 ConflictException
- ✅ 错误消息包含重复的 code

---

##### 测试场景 6.1.4: name 重复应抛出异常

**测试代码**:

```typescript
it("[测试场景 6.1.4] name 重复应抛出异常", async () => {
  const createDto: CreateWorkflowRoleDto = {
    name: "直属上级",
    code: "WF_DIRECT_MANAGER",
    ruleType: WorkflowRuleType.ORGANIZATION_RELATION,
    ruleConfig: { relation: "manager" },
  };

  await workflowRolesService.create(createDto);

  await expect(
    workflowRolesService.create({
      ...createDto,
      code: "WF_DIRECT_MANAGER_2",
    }),
  ).rejects.toThrow(ConflictException);
  await expect(
    workflowRolesService.create({
      ...createDto,
      code: "WF_DIRECT_MANAGER_2",
    }),
  ).rejects.toThrow("name '直属上级' already exists");
});
```

**断言**:

- ✅ 抛出 ConflictException
- ✅ 错误消息包含重复的 name

---

#### 6.2 流程角色解析 (resolve)

##### 测试场景 6.2.1: 成功解析直属上级（使用主部门）

**测试代码**:

```typescript
it("[测试场景 6.2.1] 应该成功解析直属上级（使用主部门）", async () => {
  // 准备数据
  const manager = await createTestUser({ displayName: "经理" });
  const employee = await createTestUser({ displayName: "员工" });
  const dept = await createTestDepartment({ name: "销售部" });

  // 员工在销售部，经理是直属上级
  await addUserDepartment({
    userId: employee.id,
    departmentId: dept.id,
    isPrimary: true,
    managerId: manager.id,
  });

  // 创建流程角色
  const workflowRole = await workflowRolesService.create({
    name: "直属上级",
    code: "WF_DIRECT_MANAGER",
    ruleType: WorkflowRuleType.ORGANIZATION_RELATION,
    ruleConfig: { relation: "manager" },
  });

  // 解析
  const result = await workflowRolesService.resolve({
    workflowRoleCode: "WF_DIRECT_MANAGER",
    context: {
      initiatorUserId: employee.id,
    },
  });

  expect(result.users).toHaveLength(1);
  expect(result.users[0].userId).toBe(manager.id);
  expect(result.resolvedBy).toBe("ORGANIZATION_RELATION");
  expect(result.fallbackUsed).toBe(false);
});
```

**断言**:

- ✅ 解析出 1 个审批人
- ✅ 审批人为员工的直属上级
- ✅ resolvedBy 为 ORGANIZATION_RELATION
- ✅ 未使用兜底策略

---

##### 测试场景 6.2.2: 成功解析直属上级（使用指定部门）

**测试代码**:

```typescript
it("[测试场景 6.2.2] 应该成功解析直属上级（使用指定部门）", async () => {
  const manager1 = await createTestUser({ displayName: "经理1" });
  const manager2 = await createTestUser({ displayName: "经理2" });
  const employee = await createTestUser({ displayName: "员工" });
  const dept1 = await createTestDepartment({ name: "销售部" });
  const dept2 = await createTestDepartment({ name: "市场部" });

  // 员工在两个部门，有不同的上级
  await addUserDepartment({
    userId: employee.id,
    departmentId: dept1.id,
    isPrimary: true,
    managerId: manager1.id,
  });
  await addUserDepartment({
    userId: employee.id,
    departmentId: dept2.id,
    isPrimary: false,
    managerId: manager2.id,
  });

  const workflowRole = await workflowRolesService.create({
    name: "直属上级",
    code: "WF_DIRECT_MANAGER",
    ruleType: WorkflowRuleType.ORGANIZATION_RELATION,
    ruleConfig: { relation: "manager" },
  });

  // 解析时指定部门2
  const result = await workflowRolesService.resolve({
    workflowRoleCode: "WF_DIRECT_MANAGER",
    context: {
      initiatorUserId: employee.id,
      formData: {
        departmentId: dept2.id,
      },
    },
  });

  expect(result.users).toHaveLength(1);
  expect(result.users[0].userId).toBe(manager2.id);
});
```

**断言**:

- ✅ 使用指定部门的汇报关系
- ✅ 返回部门2的上级（不是主部门的上级）

---

##### 测试场景 6.2.3: 成功解析部门主管

**测试代码**:

```typescript
it("[测试场景 6.2.3] 应该成功解析部门主管", async () => {
  const deptHead = await createTestUser({ displayName: "部门主管" });
  const employee = await createTestUser({ displayName: "员工" });
  const dept = await createTestDepartment({
    name: "技术部",
    headId: deptHead.id,
  });

  await addUserDepartment({
    userId: employee.id,
    departmentId: dept.id,
    isPrimary: true,
  });

  const workflowRole = await workflowRolesService.create({
    name: "部门主管",
    code: "WF_DEPT_HEAD",
    ruleType: WorkflowRuleType.ORGANIZATION_RELATION,
    ruleConfig: { relation: "departmentHead" },
  });

  const result = await workflowRolesService.resolve({
    workflowRoleCode: "WF_DEPT_HEAD",
    context: {
      initiatorUserId: employee.id,
    },
  });

  expect(result.users).toHaveLength(1);
  expect(result.users[0].userId).toBe(deptHead.id);
});
```

**断言**:

- ✅ 解析出部门主管
- ✅ 使用 Department.headId

---

##### 测试场景 6.2.3.1: 成功解析连续部门主管链（逐级审批）⭐ 新增

**测试代码**:

```typescript
it("[测试场景 6.2.3.1] 应该成功解析连续部门主管链", async () => {
  // 创建4层部门结构：CEO -> VP -> 总监 -> 部门主管
  const ceo = await createTestUser({ displayName: "CEO" });
  const vp = await createTestUser({ displayName: "VP" });
  const director = await createTestUser({ displayName: "总监" });
  const manager = await createTestUser({ displayName: "部门主管" });
  const employee = await createTestUser({ displayName: "员工" });

  // 创建组织层级
  const company = await createTestDepartment({
    name: "公司",
    headId: ceo.id,
    parentId: null,
  });
  const division = await createTestDepartment({
    name: "事业部",
    headId: vp.id,
    parentId: company.id,
  });
  const dept = await createTestDepartment({
    name: "技术部",
    headId: director.id,
    parentId: division.id,
  });
  const team = await createTestDepartment({
    name: "开发组",
    headId: manager.id,
    parentId: dept.id,
  });

  // 员工在开发组
  await addUserDepartment({
    userId: employee.id,
    departmentId: team.id,
    isPrimary: true,
  });

  // 创建连续部门主管链流程角色
  const workflowRole = await workflowRolesService.create({
    name: "逐级审批",
    code: "WF_DEPT_HEAD_CHAIN",
    ruleType: WorkflowRuleType.ORGANIZATION_RELATION,
    ruleConfig: { relation: "departmentHeadChain" },
  });

  // 解析
  const result = await workflowRolesService.resolve({
    workflowRoleCode: "WF_DEPT_HEAD_CHAIN",
    context: {
      initiatorUserId: employee.id,
    },
  });

  // 验证结果
  expect(result.users).toHaveLength(4);
  expect(result.users[0].userId).toBe(manager.id); // 第1级：部门主管
  expect(result.users[1].userId).toBe(director.id); // 第2级：总监
  expect(result.users[2].userId).toBe(vp.id); // 第3级：VP
  expect(result.users[3].userId).toBe(ceo.id); // 第4级：CEO
  expect(result.strategy).toBe("SEQUENTIAL"); // 顺序审批策略
  expect(result.resolvedBy).toBe("ORGANIZATION_RELATION");
});
```

**断言**:

- ✅ 解析出完整的部门主管链
- ✅ 按从下到上的顺序返回（部门主管 → 总监 → VP → CEO）
- ✅ strategy 为 SEQUENTIAL（顺序审批）
- ✅ resolvedBy 为 ORGANIZATION_RELATION

---

##### 测试场景 6.2.3.2: 跳过没有主管的部门

**测试代码**:

```typescript
it("[测试场景 6.2.3.2] 应该跳过没有主管的部门", async () => {
  const ceo = await createTestUser({ displayName: "CEO" });
  const manager = await createTestUser({ displayName: "部门主管" });
  const employee = await createTestUser({ displayName: "员工" });

  // 创建层级，但中间层没有主管
  const company = await createTestDepartment({
    name: "公司",
    headId: ceo.id,
    parentId: null,
  });
  const division = await createTestDepartment({
    name: "事业部",
    headId: null, // 没有主管
    parentId: company.id,
  });
  const team = await createTestDepartment({
    name: "开发组",
    headId: manager.id,
    parentId: division.id,
  });

  await addUserDepartment({
    userId: employee.id,
    departmentId: team.id,
    isPrimary: true,
  });

  const workflowRole = await workflowRolesService.create({
    name: "逐级审批",
    code: "WF_DEPT_HEAD_CHAIN",
    ruleType: WorkflowRuleType.ORGANIZATION_RELATION,
    ruleConfig: { relation: "departmentHeadChain" },
  });

  const result = await workflowRolesService.resolve({
    workflowRoleCode: "WF_DEPT_HEAD_CHAIN",
    context: {
      initiatorUserId: employee.id,
    },
  });

  // 应该只有2个主管（跳过了事业部）
  expect(result.users).toHaveLength(2);
  expect(result.users[0].userId).toBe(manager.id);
  expect(result.users[1].userId).toBe(ceo.id);
});
```

**断言**:

- ✅ 自动跳过没有主管的部门
- ✅ 继续向上查找有主管的部门

---

##### 测试场景 6.2.3.3: 避免重复的主管

**测试代码**:

```typescript
it("[测试场景 6.2.3.3] 应该避免重复的主管", async () => {
  // 同一个人担任多个部门的主管
  const boss = await createTestUser({ displayName: "老板" });
  const employee = await createTestUser({ displayName: "员工" });

  const company = await createTestDepartment({
    name: "公司",
    headId: boss.id,
    parentId: null,
  });
  const division = await createTestDepartment({
    name: "事业部",
    headId: boss.id, // 同一个人
    parentId: company.id,
  });
  const team = await createTestDepartment({
    name: "开发组",
    headId: boss.id, // 同一个人
    parentId: division.id,
  });

  await addUserDepartment({
    userId: employee.id,
    departmentId: team.id,
    isPrimary: true,
  });

  const workflowRole = await workflowRolesService.create({
    name: "逐级审批",
    code: "WF_DEPT_HEAD_CHAIN",
    ruleType: WorkflowRuleType.ORGANIZATION_RELATION,
    ruleConfig: { relation: "departmentHeadChain" },
  });

  const result = await workflowRolesService.resolve({
    workflowRoleCode: "WF_DEPT_HEAD_CHAIN",
    context: {
      initiatorUserId: employee.id,
    },
  });

  // 应该只返回1个主管（去重）
  expect(result.users).toHaveLength(1);
  expect(result.users[0].userId).toBe(boss.id);
});
```

**断言**:

- ✅ 去重逻辑生效，同一用户只出现一次
- ✅ 返回结果为数组且长度为1

---

##### 测试场景 6.2.3.4: 应该支持在指定级别终止 (v2.1.18 ⭐)

**前置条件**:

- 创建4层部门结构：公司(level=0) > 事业部(level=1) > 技术部(level=2) > 开发组(level=3)
- 每个部门都有不同的主管
- 员工在开发组（3级部门）

**测试代码**:

```typescript
it("[测试场景 6.2.3.4] 应该在指定级别终止解析", async () => {
  const manager = await createTestUser({ displayName: "小组长" });
  const director = await createTestUser({ displayName: "部门主管" });
  const vp = await createTestUser({ displayName: "事业部总监" });
  const ceo = await createTestUser({ displayName: "CEO" });
  const employee = await createTestUser({ displayName: "员工" });

  // 创建4层部门结构
  const company = await createTestDepartment({
    name: "公司",
    headId: ceo.id,
    parentId: null, // level = 0
  });
  const division = await createTestDepartment({
    name: "事业部",
    headId: vp.id,
    parentId: company.id, // level = 1
  });
  const dept = await createTestDepartment({
    name: "技术部",
    headId: director.id,
    parentId: division.id, // level = 2
  });
  const team = await createTestDepartment({
    name: "开发组",
    headId: manager.id,
    parentId: dept.id, // level = 3
  });

  await addUserDepartment({
    userId: employee.id,
    departmentId: team.id,
    isPrimary: true,
  });

  // 创建流程角色：在一级部门（事业部）停止
  const workflowRole = await workflowRolesService.create({
    name: "逐级审批（到事业部）",
    code: "WF_DEPT_CHAIN_L1",
    ruleType: WorkflowRuleType.ORGANIZATION_RELATION,
    ruleConfig: {
      relation: "departmentHeadChain",
      stopAtLevel: 1, // 在一级部门停止
    },
  });

  const result = await workflowRolesService.resolve({
    workflowRoleCode: "WF_DEPT_CHAIN_L1",
    context: {
      initiatorUserId: employee.id,
    },
  });

  // 应该返回3个主管（不包括 CEO）
  expect(result.users).toHaveLength(3);
  expect(result.users[0].userId).toBe(manager.id); // 开发组长
  expect(result.users[1].userId).toBe(director.id); // 部门主管
  expect(result.users[2].userId).toBe(vp.id); // 事业部总监
  // ceo 不应该被包含（因为 level=0 < stopAtLevel=1）
  expect(result.users.map((u) => u.userId)).not.toContain(ceo.id);
  expect(result.strategy).toBe("SEQUENTIAL");
});
```

**断言**:

- ✅ 返回3个主管（开发组、技术部、事业部）
- ✅ 不包含顶级部门（公司）的 CEO
- ✅ 按从下到上的顺序排列
- ✅ 策略为 SEQUENTIAL

---

##### 测试场景 6.2.3.5: stopAtLevel 为 0 应该包含顶级部门 (v2.1.18 ⭐)

**测试代码**:

```typescript
it("[测试场景 6.2.3.5] stopAtLevel 为 0 应该包含顶级部门", async () => {
  // 与 6.2.3.4 相同的部门结构
  // ...

  const workflowRole = await workflowRolesService.create({
    name: "全链审批",
    code: "WF_DEPT_CHAIN_L0",
    ruleType: WorkflowRuleType.ORGANIZATION_RELATION,
    ruleConfig: {
      relation: "departmentHeadChain",
      stopAtLevel: 0, // 追溯到顶级部门
    },
  });

  const result = await workflowRolesService.resolve({
    workflowRoleCode: "WF_DEPT_CHAIN_L0",
    context: {
      initiatorUserId: employee.id,
    },
  });

  // 应该返回4个主管（包括 CEO）
  expect(result.users).toHaveLength(4);
  expect(result.users[3].userId).toBe(ceo.id); // 最后一个是 CEO
});
```

**断言**:

- ✅ 自动去重
- ✅ 同一个主管只出现一次

---

##### 测试场景 6.2.4: 成功解析固定用户列表

**测试代码**:

```typescript
it("[测试场景 6.2.4] 应该成功解析固定用户列表", async () => {
  const approver1 = await createTestUser({ displayName: "审批人1" });
  const approver2 = await createTestUser({ displayName: "审批人2" });
  const initiator = await createTestUser({ displayName: "发起人" });

  const workflowRole = await workflowRolesService.create({
    name: "财务审批人",
    code: "WF_FINANCE_APPROVER",
    ruleType: WorkflowRuleType.FIXED_USERS,
    ruleConfig: {},
  });

  // 分配固定用户
  await workflowRolesService.assignUsers(workflowRole.id, [
    approver1.id,
    approver2.id,
  ]);

  const result = await workflowRolesService.resolve({
    workflowRoleCode: "WF_FINANCE_APPROVER",
    context: {
      initiatorUserId: initiator.id,
    },
  });

  expect(result.users).toHaveLength(2);
  expect(result.users.map((u) => u.userId)).toContain(approver1.id);
  expect(result.users.map((u) => u.userId)).toContain(approver2.id);
  expect(result.resolvedBy).toBe("FIXED_USERS");
});
```

**断言**:

- ✅ 返回所有固定用户
- ✅ 与上下文无关

---

##### 测试场景 6.2.5: 用户不在指定部门应抛出异常

**测试代码**:

```typescript
it("[测试场景 6.2.5] 用户不在指定部门应抛出异常", async () => {
  const employee = await createTestUser({ displayName: "员工" });
  const dept1 = await createTestDepartment({ name: "销售部" });
  const dept2 = await createTestDepartment({ name: "市场部" });

  await addUserDepartment({
    userId: employee.id,
    departmentId: dept1.id,
    isPrimary: true,
  });

  const workflowRole = await workflowRolesService.create({
    name: "直属上级",
    code: "WF_DIRECT_MANAGER",
    ruleType: WorkflowRuleType.ORGANIZATION_RELATION,
    ruleConfig: { relation: "manager" },
  });

  await expect(
    workflowRolesService.resolve({
      workflowRoleCode: "WF_DIRECT_MANAGER",
      context: {
        initiatorUserId: employee.id,
        formData: {
          departmentId: dept2.id, // 用户不属于这个部门
        },
      },
    }),
  ).rejects.toThrow("IAM_USER_NOT_IN_DEPARTMENT");
});
```

**断言**:

- ✅ 抛出异常
- ✅ 错误码为 IAM_USER_NOT_IN_DEPARTMENT

---

##### 测试场景 6.2.6: 解析结果为空应抛出异常

**测试代码**:

```typescript
it("[测试场景 6.2.6] 解析结果为空应抛出异常", async () => {
  const employee = await createTestUser({ displayName: "员工" });
  const dept = await createTestDepartment({ name: "销售部" });

  // 员工在部门但没有上级
  await addUserDepartment({
    userId: employee.id,
    departmentId: dept.id,
    isPrimary: true,
    managerId: null,
  });

  const workflowRole = await workflowRolesService.create({
    name: "直属上级",
    code: "WF_DIRECT_MANAGER",
    ruleType: WorkflowRuleType.ORGANIZATION_RELATION,
    ruleConfig: {
      relation: "manager",
      // 无兜底策略
    },
  });

  await expect(
    workflowRolesService.resolve({
      workflowRoleCode: "WF_DIRECT_MANAGER",
      context: {
        initiatorUserId: employee.id,
      },
    }),
  ).rejects.toThrow("IAM_WORKFLOW_ROLE_RESOLVE_EMPTY");
});
```

**断言**:

- ✅ 抛出异常
- ✅ 错误码为 IAM_WORKFLOW_ROLE_RESOLVE_EMPTY

---

##### 测试场景 6.2.7: 流程角色不存在应抛出异常

**测试代码**:

```typescript
it("[测试场景 6.2.7] 流程角色不存在应抛出异常", async () => {
  const employee = await createTestUser({ displayName: "员工" });

  await expect(
    workflowRolesService.resolve({
      workflowRoleCode: "WF_NON_EXISTENT",
      context: {
        initiatorUserId: employee.id,
      },
    }),
  ).rejects.toThrow(NotFoundException);
  await expect(
    workflowRolesService.resolve({
      workflowRoleCode: "WF_NON_EXISTENT",
      context: {
        initiatorUserId: employee.id,
      },
    }),
  ).rejects.toThrow("Workflow role 'WF_NON_EXISTENT' not found");
});
```

**断言**:

- ✅ 抛出 NotFoundException

---

#### 6.3 用户分配

##### 测试场景 6.3.1: 应该成功分配用户到 FIXED_USERS 类型

**测试代码**:

```typescript
it("[测试场景 6.3.1] 应该成功分配用户到 FIXED_USERS 类型", async () => {
  const user1 = await createTestUser({ displayName: "用户1" });
  const user2 = await createTestUser({ displayName: "用户2" });

  const workflowRole = await workflowRolesService.create({
    name: "固定审批人",
    code: "WF_FIXED_APPROVER",
    ruleType: WorkflowRuleType.FIXED_USERS,
    ruleConfig: {},
  });

  const result = await workflowRolesService.assignUsers(workflowRole.id, [
    user1.id,
    user2.id,
  ]);

  expect(result).toHaveLength(2);
  expect(result.map((u) => u.id)).toContain(user1.id);
  expect(result.map((u) => u.id)).toContain(user2.id);
});
```

**断言**:

- ✅ 分配成功
- ✅ 返回分配的用户列表

---

##### 测试场景 6.3.2: 非 FIXED_USERS 类型不能分配用户

**测试代码**:

```typescript
it("[测试场景 6.3.2] 非 FIXED_USERS 类型不能分配用户", async () => {
  const user = await createTestUser({ displayName: "用户" });

  const workflowRole = await workflowRolesService.create({
    name: "直属上级",
    code: "WF_DIRECT_MANAGER",
    ruleType: WorkflowRuleType.ORGANIZATION_RELATION,
    ruleConfig: { relation: "manager" },
  });

  await expect(
    workflowRolesService.assignUsers(workflowRole.id, [user.id]),
  ).rejects.toThrow(BadRequestException);
  await expect(
    workflowRolesService.assignUsers(workflowRole.id, [user.id]),
  ).rejects.toThrow("Only 'FIXED_USERS' type supports user assignment");
});
```

**断言**:

- ✅ 抛出 BadRequestException
- ✅ 错误消息说明类型限制

---

##### 测试场景 6.3.3: 重复分配用户应幂等

**测试代码**:

```typescript
it("[测试场景 6.3.3] 重复分配用户应幂等", async () => {
  const user = await createTestUser({ displayName: "用户" });

  const workflowRole = await workflowRolesService.create({
    name: "固定审批人",
    code: "WF_FIXED_APPROVER",
    ruleType: WorkflowRoleType.FIXED_USERS,
    ruleConfig: {},
  });

  await workflowRolesService.assignUsers(workflowRole.id, [user.id]);

  // 重复分配
  const result = await workflowRolesService.assignUsers(workflowRole.id, [
    user.id,
  ]);

  expect(result).toHaveLength(1);
  expect(result[0].id).toBe(user.id);
});
```

**断言**:

- ✅ 不抛出异常
- ✅ 结果仍为 1 个用户

---

#### 6.4 更新和删除

##### 测试场景 6.4.1: 应该成功更新流程角色

**测试代码**:

```typescript
it("[测试场景 6.4.1] 应该成功更新流程角色", async () => {
  const workflowRole = await workflowRolesService.create({
    name: "原名称",
    code: "WF_TEST",
    ruleType: WorkflowRuleType.FIXED_USERS,
    ruleConfig: {},
  });

  const result = await workflowRolesService.update(workflowRole.id, {
    name: "新名称",
    description: "新描述",
  });

  expect(result.name).toBe("新名称");
  expect(result.description).toBe("新描述");
  expect(result.code).toBe("WF_TEST"); // code 不变
});
```

**断言**:

- ✅ 更新成功
- ✅ code 不可修改

---

##### 测试场景 6.4.2: 应该成功删除流程角色

**测试代码**:

```typescript
it("[测试场景 6.4.2] 应该成功删除流程角色", async () => {
  const workflowRole = await workflowRolesService.create({
    name: "测试角色",
    code: "WF_TEST",
    ruleType: WorkflowRuleType.FIXED_USERS,
    ruleConfig: {},
  });

  const result = await workflowRolesService.remove(workflowRole.id);

  expect(result.message).toBe("Workflow role deleted successfully");

  await expect(workflowRolesService.findOne(workflowRole.id)).rejects.toThrow(
    NotFoundException,
  );
});
```

**断言**:

- ✅ 删除成功
- ✅ 再次查询抛出 NotFoundException

---

### 7. 组织管理 API 集成测试 - v2.0 新增 ⭐

#### 7.1 组织 CRUD 完整流程

**测试场景**: 创建组织 → 添加区域 → 更新信息 → 查询统计 → 删除

**测试代码**:

```typescript
describe("Organization Management Integration", () => {
  it("should handle complete organization lifecycle", async () => {
    // 1. 创建组织
    const createResponse = await request(app.getHttpServer())
      .post("/api/v1/organizations")
      .set("Authorization", `Bearer ${adminToken}`)
      .send({
        name: "FF China",
        shortName: "FF-CN",
        legalName: "Flying Fox China Co., Ltd.",
        taxId: "91110000MA001234XX",
        address: "北京市朝阳区",
      })
      .expect(201);

    const orgId = createResponse.body.data.id;

    // 2. 添加区域
    const region = await createTestRegion({ code: "CN" });
    await request(app.getHttpServer())
      .post(`/api/v1/organizations/${orgId}/regions`)
      .set("Authorization", `Bearer ${adminToken}`)
      .send({
        regionId: region.id,
        isPrimary: true,
      })
      .expect(201);

    // 3. 创建部门
    const dept = await request(app.getHttpServer())
      .post("/api/v1/departments")
      .set("Authorization", `Bearer ${adminToken}`)
      .send({
        name: "技术部",
        code: "TECH",
        organizationId: orgId,
      })
      .expect(201);

    // 4. 查询组织统计
    const statsResponse = await request(app.getHttpServer())
      .get(`/api/v1/organizations/${orgId}/stats`)
      .set("Authorization", `Bearer ${adminToken}`)
      .expect(200);

    expect(statsResponse.body.data.departmentCount).toBe(1);
    expect(statsResponse.body.data.regionCount).toBe(1);

    // 5. 更新组织信息
    await request(app.getHttpServer())
      .patch(`/api/v1/organizations/${orgId}`)
      .set("Authorization", `Bearer ${adminToken}`)
      .send({
        address: "北京市海淀区",
      })
      .expect(200);

    // 6. 验证更新
    const updatedOrg = await request(app.getHttpServer())
      .get(`/api/v1/organizations/${orgId}`)
      .set("Authorization", `Bearer ${adminToken}`)
      .expect(200);

    expect(updatedOrg.body.data.address).toBe("北京市海淀区");
  });
});
```

---

#### 7.2 多组织权限隔离测试 - v2.1 ⭐

**测试场景**: 创建两个组织 → 分配用户不同角色 → 验证权限隔离

**测试代码**:

```typescript
describe("Multi-Organization Permission Isolation", () => {
  it("should isolate permissions between organizations", async () => {
    // 1. 创建两个组织
    const orgChina = await createTestOrganization({ name: "FF China" });
    const orgUSA = await createTestOrganization({ name: "FF USA" });

    // 2. 创建测试用户
    const user = await createTestUser();

    // 3. 创建角色
    const hrRole = await createTestRole({
      name: "HR Manager",
      permissions: ["user:read:organization", "user:update:organization"],
    });
    const employeeRole = await createTestRole({
      name: "Employee",
      permissions: ["user:read:own"],
    });

    // 4. 在 FF China 分配 HR Manager 角色
    await request(app.getHttpServer())
      .post(`/api/v1/users/${user.id}/roles`)
      .set("Authorization", `Bearer ${adminToken}`)
      .send({
        assignments: [
          {
            roleId: hrRole.id,
            organizationId: orgChina.id,
          },
        ],
      })
      .expect(201);

    // 5. 在 FF USA 分配 Employee 角色
    await request(app.getHttpServer())
      .post(`/api/v1/users/${user.id}/roles`)
      .set("Authorization", `Bearer ${adminToken}`)
      .send({
        assignments: [
          {
            roleId: employeeRole.id,
            organizationId: orgUSA.id,
          },
        ],
      })
      .expect(201);

    // 6. 验证用户在 FF China 有管理权限
    const userToken = await generateTokenForUser(user.id);
    const chinaUsers = await request(app.getHttpServer())
      .get(`/api/v1/users`)
      .set("Authorization", `Bearer ${userToken}`)
      .set("X-Organization-Id", orgChina.id)
      .expect(200);

    // 应该能看到 FF China 的所有用户
    expect(chinaUsers.body.data.length).toBeGreaterThan(0);

    // 7. 验证用户在 FF USA 只能看自己
    const usaUsers = await request(app.getHttpServer())
      .get(`/api/v1/users`)
      .set("Authorization", `Bearer ${userToken}`)
      .set("X-Organization-Id", orgUSA.id)
      .expect(200);

    // 应该只能看到自己
    expect(usaUsers.body.data).toHaveLength(1);
    expect(usaUsers.body.data[0].id).toBe(user.id);
  });
});
```

---

## 🔗 集成测试场景

### 1. 用户完整生命周期

**测试场景**: 创建 → 分配部门 → 分配角色 → 更新 → 离职

**测试代码**:

```typescript
describe("User Lifecycle Integration", () => {
  it("should handle complete user lifecycle", async () => {
    // 1. 创建用户
    const user = await request(app.getHttpServer())
      .post("/api/v1/users")
      .send({
        username: "newuser",
        email: "new@example.com",
        displayName: "新用户",
      })
      .expect(201);

    const userId = user.body.data.id;

    // 2. 添加到部门
    const dept = await createTestDepartment();
    await request(app.getHttpServer())
      .post(`/api/v1/users/${userId}/departments`)
      .send({
        departmentId: dept.id,
        isPrimary: true,
      })
      .expect(201);

    // 3. 分配角色
    const role = await createTestRole();
    await request(app.getHttpServer())
      .post(`/api/v1/users/${userId}/roles`)
      .send({ roleId: role.id })
      .expect(201);

    // 4. 更新状态为离职
    await request(app.getHttpServer())
      .post(`/api/v1/users/${userId}/terminate`)
      .send({
        reason: "正常离职",
        terminatedAt: new Date().toISOString(),
      })
      .expect(200);

    // 5. 验证最终状态
    const finalUser = await request(app.getHttpServer())
      .get(`/api/v1/users/${userId}`)
      .expect(200);

    expect(finalUser.body.data.status).toBe("TERMINATED");
    expect(finalUser.body.data.terminatedAt).toBeDefined();
  });
});
```

---

### 2. 组织架构重组

**测试场景**: 创建部门树 → 调整层级 → 转移员工

**测试代码**:

```typescript
describe("Organization Restructure Integration", () => {
  it("should handle department restructuring", async () => {
    // 1. 创建部门树
    const root = await createTestDepartment({ code: "ROOT" });
    const child1 = await createTestDepartment({
      code: "CHILD1",
      parentId: root.id,
    });
    const child2 = await createTestDepartment({
      code: "CHILD2",
      parentId: root.id,
    });

    // 2. 添加员工到 CHILD1
    const user = await createTestUser();
    await createUserDepartment({
      userId: user.id,
      departmentId: child1.id,
    });

    // 3. 转移到 CHILD2
    await request(app.getHttpServer())
      .patch(`/api/v1/users/${user.id}/departments/${child1.id}`)
      .send({ departmentId: child2.id })
      .expect(200);

    // 4. 验证
    const memberships = await request(app.getHttpServer())
      .get(`/api/v1/users/${user.id}/departments`)
      .expect(200);

    expect(memberships.body.data).toHaveLength(1);
    expect(memberships.body.data[0].departmentId).toBe(child2.id);
  });
});
```

---

### 3. 权限验证流程

**测试场景**: 分配权限 → 验证访问 → 移除权限 → 验证拒绝

**测试代码**:

```typescript
describe("Permission Verification Integration", () => {
  it("should verify permissions correctly", async () => {
    const user = await createTestUser();
    const role = await createTestRole();
    const permission = await createTestPermission({
      resource: "department",
      action: "create",
    });

    // 1. 添加权限到角色
    await rolesService.addPermission(role.id, permission.id);

    // 2. 分配角色给用户
    await rolesService.assignToUser(user.id, role.id);

    // 3. 验证有权限
    const hasPermission = await authService.checkPermission(
      user.id,
      "department:create",
    );
    expect(hasPermission).toBe(true);

    // 4. 移除角色
    await rolesService.removeFromUser(user.id, role.id);

    // 5. 验证无权限
    const noPermission = await authService.checkPermission(
      user.id,
      "department:create",
    );
    expect(noPermission).toBe(false);
  });
});
```

---

### 5. 组织管理页面 E2E 测试 - v2.0 新增 ⭐

#### 5.1 组织列表页面

**测试场景**: 访问组织列表 → 搜索 → 查看详情

**E2E 用例（历史参考）**:

```typescript
test("Organization list and search", async ({ page }) => {
  await loginAsAdmin(page);

  // 1. 导航到组织管理
  await page.goto(`${BASE_URL}/organization/organizations`);

  // 2. 验证页面标题
  await expect(page.locator('[data-testid="page-title"]')).toContainText(
    "组织管理",
  );

  // 3. 验证列表加载
  await expect(
    page.locator('[data-testid="organization-table"]'),
  ).toBeVisible();

  // 4. 搜索组织
  await page.fill('[data-testid="search-input"]', "FF China");
  await page.click('[data-testid="search-button"]');

  // 5. 验证搜索结果
  await expect(page.locator('[data-testid="org-row"]')).toContainText(
    "FF China",
  );

  // 6. 点击查看详情
  await page.click('[data-testid="view-org-button"]');

  // 7. 验证详情页
  await page.waitForURL(/\/organization\/organizations\/[a-f0-9-]+/);
  await expect(page.locator('[data-testid="org-name"]')).toContainText(
    "FF China",
  );
});
```

---

#### 5.2 创建组织流程

**测试场景**: 打开创建表单 → 填写信息 → 提交 → 验证成功

**E2E 用例（历史参考）**:

```typescript
test("Create new organization", async ({ page }) => {
  await loginAsAdmin(page);
  await page.goto(`${BASE_URL}/organization/organizations`);

  // 1. 点击新建按钮
  await page.click('[data-testid="create-org-button"]');

  // 2. 填写基本信息
  await page.fill('[data-testid="org-name"]', "FF Japan");
  await page.fill('[data-testid="org-shortName"]', "FF-JP");
  await page.fill('[data-testid="org-legalName"]', "Flying Fox Japan K.K.");
  await page.fill('[data-testid="org-taxId"]', "JP1234567890");
  await page.fill('[data-testid="org-address"]', "東京都渋谷区");

  // 3. 选择状态
  await page.selectOption('[data-testid="org-status"]', "ACTIVE");

  // 4. 提交表单
  await page.click('[data-testid="submit-button"]');

  // 5. 验证成功消息
  await expect(page.locator('[data-testid="success-message"]')).toContainText(
    "组织创建成功",
  );

  // 6. 验证跳转到详情页
  await page.waitForURL(/\/organization\/organizations\/[a-f0-9-]+/);
  await expect(page.locator('[data-testid="org-name"]')).toContainText(
    "FF Japan",
  );
});
```

---

#### 5.3 组织区域配置

**测试场景**: 打开组织详情 → 添加区域 → 设置主区域 → 保存

**E2E 用例（历史参考）**:

```typescript
test("Configure organization regions", async ({ page }) => {
  await loginAsAdmin(page);

  // 创建测试组织
  const org = await createTestOrganization({ name: "FF Test" });

  // 1. 打开组织详情
  await page.goto(`${BASE_URL}/organization/organizations/${org.id}`);

  // 2. 切换到区域配置标签
  await page.click('[data-testid="tab-regions"]');

  // 3. 添加区域
  await page.click('[data-testid="add-region-button"]');

  // 4. 选择区域
  await page.click('[data-testid="region-select"]');
  await page.click('[data-testid="region-option-CN"]');

  // 5. 设置为主区域
  await page.check('[data-testid="is-primary-checkbox"]');

  // 6. 保存
  await page.click('[data-testid="save-region-button"]');

  // 7. 验证成功
  await expect(page.locator('[data-testid="success-message"]')).toContainText(
    "区域添加成功",
  );

  // 8. 验证区域出现在列表中
  await expect(page.locator('[data-testid="region-list"]')).toContainText(
    "中国",
  );
  await expect(page.locator('[data-testid="primary-badge"]')).toBeVisible();
});
```

---

#### 5.4 组织统计信息

**测试场景**: 查看组织统计 → 验证部门数、用户数、区域数

**E2E 用例（历史参考）**:

```typescript
test("View organization statistics", async ({ page }) => {
  await loginAsAdmin(page);

  // 准备测试数据
  const org = await createTestOrganization({ name: "FF Stats Test" });
  await createTestDepartment({ organizationId: org.id, name: "部门1" });
  await createTestDepartment({ organizationId: org.id, name: "部门2" });
  const region = await createTestRegion({ code: "CN" });
  await addRegionToOrganization(org.id, region.id);

  // 1. 打开组织详情
  await page.goto(`${BASE_URL}/organization/organizations/${org.id}`);

  // 2. 验证统计卡片
  await expect(page.locator('[data-testid="stat-departments"]')).toContainText(
    "2",
  );
  await expect(page.locator('[data-testid="stat-regions"]')).toContainText("1");
  await expect(page.locator('[data-testid="stat-users"]')).toContainText("0");

  // 3. 点击部门统计
  await page.click('[data-testid="stat-departments"]');

  // 4. 验证跳转到部门列表
  await page.waitForURL(/\/organization\/departments/);
  await expect(page.locator('[data-testid="department-list"]')).toBeVisible();
});
```

---

## 🎭 E2E 测试场景

### 1. 用户登录流程（含回跳）

**测试场景**: 未登录访问受保护页 → 跳转登录页 → 输入凭证 → 成功登录 → 自动回跳原页面

**E2E 用例（历史参考）**:

```typescript
test("User login redirect-back flow", async ({ page }) => {
  // 1. 未登录访问受保护页面
  await page.goto(`${BASE_URL}/organization/members`);

  // 2. 验证跳转到登录页（携带 redirect）
  await page.waitForURL(/\/login\?redirect=/);

  // 3. 输入用户名和密码
  await page.fill('[data-testid="username-input"]', "admin");
  await page.fill('[data-testid="password-input"]', "admin123");

  // 4. 点击登录按钮
  await page.click('[data-testid="login-button"]');

  // 5. 等待回跳并验证到达
  await page.waitForURL(/\/organization\/members/);
  await expect(page.locator('[data-testid="page-title"]')).toContainText(
    "用户管理",
  );
});
```

---

### 2. 创建用户完整流程

**测试场景**: 登录 → 导航到用户管理 → 创建用户 → 验证创建成功

**E2E 用例（历史参考）**:

```typescript
test("Create user E2E flow", async ({ page }) => {
  // 1. 登录
  await loginAsAdmin(page);

  // 2. 导航到用户管理
  await page.click('[data-testid="menu-organization"]');
  await page.click('[data-testid="menu-users"]');

  // 3. 点击新建用户
  await page.click('[data-testid="create-user-button"]');

  // 4. 填写表单
  await page.fill('[data-testid="username"]', "newuser");
  await page.fill('[data-testid="email"]', "new@example.com");
  await page.fill('[data-testid="displayName"]', "新用户");
  await page.selectOption('[data-testid="region"]', "CN");

  // 5. 选择部门
  await page.click('[data-testid="department-select"]');
  await page.click('[data-testid="department-option-tech"]');

  // 6. 提交
  await page.click('[data-testid="submit-button"]');

  // 7. 验证成功消息
  await expect(page.locator('[data-testid="success-message"]')).toContainText(
    "用户创建成功",
  );

  // 8. 验证用户出现在列表中
  await expect(page.locator('[data-testid="user-list"]')).toContainText(
    "newuser",
  );
});
```

---

### 3. 组织架构树操作

**测试场景**: 展开部门树 → 添加子部门 → 拖拽调整层级

**E2E 用例（历史参考）**:

```typescript
test("Organization tree manipulation", async ({ page }) => {
  await loginAsAdmin(page);

  // 1. 导航到组织架构
  await page.goto(`${BASE_URL}/organization/structure`);

  // 2. 展开部门树
  await page.click('[data-testid="expand-root"]');
  await expect(page.locator('[data-testid="dept-child"]')).toBeVisible();

  // 3. 添加子部门
  await page.click('[data-testid="add-child-dept"]');
  await page.fill('[data-testid="dept-name"]', "新部门");
  await page.fill('[data-testid="dept-code"]', "NEW_DEPT");
  await page.click('[data-testid="confirm-button"]');

  // 4. 验证新部门出现
  await expect(page.locator('[data-testid="dept-NEW_DEPT"]')).toBeVisible();
});
```

---

### 4. 多部门归属配置

**测试场景**: 打开用户详情 → 添加兼职部门 → 设置上级 → 保存

**E2E 用例（历史参考）**:

```typescript
test("Multi-department membership configuration", async ({ page }) => {
  await loginAsAdmin(page);

  // 1. 打开用户详情
  await page.goto(`${BASE_URL}/users/user-123`);

  // 2. 切换到部门归属标签
  await page.click('[data-testid="tab-departments"]');

  // 3. 添加部门归属
  await page.click('[data-testid="add-department"]');

  // 4. 选择部门
  await page.click('[data-testid="department-select"]');
  await page.click('[data-testid="dept-option-marketing"]');

  // 5. 选择岗位
  await page.selectOption('[data-testid="position-select"]', "MANAGER");

  // 6. 选择上级
  await page.click('[data-testid="manager-select"]');
  await page.click('[data-testid="manager-option-wang"]');

  // 7. 保存
  await page.click('[data-testid="save-button"]');

  // 8. 验证成功
  await expect(page.locator('[data-testid="dept-list"]')).toContainText(
    "市场部",
  );
});
```

---

## 📊 性能测试场景

### 1. 用户列表加载性能

**测试场景**: 加载1000+用户列表

**测试代码**:

```typescript
describe("Performance: User List", () => {
  it("should load 1000 users within 2 seconds", async () => {
    // 准备数据：创建1000个用户
    await createManyTestUsers(1000);

    const startTime = Date.now();

    const response = await request(app.getHttpServer())
      .get("/api/v1/users?limit=100")
      .expect(200);

    const endTime = Date.now();
    const duration = endTime - startTime;

    expect(duration).toBeLessThan(2000); // 小于2秒
    expect(response.body.data).toHaveLength(100);
  });
});
```

---

### 2. 组织架构树查询性能

**测试场景**: 加载深度10层、1000+节点的组织树

**测试代码**:

```typescript
describe("Performance: Organization Tree", () => {
  it("should load deep hierarchy within 3 seconds", async () => {
    // 创建深度为10、每层10个子部门的树（共1111个节点）
    const org = await createDeepDepartmentTree(10, 10);

    const startTime = Date.now();

    const response = await request(app.getHttpServer())
      .get(`/api/v1/organizations/${org.id}/departments`)
      .expect(200);

    const endTime = Date.now();
    const duration = endTime - startTime;

    expect(duration).toBeLessThan(3000); // 小于3秒
    expect(response.body.data).toBeDefined();
  });
});
```

---

### 3. 权限查询性能（v2.1）⭐ 新增

**测试场景**: 组织级权限查询，用户拥有多个组织的多个角色

**测试代码**:

```typescript
describe("Performance: Permission Query (v2.1)", () => {
  it("should query user permissions within 100ms", async () => {
    // 准备数据：用户在 5 个组织各有 5 个角色，每个角色 10 个权限
    const user = await createTestUser();
    const organizations = await createManyOrganizations(5);

    for (const org of organizations) {
      const roles = await createManyRoles(5, 10); // 5 个角色，每个 10 个权限
      for (const role of roles) {
        await assignRoleToUser(user.id, role.id, org.id);
      }
    }

    const startTime = Date.now();

    // 查询用户在特定组织的权限
    const permissions = await permissionsService.getUserPermissions(
      user.id,
      organizations[0].id,
    );

    const duration = Date.now() - startTime;

    expect(duration).toBeLessThan(100); // 小于 100ms
    expect(permissions).toHaveLength(50); // 5 角色 * 10 权限
  });

  it("should cache permissions effectively", async () => {
    const user = await createTestUser();
    const org = await createTestOrganization();

    // 首次查询（写入缓存）
    const start1 = Date.now();
    await permissionsService.getUserPermissions(user.id, org.id);
    const duration1 = Date.now() - start1;

    // 第二次查询（命中缓存）
    const start2 = Date.now();
    await permissionsService.getUserPermissions(user.id, org.id);
    const duration2 = Date.now() - start2;

    // 缓存命中应该快 10 倍以上
    expect(duration2).toBeLessThan(duration1 / 10);
    expect(duration2).toBeLessThan(10); // 小于 10ms
  });
});
```

---

### 4. 批量操作性能

**测试场景**: 批量分配角色、批量更新用户状态

**测试代码**:

```typescript
describe("Performance: Batch Operations", () => {
  it("should batch assign roles to 100 users within 5 seconds", async () => {
    const users = await createManyTestUsers(100);
    const role = await createTestRole();
    const org = await createTestOrganization();

    const startTime = Date.now();

    // 批量分配角色
    await Promise.all(
      users.map((user) => assignRoleToUser(user.id, role.id, org.id)),
    );

    const duration = Date.now() - startTime;

    expect(duration).toBeLessThan(5000); // 小于 5 秒
  });

  it("should batch update user status within 3 seconds", async () => {
    const users = await createManyTestUsers(100);

    const startTime = Date.now();

    // 批量更新状态
    await Promise.all(
      users.map((user) => updateUserStatus(user.id, "INACTIVE")),
    );

    const duration = Date.now() - startTime;

    expect(duration).toBeLessThan(3000); // 小于 3 秒
  });
});
```

---

## 🔄 并发测试场景 ⭐ 新增

### 1. 并发创建用户

**测试场景**: 多个管理员同时创建用户，检查数据一致性

**测试代码**:

```typescript
describe("Concurrency: User Creation", () => {
  it("should handle concurrent user creation", async () => {
    const promises = Array.from({ length: 10 }, (_, i) =>
      request(app.getHttpServer())
        .post("/api/v1/users")
        .send({
          username: `user_${i}_${Date.now()}`,
          email: `user${i}@test.com`,
          displayName: `User ${i}`,
          password: "password123",
        }),
    );

    const results = await Promise.all(promises);

    // 所有请求都应该成功
    results.forEach((result) => {
      expect(result.status).toBe(201);
    });

    // 验证创建了 10 个用户
    const users = await prisma.user.count();
    expect(users).toBe(10);
  });

  it("should prevent duplicate username in concurrent creation", async () => {
    const username = `user_${Date.now()}`;

    // 10 个并发请求创建相同用户名
    const promises = Array.from({ length: 10 }, () =>
      request(app.getHttpServer())
        .post("/api/v1/users")
        .send({
          username,
          email: `${Math.random()}@test.com`,
          displayName: "Test User",
          password: "password123",
        })
        .then((res) => res.status)
        .catch((err) => err.status),
    );

    const statuses = await Promise.all(promises);

    // 应该只有 1 个成功（201），其他都失败（409 冲突）
    const successCount = statuses.filter((s) => s === 201).length;
    const conflictCount = statuses.filter((s) => s === 409).length;

    expect(successCount).toBe(1);
    expect(conflictCount).toBe(9);
  });
});
```

---

### 2. 并发组织切换

**测试场景**: 同一用户在多个会话中同时切换组织

**测试代码**:

```typescript
describe("Concurrency: Organization Switching", () => {
  it("should handle concurrent organization switches", async () => {
    const user = await createTestUser();
    const orgs = await createManyOrganizations(5);

    // 为用户在所有组织分配角色
    for (const org of orgs) {
      const role = await createTestRole();
      await assignRoleToUser(user.id, role.id, org.id);
    }

    const token = await loginAs(user);

    // 10 个并发请求切换到不同组织
    const promises = Array.from({ length: 10 }, (_, i) =>
      request(app.getHttpServer())
        .get("/api/v1/users/me/permissions")
        .set("Authorization", `Bearer ${token}`)
        .set("X-Organization-Id", orgs[i % 5].id),
    );

    const results = await Promise.all(promises);

    // 所有请求都应该成功
    results.forEach((result) => {
      expect(result.status).toBe(200);
      expect(result.body.success).toBe(true);
    });
  });
});
```

---

### 3. 并发角色分配

**测试场景**: 多个管理员同时为同一用户分配不同角色

**测试代码**:

```typescript
describe("Concurrency: Role Assignment", () => {
  it("should handle concurrent role assignments", async () => {
    const user = await createTestUser();
    const org = await createTestOrganization();
    const roles = await createManyRoles(10);

    // 10 个并发请求分配不同角色
    const promises = roles.map((role) =>
      request(app.getHttpServer())
        .post(`/api/v1/users/${user.id}/roles`)
        .send({
          assignments: [
            {
              roleId: role.id,
              organizationId: org.id,
            },
          ],
        }),
    );

    const results = await Promise.all(promises);

    // 所有请求都应该成功
    results.forEach((result) => {
      expect(result.status).toBe(200);
    });

    // 验证用户有 10 个角色
    const userRoles = await prisma.userRole.count({
      where: { userId: user.id, organizationId: org.id },
    });
    expect(userRoles).toBe(10);
  });

  it("should prevent duplicate role assignment", async () => {
    const user = await createTestUser();
    const org = await createTestOrganization();
    const role = await createTestRole();

    // 10 个并发请求分配相同角色
    const promises = Array.from({ length: 10 }, () =>
      request(app.getHttpServer())
        .post(`/api/v1/users/${user.id}/roles`)
        .send({
          assignments: [
            {
              roleId: role.id,
              organizationId: org.id,
            },
          ],
        })
        .then((res) => res.status)
        .catch((err) => err.status),
    );

    const statuses = await Promise.all(promises);

    // 应该只有 1 个成功，其他都应该是幂等的或冲突
    const successCount = statuses.filter((s) => s === 200 || s === 201).length;
    expect(successCount).toBeGreaterThanOrEqual(1);

    // 验证只有 1 个角色关联记录
    const userRoles = await prisma.userRole.count({
      where: {
        userId: user.id,
        roleId: role.id,
        organizationId: org.id,
      },
    });
    expect(userRoles).toBe(1);
  });
});
```

---

## 🎯 边界测试场景 ⭐ 新增

### 1. 极限值测试

**测试场景**: 测试系统对极限值的处理

**测试代码**:

```typescript
describe("Boundary: Extreme Values", () => {
  it("should handle maximum department depth (20 levels)", async () => {
    // 创建 20 层深度的部门树
    const org = await createTestOrganization();
    let parentId = null;

    for (let i = 0; i < 20; i++) {
      const dept = await createTestDepartment({
        name: `Level ${i}`,
        organizationId: org.id,
        parentId,
      });
      parentId = dept.id;
    }

    // 查询最深层部门
    const deepestDept = await prisma.department.findFirst({
      where: { parentId },
      include: { parent: true },
    });

    expect(deepestDept).toBeDefined();

    // 查询完整路径
    const path = await getDepartmentPath(deepestDept!.id);
    expect(path).toHaveLength(20);
  });

  it("should handle user with maximum departments (10)", async () => {
    const user = await createTestUser();
    const org = await createTestOrganization();
    const departments = await createManyDepartments(10, org.id);

    // 为用户添加 10 个部门归属
    for (const dept of departments) {
      await addUserToDepartment(user.id, dept.id, org.id);
    }

    // 查询用户部门归属
    const memberships = await prisma.userDepartment.findMany({
      where: { userId: user.id },
    });

    expect(memberships).toHaveLength(10);
  });

  it("should handle role with maximum permissions (100)", async () => {
    const role = await createTestRole();
    const permissions = await createManyPermissions(100);

    // 为角色分配 100 个权限
    await Promise.all(
      permissions.map((perm) => assignPermissionToRole(role.id, perm.id)),
    );

    // 查询角色权限
    const rolePerms = await prisma.rolePermission.findMany({
      where: { roleId: role.id },
    });

    expect(rolePerms).toHaveLength(100);
  });
});
```

---

### 2. 空值和特殊字符测试

**测试场景**: 测试系统对边界输入的处理

**测试代码**:

```typescript
describe("Boundary: Special Inputs", () => {
  it("should reject user with empty username", async () => {
    const response = await request(app.getHttpServer())
      .post("/api/v1/users")
      .send({
        username: "",
        email: "test@test.com",
        displayName: "Test",
        password: "password123",
      })
      .expect(400);

    expect(response.body.error.code).toBe("VALIDATION_ERROR");
  });

  it("should handle special characters in names", async () => {
    const specialChars = [
      "O'Brien", // 单引号
      "François Müller", // 重音符号
      "李明", // 中文
      "محمد", // 阿拉伯文
      "Владимир", // 西里尔文
    ];

    for (const name of specialChars) {
      const response = await request(app.getHttpServer())
        .post("/api/v1/users")
        .send({
          username: `user_${Math.random()}`,
          email: `${Math.random()}@test.com`,
          displayName: name,
          password: "password123",
        });

      expect(response.status).toBe(201);
      expect(response.body.data.displayName).toBe(name);
    }
  });

  it("should reject SQL injection attempts", async () => {
    const sqlInjections = [
      "'; DROP TABLE users; --",
      "' OR '1'='1",
      "admin'--",
      "1' UNION SELECT * FROM users--",
    ];

    for (const injection of sqlInjections) {
      const response = await request(app.getHttpServer())
        .post("/api/v1/auth/login")
        .send({
          username: injection,
          password: "password",
        });

      // 应该返回 401 而不是 500（服务器错误）
      expect([401, 400]).toContain(response.status);
    }
  });

  it("should handle very long input strings", async () => {
    const longString = "a".repeat(1000);

    const response = await request(app.getHttpServer())
      .post("/api/v1/users")
      .send({
        username: longString,
        email: "test@test.com",
        displayName: "Test",
        password: "password123",
      })
      .expect(400);

    expect(response.body.error.code).toBe("VALIDATION_ERROR");
  });
});
```

---

### 3. 权限边界测试（v2.1）⭐ 新增

**测试场景**: 测试组织级权限隔离边界

**测试代码**:

```typescript
describe("Boundary: Organization Permission Isolation", () => {
  it("should prevent cross-organization data access", async () => {
    // 创建两个组织
    const orgA = await createTestOrganization({ name: "Org A" });
    const orgB = await createTestOrganization({ name: "Org B" });

    // 用户在 Org A 有管理员权限
    const user = await createTestUser();
    const adminRole = await createTestRole({
      permissions: ["user:read:organization"],
    });
    await assignRoleToUser(user.id, adminRole.id, orgA.id);

    // 在 Org B 创建用户
    const userInOrgB = await createTestUser({ organizationId: orgB.id });

    const token = await loginAs(user);

    // 尝试访问 Org B 的用户（应该失败）
    const response = await request(app.getHttpServer())
      .get(`/api/v1/users/${userInOrgB.id}`)
      .set("Authorization", `Bearer ${token}`)
      .set("X-Organization-Id", orgA.id) // 当前在 Org A
      .expect(403);

    expect(response.body.error.code).toBe("IAM_FORBIDDEN");
  });

  it("should allow global admin to access all organizations", async () => {
    const orgA = await createTestOrganization();
    const orgB = await createTestOrganization();

    // 全局管理员（organizationId = null）
    const admin = await createTestUser();
    const globalAdminRole = await createTestRole({
      permissions: ["user:read"],
    });
    await assignRoleToUser(admin.id, globalAdminRole.id, null); // 全局角色

    // 在两个组织创建用户
    const userA = await createTestUser({ organizationId: orgA.id });
    const userB = await createTestUser({ organizationId: orgB.id });

    const token = await loginAs(admin);

    // 访问 Org A 的用户（应该成功）
    const responseA = await request(app.getHttpServer())
      .get(`/api/v1/users/${userA.id}`)
      .set("Authorization", `Bearer ${token}`)
      .expect(200);

    // 访问 Org B 的用户（应该成功）
    const responseB = await request(app.getHttpServer())
      .get(`/api/v1/users/${userB.id}`)
      .set("Authorization", `Bearer ${token}`)
      .expect(200);

    expect(responseA.body.success).toBe(true);
    expect(responseB.body.success).toBe(true);
  });

  it("should handle user with roles in multiple organizations", async () => {
    const orgA = await createTestOrganization();
    const orgB = await createTestOrganization();
    const orgC = await createTestOrganization();

    // 用户在三个组织都有不同角色
    const user = await createTestUser();

    const adminRoleA = await createTestRole({
      permissions: ["user:read:organization"],
    });
    const employeeRoleB = await createTestRole({
      permissions: ["user:read:own"],
    });
    const managerRoleC = await createTestRole({
      permissions: ["user:read:department"],
    });

    await assignRoleToUser(user.id, adminRoleA.id, orgA.id);
    await assignRoleToUser(user.id, employeeRoleB.id, orgB.id);
    await assignRoleToUser(user.id, managerRoleC.id, orgC.id);

    const token = await loginAs(user);

    // 查询在 Org A 的权限（应该是 organization 级别）
    const permsA = await request(app.getHttpServer())
      .get("/api/v1/users/me/permissions")
      .set("Authorization", `Bearer ${token}`)
      .set("X-Organization-Id", orgA.id)
      .expect(200);

    expect(permsA.body.data).toContain("user:read:organization");

    // 查询在 Org B 的权限（应该是 own 级别）
    const permsB = await request(app.getHttpServer())
      .get("/api/v1/users/me/permissions")
      .set("Authorization", `Bearer ${token}`)
      .set("X-Organization-Id", orgB.id)
      .expect(200);

    expect(permsB.body.data).toContain("user:read:own");
    expect(permsB.body.data).not.toContain("user:read:organization");
  });

  it("should correctly filter permissions by organization context", async () => {
    const orgA = await createTestOrganization();
    const orgB = await createTestOrganization();

    const user = await createTestUser();

    // 在 Org A 有完整权限
    const adminRoleA = await createTestRole({
      permissions: ["user:read:organization", "user:create", "user:update"],
    });
    await assignRoleToUser(user.id, adminRoleA.id, orgA.id);

    // 在 Org B 只有只读权限
    const readOnlyRoleB = await createTestRole({
      permissions: ["user:read:organization"],
    });
    await assignRoleToUser(user.id, readOnlyRoleB.id, orgB.id);

    const token = await loginAs(user);

    // 在 Org A 上下文：应该有完整权限
    const permsA = await request(app.getHttpServer())
      .get("/api/v1/users/me/permissions")
      .set("Authorization", `Bearer ${token}`)
      .set("X-Organization-Id", orgA.id)
      .expect(200);

    expect(permsA.body.data.permissions).toContain("user:read:organization");
    expect(permsA.body.data.permissions).toContain("user:create");
    expect(permsA.body.data.permissions).toContain("user:update");

    // 在 Org B 上下文：应该只有只读权限
    const permsB = await request(app.getHttpServer())
      .get("/api/v1/users/me/permissions")
      .set("Authorization", `Bearer ${token}`)
      .set("X-Organization-Id", orgB.id)
      .expect(200);

    expect(permsB.body.data.permissions).toContain("user:read:organization");
    expect(permsB.body.data.permissions).not.toContain("user:create");
    expect(permsB.body.data.permissions).not.toContain("user:update");
  });

  it("should prevent role assignment without organization context", async () => {
    const orgA = await createTestOrganization();
    const user = await createTestUser();
    const role = await createTestRole();

    const token = await loginAs(user);

    // 尝试分配角色但不指定组织（应该失败或使用默认值）
    const response = await request(app.getHttpServer())
      .post(`/api/v1/users/${user.id}/roles`)
      .set("Authorization", `Bearer ${token}`)
      .send({
        assignments: [{ roleId: role.id }], // 缺少 organizationId
      })
      .expect(400);

    expect(response.body.error.code).toBe("VALIDATION_ERROR");
  });

  it("should allow assigning same role to different organizations", async () => {
    const orgA = await createTestOrganization();
    const orgB = await createTestOrganization();
    const user = await createTestUser();
    const role = await createTestRole();

    const token = await loginAs(user);

    // 在 Org A 分配角色
    await request(app.getHttpServer())
      .post(`/api/v1/users/${user.id}/roles`)
      .set("Authorization", `Bearer ${token}`)
      .send({
        assignments: [{ roleId: role.id, organizationId: orgA.id }],
      })
      .expect(201);

    // 在 Org B 分配相同角色（应该成功）
    await request(app.getHttpServer())
      .post(`/api/v1/users/${user.id}/roles`)
      .set("Authorization", `Bearer ${token}`)
      .send({
        assignments: [{ roleId: role.id, organizationId: orgB.id }],
      })
      .expect(201);

    // 验证用户在两个组织都有该角色
    const roles = await request(app.getHttpServer())
      .get(`/api/v1/users/${user.id}/roles`)
      .set("Authorization", `Bearer ${token}`)
      .expect(200);

    const orgARole = roles.body.data.find(
      (r: any) => r.organizationId === orgA.id,
    );
    const orgBRole = roles.body.data.find(
      (r: any) => r.organizationId === orgB.id,
    );

    expect(orgARole).toBeDefined();
    expect(orgBRole).toBeDefined();
    expect(orgARole.roleId).toBe(role.id);
    expect(orgBRole.roleId).toBe(role.id);
  });
});
```

---

## 🔧 测试工具和辅助函数

### 测试数据工厂

```typescript
// test/factories/user.factory.ts
export async function createTestUser(overrides = {}) {
  return await prisma.user.create({
    data: {
      username: `user_${randomString()}`,
      email: `${randomString()}@example.com`,
      displayName: `测试用户_${randomString()}`,
      status: "ACTIVE",
      source: "LOCAL",
      region: "CN",
      ...overrides,
    },
  });
}

export async function createManyTestUsers(count: number) {
  const users = Array.from({ length: count }, (_, i) => ({
    username: `user_${i}`,
    email: `user${i}@example.com`,
    displayName: `用户${i}`,
    status: "ACTIVE",
    source: "LOCAL",
    region: "CN",
  }));

  return await prisma.user.createMany({ data: users });
}

// ⭐ 新增：组织工厂
export async function createTestOrganization(overrides = {}) {
  return await prisma.organization.create({
    data: {
      code: `ORG_${randomString()}`,
      name: `测试组织_${randomString()}`,
      legalName: `测试法人_${randomString()}`,
      status: "ACTIVE",
      ...overrides,
    },
  });
}

export async function createManyOrganizations(count: number) {
  const orgs = Array.from({ length: count }, (_, i) => ({
    code: `ORG_${i}`,
    name: `组织${i}`,
    legalName: `法人${i}`,
    status: "ACTIVE",
  }));

  return await Promise.all(
    orgs.map((org) => prisma.organization.create({ data: org })),
  );
}

// ⭐ 新增：角色工厂（支持指定权限数量）
export async function createTestRole(overrides = {}) {
  return await prisma.role.create({
    data: {
      code: `ROLE_${randomString()}`,
      name: `测试角色_${randomString()}`,
      enabled: true,
      ...overrides,
    },
  });
}

export async function createManyRoles(
  count: number,
  permissionsPerRole: number = 0,
) {
  const roles = [];

  for (let i = 0; i < count; i++) {
    const role = await createTestRole({ name: `角色${i}` });

    if (permissionsPerRole > 0) {
      const permissions = await createManyPermissions(permissionsPerRole);
      await Promise.all(
        permissions.map((perm) =>
          prisma.rolePermission.create({
            data: { roleId: role.id, permissionId: perm.id },
          }),
        ),
      );
    }

    roles.push(role);
  }

  return roles;
}

// ⭐ 新增：权限工厂
export async function createManyPermissions(count: number) {
  const permissions = [];

  for (let i = 0; i < count; i++) {
    const perm = await prisma.permission.create({
      data: {
        code: `resource${i}:action${i}`,
        name: `权限${i}`,
        resource: `resource${i}`,
        action: `action${i}`,
      },
    });
    permissions.push(perm);
  }

  return permissions;
}

// ⭐ 新增：部门工厂（支持深度树）
export async function createManyDepartments(
  count: number,
  organizationId: string,
  parentId: string | null = null,
) {
  const departments = [];

  for (let i = 0; i < count; i++) {
    const dept = await prisma.department.create({
      data: {
        code: `DEPT_${randomString()}`,
        name: `部门${i}`,
        organizationId,
        parentId,
      },
    });
    departments.push(dept);
  }

  return departments;
}

export async function createDeepDepartmentTree(
  depth: number,
  childrenPerLevel: number,
) {
  const org = await createTestOrganization();
  let currentLevelNodes = [null]; // 根节点

  for (let level = 0; level < depth; level++) {
    const nextLevelNodes = [];

    for (const parentId of currentLevelNodes) {
      const children = await createManyDepartments(
        childrenPerLevel,
        org.id,
        parentId,
      );
      nextLevelNodes.push(...children.map((c) => c.id));
    }

    currentLevelNodes = nextLevelNodes;
  }

  return org;
}

// ⭐ 新增：角色分配辅助函数（支持组织级别）
export async function assignRoleToUser(
  userId: string,
  roleId: string,
  organizationId: string | null,
) {
  return await prisma.userRole.create({
    data: {
      userId,
      roleId,
      organizationId,
    },
  });
}

// ⭐ 新增：部门成员辅助函数
export async function addUserToDepartment(
  userId: string,
  departmentId: string,
  organizationId: string,
) {
  return await prisma.userDepartment.create({
    data: {
      userId,
      departmentId,
      organizationId,
      positionId: null,
      isPrimary: false,
      joinedAt: new Date(),
    },
  });
}
```

---

### 测试数据清理

```typescript
// test/helpers/cleanup.ts
export async function cleanupTestData() {
  await prisma.userRole.deleteMany({});
  await prisma.rolePermission.deleteMany({});
  await prisma.userDepartment.deleteMany({});
  await prisma.user.deleteMany({});
  await prisma.department.deleteMany({});
  await prisma.role.deleteMany({});
  await prisma.permission.deleteMany({});
  await prisma.position.deleteMany({});
  await prisma.region.deleteMany({});
  await prisma.organization.deleteMany({}); // ⭐ 新增
}

// 在每个测试前/后使用
beforeEach(async () => {
  await cleanupTestData();
});
```

---

### 性能监控辅助函数 ⭐ 新增

```typescript
// test/helpers/performance.ts

/**
 * 性能计时器
 */
export class PerformanceTimer {
  private startTime: number;

  start() {
    this.startTime = Date.now();
  }

  end(): number {
    return Date.now() - this.startTime;
  }

  async measure<T>(
    fn: () => Promise<T>,
  ): Promise<{ result: T; duration: number }> {
    this.start();
    const result = await fn();
    const duration = this.end();
    return { result, duration };
  }
}

/**
 * 批量性能测试
 */
export async function runPerformanceTest(
  testName: string,
  fn: () => Promise<void>,
  iterations: number = 10,
) {
  const durations: number[] = [];

  for (let i = 0; i < iterations; i++) {
    const timer = new PerformanceTimer();
    const { duration } = await timer.measure(fn);
    durations.push(duration);
  }

  const avg = durations.reduce((a, b) => a + b, 0) / durations.length;
  const min = Math.min(...durations);
  const max = Math.max(...durations);

  console.log(`\n[Performance] ${testName}`);
  console.log(`  Avg: ${avg.toFixed(2)}ms`);
  console.log(`  Min: ${min}ms`);
  console.log(`  Max: ${max}ms`);

  return { avg, min, max, durations };
}

/**
 * 并发测试辅助函数
 */
export async function runConcurrentRequests<T>(
  fn: () => Promise<T>,
  count: number,
): Promise<T[]> {
  const promises = Array.from({ length: count }, () => fn());
  return await Promise.all(promises);
}

/**
 * 内存使用监控
 */
export function getMemoryUsage() {
  const usage = process.memoryUsage();
  return {
    heapUsed: (usage.heapUsed / 1024 / 1024).toFixed(2) + " MB",
    heapTotal: (usage.heapTotal / 1024 / 1024).toFixed(2) + " MB",
    external: (usage.external / 1024 / 1024).toFixed(2) + " MB",
    rss: (usage.rss / 1024 / 1024).toFixed(2) + " MB",
  };
}
```

---

## 📋 测试覆盖率报告

### 目标覆盖率

| 类型       | 目标 | 当前 |
| ---------- | ---- | ---- |
| 语句覆盖率 | 80%  | -    |
| 分支覆盖率 | 75%  | -    |
| 函数覆盖率 | 80%  | -    |
| 行覆盖率   | 80%  | -    |

### 生成覆盖率报告

```bash
# 后端单元测试覆盖率
cd testing && npm run test:backend:all:cov

# 查看HTML报告
open coverage/lcov-report/index.html
```

---

## 🤖 AI 工具授权 API 测试（v2.2 权限 MVP）

集成测试文件：`testing/backend/integration/organization/ai-tools.api.test.ts`

| 用例编号 | 场景 | 期望结果 |
|---|---|---|
| API-AITOOL-001 | 创建角色级授权（合法 roleId + 合法工具） | 201，返回含 role 关联的记录 |
| API-AITOOL-002 | 重复 (roleId, toolName) | 409 `IAM_AI_TOOL_GRANT_ROLE_EXISTS` |
| API-AITOOL-003 | 工具不在可用清单 | 400 `IAM_AI_TOOL_UNKNOWN` |
| API-AITOOL-004 | roleId 非 UUID 格式 | 400 (DTO 校验) |
| API-AITOOL-005 | 未带 token | 401 |
| API-AITOOL-010 | 批量创建：全部新增 | 201，createdCount = N，skippedCount = 0 |
| API-AITOOL-011 | 批量创建：部分已存在 | 201，跳过的不计入 createdCount |
| API-AITOOL-012 | 批量创建：任一未知工具 | 400 整体回滚，无任何记录写入 |
| API-AITOOL-013 | 批量创建：空数组 | 400 (DTO `@ArrayMinSize(1)`) |
| API-AITOOL-020 | 列表 + 按 roleId 筛选 | 200，结果只含指定 roleId |
| API-AITOOL-030 | DELETE 存在的规则 | 200 + DB 中已删 |
| API-AITOOL-031 | DELETE 不存在的 id | 404 `IAM_AI_TOOL_GRANT_NOT_FOUND` |
| API-AITOOL-040 | 用户级授权含 reason | 201 |
| API-AITOOL-041 | reason 字段缺失 | 400 (DTO 校验) |
| API-AITOOL-042 | reason 空字符串 | 400 (`@IsNotEmpty`) |
| API-AITOOL-043 | 重复 (userId, toolName) | 409 `IAM_AI_TOOL_GRANT_USER_EXISTS` |
| API-AITOOL-050 | 用户级列表 + 按 userId 筛选 | 200 |
| API-AITOOL-051 | DELETE 用户级规则 | 200 |
| API-AITOOL-060 | GET available-tools | 200，返回非空 + 含 name/label/category |
| API-AITOOL-070 | user-effective: 仅角色授权 | sources 全部 type=role |
| API-AITOOL-071 | user-effective: 仅用户直接授权 | sources type=user，含 reason |
| API-AITOOL-072 | user-effective: 同一工具角色+用户叠加 | 同条目下 sources 长度=2，type 集合 = {role, user} |
| API-AITOOL-073 | user-effective: 多角色合并 | 多条工具来自不同角色 |
| API-AITOOL-074 | user-effective: 用户无任何授权 | 200 + 空数组 |
| API-AITOOL-080 | tool-subjects: 角色 + 用户来源混合 | 同时返回角色路径用户和直接授权用户 |
| API-AITOOL-090 | /sync 路由 | **本期 it.skip 占位**，待 OpenClaw 同步脚本 PR 落地后改为正向测试 |

**测试数据策略**：
- 每个测试 `beforeEach` 通过 `setupIntegrationTest` 创建独立 admin token
- 测试角色 / 用户通过 `createTestRole` / `createTestUser` 工厂创建（随机 code/email），`afterEach` 显式 cleanup
- AI 工具授权写入因 FK Cascade 在 user/role 删除时会自动清理，`afterEach` 显式删一道作为双保险

**测试 admin 权限**：`testing/backend/helpers/factories/user.factory.ts` 的 `ensureAdminRoleAndPermissions` 已在 v2.2 PR 中追加 `ai_tool:read` / `ai_tool:manage` 两条权限。

---

## 🔗 相关文档

- [架构设计](./03-architecture.md) - 测试架构设计
- [API 文档](./07-api.md) - API测试接口
- [错误码](./08-error-codes.md) - 错误场景测试
- [用户旅程](./02-user-journey.md) - E2E测试场景

---

**最后更新**: 2026-04-15  
**维护者**: FFOA 测试团队  
**版本**: v2.1.1（身份源管理测试）

**v2.1.1 更新内容**:

- ✅ 新增 LDAP 用户创建测试场景
- ✅ 新增 LDAP（Entra 同步）用户创建测试场景
- ✅ 新增身份源验证测试（LDAP 用户不能有密码）
- ✅ 新增 LDAP 用户登录测试
- ✅ 新增 LDAP（Entra 同步）用户登录测试
- ✅ 新增密码修改测试（LOCAL/LDAP 用户区分）
- ✅ 新增密码复杂度验证测试
- 年假释放计划页在当前年度无释放计划的员工仍然展示在矩阵中，并在总计划天数位置明确显示未生成原因。

---

## 钉钉年假计划参数编辑测试场景

该部分已迁移至独立模块文档维护，见 [docs/modules/dingtalk/09-test-scenarios.md](../dingtalk/09-test-scenarios.md)。
