report-closedloop-contract.spec.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. /**
  2. * EVD-7 报告闭环批次(23 SC)— 三类流 API 级 e2e
  3. * ================================================================
  4. * 覆盖 SC:SC-A-02/A-16、SC-C-06/C-07、SC-P-24/P-25/P-39/P-67/P-84/P-91、
  5. * SC-S-09、SC-T-03/T-11/T-12/T-21/T-23、SC-X-02/X-10/X-13。
  6. * 与 cfc-backend/src/test/java/com/etotem/cfc/unit/*Report*ContractTest 契约 JUnit 互补:
  7. * - JUnit:Service/Controller 分支级契约(纯 Mockito,确定性);
  8. * - 本 spec:真实 HTTP + 真实 DB,验证「登录 → 家庭上下文 → 端点 → Result 外壳」主链路。
  9. * 不在本 spec 覆盖:SC-B-04/B-11(LangGraph 报告解读/追问,见
  10. * cfc-langgraph/tests_contract/test_growth_report_contract.py)、SC-P-82(MCP JSON-RPC
  11. * 需 Mcp-Api-Key,见 GlobalSearchScP82ContractTest)。
  12. *
  13. * 运行(默认远端;可用 CFC_API_BASE 指向本地 9082):
  14. * npx playwright test api/report-closedloop-contract.spec.js \
  15. * -c playwright.config.api.js --project=desktop
  16. * CFC_API_BASE=http://127.0.0.1:9082 npx playwright test api/report-closedloop-contract.spec.js \
  17. * -c playwright.config.api.js --project=desktop
  18. *
  19. * 安全约定:用例只自注册临时账号、只读写自己的数据;不打印 token / 手机号。
  20. */
  21. const { test, expect } = require('@playwright/test');
  22. const { registerRole, apiPost, pickMemberByUserId, uniqueSuffix } = require('../helpers/api');
  23. test.describe('EVD-7 报告闭环 三类流(23 SC)', () => {
  24. let parent;
  25. let teacher;
  26. let selfMemberId;
  27. let teacherMemberId;
  28. test.beforeAll(async ({ request }) => {
  29. parent = await registerRole(request, 'parent', 'QA-EVD7-P' + uniqueSuffix());
  30. const list = await apiPost(request, '/api/family/member/list', {}, parent.token);
  31. expect(list.code, list.message).toBe(200);
  32. const self = pickMemberByUserId(list.data, parent.userId);
  33. expect(self, '成员列表应包含登录用户自身').toBeTruthy();
  34. selfMemberId = self.id;
  35. teacher = await registerRole(request, 'teacher', 'QA-EVD7-T' + uniqueSuffix());
  36. const teacherList = await apiPost(request, '/api/family/member/list', {}, teacher.token);
  37. expect(teacherList.code, teacherList.message).toBe(200);
  38. const teacherSelf = pickMemberByUserId(teacherList.data, teacher.userId);
  39. expect(teacherSelf, 'teacher 成员列表应包含自身').toBeTruthy();
  40. teacherMemberId = teacherSelf.id;
  41. });
  42. // ==================== 正常流 ====================
  43. test.describe('正常流', () => {
  44. test('SC-C-06/P-24 报告列表按成员返回数组(只读)', async ({ request }) => {
  45. const r = await apiPost(request, '/api/health/report/list', { memberId: selfMemberId }, parent.token);
  46. expect(r.code, r.message).toBe(200);
  47. expect(Array.isArray(r.data)).toBe(true);
  48. });
  49. test('SC-P-24/X-02 家庭报告列表返回数组', async ({ request }) => {
  50. const r = await apiPost(request, '/api/health/reports/family', {}, parent.token);
  51. expect(r.code, r.message).toBe(200);
  52. expect(Array.isArray(r.data)).toBe(true);
  53. });
  54. test('SC-P-25/X-10 报告趋势返回 scores/dates 结构', async ({ request }) => {
  55. const r = await apiPost(request, '/api/health/nutrition/trend', { memberId: selfMemberId }, parent.token);
  56. expect(r.code, r.message).toBe(200);
  57. expect(r.data && Array.isArray(r.data.scores)).toBe(true);
  58. expect(r.data && Array.isArray(r.data.dates)).toBe(true);
  59. });
  60. test('SC-P-84 授权→查询→撤销闭环(成员级报告权限)', async ({ request }) => {
  61. const grant = await apiPost(request, '/api/health/report/member-permission/grant',
  62. { targetMemberId: selfMemberId, granteeUserId: parent.userId }, parent.token);
  63. expect(grant.code, grant.message).toBe(200);
  64. expect(grant.data).toBe(true);
  65. const byMember = await apiPost(request,
  66. '/api/health/report/member-permission/members/' + selfMemberId, {}, parent.token);
  67. expect(byMember.code, byMember.message).toBe(200);
  68. expect(byMember.data.map(String)).toContain(String(parent.userId));
  69. const all = await apiPost(request, '/api/health/report/member-permission/list', {}, parent.token);
  70. expect(all.code, all.message).toBe(200);
  71. expect(Array.isArray(all.data)).toBe(true);
  72. // 家庭管理员 visible-members 返回 null(全部可见语义);普通成员返回集合
  73. const visible = await apiPost(request,
  74. '/api/health/report/member-permission/visible-members', {}, parent.token);
  75. expect(visible.code, visible.message).toBe(200);
  76. expect(visible.data === null || Array.isArray(visible.data)).toBe(true);
  77. const revoke = await apiPost(request, '/api/health/report/member-permission/revoke',
  78. { targetMemberId: selfMemberId, granteeUserId: parent.userId }, parent.token);
  79. expect(revoke.code, revoke.message).toBe(200);
  80. expect(revoke.data).toBe(true);
  81. });
  82. test('SC-C-07/P-39/T-12 DAN 报告列表返回数组', async ({ request }) => {
  83. const r = await apiPost(request, '/api/dan-report/list', { memberId: selfMemberId }, parent.token);
  84. expect(r.code, r.message).toBe(200);
  85. expect(Array.isArray(r.data)).toBe(true);
  86. });
  87. test('SC-S-09/A-02 报告解析类型↔商品链接列表(服务商接入真实契约)', async ({ request }) => {
  88. const r = await apiPost(request, '/api/report-parser/types/links', {}, parent.token);
  89. expect(r.code, r.message).toBe(200);
  90. expect(Array.isArray(r.data)).toBe(true);
  91. });
  92. test('SC-X-13 报告转交请求列表返回 incoming/outgoing', async ({ request }) => {
  93. const r = await apiPost(request, '/api/health/report/transfer/list', {}, parent.token);
  94. expect(r.code, r.message).toBe(200);
  95. expect(Array.isArray(r.data && r.data.incoming)).toBe(true);
  96. expect(Array.isArray(r.data && r.data.outgoing)).toBe(true);
  97. });
  98. test('SC-T-03/T-23 代传确认(teacher 角色通过权限闸门后进入参数校验)', async ({ request }) => {
  99. // teacher 属于代传上传者角色:通过 isProxyUploader 后,缺 draftId 返回业务错误而非「无权」
  100. const r = await apiPost(request, '/api/health/report/proxy-confirm', {}, teacher.token);
  101. expect(r.code, r.message).not.toBe(200);
  102. expect(String(r.message)).toContain('draftId');
  103. });
  104. });
  105. // ==================== 异常流 ====================
  106. test.describe('异常流', () => {
  107. test('SC-P-25 趋势缺 memberId → memberId不能为空', async ({ request }) => {
  108. const r = await apiPost(request, '/api/health/nutrition/trend', {}, parent.token);
  109. expect(r.code, r.message).not.toBe(200);
  110. expect(String(r.message)).toContain('memberId不能为空');
  111. });
  112. test('SC-P-24 指标对比缺 reportId → reportId不能为空', async ({ request }) => {
  113. const r = await apiPost(request, '/api/health/report/indicators', {}, parent.token);
  114. expect(r.code, r.message).not.toBe(200);
  115. expect(String(r.message)).toContain('reportId不能为空');
  116. });
  117. test('SC-P-91 指标对比 reportId 不存在 → 报告不存在', async ({ request }) => {
  118. const r = await apiPost(request, '/api/health/report/indicators', { reportId: 999999999 }, parent.token);
  119. expect(r.code, r.message).not.toBe(200);
  120. expect(String(r.message)).toContain('报告不存在');
  121. });
  122. test('SC-A-02/A-16 解析检测缺 filePath → filePath 不能为空', async ({ request }) => {
  123. const r = await apiPost(request, '/api/report-parser/detect', {}, parent.token);
  124. expect(r.code, r.message).not.toBe(200);
  125. expect(String(r.message)).toContain('filePath');
  126. });
  127. test('SC-P-84 授权缺参数 → targetMemberId/granteeUserId 不能为空', async ({ request }) => {
  128. const r = await apiPost(request, '/api/health/report/member-permission/grant', {}, parent.token);
  129. expect(r.code, r.message).not.toBe(200);
  130. expect(String(r.message)).toContain('不能为空');
  131. });
  132. test('SC-T-21/X-10 新成员无两次测评 → 对比返回业务错误', async ({ request }) => {
  133. const r = await apiPost(request, '/api/assessment/compare/by-child', { memberId: selfMemberId }, parent.token);
  134. expect(r.code, r.message).not.toBe(200);
  135. expect(String(r.message)).toContain('两次');
  136. });
  137. test('SC-C-07/T-12 DAN 对比缺 uploadId → 缺少 uploadIdA 与 uploadIdB', async ({ request }) => {
  138. const r = await apiPost(request, '/api/dan-report/compare', {}, parent.token);
  139. expect(r.code, r.message).not.toBe(200);
  140. expect(String(r.message)).toContain('uploadIdA');
  141. });
  142. test('SC-X-13 转交审批缺 requestId → 请求ID不能为空', async ({ request }) => {
  143. const r = await apiPost(request, '/api/health/report/transfer/approve', {}, parent.token);
  144. expect(r.code, r.message).not.toBe(200);
  145. expect(String(r.message)).toContain('申请ID不能为空');
  146. });
  147. test('SC-T-03/X-02 确认不存在的草稿 → 草稿不存在或已归档', async ({ request }) => {
  148. const r = await apiPost(request, '/api/health/report/audit/confirm', { draftId: 999999999 }, parent.token);
  149. expect(r.code, r.message).not.toBe(200);
  150. expect(String(r.message)).toContain('草稿不存在');
  151. });
  152. });
  153. // ==================== 权限流 ====================
  154. test.describe('权限流', () => {
  155. test('SC-C-06/X-02 报告列表无 token → 401', async ({ request }) => {
  156. const r = await apiPost(request, '/api/health/report/list', { memberId: selfMemberId }, null);
  157. expect(r.status).toBe(401);
  158. });
  159. test('SC-X-13 转交列表无 token → 401', async ({ request }) => {
  160. const r = await apiPost(request, '/api/health/report/transfer/list', {}, null);
  161. expect(r.status).toBe(401);
  162. });
  163. test('SC-P-67/T-03 代传确认无 token → 401(LOGIN_REQUIRED_PATHS 闸门)', async ({ request }) => {
  164. const r = await apiPost(request, '/api/health/report/proxy-confirm', { draftId: 1 }, null);
  165. expect(r.status).toBe(401);
  166. });
  167. test('SC-C-07/T-12 DAN 列表非法 token → 401', async ({ request }) => {
  168. const r = await apiPost(request, '/api/dan-report/list', { memberId: selfMemberId }, 'invalid-token-value');
  169. expect(r.status).toBe(401);
  170. });
  171. test('SC-S-09 解析类型链接非法 token → 401', async ({ request }) => {
  172. const r = await apiPost(request, '/api/report-parser/types/links', {}, 'invalid-token-value');
  173. expect(r.status).toBe(401);
  174. });
  175. test('SC-P-67/T-23 家长非代传角色调用代传确认 → 无权', async ({ request }) => {
  176. const r = await apiPost(request, '/api/health/report/proxy-confirm', { draftId: 1 }, parent.token);
  177. expect(r.code, r.message).not.toBe(200);
  178. expect(String(r.message)).toContain('无权');
  179. });
  180. test('SC-T-03/X-02 非草稿所有者确认他人草稿 → 无权确认他人创建的草稿', async ({ request }) => {
  181. const pending = await apiPost(request, '/api/health/report/audit/pending', { page: 1, size: 20 }, parent.token);
  182. expect(pending.code, pending.message).toBe(200);
  183. const othersDraft = (pending.data || []).find((d) => String(d.userId) !== String(parent.userId));
  184. if (!othersDraft) {
  185. test.skip(true, '当前测试库无他人待审草稿,跳过越权确认用例');
  186. return;
  187. }
  188. const r = await apiPost(request, '/api/health/report/audit/confirm',
  189. { draftId: othersDraft.id }, parent.token);
  190. expect(r.code, r.message).not.toBe(200);
  191. expect(String(r.message)).toContain('无权确认他人创建的草稿');
  192. });
  193. test('SC-P-84/C-06 跨家庭成员报告查询 → 空列表(成员级权限隔离,不泄露)', async ({ request }) => {
  194. // parent 传 teacher 家庭的成员 ID:非本人、非本家庭管理员、未授权 → 返回空列表而非他人报告
  195. const r = await apiPost(request, '/api/health/report/list', { memberId: teacherMemberId }, parent.token);
  196. expect(r.code, r.message).toBe(200);
  197. expect(Array.isArray(r.data)).toBe(true);
  198. expect(r.data.length).toBe(0);
  199. const dan = await apiPost(request, '/api/dan-report/list', { memberId: teacherMemberId }, parent.token);
  200. expect(dan.code, dan.message).toBe(200);
  201. expect(Array.isArray(dan.data)).toBe(true);
  202. expect(dan.data.length).toBe(0);
  203. });
  204. });
  205. });