From 44441c54639d920c2a103c5885d003d8eefff6a9 Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 15 May 2026 09:49:27 +0800 Subject: [PATCH] feat(usr): GET + PUT /api/v1/users/{userId} controller + 端到端测试 REQ-USR-003 --- backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java | 24 ++++++++++++++++++++++++ backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerUpdateTest.java | 332 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 356 insertions(+), 0 deletions(-) create mode 100644 backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerUpdateTest.java diff --git a/backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java b/backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java index 33e136a..8aaf0fe 100644 --- a/backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java +++ b/backend/src/main/java/com/xly/erp/module/usr/controller/UserController.java @@ -4,13 +4,20 @@ import com.xly.erp.common.response.Result; import com.xly.erp.common.security.LoginContext; import com.xly.erp.common.security.RequireSuperAdmin; import com.xly.erp.module.usr.dto.CreateUserReq; +import com.xly.erp.module.usr.dto.UpdateUserReq; import com.xly.erp.module.usr.service.UserCreateService; +import com.xly.erp.module.usr.service.UserDetailService; +import com.xly.erp.module.usr.service.UserUpdateService; import com.xly.erp.module.usr.vo.CreateUserVo; +import com.xly.erp.module.usr.vo.UserDetailVo; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -21,6 +28,8 @@ import org.springframework.web.bind.annotation.RestController; public class UserController { private final UserCreateService userCreateService; + private final UserDetailService userDetailService; + private final UserUpdateService userUpdateService; @PostMapping @RequireSuperAdmin @@ -29,4 +38,19 @@ public class UserController { CreateUserVo vo = userCreateService.create(req, operator); return ResponseEntity.status(HttpStatus.CREATED).body(Result.ok(vo)); } + + @GetMapping("/{userId}") + @RequireSuperAdmin + public Result getById(@PathVariable Integer userId) { + return Result.ok(userDetailService.getById(userId)); + } + + @PutMapping("/{userId}") + @RequireSuperAdmin + public Result update(@PathVariable Integer userId, + @RequestBody @Valid UpdateUserReq req) { + LoginContext.LoginUser cur = LoginContext.current(); + UserDetailVo vo = userUpdateService.update(userId, req, cur.userId(), cur.username()); + return Result.ok(vo); + } } diff --git a/backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerUpdateTest.java b/backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerUpdateTest.java new file mode 100644 index 0000000..45c3a01 --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerUpdateTest.java @@ -0,0 +1,332 @@ +package com.xly.erp.module.usr.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.xly.erp.common.response.ErrorCode; +import com.xly.erp.common.security.JwtUtil; +import com.xly.erp.module.usr.dto.UpdateUserReq; +import com.xly.erp.module.usr.entity.SysUserPermissionCategory; +import com.xly.erp.module.usr.mapper.SysUserPermissionCategoryMapper; +import com.xly.erp.module.usr.support.LoginTestSeeder; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class UserControllerUpdateTest { + + @Autowired private MockMvc mvc; + @Autowired private ObjectMapper json; + @Autowired private LoginTestSeeder seeder; + @Autowired private JwtUtil jwtUtil; + @Autowired private SysUserPermissionCategoryMapper upcMapper; + @Autowired private com.xly.erp.module.usr.mapper.SysUserMapper userMapper; + + private LoginTestSeeder.Fixture fx; + private String adminToken; + private String normalToken; + + @BeforeEach + void setUp() { + fx = seeder.reset(); + adminToken = issue(LoginTestSeeder.USER_ADMIN, "SUPER_ADMIN", fx.adminId()); + normalToken = issue(LoginTestSeeder.USER_OK, "NORMAL", fx.aliceId()); + } + + private String issue(String username, String userType, Integer userId) { + Map c = new HashMap<>(); + c.put("sub", userId); + c.put("username", username); + c.put("userType", userType); + c.put("companyCode", LoginTestSeeder.COMPANY_OK); + c.put("language", "zh-CN"); + return jwtUtil.issue(c, 7200); + } + + // ===== GET ===== + + @Test + void get_existingUser_returns200_andFullVo() throws Exception { + mvc.perform(get("/api/v1/users/" + fx.aliceId()) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ErrorCode.OK)) + .andExpect(jsonPath("$.data.userId").value(fx.aliceId())) + .andExpect(jsonPath("$.data.username").value(LoginTestSeeder.USER_OK)) + .andExpect(jsonPath("$.data.employeeName").value("张三")); + } + + @Test + void get_unknownUser_returns404_40401() throws Exception { + mvc.perform(get("/api/v1/users/99999") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.USER_NOT_FOUND)); + } + + @Test + void get_normalUser_returns403_40301() throws Exception { + mvc.perform(get("/api/v1/users/" + fx.aliceId()) + .header("Authorization", "Bearer " + normalToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN)); + } + + @Test + void get_noAuthHeader_returns401_40101() throws Exception { + mvc.perform(get("/api/v1/users/" + fx.aliceId())) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS)); + } + + @Test + void get_deletedUser_stillReturns200() throws Exception { + mvc.perform(get("/api/v1/users/" + fx.bobDeletedId()) + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isDeleted").value(true)); + } + + // ===== PUT ===== + + private String body(Object o) throws Exception { + return json.writeValueAsString(o); + } + + private UpdateUserReq req() { + return new UpdateUserReq(); + } + + @Test + void put_updateUserCodeAndType_returns200() throws Exception { + UpdateUserReq r = req(); + r.setUserCode("U_NEW"); + r.setUserType("SUPER_ADMIN"); + mvc.perform(put("/api/v1/users/" + fx.aliceId()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body(r))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.userCode").value("U_NEW")) + .andExpect(jsonPath("$.data.userType").value("SUPER_ADMIN")); + } + + @Test + void put_updateEmployeeId_toAnotherEmployee_setsValue() throws Exception { + UpdateUserReq r = req(); + r.setEmployeeId(fx.employeeId()); + mvc.perform(put("/api/v1/users/" + fx.adminId()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body(r))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.employeeId").value(fx.employeeId())); + } + + @Test + void put_updateEmployeeId_zero_clearsRelation() throws Exception { + UpdateUserReq r = req(); + r.setEmployeeId(0); + mvc.perform(put("/api/v1/users/" + fx.aliceId()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body(r))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.employeeId").doesNotExist()); + } + + @Test + void put_updateEmployeeId_unknown_returns400_40004() throws Exception { + UpdateUserReq r = req(); + r.setEmployeeId(99999); + mvc.perform(put("/api/v1/users/" + fx.aliceId()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body(r))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_FOUND)); + } + + @Test + void put_isDeletedTrue_marksAndOriginalTokenRejectedNextCall() throws Exception { + // 把 alice 设为作废 + UpdateUserReq r = req(); + r.setIsDeleted(true); + mvc.perform(put("/api/v1/users/" + fx.aliceId()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body(r))) + .andExpect(status().isOk()); + + // 用 alice 的 token 调任何 /api/v1/** 接口应 40101 + // 但 alice 是 NORMAL,连 admin 路径都会先 401(用户已作废),不是 403 + mvc.perform(get("/api/v1/users/" + fx.aliceId()) + .header("Authorization", "Bearer " + normalToken)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS)); + } + + @Test + void put_permissionCategories_subsetDelta() throws Exception { + Integer pur = fx.activePermissionCategoryIds().get(0); + Integer sal = fx.activePermissionCategoryIds().get(1); + // 预置 alice 有 {pur, sal} + for (Integer pcId : List.of(pur, sal)) { + SysUserPermissionCategory l = new SysUserPermissionCategory(); + l.setIUserId(fx.aliceId()); + l.setIPermissionCategoryId(pcId); + l.setSGrantedBy("system"); + upcMapper.insert(l); + } + + UpdateUserReq r = req(); + r.setPermissionCategoryIds(List.of(sal)); // 只保留 sal + mvc.perform(put("/api/v1/users/" + fx.aliceId()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body(r))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.permissionCategoryIds.length()").value(1)) + .andExpect(jsonPath("$.data.permissionCategoryIds[0]").value(sal)); + } + + @Test + void put_permissionCategories_emptyArray_clearsAll() throws Exception { + Integer pur = fx.activePermissionCategoryIds().get(0); + SysUserPermissionCategory l = new SysUserPermissionCategory(); + l.setIUserId(fx.aliceId()); + l.setIPermissionCategoryId(pur); + l.setSGrantedBy("system"); + upcMapper.insert(l); + + UpdateUserReq r = req(); + r.setPermissionCategoryIds(List.of()); + mvc.perform(put("/api/v1/users/" + fx.aliceId()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body(r))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.permissionCategoryIds.length()").value(0)); + } + + @Test + void put_permissionCategories_unknownId_returns400_40004_andRollsBack() throws Exception { + UpdateUserReq r = req(); + r.setUserCode("U_NEW"); + r.setPermissionCategoryIds(List.of(99999)); + mvc.perform(put("/api/v1/users/" + fx.aliceId()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body(r))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.COMPANY_NOT_FOUND)); + + // 验证回滚:alice 的 userCode 仍是 U001 + com.xly.erp.module.usr.entity.SysUser db = userMapper.selectById(fx.aliceId()); + org.junit.jupiter.api.Assertions.assertEquals("U001", db.getSUserCode()); + } + + @Test + void put_duplicateUserCode_returns409_40902() throws Exception { + UpdateUserReq r = req(); + r.setUserCode("U001"); // alice 的 userCode + mvc.perform(put("/api/v1/users/" + fx.adminId()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body(r))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(ErrorCode.CONFLICT_USERCODE)); + } + + @Test + void put_userCodeUnchangedSameAsSelf_returns200() throws Exception { + UpdateUserReq r = req(); + r.setUserCode("U001"); // alice 的当前 userCode + mvc.perform(put("/api/v1/users/" + fx.aliceId()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body(r))) + .andExpect(status().isOk()); + } + + @Test + void put_selfDeactivate_returns403_40302() throws Exception { + UpdateUserReq r = req(); + r.setIsDeleted(true); + mvc.perform(put("/api/v1/users/" + fx.adminId()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body(r))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.USER_FORBIDDEN_SELF_DEACTIVATE)); + } + + @Test + void put_unknownProperty_username_returns400_40001() throws Exception { + ObjectNode b = json.createObjectNode(); + b.put("username", "hacker"); + mvc.perform(put("/api/v1/users/" + fx.aliceId()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(b.toString())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST)); + } + + @Test + void put_unknownProperty_password_returns400_40001() throws Exception { + ObjectNode b = json.createObjectNode(); + b.put("password", "newpass"); + mvc.perform(put("/api/v1/users/" + fx.aliceId()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(b.toString())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST)); + } + + @Test + void put_unknownUserId_returns404_40401() throws Exception { + mvc.perform(put("/api/v1/users/99999") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body(req()))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(ErrorCode.USER_NOT_FOUND)); + } + + @Test + void put_normalUser_returns403_40301() throws Exception { + mvc.perform(put("/api/v1/users/" + fx.aliceId()) + .header("Authorization", "Bearer " + normalToken) + .contentType(MediaType.APPLICATION_JSON) + .content(body(req()))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN)); + } + + @Test + void put_emptyBody_only_updates_audit_fields() throws Exception { + mvc.perform(put("/api/v1/users/" + fx.aliceId()) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.updatedBy").value(LoginTestSeeder.USER_ADMIN)); + } +} -- libgit2 0.22.2