|
@@ -0,0 +1,240 @@
|
|
|
|
|
+package com.etotem.cfc.service;
|
|
|
|
|
+
|
|
|
|
|
+import com.etotem.cfc.config.JwtConfig;
|
|
|
|
|
+import com.etotem.cfc.dto.LoginResultDTO;
|
|
|
|
|
+import com.etotem.cfc.dto.WechatLoginDTO;
|
|
|
|
|
+import com.etotem.cfc.entity.Family;
|
|
|
|
|
+import com.etotem.cfc.entity.FamilyMember;
|
|
|
|
|
+import com.etotem.cfc.entity.User;
|
|
|
|
|
+import com.etotem.cfc.mapper.FamilyMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.UserMapper;
|
|
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
|
+import org.mockito.ArgumentCaptor;
|
|
|
|
|
+import org.mockito.InjectMocks;
|
|
|
|
|
+import org.mockito.Mock;
|
|
|
|
|
+import org.mockito.MockitoAnnotations;
|
|
|
|
|
+
|
|
|
|
|
+import java.util.Collections;
|
|
|
|
|
+import java.util.HashMap;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.anyString;
|
|
|
|
|
+import static org.mockito.Mockito.doAnswer;
|
|
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * SC-G-02 微信一键登录(auth.guest.wechatLogin / S-cfc-9)服务层契约测试。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>口径(docs/flows/user-auth-flow.md §阶段一、tests/user-scenarios/01-guest.md 场景 G-2):
|
|
|
|
|
+ * <ol>
|
|
|
|
|
+ * <li>正常流:微信 code → code2Session → openid;无账号则自动创建 User(默认角色 parent)+
|
|
|
|
|
+ * 自动建家庭 + family_members 成员记录;签发 JWT。</li>
|
|
|
|
|
+ * <li>异常流:code2Session 取不到 openid / 微信接口异常 → 抛出业务异常,<b>不产出脏号</b>
|
|
|
|
|
+ * (不 insert user/family/family_member)。</li>
|
|
|
|
|
+ * <li>幂等:同一 openid 二次登录复用已有账号(isNewUser=false),不重复建号/建家庭;
|
|
|
|
|
+ * 手机号已存在但 openid 不同 → 绑定 openid,不新建。</li>
|
|
|
|
|
+ * </ol>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>采用 Mockito 纯单元测试(不启动 Spring、不连数据库),以 MyBatis-Plus 自增主键回填模拟
|
|
|
|
|
+ * ({@code insert} 后回写实体 id),断言建档与令牌签发结果。</p>
|
|
|
|
|
+ */
|
|
|
|
|
+class UserServiceWechatLoginTest {
|
|
|
|
|
+
|
|
|
|
|
+ @Mock
|
|
|
|
|
+ private WechatService wechatService;
|
|
|
|
|
+ @Mock
|
|
|
|
|
+ private UserMapper userMapper;
|
|
|
|
|
+ @Mock
|
|
|
|
|
+ private FamilyMapper familyMapper;
|
|
|
|
|
+ @Mock
|
|
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
|
|
+ @Mock
|
|
|
|
|
+ private JwtConfig jwtConfig;
|
|
|
|
|
+ @Mock
|
|
|
|
|
+ private CouponService couponService;
|
|
|
|
|
+ @Mock
|
|
|
|
|
+ private OnboardingService onboardingService;
|
|
|
|
|
+
|
|
|
|
|
+ @InjectMocks
|
|
|
|
|
+ private UserService userService;
|
|
|
|
|
+
|
|
|
|
|
+ @BeforeEach
|
|
|
|
|
+ void setUp() {
|
|
|
|
|
+ MockitoAnnotations.openMocks(this);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private WechatLoginDTO dto(String code) {
|
|
|
|
|
+ WechatLoginDTO d = new WechatLoginDTO();
|
|
|
|
|
+ d.setCode(code);
|
|
|
|
|
+ return d;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private Map<String, String> session(String openid, String unionid) {
|
|
|
|
|
+ Map<String, String> m = new HashMap<>();
|
|
|
|
|
+ m.put("openid", openid);
|
|
|
|
|
+ m.put("unionid", unionid);
|
|
|
|
|
+ m.put("session_key", "sk-" + openid);
|
|
|
|
|
+ return m;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 模拟 MyBatis-Plus 自增主键回填:insert 后回写实体 id */
|
|
|
|
|
+ private void autoKey() {
|
|
|
|
|
+ doAnswer(inv -> {
|
|
|
|
|
+ User u = inv.getArgument(0);
|
|
|
|
|
+ u.setId(9001L);
|
|
|
|
|
+ return 1;
|
|
|
|
|
+ }).when(userMapper).insert(any(User.class));
|
|
|
|
|
+ doAnswer(inv -> {
|
|
|
|
|
+ Family f = inv.getArgument(0);
|
|
|
|
|
+ f.setId(90001L);
|
|
|
|
|
+ return 1;
|
|
|
|
|
+ }).when(familyMapper).insert(any(Family.class));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 正常流 ====================
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 正常流(TC-G-02):微信首次登录 → 自动创建 User(role=parent)+ 家庭 + family_members 成员,
|
|
|
|
|
+ * 返回 Result 含 token + 用户信息,isNewUser=true。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void 首次微信登录_自动建档默认家长并创建家庭与成员() {
|
|
|
|
|
+ autoKey();
|
|
|
|
|
+ when(wechatService.code2Session("wx-code-1")).thenReturn(session("openid_first", "union_1"));
|
|
|
|
|
+ when(userMapper.selectOne(any())).thenReturn(null);
|
|
|
|
|
+ when(familyMemberMapper.selectCount(any())).thenReturn(0L);
|
|
|
|
|
+ when(jwtConfig.generateToken(9001L, "parent")).thenReturn("jwt-token-first");
|
|
|
|
|
+
|
|
|
|
|
+ LoginResultDTO result = userService.wechatLogin(dto("wx-code-1"));
|
|
|
|
|
+
|
|
|
|
|
+ assertNotNull(result.getToken());
|
|
|
|
|
+ assertEquals("jwt-token-first", result.getToken());
|
|
|
|
|
+ assertEquals("parent", result.getRole());
|
|
|
|
|
+ assertTrue(result.getIsNewUser());
|
|
|
|
|
+ assertEquals(9001L, result.getUserId());
|
|
|
|
|
+ assertEquals(90001L, result.getFamilyId());
|
|
|
|
|
+ assertEquals("openid_first", result.getOpenid());
|
|
|
|
|
+ assertFalse(result.getNeedsFamily()); // 自动建档已建家庭
|
|
|
|
|
+ assertTrue(result.getNeedsRoleSelect()); // 新用户需要选择角色
|
|
|
|
|
+ assertFalse(result.getHasTeacherRole());
|
|
|
|
|
+
|
|
|
|
|
+ // users 表 insert(默认 parent、家庭管理员)
|
|
|
|
|
+ ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
|
|
|
|
|
+ verify(userMapper).insert(userCaptor.capture());
|
|
|
|
|
+ User created = userCaptor.getValue();
|
|
|
|
|
+ assertEquals("openid_first", created.getOpenid());
|
|
|
|
|
+ assertEquals("parent", created.getRole());
|
|
|
|
|
+ assertTrue(created.getIsFamilyAdmin());
|
|
|
|
|
+
|
|
|
|
|
+ // families 表 insert
|
|
|
|
|
+ verify(familyMapper).insert(any(Family.class));
|
|
|
|
|
+
|
|
|
|
|
+ // family_members 表 insert:创建人自己是成员(能登录成员,VO 层派生 memberType=login)
|
|
|
|
|
+ ArgumentCaptor<FamilyMember> memberCaptor = ArgumentCaptor.forClass(FamilyMember.class);
|
|
|
|
|
+ verify(familyMemberMapper).insert(memberCaptor.capture());
|
|
|
|
|
+ assertEquals(9001L, memberCaptor.getValue().getUserId());
|
|
|
|
|
+ assertEquals(90001L, memberCaptor.getValue().getFamilyId());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 正常流-幂等(TC-G-02a):同一 openid 二次登录 → 复用已有账号(isNewUser=false),
|
|
|
|
|
+ * 不重复建号、不重复建家庭、不重复建成员;仅刷新 sessionKey。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void 同一openid二次登录_复用账号不重复建号不重复建家庭() {
|
|
|
|
|
+ User existing = new User();
|
|
|
|
|
+ existing.setId(5001L);
|
|
|
|
|
+ existing.setOpenid("openid_first");
|
|
|
|
|
+ existing.setRole("parent");
|
|
|
|
|
+ existing.setFamilyId(20001L);
|
|
|
|
|
+ existing.setNickname("老用户");
|
|
|
|
|
+ when(wechatService.code2Session("wx-code-1")).thenReturn(session("openid_first", "union_1"));
|
|
|
|
|
+ when(userMapper.selectOne(any())).thenReturn(existing);
|
|
|
|
|
+ when(jwtConfig.generateToken(5001L, "parent")).thenReturn("jwt-token-again");
|
|
|
|
|
+
|
|
|
|
|
+ LoginResultDTO result = userService.wechatLogin(dto("wx-code-1"));
|
|
|
|
|
+
|
|
|
|
|
+ assertFalse(result.getIsNewUser());
|
|
|
|
|
+ assertEquals(5001L, result.getUserId());
|
|
|
|
|
+ assertEquals(20001L, result.getFamilyId());
|
|
|
|
|
+ assertFalse(result.getNeedsRoleSelect());
|
|
|
|
|
+
|
|
|
|
|
+ verify(userMapper, never()).insert(any(User.class));
|
|
|
|
|
+ verify(familyMapper, never()).insert(any(Family.class));
|
|
|
|
|
+ verify(familyMemberMapper, never()).insert(any(FamilyMember.class));
|
|
|
|
|
+ // 老用户:刷新 sessionKey
|
|
|
|
|
+ verify(userMapper).updateById(existing);
|
|
|
|
|
+ assertEquals("sk-openid_first", existing.getSessionKey());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 正常流-变体:手机号已存在但 openid 不同 → 绑定 openid(不新建账号、isNewUser=false)。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void 手机号已存在但openid不同_绑定openid不新建() {
|
|
|
|
|
+ User phoneUser = new User();
|
|
|
|
|
+ phoneUser.setId(6002L);
|
|
|
|
|
+ phoneUser.setPhone("13800000001");
|
|
|
|
|
+ phoneUser.setOpenid(null);
|
|
|
|
|
+ phoneUser.setRole("parent");
|
|
|
|
|
+ phoneUser.setFamilyId(30001L);
|
|
|
|
|
+ when(wechatService.code2Session("wx-phone")).thenReturn(session("openid_bound", "union_2"));
|
|
|
|
|
+ when(wechatService.getPhoneNumber("pc-1")).thenReturn("13800000001");
|
|
|
|
|
+ when(userMapper.selectOne(any())).thenReturn(null, phoneUser); // 先按 openid 查不到,再按手机号查到
|
|
|
|
|
+ when(jwtConfig.generateToken(6002L, "parent")).thenReturn("jwt-token-bound");
|
|
|
|
|
+
|
|
|
|
|
+ WechatLoginDTO d = dto("wx-phone");
|
|
|
|
|
+ d.setPhoneCode("pc-1");
|
|
|
|
|
+ LoginResultDTO result = userService.wechatLogin(d);
|
|
|
|
|
+
|
|
|
|
|
+ assertFalse(result.getIsNewUser());
|
|
|
|
|
+ assertEquals(6002L, result.getUserId());
|
|
|
|
|
+ assertEquals("openid_bound", phoneUser.getOpenid());
|
|
|
|
|
+ verify(userMapper, never()).insert(any(User.class));
|
|
|
|
|
+ verify(userMapper).updateById(phoneUser);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 异常流 ====================
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 异常流(TC-G-02-E #1):code2Session 取不到 openid(授权失败/回调超时)→ 业务异常,
|
|
|
|
|
+ * 不产出脏号(不 insert user/family/family_member)。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void code2Session返回空openid_抛出业务异常且不产出脏号() {
|
|
|
|
|
+ when(wechatService.code2Session("bad-code")).thenReturn(Collections.singletonMap("session_key", "sk"));
|
|
|
|
|
+
|
|
|
|
|
+ RuntimeException ex = assertThrows(RuntimeException.class, () -> userService.wechatLogin(dto("bad-code")));
|
|
|
|
|
+ assertEquals("获取openid失败,请重试", ex.getMessage());
|
|
|
|
|
+
|
|
|
|
|
+ verify(userMapper, never()).insert(any(User.class));
|
|
|
|
|
+ verify(familyMapper, never()).insert(any(Family.class));
|
|
|
|
|
+ verify(familyMemberMapper, never()).insert(any(FamilyMember.class));
|
|
|
|
|
+ verify(userMapper, never()).updateById(any(User.class));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 异常流(TC-G-02-E #3):微信接口异常(超时/网络)→ 抛出异常,不建脏号。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void 微信接口异常_不产出脏号() {
|
|
|
|
|
+ when(wechatService.code2Session(anyString())).thenThrow(new RuntimeException("微信接口超时"));
|
|
|
|
|
+
|
|
|
|
|
+ RuntimeException ex = assertThrows(RuntimeException.class, () -> userService.wechatLogin(dto("timeout-code")));
|
|
|
|
|
+ assertNotNull(ex.getMessage());
|
|
|
|
|
+
|
|
|
|
|
+ verify(userMapper, never()).insert(any(User.class));
|
|
|
|
|
+ verify(familyMapper, never()).insert(any(Family.class));
|
|
|
|
|
+ verify(familyMemberMapper, never()).insert(any(FamilyMember.class));
|
|
|
|
|
+ }
|
|
|
|
|
+}
|