package com.ruoyi.app.user; import com.auth0.jwt.JWT; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.ruoyi.common.constant.HttpStatus; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.core.redis.RedisCache; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.app.user.dto.OAuthBindDto; import com.ruoyi.app.user.dto.OAuthLoginDto; import com.ruoyi.app.utils.oauth.OAuthVerifyService; import com.ruoyi.app.utils.oauth.LineOAuthStateService; import com.ruoyi.system.domain.InfoUser; import com.ruoyi.system.domain.InfoUserOauth; import com.ruoyi.system.domain.PosOrder; import com.ruoyi.system.domain.vo.UserDTO; import com.ruoyi.system.mapper.InfoUserOauthMapper; import com.ruoyi.system.service.IInfoUserService; import com.ruoyi.system.service.IPosOrderService; import com.ruoyi.system.service.IUserWalletService; import com.ruoyi.system.service.MerchantStoreAccessService; import com.ruoyi.system.utils.AuthContext; import com.ruoyi.system.utils.JwtUtil; import com.ruoyi.common.utils.spring.SpringUtils; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; import org.springframework.beans.factory.support.DefaultListableBeanFactory; import org.springframework.context.support.StaticMessageSource; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Locale; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; 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.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class InfoUserControllerTest { private InfoUserController controller; private IPosOrderService posOrderService; private IInfoUserService infoUserService; private IUserWalletService userWalletService; private MerchantStoreAccessService merchantStoreAccessService; private MerchantTokenSessionService merchantTokenSessionService; private BusinessPhoneService businessPhoneService; private OAuthVerifyService oauthVerifyService; private InfoUserOauthMapper infoUserOauthMapper; private RedisCache redisCache; private static ConfigurableListableBeanFactory originalBeanFactory; @BeforeAll static void initializeTableMetadata() { TableInfoHelper.initTableInfo( new MapperBuilderAssistant(new MybatisConfiguration(), ""), PosOrder.class); TableInfoHelper.initTableInfo( new MapperBuilderAssistant(new MybatisConfiguration(), ""), InfoUser.class); TableInfoHelper.initTableInfo( new MapperBuilderAssistant(new MybatisConfiguration(), ""), InfoUserOauth.class); originalBeanFactory = (ConfigurableListableBeanFactory) ReflectionTestUtils.getField(SpringUtils.class, "beanFactory"); DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); StaticMessageSource messageSource = new StaticMessageSource(); messageSource.addMessage("no.user.audit.reject.reason.required", Locale.getDefault(), "审核不通过时必须填写审核不通过原因"); messageSource.addMessage("no.action.success", Locale.getDefault(), "操作成功"); messageSource.addMessage("merchant.subaccount.platform.managed", Locale.getDefault(), "分管账号只能通过专用入口管理"); messageSource.addMessage("no.oauth.phone.blank", Locale.getDefault(), "手机号不能为空"); messageSource.addMessage("no.user.login.success", Locale.getDefault(), "登录成功"); messageSource.addMessage("no.user.stop", Locale.getDefault(), "账号已停用"); messageSource.addMessage("no.user.not.exist", Locale.getDefault(), "账号不存在"); messageSource.addMessage("no.system.error", Locale.getDefault(), "系统错误"); messageSource.addMessage("no.user.jcaptcha.error", Locale.getDefault(), "验证码错误"); messageSource.addMessage("no.oauth.tempkey.expired", Locale.getDefault(), "登录凭证已过期"); messageSource.addMessage("no.oauth.phone.duplicate", Locale.getDefault(), "该手机号关联多个账号,请联系平台"); messageSource.addMessage("no.user.phone.duplicate", Locale.getDefault(), "该手机号关联多个账号,请联系平台"); beanFactory.registerSingleton("messageSource", messageSource); beanFactory.registerSingleton("redisCache", mock(RedisCache.class)); new SpringUtils().postProcessBeanFactory(beanFactory); } @AfterAll static void restoreBeanFactory() { new SpringUtils().postProcessBeanFactory(originalBeanFactory); } @BeforeEach void setUp() { MockHttpServletRequest request = new MockHttpServletRequest(); request.setRemoteAddr("127.0.0.1"); request.addHeader("User-Agent", "JUnit"); RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); controller = new TestInfoUserController(); posOrderService = mock(IPosOrderService.class); infoUserService = mock(IInfoUserService.class); userWalletService = mock(IUserWalletService.class); merchantStoreAccessService = mock(MerchantStoreAccessService.class); merchantTokenSessionService = mock(MerchantTokenSessionService.class); businessPhoneService = mock(BusinessPhoneService.class); oauthVerifyService = mock(OAuthVerifyService.class); infoUserOauthMapper = mock(InfoUserOauthMapper.class); redisCache = mock(RedisCache.class); ReflectionTestUtils.setField(controller, "posOrderService", posOrderService); ReflectionTestUtils.setField(controller, "infoUserService", infoUserService); ReflectionTestUtils.setField(controller, "userWalletService", userWalletService); ReflectionTestUtils.setField(controller, "merchantStoreAccessService", merchantStoreAccessService); ReflectionTestUtils.setField(controller, "merchantTokenSessionService", merchantTokenSessionService); ReflectionTestUtils.setField(controller, "businessPhoneService", businessPhoneService); ReflectionTestUtils.setField(controller, "oauthVerifyService", oauthVerifyService); ReflectionTestUtils.setField(controller, "infoUserOauthMapper", infoUserOauthMapper); ReflectionTestUtils.setField(controller, "redisCache", redisCache); when(infoUserOauthMapper.insert(any(InfoUserOauth.class))).thenReturn(1); } @AfterEach void clearRequestContext() { RequestContextHolder.resetRequestAttributes(); } @Test void refusesDeletionWhenUserHasAnUnfinishedOrder() { when(posOrderService.exists(any(Wrapper.class))).thenReturn(true); ServiceException exception = assertThrows(ServiceException.class, () -> controller.deleuser(tokenFor(42L))); assertEquals("抱歉,您還有未完成的訂單,無法刪除帳號", exception.getMessage()); verify(infoUserService, never()).deleteInfoUserByUserId(42L); } @Test void checksOnlyLatestUnfinishedStatesForEveryOrderRole() { when(posOrderService.exists(any(Wrapper.class))).thenReturn(false); when(infoUserService.deleteInfoUserByUserId(42L)).thenReturn(1); AjaxResult result = controller.deleuser(tokenFor(42L)); assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG)); ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); verify(posOrderService).exists(captor.capture()); LambdaQueryWrapper query = captor.getValue(); String sql = query.getSqlSegment(); Collection parameters = new ArrayList<>(query.getParamNameValuePairs().values()); assertEquals(6, parameters.size()); assertEquals(1, Collections.frequency(parameters, 0L)); assertEquals(1, Collections.frequency(parameters, 1L)); assertEquals(1, Collections.frequency(parameters, 2L)); assertEquals(3, Collections.frequency(parameters, 42L)); assertTrue(sql.contains("user_id")); assertTrue(sql.contains("qs_id")); assertTrue(sql.contains("sh_id")); } @Test void rejectsAuditRejectionWithoutReason() { InfoUser user = new InfoUser(); user.setAuditStatus("2"); user.setAuditRejectReason(" "); AjaxResult result = controller.edit(user); assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG)); verify(infoUserService, never()).updateInfoUser(any(InfoUser.class)); } @Test void rejectsNewAuditRejectionWithoutReason() { InfoUser user = new InfoUser(); user.setUserName("merchant"); user.setPhone("0912345678"); user.setAuditStatus("2"); user.setAuditRejectReason(" "); AjaxResult result = controller.add(user); assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG)); verify(infoUserService, never()).insertInfoUser(any(InfoUser.class)); } @Test void profileUpdateCannotChangeAuditStatus() { InfoUser request = new InfoUser(); request.setAuditStatus("1"); when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true); controller.setuser(tokenFor(42L), request); ArgumentCaptor captor = ArgumentCaptor.forClass(InfoUser.class); verify(infoUserService).saveOrUpdate(captor.capture()); assertNull(captor.getValue().getAuditStatus()); } @Test void resubmittedRejectedProfileReturnsToPendingReview() { InfoUser request = new InfoUser(); request.setUpdateUserInfo(true); when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true); controller.setuser(tokenFor(42L), request); ArgumentCaptor captor = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); verify(infoUserService).update(captor.capture()); LambdaUpdateWrapper update = captor.getValue(); Collection parameters = update.getParamNameValuePairs().values(); assertTrue(update.getSqlSegment().contains("user_id")); assertTrue(update.getSqlSegment().contains("audit_status")); assertTrue(update.getSqlSet().contains("audit_status")); assertTrue(update.getSqlSet().contains("audit_reject_reason")); assertTrue(parameters.contains(42L)); assertTrue(parameters.contains("2")); assertTrue(parameters.contains("0")); } @Test void ordinaryProfileUpdateDoesNotChangeReviewState() { InfoUser request = new InfoUser(); request.setUpdateUserInfo(false); when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true); controller.setuser(tokenFor(42L), request); verify(infoUserService, never()).update(any(Wrapper.class)); } @Test void trimsAndSavesAuditRejectionReason() { InfoUser user = new InfoUser(); user.setAuditStatus("2"); user.setAuditRejectReason(" 证件照片模糊 "); when(infoUserService.updateInfoUser(user)).thenReturn(1); controller.edit(user); assertEquals("证件照片模糊", user.getAuditRejectReason()); verify(infoUserService).updateInfoUser(user); } @Test void clearsOldReasonWhenAuditPasses() { InfoUser user = new InfoUser(); user.setAuditStatus("1"); user.setAuditRejectReason("旧原因"); when(infoUserService.updateInfoUser(user)).thenReturn(1); controller.edit(user); assertNull(user.getAuditRejectReason()); verify(infoUserService).updateInfoUser(user); } @Test void platformGenericAddCannotCreateMerchantSubaccount() { InfoUser user = new InfoUser(); user.setUserType("5"); AjaxResult result = controller.add(user); assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG)); verify(infoUserService, never()).insertInfoUser(any(InfoUser.class)); } @Test void platformGenericEditCannotModifyMerchantSubaccount() { InfoUser existing = new InfoUser(); existing.setUserId(55L); existing.setUserType("5"); when(infoUserService.selectInfoUserByUserId(55L)).thenReturn(existing); InfoUser request = new InfoUser(); request.setUserId(55L); AjaxResult result = controller.edit(request); assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG)); verify(infoUserService, never()).updateInfoUser(request); } @Test void merchantLogoutRevokesOnlyCurrentTokenSession() { try (MockedStatic authContext = mockStatic(AuthContext.class)) { authContext.when(AuthContext::requireJti) .thenReturn("qtw_tokens:sh:app:936:current-session"); controller.merchantLogout("merchant-token"); } verify(merchantTokenSessionService) .logoutCurrent("qtw_tokens:sh:app:936:current-session"); } @Test void phoneRegistrationMarksTokenProviderAsPhone() { UserDTO request = new UserDTO(); request.setPhone("0912345678"); InfoUser created = new InfoUser(); created.setUserId(42L); created.setPhone(request.getPhone()); when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true); when(infoUserService.list(any(Wrapper.class))).thenReturn(java.util.List.of(created)); AjaxResult result = controller.createUser(request); String token = (String) result.get("token"); assertEquals("phone", JWT.decode(token).getClaim("provider").asString()); } @Test void phoneLoginRejectsPhoneLinkedToMultipleAccounts() { UserDTO request = new UserDTO(); request.setPhone("0912345678"); request.setCode("8888"); when(redisCache.getCacheObject("0912345678")).thenReturn(null); when(infoUserService.list(any(Wrapper.class))).thenReturn(java.util.List.of( activeUser(71L, "0"), activeUser(72L, "0"))); ServiceException exception = assertThrows(ServiceException.class, () -> controller.lodeing(request)); assertEquals("该手机号关联多个账号,请联系平台", exception.getMessage()); verify(infoUserService, never()).saveOrUpdate(any(InfoUser.class)); } @Test void phoneLoginOnlyMatchesMemberAccounts() { UserDTO request = new UserDTO(); request.setPhone("0912345678"); request.setCode("8888"); when(redisCache.getCacheObject("0912345678")).thenReturn(null); InfoUser created = activeUser(42L, "0"); created.setPhone("0912345678"); // 查询限定 user_type=0:同号商家行不命中,未注册则新建会员(首查空 + 新建回查) when(infoUserService.list(any(Wrapper.class))) .thenReturn(java.util.List.of(), java.util.List.of(created)); when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true); AjaxResult result = controller.lodeing(request); assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG)); ArgumentCaptor createdCaptor = ArgumentCaptor.forClass(InfoUser.class); verify(infoUserService).saveOrUpdate(createdCaptor.capture()); assertEquals("0", createdCaptor.getValue().getUserType()); } @Test void riderRegistrationRejectsDuplicateBusinessPhone() { com.ruoyi.system.domain.vo.UserDTO request = new com.ruoyi.system.domain.vo.UserDTO(); request.setTelPhone("0912345678"); org.mockito.Mockito.doThrow(new ServiceException("手机号已存在")) .when(businessPhoneService).ensureUnique("0912345678", null); assertThrows(ServiceException.class, () -> controller.createQsUser(request)); verify(infoUserService, never()).insertInfoUser(any(InfoUser.class)); } @Test void platformEditChecksBusinessPhoneExcludingCurrentAccount() { InfoUser existing = new InfoUser(); existing.setUserId(42L); existing.setUserType("2"); when(infoUserService.selectInfoUserByUserId(42L)).thenReturn(existing); when(businessPhoneService.isBusinessUserType("2")).thenReturn(true); when(infoUserService.updateInfoUser(any(InfoUser.class))).thenReturn(1); InfoUser request = new InfoUser(); request.setUserId(42L); request.setTelPhone("0912345678"); controller.edit(request); verify(businessPhoneService).ensureUnique("0912345678", 42L); } @Test void oauthBindPhoneRejectsMissingPhoneWithoutWritingBinding() { OAuthBindDto request = new OAuthBindDto(); request.setTempKey("temp-key"); when(redisCache.getCacheObject("oauth:bind:temp-key")) .thenReturn("line_user@line-uid"); when(redisCache.deleteObject("oauth:bind:temp-key")).thenReturn(true); AjaxResult result = controller.oauthBindPhone(request); assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG)); assertEquals("手机号不能为空", result.get(AjaxResult.MSG_TAG)); verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class)); } @Test void lineUserBindingCreatesNormalUserInsteadOfBindingLegacyBusinessPhone() { OAuthBindDto request = new OAuthBindDto(); request.setTempKey("temp-key"); request.setPhone("0912345678"); request.setCode("8888"); when(redisCache.getCacheObject("oauth:bind:temp-key")) .thenReturn("line_user@line-uid"); when(redisCache.deleteObject("oauth:bind:temp-key")).thenReturn(true); InfoUser legacyMerchant = activeUser(7L, "1"); legacyMerchant.setPhone("0912345678"); when(infoUserService.getuser("0912345678")).thenReturn(legacyMerchant); InfoUser createdUser = activeUser(99L, "0"); createdUser.setPhone("0912345678"); when(infoUserService.list(any(Wrapper.class))) .thenReturn(java.util.List.of(), java.util.List.of(createdUser)); when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true); AjaxResult result = controller.oauthBindPhone(request); assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG)); ArgumentCaptor binding = ArgumentCaptor.forClass(InfoUserOauth.class); verify(infoUserOauthMapper).insert(binding.capture()); assertEquals(99L, binding.getValue().getUserId()); verify(infoUserService, never()).getuser("0912345678"); } @Test void profileUpdateCannotChangeOwnRoleOrStatus() { InfoUser current = activeUser(42L, "0"); when(infoUserService.getById("42")).thenReturn(current); when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true); InfoUser request = new InfoUser(); request.setUserType("2"); request.setStatus("0"); controller.setuser(tokenFor(42L), request); ArgumentCaptor saved = ArgumentCaptor.forClass(InfoUser.class); verify(infoUserService).saveOrUpdate(saved.capture()); assertNull(saved.getValue().getUserType()); assertNull(saved.getValue().getStatus()); } @Test void oauthBindPhoneRejectsTempKeyAlreadyClaimedByAnotherRequest() { OAuthBindDto request = new OAuthBindDto(); request.setTempKey("replayed-key"); request.setPhone("0912345678"); request.setCode("8888"); when(redisCache.getCacheObject("oauth:bind:replayed-key")) .thenReturn("line_rider@line-uid"); when(redisCache.deleteObject("oauth:bind:replayed-key")).thenReturn(false); when(infoUserService.getOne(any(Wrapper.class))).thenReturn(activeUser(22L, "2")); AjaxResult result = controller.oauthBindPhone(request); assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG)); verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class)); } @Test void lineMerchantBindingRejectsSubaccountWithUnavailableOwner() { OAuthBindDto request = new OAuthBindDto(); request.setTempKey("merchant-key"); when(redisCache.getCacheObject("oauth:bind:merchant-key")) .thenReturn("line_merchant@line-uid"); when(redisCache.deleteObject("oauth:bind:merchant-key")).thenReturn(true); InfoUserOauth binding = new InfoUserOauth(); binding.setUserId(55L); when(infoUserOauthMapper.selectOne(any())).thenReturn(binding); InfoUser subaccount = activeUser(55L, "5"); subaccount.setSubaccountStatus("0"); when(infoUserService.getById(55L)).thenReturn(subaccount); doThrow(new ServiceException("owner unavailable")) .when(merchantStoreAccessService).resolve(55L); AjaxResult result = controller.oauthBindPhone(request); assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG)); assertEquals("账号已停用", result.get(AjaxResult.MSG_TAG)); } @Test void oauthBindingRaceRejectsDisabledNonLineAccount() { OAuthBindDto request = new OAuthBindDto(); request.setTempKey("google-key"); when(redisCache.getCacheObject("oauth:bind:google-key")) .thenReturn("google@google-uid"); when(redisCache.deleteObject("oauth:bind:google-key")).thenReturn(true); InfoUserOauth binding = new InfoUserOauth(); binding.setUserId(66L); when(infoUserOauthMapper.selectOne(any())).thenReturn(binding); InfoUser disabled = activeUser(66L, "0"); disabled.setStatus("1"); when(infoUserService.getById(66L)).thenReturn(disabled); AjaxResult result = controller.oauthBindPhone(request); assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG)); assertEquals("账号已停用", result.get(AjaxResult.MSG_TAG)); } @ParameterizedTest @CsvSource({"apple_rider,2,qtw_tokens:qs:", "google_rider,2,qtw_tokens:qs:", "apple_merchant,1,qtw_tokens:sh:app:", "google_merchant,3,qtw_tokens:sh:app:"}) void businessOauthBindsExistingBusinessPhoneAndIssuesItsOwnSession( String provider, String role, String tokenPrefix) { OAuthBindDto request = oauthRequest(provider); InfoUser business = activeUser(72L, role); business.setTelPhone(request.getPhone()); when(infoUserService.list(any(Wrapper.class))).thenReturn(java.util.List.of(business)); when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true); AjaxResult result = controller.oauthBindPhone(request); assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG)); String token = (String) result.get("token"); assertTrue(JWT.decode(token).getId().startsWith(tokenPrefix)); assertEquals(provider, JWT.decode(token).getClaim("provider").asString()); ArgumentCaptor binding = ArgumentCaptor.forClass(InfoUserOauth.class); verify(infoUserOauthMapper).insert(binding.capture()); assertEquals(72L, binding.getValue().getUserId()); assertEquals(provider, binding.getValue().getProvider()); verify(infoUserService, never()).getuser(any()); ArgumentCaptor> query = ArgumentCaptor.forClass(Wrapper.class); verify(infoUserService).list(query.capture()); assertTrue(query.getValue().getSqlSegment().contains("tel_phone")); } @ParameterizedTest @CsvSource({"apple_rider", "google_rider", "apple_merchant", "google_merchant"}) void businessOauthCannotCreateAnAccount(String provider) { AjaxResult result = controller.oauthBindPhone(oauthRequest(provider)); assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG)); assertEquals("账号不存在", result.get(AjaxResult.MSG_TAG)); verify(infoUserService, never()).saveOrUpdate(any(InfoUser.class)); verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class)); } @ParameterizedTest @CsvSource({"apple_rider,1", "google_rider,0", "apple_merchant,2", "google_merchant,0"}) void businessOauthRejectsBindingToTheWrongRole(String provider, String role) { OAuthBindDto request = oauthRequest(provider); InfoUserOauth binding = new InfoUserOauth(); binding.setUserId(72L); when(infoUserOauthMapper.selectOne(any())).thenReturn(binding); when(infoUserService.getById(72L)).thenReturn(activeUser(72L, role)); AjaxResult result = controller.oauthBindPhone(request); assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG)); assertNull(result.get("token")); } @ParameterizedTest @CsvSource({"apple", "google"}) void existingUserOauthKeepsProviderAndUserSession(String provider) { OAuthBindDto request = oauthRequest(provider); InfoUserOauth binding = new InfoUserOauth(); binding.setUserId(72L); InfoUser user = activeUser(72L, "0"); user.setPhone(request.getPhone()); when(infoUserOauthMapper.selectOne(any())).thenReturn(binding); when(infoUserService.getById(72L)).thenReturn(user); AjaxResult result = controller.oauthBindPhone(request); assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG)); String token = (String) result.get("token"); assertTrue(JWT.decode(token).getId().startsWith("qtw_tokens:user:")); assertEquals(provider, JWT.decode(token).getClaim("provider").asString()); } @ParameterizedTest @CsvSource({"apple_rider,2,qtw_tokens:qs:", "google_rider,2,qtw_tokens:qs:", "apple_merchant,4,qtw_tokens:sh:app:", "google_merchant,1,qtw_tokens:sh:app:", "apple,0,qtw_tokens:user:", "google,0,qtw_tokens:user:", "line,0,qtw_tokens:user:", "line_user,0,qtw_tokens:user:"}) void boundOauthLoginKeepsEachClientSession(String provider, String role, String tokenPrefix) { OAuthLoginDto request = new OAuthLoginDto(); request.setProvider(provider); request.setCredential("credential"); when(oauthVerifyService.verify(provider, "credential")).thenReturn("provider-uid"); InfoUserOauth binding = new InfoUserOauth(); binding.setUserId(72L); when(infoUserOauthMapper.selectOne(any())).thenReturn(binding); InfoUser user = activeUser(72L, role); user.setPhone("0912345678"); when(infoUserService.getOne(any(Wrapper.class))).thenReturn(user); AjaxResult result = controller.oauthLogin(request); assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG)); assertTrue(JWT.decode((String) result.get("token")).getId().startsWith(tokenPrefix)); assertEquals(provider, JWT.decode((String) result.get("token")).getClaim("provider").asString()); if (provider.startsWith("line")) { ArgumentCaptor> query = ArgumentCaptor.forClass(LambdaQueryWrapper.class); verify(infoUserOauthMapper).selectOne(query.capture()); query.getValue().getSqlSegment(); assertTrue(query.getValue().getParamNameValuePairs().containsValue("line")); assertTrue(query.getValue().getParamNameValuePairs().containsValue("line_user")); } } @ParameterizedTest @CsvSource({"apple", "google"}) void userFirstBindingStillCreatesAUserAndWallet(String provider) { OAuthBindDto request = oauthRequest(provider); InfoUser user = activeUser(72L, "0"); user.setPhone(request.getPhone()); when(infoUserService.list(any(Wrapper.class))) .thenReturn(java.util.List.of(), java.util.List.of(user)); when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true); AjaxResult result = controller.oauthBindPhone(request); assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG)); ArgumentCaptor created = ArgumentCaptor.forClass(InfoUser.class); verify(infoUserService).saveOrUpdate(created.capture()); assertEquals("0", created.getValue().getUserType()); assertEquals(request.getPhone(), created.getValue().getPhone()); verify(userWalletService).createUserWallet(72L); } @ParameterizedTest @CsvSource({"apple_merchant", "google_merchant"}) void businessOauthRejectsSubaccountWhoseOwnerIsUnavailable(String provider) { OAuthBindDto request = oauthRequest(provider); InfoUser user = activeUser(72L, "5"); user.setSubaccountStatus("0"); when(infoUserService.list(any(Wrapper.class))).thenReturn(java.util.List.of(user)); doThrow(new ServiceException("owner unavailable")).when(merchantStoreAccessService).resolve(72L); AjaxResult result = controller.oauthBindPhone(request); assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG)); verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class)); } @Test void directBusinessLineLoginCannotBypassStateValidation() { OAuthLoginDto request = new OAuthLoginDto(); request.setProvider("line_rider"); request.setCredential("code"); LineOAuthStateService stateService = mock(LineOAuthStateService.class); doThrow(new ServiceException("invalid state")).when(stateService).consume("line_rider", null); ReflectionTestUtils.setField(controller, "lineOAuthStateService", stateService); assertThrows(ServiceException.class, () -> controller.oauthLogin(request)); verify(oauthVerifyService, never()).verify(any(), any()); } @ParameterizedTest @CsvSource({"apple_rider", "google_merchant", "line_rider", "line_merchant"}) void businessBindingRequiresAnActualSmsCode(String provider) { OAuthBindDto request = oauthRequest(provider); request.setCode("8888"); InfoUser user = activeUser(72L, provider.endsWith("rider") ? "2" : "1"); when(infoUserService.getOne(any(Wrapper.class))).thenReturn(user); when(infoUserService.saveOrUpdate(any(InfoUser.class))).thenReturn(true); AjaxResult result = controller.oauthBindPhone(request); assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG)); assertEquals("验证码错误", result.get(AjaxResult.MSG_TAG)); verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class)); } @Test void oauthBindPhoneRejectsPhoneLinkedToMultipleAccounts() { OAuthBindDto request = oauthRequest("apple"); when(infoUserService.list(any(Wrapper.class))).thenReturn(java.util.List.of( activeUser(71L, "0"), activeUser(72L, "0"))); ServiceException exception = assertThrows(ServiceException.class, () -> controller.oauthBindPhone(request)); assertEquals("该手机号关联多个账号,请联系平台", exception.getMessage()); verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class)); verify(infoUserService, never()).saveOrUpdate(any(InfoUser.class)); } @Test void merchantOauthBindPhoneRejectsPhoneLinkedToMultipleStores() { OAuthBindDto request = oauthRequest("line_merchant"); when(infoUserService.list(any(Wrapper.class))).thenReturn(java.util.List.of( activeUser(71L, "1"), activeUser(72L, "1"))); ServiceException exception = assertThrows(ServiceException.class, () -> controller.oauthBindPhone(request)); assertEquals("该手机号关联多个账号,请联系平台", exception.getMessage()); verify(infoUserOauthMapper, never()).insert(any(InfoUserOauth.class)); } private OAuthBindDto oauthRequest(String provider) { OAuthBindDto request = new OAuthBindDto(); request.setTempKey("business-key"); request.setPhone("0912345678"); request.setCode("456789"); when(redisCache.getCacheObject("oauth:bind:business-key")) .thenReturn(provider + "@provider-uid"); when(redisCache.deleteObject("oauth:bind:business-key")).thenReturn(true); when(redisCache.getCacheObject("0912345678")).thenReturn("456789"); return request; } private InfoUser activeUser(Long userId, String userType) { InfoUser user = new InfoUser(); user.setUserId(userId); user.setUserType(userType); user.setStatus("0"); user.setDelFlag("0"); user.setUserName("user-" + userId); return user; } private String tokenFor(Long userId) { return JwtUtil.setToken(String.valueOf(userId), "test-user"); } private static class TestInfoUserController extends InfoUserController { @Override protected AjaxResult toAjax(int rows) { return new AjaxResult(HttpStatus.SUCCESS, "ok"); } } }