/** * O-7 契约补建 · 五维能量批次 — 三类流 API 级 e2e * ================================================================ * 覆盖 SC:SC-P-17/18/89/93、SC-T-31/32、SC-X-19/20/21(12 SC 开工包中的家长×跨角色×规划师代表端点, * 与 cfc-backend/src/test/java/com/etotem/cfc/unit/Energy*ContractTest 契约 JUnit 互补。 * 契约核对 = 文档核对 + 端点比对(防假 RED,O-7 已核对 12/12 端点存在)。 * * 端点(EnergyController / FiveDimensionSelfCheckController / ManualDimensionScoreController): * overview / logs / grant / deduct-for-wish / configure * /api/family/self-check/{questions,submit} * /api/family/manual-score/submit * * 运行:npx playwright test api/dimension-energy-contract.spec.js \ * -c playwright.config.api.js --project=desktop * (对远端 http://cfc.iwintrue.com 实跑;写入型用例如 admin 回归验证中亦以 JUnit 契约为确定性证据) */ const { test, expect } = require('@playwright/test'); const { registerRole, apiPost, pickMemberByUserId, uniqueSuffix } = require('../helpers/api'); const DIMS = ['body', 'mind', 'wisdom', 'action', 'wealth']; test.describe('O-7 五维能量 三类流(SC-P-17/18/89/93, SC-T-31/32, SC-X-19/20/21)', () => { let parent; let teacher; let selfMemberId; test.beforeAll(async ({ request }) => { parent = await registerRole(request, 'parent', 'QA-EN-P' + uniqueSuffix()); const list = await apiPost(request, '/api/family/member/list', {}, parent.token); expect(list.code, list.message).toBe(200); const self = pickMemberByUserId(list.data, parent.userId); expect(self, '成员列表应包含登录用户自身').toBeTruthy(); selfMemberId = self.id; teacher = await registerRole(request, 'teacher', 'QA-EN-T' + uniqueSuffix()); }); // ==================== 正常流 ==================== test.describe('正常流', () => { test('SC-P-17 overview 返回五维(body/mind/wisdom/action/wealth)', async ({ request }) => { const r = await apiPost(request, '/api/energy/overview', { memberId: selfMemberId }, parent.token); expect(r.code, r.message).toBe(200); const dims = r.data && r.data.dimensions; expect(Array.isArray(dims)).toBe(true); expect(dims.length).toBe(5); const codes = dims.map((d) => d.code); for (const dim of DIMS) expect(codes).toContain(dim); expect(typeof r.data.totalEnergy).toBe('number'); }); test('SC-P-18 logs 分页返回流水结构', async ({ request }) => { const r = await apiPost(request, '/api/energy/logs', { memberId: selfMemberId, page: 1, size: 10 }, parent.token); expect(r.code, r.message).toBe(200); expect(Array.isArray(r.data && r.data.records)).toBe(true); expect(r.data.size).toBe(10); expect(r.data.current).toBe(1); }); test('SC-T-32 self-check questions 返回 15 题(Q6 含 E 跳过项)', async ({ request }) => { const r = await apiPost(request, '/api/family/self-check/questions', {}, parent.token); expect(r.code, r.message).toBe(200); expect(r.data.length).toBe(15); const q6 = r.data[5]; expect(String(q6.options.length)).toBe('5'); }); test('SC-T-32 self-check submit 15 题全 A → totalScore=45', async ({ request }) => { const answers = []; for (let i = 1; i <= 15; i++) answers.push({ questionId: i, answer: 'A' }); const r = await apiPost(request, '/api/family/self-check/submit', { familyMemberId: selfMemberId, answers }, parent.token); expect(r.code, r.message).toBe(200); expect(r.data.totalScore).toBe(45); expect(r.data.dimensions.length).toBe(5); }); test('SC-T-31 manual-score submit 五维 → totalScore=350', async ({ request }) => { const r = await apiPost(request, '/api/family/manual-score/submit', { memberId: selfMemberId, scores: { body: 80, mind: 60, wisdom: 70, action: 50, wealth: 90 }, }, teacher.token); expect(r.code, r.message).toBe(200); expect(r.data.totalScore).toBe(350); expect(Object.keys(r.data.scores).length).toBe(5); }); }); // ==================== 异常流 ==================== test.describe('异常流', () => { test('SC-P-17 overview 无 memberId → 使用当前登录成员回落(观察行为)', async ({ request }) => { // 观察:JwtInterceptor 注入 currentMemberId,controller 在 memberId 缺省时回落当前成员 → 200 const r = await apiPost(request, '/api/energy/overview', {}, parent.token); expect(r.code, r.message).toBe(200); expect(r.data && r.data.dimensions).toBeTruthy(); }); test('SC-P-89 grant 非法维度 code → 业务错误', async ({ request }) => { const r = await apiPost(request, '/api/energy/grant', { memberId: selfMemberId, taskId: 999999, amount: 10, dimensionCode: 'nonexistent-dim', }, parent.token); expect([400, 500]).toContain(r.code); expect(String(r.message)).toContain('维度'); }); test('SC-T-32 self-check submit 少于 10 题 → 业务错误', async ({ request }) => { const answers = []; for (let i = 1; i <= 9; i++) answers.push({ questionId: i, answer: 'A' }); const r = await apiPost(request, '/api/family/self-check/submit', { familyMemberId: selfMemberId, answers }, parent.token); expect(r.code).not.toBe(200); expect(String(r.message)).toContain('至少10道题目'); }); test('SC-T-31 manual-score 缺维度 → 业务错误', async ({ request }) => { const r = await apiPost(request, '/api/family/manual-score/submit', { memberId: selfMemberId, scores: { body: 80, mind: 60, wisdom: 70, action: 50 }, // 缺 wealth }, teacher.token); expect(r.code).not.toBe(200); expect(String(r.message)).toContain('维度'); }); }); // ==================== 权限流 ==================== test.describe('权限流(JwtInterceptor 401 闸门)', () => { test('SC-X-19 overview 无 token → 401', async ({ request }) => { const r = await apiPost(request, '/api/energy/overview', { memberId: selfMemberId }, null); expect(r.status).toBe(401); expect(String(r.message)).toContain('请先登录'); }); test('SC-X-19 logs 无 token → 401', async ({ request }) => { const r = await apiPost(request, '/api/energy/logs', { memberId: selfMemberId }, null); expect(r.status).toBe(401); }); test('_pinsObserved self-check/questions 无 token → 观察 200(userId=-1 匿名放行,未列 LOGIN_REQUIRED_PATHS)', async ({ request }) => { // 观察:/api/family/self-check/ 未列入 LOGIN_REQUIRED_PATHS,匿名降级 userId=-1 放行 → 200(契约缺口,仅登记) const r = await apiPost(request, '/api/family/self-check/questions', {}, null); expect(r.status).toBe(200); expect(r.data && r.data.length).toBe(15); }); test('SC-X-19 另一家庭家长无法通过游客通道读取能量(登出后 401)', async ({ request }) => { const r = await apiPost(request, '/api/energy/logs', { memberId: selfMemberId }, 'invalid-token-value'); expect(r.status).toBe(401); }); }); // ==================== 观察钉住(gap,只读验证,不修改实现) ==================== test.describe('_pinsObserved(契约缺口观察)', () => { test('overview 传跨家庭 memberId(999999)→ 观察返回 200(无归属校验,仅登记)', async ({ request }) => { const r = await apiPost(request, '/api/energy/overview', { memberId: 999999 }, parent.token); expect(r.code).toBe(200); expect(r.data && r.data.dimensions).toBeTruthy(); }); }); });