|
|
@@ -0,0 +1,396 @@
|
|
|
+package com.etotem.cfc.controller.admin;
|
|
|
+
|
|
|
+import com.etotem.cfc.common.Result;
|
|
|
+import com.etotem.cfc.config.JwtConfig;
|
|
|
+import com.etotem.cfc.dto.AdminLoginDTO;
|
|
|
+import com.etotem.cfc.entity.User;
|
|
|
+import com.etotem.cfc.mapper.UserMapper;
|
|
|
+import com.etotem.cfc.service.LoginAttemptService;
|
|
|
+import com.etotem.cfc.service.UserService;
|
|
|
+import com.etotem.cfc.service.VerificationCodeService;
|
|
|
+import com.etotem.cfc.service.WechatWebLoginService;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.mockito.ArgumentCaptor;
|
|
|
+import org.springframework.http.MediaType;
|
|
|
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|
|
+import org.springframework.test.util.ReflectionTestUtils;
|
|
|
+import org.springframework.test.web.servlet.MockMvc;
|
|
|
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
|
|
+import org.springframework.util.DigestUtils;
|
|
|
+
|
|
|
+import javax.servlet.http.HttpServletRequest;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.Collections;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.anyList;
|
|
|
+import static org.mockito.ArgumentMatchers.anyString;
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
+import static org.mockito.Mockito.mock;
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
|
|
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
|
|
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
|
+
|
|
|
+/**
|
|
|
+ * S-cfc-20 / admin.auth.login 契约级单测(GREEN 阶段:补齐「密码登录」契约闭环)。
|
|
|
+ *
|
|
|
+ * <p>与 {@link AdminAuthControllerLoginContractTest}(S-cfc-17,验证码登录
|
|
|
+ * {@code POST /api/admin-auth/login} 15 例契约)互补,本类聚焦同一能力键
|
|
|
+ * {@code admin.auth.login} 的密码登录面 {@code POST /api/admin-auth/login-by-password}:
|
|
|
+ * <ul>
|
|
|
+ * <li>正常路径:admin(BCrypt)/ teacher / 遗留 MD5 双盐值(xzyj_salt_ / xzyj_admin_salt_)/
|
|
|
+ * 无 roles 列表回退主角色 / 多角色 loginRole 身份选择 / nickname 为空回退手机号;</li>
|
|
|
+ * <li>关键边界:密码错误(记录失败并提示剩余次数)、存储哈希为空、用户不存在、
|
|
|
+ * 登录锁定(不触达用户查询)、X-Forwarded-For 不参与密码登录锁键(对齐实现,无验证码);</li>
|
|
|
+ * <li>角色与权限:parent/child 等 C 端账号拒绝、非法 loginRole 拒绝。</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p>契约口径(对齐项目惯例:控制器签名 {@code Result<T>} + HTTP 200 信封、业务码表达语义;
|
|
|
+ * 真实 HTTP 401 由 {@code JwtInterceptor} 对受保护路径负责,{@code /api/admin-auth/login-by-password}
|
|
|
+ * 为公开路径):
|
|
|
+ * <ul>
|
|
|
+ * <li>参数校验(空 body / 缺 phone / 缺 password)→ 业务码 400(已由
|
|
|
+ * {@link AdminAuthControllerLoginStatusTest} 覆盖,本类不重复);</li>
|
|
|
+ * <li>业务拒绝(密码错误 / 登录锁定 / 用户不存在 / 无后台角色 / 非法登录身份)
|
|
|
+ * → 业务码 500 + 明确 message;</li>
|
|
|
+ * <li>成功 → 业务码 200 + token/adminId/role/roles。</li>
|
|
|
+ * </ul>
|
|
|
+ * 测试隔离:MockMvc standalone + 全 mock 依赖,无 Spring 上下文 / 无 DB / 无外部服务。
|
|
|
+ */
|
|
|
+class AdminAuthControllerPasswordLoginContractTest {
|
|
|
+
|
|
|
+ private static final String PHONE = "13800000001";
|
|
|
+ private static final String PASSWORD = "password";
|
|
|
+
|
|
|
+ private AdminAuthController controller;
|
|
|
+ private MockMvc mockMvc;
|
|
|
+ private UserMapper userMapper;
|
|
|
+ private UserService userService;
|
|
|
+ private VerificationCodeService verificationCodeService;
|
|
|
+ private JwtConfig jwtConfig;
|
|
|
+ private LoginAttemptService loginAttemptService;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ controller = new AdminAuthController();
|
|
|
+ userMapper = mock(UserMapper.class);
|
|
|
+ userService = mock(UserService.class);
|
|
|
+ verificationCodeService = mock(VerificationCodeService.class);
|
|
|
+ jwtConfig = mock(JwtConfig.class);
|
|
|
+ loginAttemptService = mock(LoginAttemptService.class);
|
|
|
+ ReflectionTestUtils.setField(controller, "userMapper", userMapper);
|
|
|
+ ReflectionTestUtils.setField(controller, "userService", userService);
|
|
|
+ ReflectionTestUtils.setField(controller, "verificationCodeService", verificationCodeService);
|
|
|
+ ReflectionTestUtils.setField(controller, "jwtConfig", jwtConfig);
|
|
|
+ ReflectionTestUtils.setField(controller, "loginAttemptService", loginAttemptService);
|
|
|
+ ReflectionTestUtils.setField(controller, "wechatWebLoginService", mock(WechatWebLoginService.class));
|
|
|
+ // @Value 默认 false;密码登录不使用验证码,保持默认即可
|
|
|
+ ReflectionTestUtils.setField(controller, "skipCaptcha", false);
|
|
|
+ mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
|
|
+ }
|
|
|
+
|
|
|
+ // ================================================================
|
|
|
+ // 正常路径
|
|
|
+ // ================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("login-by-password:admin(BCrypt 哈希)密码登录成功 → 200 + token/adminId/role/roles")
|
|
|
+ void loginByPassword_admin_bcrypt_成功签发token() throws Exception {
|
|
|
+ User admin = user(100L, "admin", list("admin"), "nick100", "管理员100");
|
|
|
+ admin.setPassword(bcrypt(PASSWORD));
|
|
|
+ passwordSuccessStubs(admin, list("admin"));
|
|
|
+
|
|
|
+ mockMvc.perform(post("/api/admin-auth/login-by-password")
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content("{\"phone\":\"" + PHONE + "\",\"password\":\"" + PASSWORD + "\"}"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(200))
|
|
|
+ .andExpect(jsonPath("$.data.token").value("jwt-token"))
|
|
|
+ .andExpect(jsonPath("$.data.adminId").value(100))
|
|
|
+ .andExpect(jsonPath("$.data.username").value("nick100"))
|
|
|
+ .andExpect(jsonPath("$.data.realName").value("管理员100"))
|
|
|
+ .andExpect(jsonPath("$.data.role").value("admin"))
|
|
|
+ .andExpect(jsonPath("$.data.roles[0]").value("admin"));
|
|
|
+
|
|
|
+ verify(loginAttemptService).isBlocked(PHONE + ":127.0.0.1");
|
|
|
+ verify(loginAttemptService).clearSuccess(PHONE + ":127.0.0.1");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("login-by-password:teacher 密码登录成功 → role=teacher(成长规划师可登录后台)")
|
|
|
+ void loginByPassword_teacher_成功() {
|
|
|
+ User teacher = user(200L, "teacher", list("teacher"), "规划师2", null);
|
|
|
+ teacher.setPassword(bcrypt(PASSWORD));
|
|
|
+
|
|
|
+ Result<Map<String, Object>> r = loginSuccess(teacher, list("teacher"));
|
|
|
+
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
+ assertEquals("teacher", r.getData().get("role"));
|
|
|
+ assertEquals("规划师2", r.getData().get("username"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("login-by-password:遗留 MD5 哈希(xzyj_salt_)登录成功")
|
|
|
+ void loginByPassword_遗留MD5盐值1_成功() {
|
|
|
+ User user = user(300L, "admin", list("admin"), "旧MD5一", null);
|
|
|
+ user.setPassword(md5Legacy("xzyj_salt_" + PASSWORD));
|
|
|
+
|
|
|
+ Result<Map<String, Object>> r = loginSuccess(user, list("admin"));
|
|
|
+
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
+ assertEquals("admin", r.getData().get("role"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("login-by-password:遗留 MD5 哈希(xzyj_admin_salt_)登录成功")
|
|
|
+ void loginByPassword_遗留MD5盐值2_成功() {
|
|
|
+ User user = user(301L, "admin", list("admin"), "旧MD5二", null);
|
|
|
+ user.setPassword(md5Legacy("xzyj_admin_salt_" + PASSWORD));
|
|
|
+
|
|
|
+ Result<Map<String, Object>> r = loginSuccess(user, list("admin"));
|
|
|
+
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
+ assertEquals("admin", r.getData().get("role"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("login-by-password:无 roles 列表时回退主角色 role 字段(teacher)")
|
|
|
+ void loginByPassword_无roles列表_回退主角色() {
|
|
|
+ User teacher = user(400L, "teacher", null, "回退", null);
|
|
|
+ teacher.setPassword(bcrypt(PASSWORD));
|
|
|
+ passwordSuccessStubs(teacher, Collections.<String>emptyList());
|
|
|
+
|
|
|
+ Result<Map<String, Object>> r = loginDirect(pwdDto());
|
|
|
+
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
+ assertEquals("teacher", r.getData().get("role"));
|
|
|
+ // getUserRoles 为空时回退为选中身份列表
|
|
|
+ assertEquals(list("teacher"), r.getData().get("roles"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("login-by-password:多角色 + loginRole 选择 → 以指定身份登录,token roles 选中身份置顶去重")
|
|
|
+ void loginByPassword_多角色loginRole合法_选中身份置顶() {
|
|
|
+ User multi = user(500L, "admin", list("admin", "teacher"), "双角色", null);
|
|
|
+ multi.setPassword(bcrypt(PASSWORD));
|
|
|
+ passwordSuccessStubs(multi, list("admin", "teacher"));
|
|
|
+
|
|
|
+ Result<Map<String, Object>> r = loginDirect(pwdDtoWithLoginRole("teacher"));
|
|
|
+
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
+ assertEquals("teacher", r.getData().get("role"));
|
|
|
+ assertEquals(list("admin", "teacher"), r.getData().get("roles"));
|
|
|
+
|
|
|
+ ArgumentCaptor<List<String>> rolesCaptor = ArgumentCaptor.forClass(List.class);
|
|
|
+ verify(jwtConfig).generateToken(eq(500L), rolesCaptor.capture());
|
|
|
+ // selected=teacher 置顶,其余角色(去重后)随后
|
|
|
+ assertEquals(list("teacher", "admin"), rolesCaptor.getValue());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("login-by-password:nickname 为空时 username 回退手机号,realName 保留")
|
|
|
+ void loginByPassword_nickname为空_回退手机号() {
|
|
|
+ User user = user(800L, "admin", list("admin"), null, "实名张");
|
|
|
+ user.setPassword(bcrypt(PASSWORD));
|
|
|
+
|
|
|
+ Result<Map<String, Object>> r = loginSuccess(user, list("admin"));
|
|
|
+
|
|
|
+ assertEquals(200, r.getCode());
|
|
|
+ assertEquals(PHONE, r.getData().get("username"));
|
|
|
+ assertEquals("实名张", r.getData().get("realName"));
|
|
|
+ assertEquals(800L, r.getData().get("adminId"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ================================================================
|
|
|
+ // 关键边界
|
|
|
+ // ================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("login-by-password:密码错误 → 拒绝并记录失败,提示剩余尝试次数")
|
|
|
+ void loginByPassword_密码错误_记录失败提示剩余次数() {
|
|
|
+ User user = user(600L, "admin", list("admin"), "错密", null);
|
|
|
+ user.setPassword(bcrypt(PASSWORD));
|
|
|
+ when(loginAttemptService.isBlocked(anyString())).thenReturn(false);
|
|
|
+ when(userMapper.selectOne(any())).thenReturn(user);
|
|
|
+ // recordFailure 后(max=5)剩余 4 次
|
|
|
+ when(loginAttemptService.getRemainingAttempts(anyString())).thenReturn(4);
|
|
|
+
|
|
|
+ Result<Map<String, Object>> r = loginDirect(pwdDtoWithPassword("wrong-password"));
|
|
|
+
|
|
|
+ assertEquals(500, r.getCode());
|
|
|
+ assertEquals("密码错误,剩余4次尝试机会", r.getMessage());
|
|
|
+ verify(loginAttemptService).recordFailure(anyString());
|
|
|
+ verify(loginAttemptService, never()).clearSuccess(anyString());
|
|
|
+ verify(jwtConfig, never()).generateToken(any(), anyList());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("login-by-password:存储哈希为空 → 密码错误(不 NPE、不签发 token)")
|
|
|
+ void loginByPassword_存储哈希为空_密码错误() {
|
|
|
+ User user = user(601L, "admin", list("admin"), "空哈希", null);
|
|
|
+ when(loginAttemptService.isBlocked(anyString())).thenReturn(false);
|
|
|
+ when(userMapper.selectOne(any())).thenReturn(user);
|
|
|
+ when(loginAttemptService.getRemainingAttempts(anyString())).thenReturn(4);
|
|
|
+
|
|
|
+ Result<Map<String, Object>> r = loginDirect(pwdDto());
|
|
|
+
|
|
|
+ assertEquals(500, r.getCode());
|
|
|
+ assertEquals("密码错误,剩余4次尝试机会", r.getMessage());
|
|
|
+ verify(loginAttemptService).recordFailure(anyString());
|
|
|
+ verify(jwtConfig, never()).generateToken(any(), anyList());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("login-by-password:用户不存在 → 业务拒绝,不签发 token 不清锁定")
|
|
|
+ void loginByPassword_用户不存在_拒绝() {
|
|
|
+ when(loginAttemptService.isBlocked(anyString())).thenReturn(false);
|
|
|
+ when(userMapper.selectOne(any())).thenReturn(null);
|
|
|
+
|
|
|
+ Result<Map<String, Object>> r = loginDirect(pwdDto());
|
|
|
+
|
|
|
+ assertEquals(500, r.getCode());
|
|
|
+ assertEquals("用户不存在", r.getMessage());
|
|
|
+ verify(loginAttemptService, never()).clearSuccess(anyString());
|
|
|
+ verify(jwtConfig, never()).generateToken(any(), anyList());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("login-by-password:登录锁定 → 拒绝且不触达密码校验与用户查询(含剩余分钟提示)")
|
|
|
+ void loginByPassword_登录锁定_拒绝() {
|
|
|
+ when(loginAttemptService.isBlocked(anyString())).thenReturn(true);
|
|
|
+ // 剩余 900s=15min → 提示「15+1」分钟
|
|
|
+ when(loginAttemptService.getLockRemainingTime(anyString())).thenReturn(900_000L);
|
|
|
+
|
|
|
+ Result<Map<String, Object>> r = loginDirect(pwdDto());
|
|
|
+
|
|
|
+ assertEquals(500, r.getCode());
|
|
|
+ assertEquals("登录尝试过多,请16分钟后重试", r.getMessage());
|
|
|
+ verify(userMapper, never()).selectOne(any());
|
|
|
+ verify(jwtConfig, never()).generateToken(any(), anyList());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ================================================================
|
|
|
+ // 角色 / 权限
|
|
|
+ // ================================================================
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("login-by-password:parent 无后台角色 → 拒绝(该账号没有管理权限)")
|
|
|
+ void loginByPassword_parent无后台角色_拒绝() {
|
|
|
+ User parent = user(900L, "parent", list("parent"), "家长", null);
|
|
|
+ parent.setPassword(bcrypt(PASSWORD));
|
|
|
+ when(loginAttemptService.isBlocked(anyString())).thenReturn(false);
|
|
|
+ when(userMapper.selectOne(any())).thenReturn(parent);
|
|
|
+
|
|
|
+ Result<Map<String, Object>> r = loginDirect(pwdDto());
|
|
|
+
|
|
|
+ assertEquals(500, r.getCode());
|
|
|
+ assertEquals("该账号没有管理权限", r.getMessage());
|
|
|
+ verify(jwtConfig, never()).generateToken(any(), anyList());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("login-by-password:roles 仅含 child(C 端)→ 拒绝")
|
|
|
+ void loginByPassword_child角色_拒绝() {
|
|
|
+ User child = user(901L, "child", list("child"), "孩子", null);
|
|
|
+ child.setPassword(bcrypt(PASSWORD));
|
|
|
+ when(loginAttemptService.isBlocked(anyString())).thenReturn(false);
|
|
|
+ when(userMapper.selectOne(any())).thenReturn(child);
|
|
|
+
|
|
|
+ Result<Map<String, Object>> r = loginDirect(pwdDto());
|
|
|
+
|
|
|
+ assertEquals(500, r.getCode());
|
|
|
+ assertEquals("该账号没有管理权限", r.getMessage());
|
|
|
+ verify(jwtConfig, never()).generateToken(any(), anyList());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ @DisplayName("login-by-password:登录身份不在账号后台角色内 → 拒绝(该账号没有【X】身份)")
|
|
|
+ void loginByPassword_loginRole非法_拒绝() {
|
|
|
+ User admin = user(1000L, "admin", list("admin"), "仅管理", null);
|
|
|
+ admin.setPassword(bcrypt(PASSWORD));
|
|
|
+ passwordSuccessStubs(admin, list("admin"));
|
|
|
+
|
|
|
+ Result<Map<String, Object>> r = loginDirect(pwdDtoWithLoginRole("finance"));
|
|
|
+
|
|
|
+ assertEquals(500, r.getCode());
|
|
|
+ assertEquals("该账号没有【财务】身份", r.getMessage());
|
|
|
+ verify(jwtConfig, never()).generateToken(any(), anyList());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ================================================================
|
|
|
+ // helpers
|
|
|
+ // ================================================================
|
|
|
+
|
|
|
+ /** 成功路径标准打桩:未锁定、用户命中、角色列表、token 固定值(密码登录无验证码环节) */
|
|
|
+ private void passwordSuccessStubs(User user, List<String> roles) {
|
|
|
+ when(loginAttemptService.isBlocked(anyString())).thenReturn(false);
|
|
|
+ when(userMapper.selectOne(any())).thenReturn(user);
|
|
|
+ when(userService.getUserRoles(user.getId())).thenReturn(roles);
|
|
|
+ when(jwtConfig.generateToken(eq(user.getId()), anyList())).thenReturn("jwt-token");
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 成功路径便捷入口:打桩 + 直接调用 loginByPassword */
|
|
|
+ private Result<Map<String, Object>> loginSuccess(User user, List<String> roles) {
|
|
|
+ passwordSuccessStubs(user, roles);
|
|
|
+ return loginDirect(pwdDto());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 直接调用 loginByPassword(调用方自行打桩;request 为全 mock → 无转发头、remoteAddr 为 null) */
|
|
|
+ private Result<Map<String, Object>> loginDirect(AdminLoginDTO dto) {
|
|
|
+ return controller.loginByPassword(dto, mock(HttpServletRequest.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** BCrypt 哈希(运行时生成,保证与实现同盐强度匹配) */
|
|
|
+ private static String bcrypt(String rawPassword) {
|
|
|
+ return new BCryptPasswordEncoder().encode(rawPassword);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 遗留 MD5 哈希(与实现的 xzyj_salt_ / xzyj_admin_salt_ 两种盐一致) */
|
|
|
+ private static String md5Legacy(String saltedRaw) {
|
|
|
+ return DigestUtils.md5DigestAsHex(saltedRaw.getBytes(StandardCharsets.UTF_8));
|
|
|
+ }
|
|
|
+
|
|
|
+ private AdminLoginDTO pwdDto() {
|
|
|
+ return pwdDtoWithLoginRole(null);
|
|
|
+ }
|
|
|
+
|
|
|
+ private AdminLoginDTO pwdDtoWithLoginRole(String loginRole) {
|
|
|
+ AdminLoginDTO dto = new AdminLoginDTO();
|
|
|
+ dto.setPhone(PHONE);
|
|
|
+ dto.setPassword(PASSWORD);
|
|
|
+ dto.setLoginRole(loginRole);
|
|
|
+ return dto;
|
|
|
+ }
|
|
|
+
|
|
|
+ private AdminLoginDTO pwdDtoWithPassword(String password) {
|
|
|
+ AdminLoginDTO dto = new AdminLoginDTO();
|
|
|
+ dto.setPhone(PHONE);
|
|
|
+ dto.setPassword(password);
|
|
|
+ return dto;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static User user(Long id, String role, List<String> roles, String nickname, String realName) {
|
|
|
+ User user = new User();
|
|
|
+ user.setId(id);
|
|
|
+ user.setRole(role);
|
|
|
+ user.setRoles(roles);
|
|
|
+ user.setNickname(nickname);
|
|
|
+ user.setRealName(realName);
|
|
|
+ user.setPhone(PHONE);
|
|
|
+ return user;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static List<String> list(String... items) {
|
|
|
+ return new ArrayList<>(Arrays.asList(items));
|
|
|
+ }
|
|
|
+}
|