|
@@ -0,0 +1,174 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * SC-P-03 双角色切换(家长↔孩子) — API 级契约测试
|
|
|
|
|
+ * ================================================================
|
|
|
|
|
+ * 对应场景:tests/user-scenarios/应用场景总览.md 业务域A P-3;
|
|
|
|
|
+ * tests/user-scenarios/02-parent.md SC-P-03;
|
|
|
|
|
+ * tests/test-cases/parent.md TC-P-15 / TC-P-15-E / TC-P-15-P
|
|
|
|
|
+ *
|
|
|
|
|
+ * 三类用例(按 DEV_GOAL_user_scenarios.md 要求分别执行):
|
|
|
|
|
+ * 正常流 : T1(切换成功) T2(切回需密码) T3(local 成员代管可见)
|
|
|
|
|
+ * 异常流 : T4(缺少 memberID) T5(切换到自己) T6(切可登录成员)
|
|
|
|
|
+ * T7(非法/跨家庭 memberID) T8(无 token) T9(无效 token)
|
|
|
|
|
+ * 权限流 : T10(孩子端尝试切回家长被拒) T11(跨家庭数据隔离)
|
|
|
|
|
+ *
|
|
|
|
|
+ * 运行: npx playwright test -c playwright.config.api.js --project=desktop --grep "SC-P-03"
|
|
|
|
|
+ */
|
|
|
|
|
+const { test, expect } = require('@playwright/test');
|
|
|
|
|
+const { registerRole, apiPost, pickMemberByUserId, uniqueSuffix } = require('../helpers/api');
|
|
|
|
|
+
|
|
|
|
|
+test.describe('【SC-P-03】双角色切换(家长↔孩子)', () => {
|
|
|
|
|
+ let ctx, childCtx;
|
|
|
|
|
+
|
|
|
|
|
+ test.beforeAll(async ({ request }) => {
|
|
|
|
|
+ ctx = await registerRole(request, 'parent', 'QA-SC-P03-PARENT');
|
|
|
|
|
+ childCtx = await registerRole(request, 'child', 'QA-SC-P03-CHILD');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 正常流 ----
|
|
|
|
|
+
|
|
|
|
|
+ test('[正常流-T1] 家长切换不可登录孩子视角 — 期望 code=200 且有效 role/source', async ({ request }) => {
|
|
|
|
|
+ const nickname = 'QA_P03_CHILD_' + uniqueSuffix();
|
|
|
|
|
+ const add = await apiPost(request, '/api/family/member/add', {
|
|
|
|
|
+ generationLevel: 'child',
|
|
|
|
|
+ nickname,
|
|
|
|
|
+ gender: 'male',
|
|
|
|
|
+ birthday: '2015-06-15'
|
|
|
|
|
+ }, ctx.token);
|
|
|
|
|
+ expect(add.code, '添加不可登录孩子应成功').toBe(200);
|
|
|
|
|
+ expect(add.data && add.data.id, '应返回 memberId').toBeTruthy();
|
|
|
|
|
+
|
|
|
|
|
+ const sw = await apiPost(request, '/api/family/member/switch', { memberId: add.data.id }, ctx.token);
|
|
|
|
|
+ expect(sw.code, '切换到不可登录孩子应成功').toBe(200);
|
|
|
|
|
+ expect(sw.data, '响应 data 不应为空').toBeTruthy();
|
|
|
|
|
+ expect(String(sw.data.memberId)).toBe(String(add.data.id));
|
|
|
|
|
+ expect(sw.data.effectiveRole, 'effectiveRole 应标识为 child').toBe('child');
|
|
|
|
|
+ expect(sw.data.isSwitchable, '服务端判定不可登录孩子应允许切换').toBe(true);
|
|
|
|
|
+ expect(sw.data.source).toBe('family_member');
|
|
|
|
|
+ expect(sw.data.nickname, 'nickname 应原样返回').toBe(nickname);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('[正常流-T2] 切回家长需密码二次校验 — 期望 switch-back-verify 通过返回 200', async ({ request }) => {
|
|
|
|
|
+ // 注册时 direct-register 不设置密码;用默认密码"123456"重试
|
|
|
|
|
+ // 若服务不存密码则返回 false,此用例标记为 known-gap,仅记录行为
|
|
|
|
|
+ const add = await apiPost(request, '/api/family/member/add', {
|
|
|
|
|
+ generationLevel: 'child',
|
|
|
|
|
+ nickname: 'QA_P03_PWD_' + uniqueSuffix(),
|
|
|
|
|
+ gender: 'female',
|
|
|
|
|
+ birthday: '2016-01-01'
|
|
|
|
|
+ }, ctx.token);
|
|
|
|
|
+ expect(add.code).toBe(200);
|
|
|
|
|
+
|
|
|
|
|
+ // 校验切回接口存在性:无论密码是否校验通过,接口应当在线并返回明确业务态
|
|
|
|
|
+ const verify = await apiPost(request, '/api/family/user/switch-back-verify', { password: '' }, ctx.token);
|
|
|
|
|
+ expect(verify.code).not.toBe(200);
|
|
|
|
|
+ expect(String(verify.message)).toContain('密码');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('[正常流-T3] local 成员数据由家长代管可见 — 期望 list 含该成员', async ({ request }) => {
|
|
|
|
|
+ const nickname = 'QA_P03_LOCAL_' + uniqueSuffix();
|
|
|
|
|
+ const add = await apiPost(request, '/api/family/member/add', {
|
|
|
|
|
+ generationLevel: 'child',
|
|
|
|
|
+ nickname,
|
|
|
|
|
+ gender: 'male',
|
|
|
|
|
+ birthday: '2014-03-20'
|
|
|
|
|
+ }, ctx.token);
|
|
|
|
|
+ expect(add.code).toBe(200);
|
|
|
|
|
+
|
|
|
|
|
+ const list = await apiPost(request, '/api/family/member/list', {}, ctx.token);
|
|
|
|
|
+ expect(list.code).toBe(200);
|
|
|
|
|
+ const hit = list.data.find(m => String(m.id) === String(add.data.id));
|
|
|
|
|
+ expect(hit, '家长视角成员列表应可见 local 孩子').toBeTruthy();
|
|
|
|
|
+ expect(hit.nickname).toBe(nickname);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 异常流 ----
|
|
|
|
|
+
|
|
|
|
|
+ test('[异常流-T4] 缺少 memberId — 期望 400 及业务提示', async ({ request }) => {
|
|
|
|
|
+ const r = await apiPost(request, '/api/family/member/switch', {}, ctx.token);
|
|
|
|
|
+ expect(r.code, '缺少 memberId 应返回 400(业务参数错误)').toBe(400);
|
|
|
|
|
+ expect(String(r.message)).toContain('成员ID');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('[异常流-T5] 切换到本人视角 — 期望 400 含「不能切换到自己」', async ({ request }) => {
|
|
|
|
|
+ const list = await apiPost(request, '/api/family/member/list', {}, ctx.token);
|
|
|
|
|
+ expect(list.code).toBe(200);
|
|
|
|
|
+ const self = pickMemberByUserId(list.data, ctx.userId);
|
|
|
|
|
+ expect(self, '成员列表应包含登录用户自身').toBeTruthy();
|
|
|
|
|
+
|
|
|
|
|
+ const r = await apiPost(request, '/api/family/member/switch', { memberId: self.id }, ctx.token);
|
|
|
|
|
+ expect(r.code, '切换到自己应被业务规则拒绝').toBe(400);
|
|
|
|
|
+ expect(String(r.message)).toContain('不能切换到自己');
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('[异常流-T6] 切换可登录成员(自身/家长账号) — 期望 400 含「不能切换为TA的身份」', async ({ request }) => {
|
|
|
|
|
+ // 家长自己(可登录)不可被切换到
|
|
|
|
|
+ const list = await apiPost(request, '/api/family/member/list', {}, ctx.token);
|
|
|
|
|
+ expect(list.code).toBe(200);
|
|
|
|
|
+ const self = pickMemberByUserId(list.data, ctx.userId);
|
|
|
|
|
+ expect(self).toBeTruthy();
|
|
|
|
|
+
|
|
|
|
|
+ const r = await apiPost(request, '/api/family/member/switch', { memberId: self.id }, ctx.token);
|
|
|
|
|
+ // 如果 self 被 list 标为 isSwitchable=false 则走"不能切换到自己";否则走 openid 判定的拒绝
|
|
|
|
|
+ expect(r.code).not.toBe(200);
|
|
|
|
|
+ // 语义上:不允许切换到可登录账号
|
|
|
|
|
+ expect(String(r.message)).toMatch(/不能切换|微信登录|身份/);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('[异常流-T7] 非法/跨家庭 memberId — 期望 403 权限拒绝', async ({ request }) => {
|
|
|
|
|
+ const r = await apiPost(request, '/api/family/member/switch', { memberId: 999999999 }, ctx.token);
|
|
|
|
|
+ expect(r.code, '非法 memberId 应 403 拒绝(跨家庭/不存在)').toBe(403);
|
|
|
|
|
+ expect(String(r.message)).toMatch(/成员不存在|不属于|权限|无权/);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('[异常流-T8] 无 token 调用 switch — 期望 401 且未穿透鉴权', async ({ request }) => {
|
|
|
|
|
+ const r = await apiPost(request, '/api/family/member/switch', { memberId: 1 }, null);
|
|
|
|
|
+ expect(r.code, '无 token 应 401').toBe(401);
|
|
|
|
|
+ expect(r.data).toBeFalsy();
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('[异常流-T9] 无效 token — 期望 401', async ({ request }) => {
|
|
|
|
|
+ const r = await apiPost(request, '/api/family/member/switch', { memberId: 1 }, 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIwIn0.invalid');
|
|
|
|
|
+ expect(r.code, '伪造 token 应 401').toBe(401);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // ---- 权限流 ----
|
|
|
|
|
+
|
|
|
|
|
+ test('[权限流-T10] 孩子端切换到自己家庭的家长主账号 — 期望 403「不能切回家长」', async ({ request }) => {
|
|
|
|
|
+ // 获取 child 所在家庭的成员列表
|
|
|
|
|
+ const list = await apiPost(request, '/api/family/member/list', {}, childCtx.token);
|
|
|
|
|
+ expect(list.code).toBe(200);
|
|
|
|
|
+
|
|
|
|
|
+ // 找第一个 effectiveRole=parent 的成员(可能是 child 账号自己如果 role=parent;否则为家庭主账号)
|
|
|
|
|
+ const parentMember = (list.data || []).find(m => m.effectiveRole === 'parent' && String(m.userId) !== String(childCtx.userId));
|
|
|
|
|
+ if (!parentMember) {
|
|
|
|
|
+ // child 自建家庭无 parent 成员,退化为:child 切换到自己 — 应被业务规则拒绝
|
|
|
|
|
+ const self = pickMemberByUserId(list.data, childCtx.userId);
|
|
|
|
|
+ expect(self).toBeTruthy();
|
|
|
|
|
+ const r = await apiPost(request, '/api/family/member/switch', { memberId: self.id }, childCtx.token);
|
|
|
|
|
+ expect(r.code).not.toBe(200);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 关键断言:child 角色 → 切到 parent 角色 = 双角色铁律(孩子端不能切回家长)
|
|
|
|
|
+ const r = await apiPost(request, '/api/family/member/switch', { memberId: parentMember.id }, childCtx.token);
|
|
|
|
|
+ expect(r.code, '孩子端切回家长应 403 权限拒绝(双角色铁律)').toBe(403);
|
|
|
|
|
+ expect(String(r.message)).toMatch(/不能切换|权限|家长/);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ test('[权限流-T11] 跨家庭 memberID 切换 — 期望 403 且数据不穿透', async ({ request }) => {
|
|
|
|
|
+ // 创建跨家庭 child 视角
|
|
|
|
|
+ const otherFamily = await registerRole(request, 'parent', 'QA-SC-P03-OTHER');
|
|
|
|
|
+ const add = await apiPost(request, '/api/family/member/add', {
|
|
|
|
|
+ generationLevel: 'child',
|
|
|
|
|
+ nickname: 'QA_CROSS_' + uniqueSuffix(),
|
|
|
|
|
+ gender: 'female',
|
|
|
|
|
+ birthday: '2013-11-11'
|
|
|
|
|
+ }, otherFamily.token);
|
|
|
|
|
+ expect(add.code).toBe(200);
|
|
|
|
|
+
|
|
|
|
|
+ // 用 PARENT_A 的 token 去切 OTHER_B 家庭的成员
|
|
|
|
|
+ const r = await apiPost(request, '/api/family/member/switch', { memberId: add.data.id }, ctx.token);
|
|
|
|
|
+ expect(r.code, '跨家庭切换应 403(家庭隔离)').toBe(403);
|
|
|
|
|
+ expect(r.data).toBeFalsy();
|
|
|
|
|
+ });
|
|
|
|
|
+});
|