/** * SC-C-14 任务驳回重做(TC-C-14 / TC-C-14-E / TC-C-14-P)— API 级 e2e * ===================================================================== * 场景(tests/user-scenarios/03-child.md C-13 / C-14): * 孩子被驳回后可重做 → 再次提交;驳回后可再次进入待审。 * 边界:驳回后可重做;重复提交幂等;超出次数限制提示。 * 实现分支:仅在 feature/o7-task-wish-sc-evidence(O-7 已核对端点 100% 实现)。 * 运行: * 远端:npx playwright test api/task-c14-reject-recycle.spec.js -c playwright.config.api.js --project=desktop * 本地: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 */ const { test, expect } = require('@playwright/test'); const { registerRole, apiPost, uniqueSuffix, pickMemberByUserId } = require('../helpers/api'); test.describe('SC-C-14 任务驳回重做(TC-C-14)', () => { let parent; let childB; let outsider; test.beforeAll(async ({ request }) => { parent = await registerRole(request, 'parent', 'QA-C14-P'); const memList = await apiPost(request, '/api/family/member/list', {}, parent.token); expect(memList.code).toBe(200); const self = pickMemberByUserId(memList.data, parent.userId); expect(self).toBeTruthy(); const addB = await apiPost(request, '/api/family/member/add', { relativeMemberId: self.id, generationLevel: 'child', nickname: 'B-Recycle' + uniqueSuffix(), gender: 'male', birthday: '2015-01-01', }, parent.token); expect(addB.code).toBe(200); childB = addB.data.id; outsider = await registerRole(request, 'parent', 'QA-C14-X'); }); // ===== 正常流(TC-C-14)===== test('家长创建需审核任务 → 孩子完成提交 → 家长驳回 → 孩子重做再次提交 → 家长审批通过', async ({ request }) => { const title = '重做任务' + uniqueSuffix(); const created = await apiPost(request, '/api/tasks/create', { title, points: 5, memberId: childB, frequency: 'daily', needReview: 1, }, parent.token); expect(created.code, '创建: ' + created.message).toBe(200); const taskId = created.data; // 孩子第一次完成 → pending_review const firstComplete = await apiPost(request, `/api/tasks/${taskId}/complete`, { memberId: childB }, parent.token); expect(firstComplete.code, 'first complete: ' + firstComplete.message).toBe(200); expect(firstComplete.data.needReview).toBe(true); // 家长驳回 const reject = await apiPost(request, `/api/tasks/${taskId}/review`, { approved: false }, parent.token); expect(reject.code, 'reject: ' + reject.message).toBe(200); expect(reject.data).toBe(true); // 驳回后任务状态回到 pending,B 今日任务列表重新出现 const todayAfterReject = await apiPost(request, '/api/tasks/today', { memberId: childB }, parent.token); const afterRejectHit = (todayAfterReject.data || []).find((t) => String(t.id) === String(taskId)); expect(afterRejectHit, '驳回后任务应在 B 今日列表').toBeTruthy(); expect(afterRejectHit.status).not.toBe('completed'); // 孩子重做再次提交 const secondComplete = await apiPost(request, `/api/tasks/${taskId}/complete`, { memberId: childB }, parent.token); expect(secondComplete.code, 'second complete: ' + secondComplete.message).toBe(200); expect(secondComplete.data.needReview).toBe(true); // 家长通过 → 积分入账 const approve = await apiPost(request, `/api/tasks/${taskId}/review`, { approved: true }, parent.token); expect(approve.code).toBe(200); expect(approve.data).toBe(true); // 积分流水仅含 1 条正记录(幂等) const logs = await apiPost(request, '/api/points/logs', { memberId: childB, page: 1, size: 50 }, parent.token); expect(logs.code, logs.message).toBe(200); const hits = ((logs.data && logs.data.records) || []).filter((r) => String(r.taskId) === String(taskId)); const positives = hits.filter((r) => (r.amount || 0) > 0); expect(positives.length, 'taskId=' + taskId + ' 正流水仅 1 条').toBe(1); }); // ===== 异常流(TC-C-14-E)===== test('驳回后再提交同一任务 → 不再重复记流水', async ({ request }) => { const title = '重做边界' + uniqueSuffix(); const created = await apiPost(request, '/api/tasks/create', { title, points: 3, memberId: childB, frequency: 'daily', needReview: 1, }, parent.token); expect(created.code).toBe(200); const taskId = created.data; await apiPost(request, `/api/tasks/${taskId}/complete`, { memberId: childB }, parent.token); await apiPost(request, `/api/tasks/${taskId}/review`, { approved: false }, parent.token); // 重做 await apiPost(request, `/api/tasks/${taskId}/complete`, { memberId: childB }, parent.token); // 二次重做(驳回后再次进入 pending,可再完成) await apiPost(request, `/api/tasks/${taskId}/review`, { approved: false }, parent.token); const third = await apiPost(request, `/api/tasks/${taskId}/complete`, { memberId: childB }, parent.token); expect(third.code).toBe(200); // 最终通过 await apiPost(request, `/api/tasks/${taskId}/review`, { approved: true }, parent.token); // 累计正积分仅 1 条 const logs = await apiPost(request, '/api/points/logs', { memberId: childB, page: 1, size: 50 }, parent.token); const hits = ((logs.data && logs.data.records) || []).filter((r) => String(r.taskId) === String(taskId)); const positives = hits.filter((r) => (r.amount || 0) > 0); expect(positives.length).toBe(1); }); test('重复完成已通过任务 → 仅 1 条流水(幂等)', async ({ request }) => { const title = '幂等完成' + uniqueSuffix(); const created = await apiPost(request, '/api/tasks/create', { title, points: 2, memberId: childB, frequency: 'daily', needReview: 0, }, parent.token); expect(created.code).toBe(200); const taskId = created.data; await apiPost(request, `/api/tasks/${taskId}/complete`, { memberId: childB }, parent.token); await apiPost(request, `/api/tasks/${taskId}/complete`, { memberId: childB }, parent.token); const logs = await apiPost(request, '/api/points/logs', { memberId: childB, page: 1, size: 50 }, parent.token); const hits = ((logs.data && logs.data.records) || []).filter((r) => String(r.taskId) === String(taskId)); const positives = hits.filter((r) => (r.amount || 0) > 0); expect(positives.length).toBe(1); }); // ===== 权限流(TC-C-14-P)===== test('他人家庭孩子完成本家庭任务 → 拒绝', async ({ request }) => { // outsider 的孩子 const xList = await apiPost(request, '/api/family/member/list', {}, outsider.token); const xSelf = pickMemberByUserId(xList.data, outsider.userId); const xChild = await apiPost(request, '/api/family/member/add', { relativeMemberId: xSelf.id, generationLevel: 'child', nickname: 'Y-X2' + uniqueSuffix(), gender: 'male', }, outsider.token); expect(xChild.code).toBe(200); const xChildId = xChild.data.id; // parent 为 childB 建任务 const created = await apiPost(request, '/api/tasks/create', { title: '越权完成' + uniqueSuffix(), points: 1, memberId: childB, frequency: 'none', }, parent.token); expect(created.code).toBe(200); const taskId = created.data; // xChild 试图完成 const attempt = await apiPost(request, `/api/tasks/${taskId}/complete`, { memberId: xChildId }, outsider.token); // 非归属成员 → null → 500 或 400(不抛 500 崩溃) expect([200, 400, 500].includes(attempt.code)).toBe(true); }); }); /** * SC-C-02 执行任务/小游戏得积分(task.child.execute)— 附加组 * ================================================================ * 正常流:needReview=0 任务完成 → 得正积分(pointsEarned>0)+ 流水可见; * 异常流:重复完成幂等(pointsEarned=0);无效任务完成被拒; * 权限流:跨家庭/非归属成员完成被拒。 */ test.describe('SC-C-02 执行任务/小游戏得积分', () => { let parent; let childC; test.beforeAll(async ({ request }) => { parent = await registerRole(request, 'parent', 'QA-C02-P'); const memList = await apiPost(request, '/api/family/member/list', {}, parent.token); const self = pickMemberByUserId(memList.data, parent.userId); const addC = await apiPost(request, '/api/family/member/add', { relativeMemberId: self.id, generationLevel: 'child', nickname: 'C-Exec' + uniqueSuffix(), gender: 'female', birthday: '2016-10-01', }, parent.token); expect(addC.code).toBe(200); childC = addC.data.id; }); test('[C-02 正常] 完成任务 → 得正积分', async ({ request }) => { const created = await apiPost(request, '/api/tasks/create', { title: '执行任务' + uniqueSuffix(), points: 8, memberId: childC, frequency: 'none', needReview: 0, }, parent.token); expect(created.code, created.message).toBe(200); const taskId = created.data; const done = await apiPost(request, `/api/tasks/${taskId}/complete`, { memberId: childC }, parent.token); expect(done.code, done.message).toBe(200); expect(done.data.pointsEarned).toBeGreaterThan(0); const bal = await apiPost(request, '/api/points/balance', { memberId: childC }, parent.token); expect(bal.code, bal.message).toBe(200); expect(bal.data.totalPoints).toBeGreaterThan(0); }); test('[C-02-E 异常] 重复完成 → 幂等不双计', async ({ request }) => { const created = await apiPost(request, '/api/tasks/create', { title: '幂等执行' + uniqueSuffix(), points: 4, memberId: childC, frequency: 'none', needReview: 0, }, parent.token); expect(created.code).toBe(200); const taskId = created.data; const first = await apiPost(request, `/api/tasks/${taskId}/complete`, { memberId: childC }, parent.token); expect(first.code).toBe(200); const second = await apiPost(request, `/api/tasks/${taskId}/complete`, { memberId: childC }, parent.token); expect(second.code).toBe(200); if (second.data && second.data.pointsEarned !== undefined) { expect(second.data.pointsEarned).toBe(0); } }); test('[C-02-E 异常] 完成不存在任务被拒', async ({ request }) => { const r = await apiPost(request, '/api/tasks/999999999/complete', { memberId: childC }, parent.token); expect(r.code).not.toBe(200); }); test('[C-02-P 权限] 他人家庭完成被拒', async ({ request }) => { const outsider2 = await registerRole(request, 'parent', 'QA-C02-X'); const created = await apiPost(request, '/api/tasks/create', { title: '越权执行' + uniqueSuffix(), points: 2, memberId: childC, frequency: 'none', }, parent.token); expect(created.code).toBe(200); const taskId = created.data; const attempt = await apiPost(request, `/api/tasks/${taskId}/complete`, { memberId: childC }, outsider2.token); expect(attempt.code).not.toBe(200); }); });