Преглед изворни кода

test: 新增 TC-H-01 真人管家并发守卫可执行规格

liaoxg пре 1 недеља
родитељ
комит
3da3b26963
1 измењених фајлова са 213 додато и 0 уклоњено
  1. 213 0
      tests/e2e/api/human-butler-workorder.spec.js

+ 213 - 0
tests/e2e/api/human-butler-workorder.spec.js

@@ -0,0 +1,213 @@
+// [TC-H-01] 真人管家 · AI 升级工单承接与乐观锁 —— 接口契约规格
+// ================================================================
+// 上游文档:tests/test-cases/human-butler.md → TC-H-01(P0);场景 tests/user-scenarios/09-human-butler.md SC-H-01
+// 运行:npx playwright test api/human-butler-workorder.spec.js -c playwright.config.api.js --project=desktop
+//
+// 契约来源(已核对后端实现):
+//   cfc-backend/.../controller/admin/AdminButlerController.java
+//     POST /api/admin/butler/review        {userId, action:'approve'|'reject', reason}
+//     POST /api/admin/butler/assign-member {profileId, memberId}   ← profileId 实为 butler 的 userId
+//   cfc-backend/.../service/ButlerService.java
+//     approve():   if (!"pending".equals(user.getButlerStatus())) return error("该用户当前无待审核的管家申请");
+//     assignMemberToButler(): 校验 approved → 统计成员数(LIKE referrerId)→ 判上限 → setReferrerId + updateById
+//
+// ⚠ 已识别实现风险(本规格 /2 为探针):
+//   approve() 的状态守卫只在「串行」下有效。并发两个 approve 会同时通过 pending 校验,
+//   各自 addRole + 各自 butler_profiles.insert(selectOne 均返回 null)→ 产生重复管家档案。
+//   → TC-H-01/2 复现时判红;若后端补乐观锁(版本号/唯一索引/条件更新)后自动转绿。
+//
+// ⚠ 另一处代码级问题(API 层无法构造,见交付说明):
+//   assignMemberToButler 用 `.like(User::getReferrerId, profileId)` 统计已分配成员,
+//   对 Long 型 referrerId 用 LIKE 子串匹配(profileId=10 会命中 1/10/100/1010…),
+//   既可能虚高误判「已达最大成员数」,也可能跨管家串数。应改为 eq。
+//
+// ⚠ 场景缺口:SC-H-01 描述的「AI 升级工单」(工单队列/认领/上下文/超时升级)
+//   后端目前无对应实体与接口(仅 /api/admin/butler/* 的管家认证审核与成员分配)。
+//   → TC-H-01/5 以 fixme 记录期望契约,待后端实现后解除。
+//
+// 安全约定:不打印 token / 手机号;所有测试数据由 direct-register 自建,不改动存量业务数据。
+
+const { test, expect } = require('@playwright/test');
+const { apiPost } = require('../helpers/api');
+
+const ADMIN_PHONE = process.env.ADMIN_PHONE || '13800138000';
+const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123';
+
+/** 注册账号:direct-register 冷启动实测可达 16s+,helpers 默认 15s 会超时,故显式 60s + 重试 */
+async function register(request, role, tag) {
+  const phone = '13' + String(Math.floor(Math.random() * 900000000) + 100000000);
+  let lastErr;
+  for (let i = 0; i < 3; i++) {
+    try {
+      const r = await request.post('/api/auth/direct-register', {
+        data: { phone, role, nickname: `QA-${tag}` },
+        timeout: 60000,
+      });
+      const body = await r.json().catch(() => ({}));
+      if (body.code === 200 && body.data && body.data.token) {
+        return { token: body.data.token, userId: body.data.userId };
+      }
+      lastErr = body;
+    } catch (e) {
+      lastErr = e.message;
+    }
+  }
+  throw new Error(`register(${role}) 失败: ${JSON.stringify(lastErr)}`);
+}
+
+/** 管理端登录,拿不到 token 时返回 null(由调用方 skip,避免账号变更导致误报) */
+async function adminLogin(request) {
+  const r = await request.post('/api/admin-auth/login-by-password', {
+    data: { phone: ADMIN_PHONE, password: ADMIN_PASSWORD },
+  });
+  const body = await r.json().catch(() => ({}));
+  if (body.code !== 200 || !body.data || !body.data.token) return null;
+  return body.data.token;
+}
+
+/** 造一个待审核的管家申请:普通用户注册 → /api/butler/apply,返回 {token,userId} */
+async function createPendingButler(request, tag) {
+  const u = await register(request, 'parent', tag);
+  const apply = await apiPost(
+    request,
+    '/api/butler/apply',
+    { tier: 'bronze', description: `自动化用例申请-${tag}`, realName: 'QA测试' },
+    u.token,
+  );
+  expect(apply.code, `管家申请应成功,实际 code=${apply.code} msg=${apply.message}`).toBe(200);
+  return u;
+}
+
+/** 查某 userId 的管家档案条数(approved 态),用于验证是否重复建档 */
+async function countApprovedProfile(request, adminToken, userId) {
+  const r = await apiPost(request, '/api/admin/butler/list', { status: 'approved', page: 1, size: 100 }, adminToken);
+  expect(r.code, `管家列表查询失败 code=${r.code} msg=${r.message}`).toBe(200);
+  const records = (r.data && r.data.records) || [];
+  return records.filter((x) => String(x.userId) === String(userId)).length;
+}
+
+test.describe('[TC-H-01] 真人管家工单承接与并发守卫', () => {
+  test('[TC-H-01/1] 串行重放审核:第二次必须被状态守卫拒绝(不重复建档)', async ({ request }) => {
+    const admin = await adminLogin(request);
+    test.skip(!admin, '管理端登录失败(账号/密码变更?),跳过');
+
+    const butler = await createPendingButler(request, 'TCH01-1');
+
+    const first = await apiPost(request, '/api/admin/butler/review', { userId: butler.userId, action: 'approve' }, admin);
+    expect(first.code, `首次审核应通过,实际 code=${first.code} msg=${first.message}`).toBe(200);
+
+    const second = await apiPost(request, '/api/admin/butler/review', { userId: butler.userId, action: 'approve' }, admin);
+    expect(second.code, `重复审核应被状态守卫拒绝,实际 code=${second.code}(若为 200 说明缺幂等守卫)`).not.toBe(200);
+    expect(String(second.message || ''), '拒绝原因应明确指向「无待审核申请」,便于排查').toContain('待审核');
+
+    const n = await countApprovedProfile(request, admin, butler.userId);
+    expect(n, `串行重复审核后管家档案应恰好 1 条,实际 ${n} 条`).toBe(1);
+    console.log(`[TC-H-01/1] 二次审核 code=${second.code} msg=${second.message},档案数=${n}`);
+  });
+
+  test('[TC-H-01/2] 并发审核:同一申请并发 approve 不得产生重复管家档案(乐观锁探针)', async ({ request }) => {
+    const admin = await adminLogin(request);
+    test.skip(!admin, '管理端登录失败(账号/密码变更?),跳过');
+
+    const butler = await createPendingButler(request, 'TCH01-2');
+
+    // 两个请求同时打:均可能在 selectOne 时读到 profile=null,从而各自 insert
+    const [a, b] = await Promise.all([
+      apiPost(request, '/api/admin/butler/review', { userId: butler.userId, action: 'approve' }, admin),
+      apiPost(request, '/api/admin/butler/review', { userId: butler.userId, action: 'approve' }, admin),
+    ]);
+    const okCount = [a, b].filter((x) => x.code === 200).length;
+
+    const n = await countApprovedProfile(request, admin, butler.userId);
+    console.log(`[TC-H-01/2] 并发结果 code=${a.code}/${b.code} 成功数=${okCount} 档案数=${n}`);
+
+    // 底线:并发不得打出 5xx
+    expect(a.status, '并发审核出现 5xx').toBeLessThan(500);
+    expect(b.status, '并发审核出现 5xx').toBeLessThan(500);
+
+    if (okCount <= 1 && n === 1) {
+      test.info().annotations.push({ type: 'info', description: '本次未复现并发重复建档(可能已被串行化或时序未撞入)' });
+      return;
+    }
+
+    // 复现:两个都成功 + 档案重复 = 缺乐观锁
+    expect(
+      n,
+      `[SC-H-01 缺口] 并发审核产生 ${n} 条重复管家档案(成功 ${okCount} 次)。` +
+        'approve() 的 pending 守卫仅对串行有效,并发时两个请求均通过校验并各自 butler_profiles.insert。' +
+        '建议:butler_profiles.user_id 加唯一索引 + 条件更新(UPDATE ... WHERE status=\'pending\')或版本号乐观锁。',
+    ).toBe(1);
+  });
+
+  test('[TC-H-01/3] 状态守卫:未通过认证的管家不可被分配成员', async ({ request }) => {
+    const admin = await adminLogin(request);
+    test.skip(!admin, '管理端登录失败(账号/密码变更?),跳过');
+
+    // 只申请、不审核 → 状态 pending
+    const pending = await createPendingButler(request, 'TCH01-3');
+    const member = await register(request, 'parent', 'TCH01-3M');
+
+    const r = await apiPost(
+      request,
+      '/api/admin/butler/assign-member',
+      { profileId: pending.userId, memberId: member.userId },
+      admin,
+    );
+    expect(r.code, `未认证管家被分配成员应被拒,实际 code=${r.code}`).not.toBe(200);
+    expect(String(r.message || ''), '拒绝原因应指明「未通过认证」').toContain('认证');
+    console.log(`[TC-H-01/3] code=${r.code} msg=${r.message}`);
+  });
+
+  test('[TC-H-01/4] 分配生效:成员被正确归属到已认证管家(重复分配不报错、不重复计数)', async ({ request }) => {
+    const admin = await adminLogin(request);
+    test.skip(!admin, '管理端登录失败(账号/密码变更?),跳过');
+
+    const butler = await createPendingButler(request, 'TCH01-4');
+    const ap = await apiPost(request, '/api/admin/butler/review', { userId: butler.userId, action: 'approve' }, admin);
+    expect(ap.code, `管家审核应通过 code=${ap.code} msg=${ap.message}`).toBe(200);
+
+    const member = await register(request, 'parent', 'TCH01-4M');
+    // 注意:assign-member 的 memberId 实为 users.id(后端 userMapper.selectById(memberId)),
+    // 不是 family_members.id —— 传成员记录 id 会报「家庭成员不存在」。
+    expect(member.userId, 'direct-register 应返回 userId,否则无法定位分配对象').toBeTruthy();
+
+    const first = await apiPost(
+      request,
+      '/api/admin/butler/assign-member',
+      { profileId: butler.userId, memberId: member.userId },
+      admin,
+    );
+    expect(first.code, `首次分配应成功 code=${first.code} msg=${first.message}`).toBe(200);
+
+    // 重复分配同一成员:应幂等(200 或明确已分配),不得 5xx
+    const again = await apiPost(
+      request,
+      '/api/admin/butler/assign-member',
+      { profileId: butler.userId, memberId: member.userId },
+      admin,
+    );
+    expect(again.status, '重复分配不应 5xx').toBeLessThan(500);
+
+    // 管家侧应能看到该成员
+    const mine = await apiPost(request, '/api/butler/members', { page: 1, size: 50 }, butler.token);
+    expect(mine.code, `管家成员列表查询失败 code=${mine.code} msg=${mine.message}`).toBe(200);
+    const mineArr = (mine.data && (mine.data.list || mine.data.records)) || [];
+    const hit = mineArr.filter((m) => String(m.id) === String(member.userId)).length;
+    expect(hit, `管家侧应恰好看到该成员 1 次,实际 ${hit} 次(重复分配导致重复归属?)`).toBe(1);
+    console.log(`[TC-H-01/4] 首次=${first.code} 重复=${again.code} 管家侧可见=${hit}`);
+  });
+});
+
+// —— 后端尚无工单实体,保留期望契约 ——
+test.fixme('[TC-H-01/5] AI 升级工单:认领乐观锁(需后端先实现 /api/butler/workorder/*)', async () => {
+  // 期望契约(待实现):
+  //   POST /api/butler/workorder/list    {status:'pending', page, size}  → 按风险等级排序的工单列表
+  //   POST /api/butler/workorder/claim   {workorderId}                   → 认领;并发认领仅 1 人成功,另一人返回「已被认领」
+  //   POST /api/butler/workorder/close   {workorderId, conclusion, duration} → 闭环并留痕
+  // 断言:
+  //   1. 两名管家并发 claim 同一工单 → 成功数 = 1,另一人 code != 200 且 message 含「已被认领」;
+  //   2. 工单携带上下文(家长诉求 / AI 已给结论 / 风险等级)三项非空;
+  //   3. close 后状态为已闭环,操作人与时间留痕;
+  //   4. 高风险工单不排在普通工单之后。
+  // 前置:需后端补齐工单实体与接口(当前仅有管家认证审核与成员分配)。
+});