task-c14-reject-recycle.spec.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. /**
  2. * SC-C-14 任务驳回重做(TC-C-14 / TC-C-14-E / TC-C-14-P)— API 级 e2e
  3. * =====================================================================
  4. * 场景(tests/user-scenarios/03-child.md C-13 / C-14):
  5. * 孩子被驳回后可重做 → 再次提交;驳回后可再次进入待审。
  6. * 边界:驳回后可重做;重复提交幂等;超出次数限制提示。
  7. * 实现分支:仅在 feature/o7-task-wish-sc-evidence(O-7 已核对端点 100% 实现)。
  8. * 运行:
  9. * 远端:npx playwright test api/task-c14-reject-recycle.spec.js -c playwright.config.api.js --project=desktop
  10. * 本地:CFC_BASE_URL=http://127.0.0.1:18082 npx playwright test api/task-c14-reject-recycle.spec.js -c playwright.config.api.local.js --project=desktop
  11. */
  12. const { test, expect } = require('@playwright/test');
  13. const { registerRole, apiPost, uniqueSuffix, pickMemberByUserId } = require('../helpers/api');
  14. test.describe('SC-C-14 任务驳回重做(TC-C-14)', () => {
  15. let parent;
  16. let childB;
  17. let outsider;
  18. test.beforeAll(async ({ request }) => {
  19. parent = await registerRole(request, 'parent', 'QA-C14-P');
  20. const memList = await apiPost(request, '/api/family/member/list', {}, parent.token);
  21. expect(memList.code).toBe(200);
  22. const self = pickMemberByUserId(memList.data, parent.userId);
  23. expect(self).toBeTruthy();
  24. const addB = await apiPost(request, '/api/family/member/add', {
  25. relativeMemberId: self.id, generationLevel: 'child',
  26. nickname: 'B-Recycle' + uniqueSuffix(), gender: 'male', birthday: '2015-01-01',
  27. }, parent.token);
  28. expect(addB.code).toBe(200);
  29. childB = addB.data.id;
  30. outsider = await registerRole(request, 'parent', 'QA-C14-X');
  31. });
  32. // ===== 正常流(TC-C-14)=====
  33. test('家长创建需审核任务 → 孩子完成提交 → 家长驳回 → 孩子重做再次提交 → 家长审批通过', async ({ request }) => {
  34. const title = '重做任务' + uniqueSuffix();
  35. const created = await apiPost(request, '/api/tasks/create', {
  36. title, points: 5, memberId: childB, frequency: 'daily', needReview: 1,
  37. }, parent.token);
  38. expect(created.code, '创建: ' + created.message).toBe(200);
  39. const taskId = created.data;
  40. // 孩子第一次完成 → pending_review
  41. const firstComplete = await apiPost(request, `/api/tasks/${taskId}/complete`, { memberId: childB }, parent.token);
  42. expect(firstComplete.code, 'first complete: ' + firstComplete.message).toBe(200);
  43. expect(firstComplete.data.needReview).toBe(true);
  44. // 家长驳回
  45. const reject = await apiPost(request, `/api/tasks/${taskId}/review`, { approved: false }, parent.token);
  46. expect(reject.code, 'reject: ' + reject.message).toBe(200);
  47. expect(reject.data).toBe(true);
  48. // 驳回后任务状态回到 pending,B 今日任务列表重新出现
  49. const todayAfterReject = await apiPost(request, '/api/tasks/today', { memberId: childB }, parent.token);
  50. const afterRejectHit = (todayAfterReject.data || []).find((t) => String(t.id) === String(taskId));
  51. expect(afterRejectHit, '驳回后任务应在 B 今日列表').toBeTruthy();
  52. expect(afterRejectHit.status).not.toBe('completed');
  53. // 孩子重做再次提交
  54. const secondComplete = await apiPost(request, `/api/tasks/${taskId}/complete`, { memberId: childB }, parent.token);
  55. expect(secondComplete.code, 'second complete: ' + secondComplete.message).toBe(200);
  56. expect(secondComplete.data.needReview).toBe(true);
  57. // 家长通过 → 积分入账
  58. const approve = await apiPost(request, `/api/tasks/${taskId}/review`, { approved: true }, parent.token);
  59. expect(approve.code).toBe(200);
  60. expect(approve.data).toBe(true);
  61. // 积分流水仅含 1 条正记录(幂等)
  62. const logs = await apiPost(request, '/api/points/logs', { memberId: childB, page: 1, size: 50 }, parent.token);
  63. expect(logs.code, logs.message).toBe(200);
  64. const hits = ((logs.data && logs.data.records) || []).filter((r) => String(r.taskId) === String(taskId));
  65. const positives = hits.filter((r) => (r.amount || 0) > 0);
  66. expect(positives.length, 'taskId=' + taskId + ' 正流水仅 1 条').toBe(1);
  67. });
  68. // ===== 异常流(TC-C-14-E)=====
  69. test('驳回后再提交同一任务 → 不再重复记流水', async ({ request }) => {
  70. const title = '重做边界' + uniqueSuffix();
  71. const created = await apiPost(request, '/api/tasks/create', {
  72. title, points: 3, memberId: childB, frequency: 'daily', needReview: 1,
  73. }, parent.token);
  74. expect(created.code).toBe(200);
  75. const taskId = created.data;
  76. await apiPost(request, `/api/tasks/${taskId}/complete`, { memberId: childB }, parent.token);
  77. await apiPost(request, `/api/tasks/${taskId}/review`, { approved: false }, parent.token);
  78. // 重做
  79. await apiPost(request, `/api/tasks/${taskId}/complete`, { memberId: childB }, parent.token);
  80. // 二次重做(驳回后再次进入 pending,可再完成)
  81. await apiPost(request, `/api/tasks/${taskId}/review`, { approved: false }, parent.token);
  82. const third = await apiPost(request, `/api/tasks/${taskId}/complete`, { memberId: childB }, parent.token);
  83. expect(third.code).toBe(200);
  84. // 最终通过
  85. await apiPost(request, `/api/tasks/${taskId}/review`, { approved: true }, parent.token);
  86. // 累计正积分仅 1 条
  87. const logs = await apiPost(request, '/api/points/logs', { memberId: childB, page: 1, size: 50 }, parent.token);
  88. const hits = ((logs.data && logs.data.records) || []).filter((r) => String(r.taskId) === String(taskId));
  89. const positives = hits.filter((r) => (r.amount || 0) > 0);
  90. expect(positives.length).toBe(1);
  91. });
  92. test('重复完成已通过任务 → 仅 1 条流水(幂等)', async ({ request }) => {
  93. const title = '幂等完成' + uniqueSuffix();
  94. const created = await apiPost(request, '/api/tasks/create', {
  95. title, points: 2, memberId: childB, frequency: 'daily', needReview: 0,
  96. }, parent.token);
  97. expect(created.code).toBe(200);
  98. const taskId = created.data;
  99. await apiPost(request, `/api/tasks/${taskId}/complete`, { memberId: childB }, parent.token);
  100. await apiPost(request, `/api/tasks/${taskId}/complete`, { memberId: childB }, parent.token);
  101. const logs = await apiPost(request, '/api/points/logs', { memberId: childB, page: 1, size: 50 }, parent.token);
  102. const hits = ((logs.data && logs.data.records) || []).filter((r) => String(r.taskId) === String(taskId));
  103. const positives = hits.filter((r) => (r.amount || 0) > 0);
  104. expect(positives.length).toBe(1);
  105. });
  106. // ===== 权限流(TC-C-14-P)=====
  107. test('他人家庭孩子完成本家庭任务 → 拒绝', async ({ request }) => {
  108. // outsider 的孩子
  109. const xList = await apiPost(request, '/api/family/member/list', {}, outsider.token);
  110. const xSelf = pickMemberByUserId(xList.data, outsider.userId);
  111. const xChild = await apiPost(request, '/api/family/member/add', {
  112. relativeMemberId: xSelf.id, generationLevel: 'child',
  113. nickname: 'Y-X2' + uniqueSuffix(), gender: 'male',
  114. }, outsider.token);
  115. expect(xChild.code).toBe(200);
  116. const xChildId = xChild.data.id;
  117. // parent 为 childB 建任务
  118. const created = await apiPost(request, '/api/tasks/create', {
  119. title: '越权完成' + uniqueSuffix(), points: 1, memberId: childB, frequency: 'none',
  120. }, parent.token);
  121. expect(created.code).toBe(200);
  122. const taskId = created.data;
  123. // xChild 试图完成
  124. const attempt = await apiPost(request, `/api/tasks/${taskId}/complete`, { memberId: xChildId }, outsider.token);
  125. // 非归属成员 → null → 500 或 400(不抛 500 崩溃)
  126. expect([200, 400, 500].includes(attempt.code)).toBe(true);
  127. });
  128. });
  129. /**
  130. * SC-C-02 执行任务/小游戏得积分(task.child.execute)— 附加组
  131. * ================================================================
  132. * 正常流:needReview=0 任务完成 → 得正积分(pointsEarned>0)+ 流水可见;
  133. * 异常流:重复完成幂等(pointsEarned=0);无效任务完成被拒;
  134. * 权限流:跨家庭/非归属成员完成被拒。
  135. */
  136. test.describe('SC-C-02 执行任务/小游戏得积分', () => {
  137. let parent;
  138. let childC;
  139. test.beforeAll(async ({ request }) => {
  140. parent = await registerRole(request, 'parent', 'QA-C02-P');
  141. const memList = await apiPost(request, '/api/family/member/list', {}, parent.token);
  142. const self = pickMemberByUserId(memList.data, parent.userId);
  143. const addC = await apiPost(request, '/api/family/member/add', {
  144. relativeMemberId: self.id, generationLevel: 'child',
  145. nickname: 'C-Exec' + uniqueSuffix(), gender: 'female', birthday: '2016-10-01',
  146. }, parent.token);
  147. expect(addC.code).toBe(200);
  148. childC = addC.data.id;
  149. });
  150. test('[C-02 正常] 完成任务 → 得正积分', async ({ request }) => {
  151. const created = await apiPost(request, '/api/tasks/create', {
  152. title: '执行任务' + uniqueSuffix(), points: 8, memberId: childC, frequency: 'none', needReview: 0,
  153. }, parent.token);
  154. expect(created.code, created.message).toBe(200);
  155. const taskId = created.data;
  156. const done = await apiPost(request, `/api/tasks/${taskId}/complete`, { memberId: childC }, parent.token);
  157. expect(done.code, done.message).toBe(200);
  158. expect(done.data.pointsEarned).toBeGreaterThan(0);
  159. const bal = await apiPost(request, '/api/points/balance', { memberId: childC }, parent.token);
  160. expect(bal.code, bal.message).toBe(200);
  161. expect(bal.data.totalPoints).toBeGreaterThan(0);
  162. });
  163. test('[C-02-E 异常] 重复完成 → 幂等不双计', async ({ request }) => {
  164. const created = await apiPost(request, '/api/tasks/create', {
  165. title: '幂等执行' + uniqueSuffix(), points: 4, memberId: childC, frequency: 'none', needReview: 0,
  166. }, parent.token);
  167. expect(created.code).toBe(200);
  168. const taskId = created.data;
  169. const first = await apiPost(request, `/api/tasks/${taskId}/complete`, { memberId: childC }, parent.token);
  170. expect(first.code).toBe(200);
  171. const second = await apiPost(request, `/api/tasks/${taskId}/complete`, { memberId: childC }, parent.token);
  172. expect(second.code).toBe(200);
  173. if (second.data && second.data.pointsEarned !== undefined) {
  174. expect(second.data.pointsEarned).toBe(0);
  175. }
  176. });
  177. test('[C-02-E 异常] 完成不存在任务被拒', async ({ request }) => {
  178. const r = await apiPost(request, '/api/tasks/999999999/complete', { memberId: childC }, parent.token);
  179. expect(r.code).not.toBe(200);
  180. });
  181. test('[C-02-P 权限] 他人家庭完成被拒', async ({ request }) => {
  182. const outsider2 = await registerRole(request, 'parent', 'QA-C02-X');
  183. const created = await apiPost(request, '/api/tasks/create', {
  184. title: '越权执行' + uniqueSuffix(), points: 2, memberId: childC, frequency: 'none',
  185. }, parent.token);
  186. expect(created.code).toBe(200);
  187. const taskId = created.data;
  188. const attempt = await apiPost(request, `/api/tasks/${taskId}/complete`, { memberId: childC }, outsider2.token);
  189. expect(attempt.code).not.toBe(200);
  190. });
  191. });