|
@@ -0,0 +1,241 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * EVD-7 报告闭环批次(23 SC)— 三类流 API 级 e2e
|
|
|
|
|
+ * ================================================================
|
|
|
|
|
+ * 覆盖 SC:SC-A-02/A-16、SC-C-06/C-07、SC-P-24/P-25/P-39/P-67/P-84/P-91、
|
|
|
|
|
+ * SC-S-09、SC-T-03/T-11/T-12/T-21/T-23、SC-X-02/X-10/X-13。
|
|
|
|
|
+ * 与 cfc-backend/src/test/java/com/etotem/cfc/unit/*Report*ContractTest 契约 JUnit 互补:
|
|
|
|
|
+ * - JUnit:Service/Controller 分支级契约(纯 Mockito,确定性);
|
|
|
|
|
+ * - 本 spec:真实 HTTP + 真实 DB,验证「登录 → 家庭上下文 → 端点 → Result 外壳」主链路。
|
|
|
|
|
+ * 不在本 spec 覆盖:SC-B-04/B-11(LangGraph 报告解读/追问,见
|
|
|
|
|
+ * cfc-langgraph/tests_contract/test_growth_report_contract.py)、SC-P-82(MCP JSON-RPC
|
|
|
|
|
+ * 需 Mcp-Api-Key,见 GlobalSearchScP82ContractTest)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 运行(默认远端;可用 CFC_API_BASE 指向本地 9082):
|
|
|
|
|
+ * npx playwright test api/report-closedloop-contract.spec.js \
|
|
|
|
|
+ * -c playwright.config.api.js --project=desktop
|
|
|
|
|
+ * CFC_API_BASE=http://127.0.0.1:9082 npx playwright test api/report-closedloop-contract.spec.js \
|
|
|
|
|
+ * -c playwright.config.api.js --project=desktop
|
|
|
|
|
+ *
|
|
|
|
|
+ * 安全约定:用例只自注册临时账号、只读写自己的数据;不打印 token / 手机号。
|
|
|
|
|
+ */
|
|
|
|
|
+const { test, expect } = require('@playwright/test');
|
|
|
|
|
+const { registerRole, apiPost, pickMemberByUserId, uniqueSuffix } = require('../helpers/api');
|
|
|
|
|
+
|
|
|
|
|
+test.describe('EVD-7 报告闭环 三类流(23 SC)', () => {
|
|
|
|
|
+ let parent;
|
|
|
|
|
+ let teacher;
|
|
|
|
|
+ let selfMemberId;
|
|
|
|
|
+ let teacherMemberId;
|
|
|
|
|
+
|
|
|
|
|
+ test.beforeAll(async ({ request }) => {
|
|
|
|
|
+ parent = await registerRole(request, 'parent', 'QA-EVD7-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-EVD7-T' + uniqueSuffix());
|
|
|
|
|
+ const teacherList = await apiPost(request, '/api/family/member/list', {}, teacher.token);
|
|
|
|
|
+ expect(teacherList.code, teacherList.message).toBe(200);
|
|
|
|
|
+ const teacherSelf = pickMemberByUserId(teacherList.data, teacher.userId);
|
|
|
|
|
+ expect(teacherSelf, 'teacher 成员列表应包含自身').toBeTruthy();
|
|
|
|
|
+ teacherMemberId = teacherSelf.id;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 正常流 ====================
|
|
|
|
|
+
|
|
|
|
|
+ test.describe('正常流', () => {
|
|
|
|
|
+ test('SC-C-06/P-24 报告列表按成员返回数组(只读)', async ({ request }) => {
|
|
|
|
|
+ const r = await apiPost(request, '/api/health/report/list', { memberId: selfMemberId }, parent.token);
|
|
|
|
|
+ expect(r.code, r.message).toBe(200);
|
|
|
|
|
+ expect(Array.isArray(r.data)).toBe(true);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('SC-P-24/X-02 家庭报告列表返回数组', async ({ request }) => {
|
|
|
|
|
+ const r = await apiPost(request, '/api/health/reports/family', {}, parent.token);
|
|
|
|
|
+ expect(r.code, r.message).toBe(200);
|
|
|
|
|
+ expect(Array.isArray(r.data)).toBe(true);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('SC-P-25/X-10 报告趋势返回 scores/dates 结构', async ({ request }) => {
|
|
|
|
|
+ const r = await apiPost(request, '/api/health/nutrition/trend', { memberId: selfMemberId }, parent.token);
|
|
|
|
|
+ expect(r.code, r.message).toBe(200);
|
|
|
|
|
+ expect(r.data && Array.isArray(r.data.scores)).toBe(true);
|
|
|
|
|
+ expect(r.data && Array.isArray(r.data.dates)).toBe(true);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('SC-P-84 授权→查询→撤销闭环(成员级报告权限)', async ({ request }) => {
|
|
|
|
|
+ const grant = await apiPost(request, '/api/health/report/member-permission/grant',
|
|
|
|
|
+ { targetMemberId: selfMemberId, granteeUserId: parent.userId }, parent.token);
|
|
|
|
|
+ expect(grant.code, grant.message).toBe(200);
|
|
|
|
|
+ expect(grant.data).toBe(true);
|
|
|
|
|
+
|
|
|
|
|
+ const byMember = await apiPost(request,
|
|
|
|
|
+ '/api/health/report/member-permission/members/' + selfMemberId, {}, parent.token);
|
|
|
|
|
+ expect(byMember.code, byMember.message).toBe(200);
|
|
|
|
|
+ expect(byMember.data.map(String)).toContain(String(parent.userId));
|
|
|
|
|
+
|
|
|
|
|
+ const all = await apiPost(request, '/api/health/report/member-permission/list', {}, parent.token);
|
|
|
|
|
+ expect(all.code, all.message).toBe(200);
|
|
|
|
|
+ expect(Array.isArray(all.data)).toBe(true);
|
|
|
|
|
+
|
|
|
|
|
+ // 家庭管理员 visible-members 返回 null(全部可见语义);普通成员返回集合
|
|
|
|
|
+ const visible = await apiPost(request,
|
|
|
|
|
+ '/api/health/report/member-permission/visible-members', {}, parent.token);
|
|
|
|
|
+ expect(visible.code, visible.message).toBe(200);
|
|
|
|
|
+ expect(visible.data === null || Array.isArray(visible.data)).toBe(true);
|
|
|
|
|
+
|
|
|
|
|
+ const revoke = await apiPost(request, '/api/health/report/member-permission/revoke',
|
|
|
|
|
+ { targetMemberId: selfMemberId, granteeUserId: parent.userId }, parent.token);
|
|
|
|
|
+ expect(revoke.code, revoke.message).toBe(200);
|
|
|
|
|
+ expect(revoke.data).toBe(true);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('SC-C-07/P-39/T-12 DAN 报告列表返回数组', async ({ request }) => {
|
|
|
|
|
+ const r = await apiPost(request, '/api/dan-report/list', { memberId: selfMemberId }, parent.token);
|
|
|
|
|
+ expect(r.code, r.message).toBe(200);
|
|
|
|
|
+ expect(Array.isArray(r.data)).toBe(true);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('SC-S-09/A-02 报告解析类型↔商品链接列表(服务商接入真实契约)', async ({ request }) => {
|
|
|
|
|
+ const r = await apiPost(request, '/api/report-parser/types/links', {}, parent.token);
|
|
|
|
|
+ expect(r.code, r.message).toBe(200);
|
|
|
|
|
+ expect(Array.isArray(r.data)).toBe(true);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('SC-X-13 报告转交请求列表返回 incoming/outgoing', async ({ request }) => {
|
|
|
|
|
+ const r = await apiPost(request, '/api/health/report/transfer/list', {}, parent.token);
|
|
|
|
|
+ expect(r.code, r.message).toBe(200);
|
|
|
|
|
+ expect(Array.isArray(r.data && r.data.incoming)).toBe(true);
|
|
|
|
|
+ expect(Array.isArray(r.data && r.data.outgoing)).toBe(true);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('SC-T-03/T-23 代传确认(teacher 角色通过权限闸门后进入参数校验)', async ({ request }) => {
|
|
|
|
|
+ // teacher 属于代传上传者角色:通过 isProxyUploader 后,缺 draftId 返回业务错误而非「无权」
|
|
|
|
|
+ const r = await apiPost(request, '/api/health/report/proxy-confirm', {}, teacher.token);
|
|
|
|
|
+ expect(r.code, r.message).not.toBe(200);
|
|
|
|
|
+ expect(String(r.message)).toContain('draftId');
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 异常流 ====================
|
|
|
|
|
+
|
|
|
|
|
+ test.describe('异常流', () => {
|
|
|
|
|
+ test('SC-P-25 趋势缺 memberId → memberId不能为空', async ({ request }) => {
|
|
|
|
|
+ const r = await apiPost(request, '/api/health/nutrition/trend', {}, parent.token);
|
|
|
|
|
+ expect(r.code, r.message).not.toBe(200);
|
|
|
|
|
+ expect(String(r.message)).toContain('memberId不能为空');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('SC-P-24 指标对比缺 reportId → reportId不能为空', async ({ request }) => {
|
|
|
|
|
+ const r = await apiPost(request, '/api/health/report/indicators', {}, parent.token);
|
|
|
|
|
+ expect(r.code, r.message).not.toBe(200);
|
|
|
|
|
+ expect(String(r.message)).toContain('reportId不能为空');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('SC-P-91 指标对比 reportId 不存在 → 报告不存在', async ({ request }) => {
|
|
|
|
|
+ const r = await apiPost(request, '/api/health/report/indicators', { reportId: 999999999 }, parent.token);
|
|
|
|
|
+ expect(r.code, r.message).not.toBe(200);
|
|
|
|
|
+ expect(String(r.message)).toContain('报告不存在');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('SC-A-02/A-16 解析检测缺 filePath → filePath 不能为空', async ({ request }) => {
|
|
|
|
|
+ const r = await apiPost(request, '/api/report-parser/detect', {}, parent.token);
|
|
|
|
|
+ expect(r.code, r.message).not.toBe(200);
|
|
|
|
|
+ expect(String(r.message)).toContain('filePath');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('SC-P-84 授权缺参数 → targetMemberId/granteeUserId 不能为空', async ({ request }) => {
|
|
|
|
|
+ const r = await apiPost(request, '/api/health/report/member-permission/grant', {}, parent.token);
|
|
|
|
|
+ expect(r.code, r.message).not.toBe(200);
|
|
|
|
|
+ expect(String(r.message)).toContain('不能为空');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('SC-T-21/X-10 新成员无两次测评 → 对比返回业务错误', async ({ request }) => {
|
|
|
|
|
+ const r = await apiPost(request, '/api/assessment/compare/by-child', { memberId: selfMemberId }, parent.token);
|
|
|
|
|
+ expect(r.code, r.message).not.toBe(200);
|
|
|
|
|
+ expect(String(r.message)).toContain('两次');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('SC-C-07/T-12 DAN 对比缺 uploadId → 缺少 uploadIdA 与 uploadIdB', async ({ request }) => {
|
|
|
|
|
+ const r = await apiPost(request, '/api/dan-report/compare', {}, parent.token);
|
|
|
|
|
+ expect(r.code, r.message).not.toBe(200);
|
|
|
|
|
+ expect(String(r.message)).toContain('uploadIdA');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('SC-X-13 转交审批缺 requestId → 请求ID不能为空', async ({ request }) => {
|
|
|
|
|
+ const r = await apiPost(request, '/api/health/report/transfer/approve', {}, parent.token);
|
|
|
|
|
+ expect(r.code, r.message).not.toBe(200);
|
|
|
|
|
+ expect(String(r.message)).toContain('申请ID不能为空');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('SC-T-03/X-02 确认不存在的草稿 → 草稿不存在或已归档', async ({ request }) => {
|
|
|
|
|
+ const r = await apiPost(request, '/api/health/report/audit/confirm', { draftId: 999999999 }, parent.token);
|
|
|
|
|
+ expect(r.code, r.message).not.toBe(200);
|
|
|
|
|
+ expect(String(r.message)).toContain('草稿不存在');
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 权限流 ====================
|
|
|
|
|
+
|
|
|
|
|
+ test.describe('权限流', () => {
|
|
|
|
|
+ test('SC-C-06/X-02 报告列表无 token → 401', async ({ request }) => {
|
|
|
|
|
+ const r = await apiPost(request, '/api/health/report/list', { memberId: selfMemberId }, null);
|
|
|
|
|
+ expect(r.status).toBe(401);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('SC-X-13 转交列表无 token → 401', async ({ request }) => {
|
|
|
|
|
+ const r = await apiPost(request, '/api/health/report/transfer/list', {}, null);
|
|
|
|
|
+ expect(r.status).toBe(401);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('SC-P-67/T-03 代传确认无 token → 401(LOGIN_REQUIRED_PATHS 闸门)', async ({ request }) => {
|
|
|
|
|
+ const r = await apiPost(request, '/api/health/report/proxy-confirm', { draftId: 1 }, null);
|
|
|
|
|
+ expect(r.status).toBe(401);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('SC-C-07/T-12 DAN 列表非法 token → 401', async ({ request }) => {
|
|
|
|
|
+ const r = await apiPost(request, '/api/dan-report/list', { memberId: selfMemberId }, 'invalid-token-value');
|
|
|
|
|
+ expect(r.status).toBe(401);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('SC-S-09 解析类型链接非法 token → 401', async ({ request }) => {
|
|
|
|
|
+ const r = await apiPost(request, '/api/report-parser/types/links', {}, 'invalid-token-value');
|
|
|
|
|
+ expect(r.status).toBe(401);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('SC-P-67/T-23 家长非代传角色调用代传确认 → 无权', async ({ request }) => {
|
|
|
|
|
+ const r = await apiPost(request, '/api/health/report/proxy-confirm', { draftId: 1 }, parent.token);
|
|
|
|
|
+ expect(r.code, r.message).not.toBe(200);
|
|
|
|
|
+ expect(String(r.message)).toContain('无权');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('SC-T-03/X-02 非草稿所有者确认他人草稿 → 无权确认他人创建的草稿', async ({ request }) => {
|
|
|
|
|
+ const pending = await apiPost(request, '/api/health/report/audit/pending', { page: 1, size: 20 }, parent.token);
|
|
|
|
|
+ expect(pending.code, pending.message).toBe(200);
|
|
|
|
|
+ const othersDraft = (pending.data || []).find((d) => String(d.userId) !== String(parent.userId));
|
|
|
|
|
+ if (!othersDraft) {
|
|
|
|
|
+ test.skip(true, '当前测试库无他人待审草稿,跳过越权确认用例');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const r = await apiPost(request, '/api/health/report/audit/confirm',
|
|
|
|
|
+ { draftId: othersDraft.id }, parent.token);
|
|
|
|
|
+ expect(r.code, r.message).not.toBe(200);
|
|
|
|
|
+ expect(String(r.message)).toContain('无权确认他人创建的草稿');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('SC-P-84/C-06 跨家庭成员报告查询 → 空列表(成员级权限隔离,不泄露)', async ({ request }) => {
|
|
|
|
|
+ // parent 传 teacher 家庭的成员 ID:非本人、非本家庭管理员、未授权 → 返回空列表而非他人报告
|
|
|
|
|
+ const r = await apiPost(request, '/api/health/report/list', { memberId: teacherMemberId }, parent.token);
|
|
|
|
|
+ expect(r.code, r.message).toBe(200);
|
|
|
|
|
+ expect(Array.isArray(r.data)).toBe(true);
|
|
|
|
|
+ expect(r.data.length).toBe(0);
|
|
|
|
|
+
|
|
|
|
|
+ const dan = await apiPost(request, '/api/dan-report/list', { memberId: teacherMemberId }, parent.token);
|
|
|
|
|
+ expect(dan.code, dan.message).toBe(200);
|
|
|
|
|
+ expect(Array.isArray(dan.data)).toBe(true);
|
|
|
|
|
+ expect(dan.data.length).toBe(0);
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+});
|