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 8aaf0fe..5837029 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 @@ -1,15 +1,19 @@ package com.xly.erp.module.usr.controller; +import com.xly.erp.common.response.PageResult; 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.dto.UserQueryReq; import com.xly.erp.module.usr.service.UserCreateService; import com.xly.erp.module.usr.service.UserDetailService; +import com.xly.erp.module.usr.service.UserListService; 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 com.xly.erp.module.usr.vo.UserListItemVo; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -30,6 +34,7 @@ public class UserController { private final UserCreateService userCreateService; private final UserDetailService userDetailService; private final UserUpdateService userUpdateService; + private final UserListService userListService; @PostMapping @RequireSuperAdmin @@ -39,6 +44,12 @@ public class UserController { return ResponseEntity.status(HttpStatus.CREATED).body(Result.ok(vo)); } + @GetMapping + @RequireSuperAdmin + public Result> list(@Valid UserQueryReq req) { + return Result.ok(userListService.list(req)); + } + @GetMapping("/{userId}") @RequireSuperAdmin public Result getById(@PathVariable Integer userId) { diff --git a/backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerListTest.java b/backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerListTest.java new file mode 100644 index 0000000..d70187b --- /dev/null +++ b/backend/src/test/java/com/xly/erp/module/usr/controller/UserControllerListTest.java @@ -0,0 +1,254 @@ +package com.xly.erp.module.usr.controller; + +import com.xly.erp.common.response.ErrorCode; +import com.xly.erp.common.security.JwtUtil; +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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.HashMap; +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class UserControllerListTest { + + @Autowired private MockMvc mvc; + @Autowired private LoginTestSeeder seeder; + @Autowired private JwtUtil jwtUtil; + @Autowired private JdbcTemplate jdbc; + + 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); + } + + @Test + void list_default_returnsAllUsers() throws Exception { + mvc.perform(get("/api/v1/users").header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ErrorCode.OK)) + .andExpect(jsonPath("$.data.total").value(3)) + .andExpect(jsonPath("$.data.records.length()").value(3)) + .andExpect(jsonPath("$.data.page").value(1)) + .andExpect(jsonPath("$.data.size").value(20)); + } + + @Test + void list_sizeOver100_returns400_40001() throws Exception { + mvc.perform(get("/api/v1/users") + .param("size", "200") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST)); + } + + @Test + void list_pageZero_returns400_40001() throws Exception { + mvc.perform(get("/api/v1/users") + .param("page", "0") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST)); + } + + @Test + void list_sortByUsernameAsc() throws Exception { + mvc.perform(get("/api/v1/users") + .param("sortField", "sUsername") + .param("sortOrder", "asc") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.records[0].username").value(LoginTestSeeder.USER_ADMIN)); + } + + @Test + void list_sortFieldInvalid_returns400_40003() throws Exception { + mvc.perform(get("/api/v1/users") + .param("sortField", "badField") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_ENUM_PARAM)); + } + + @Test + void list_sortOrderInvalid_returns400_40001() throws Exception { + mvc.perform(get("/api/v1/users") + .param("sortOrder", "foo") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST)); + } + + @Test + void list_queryByUsernameContains() throws Exception { + mvc.perform(get("/api/v1/users") + .param("queryField", "username") + .param("matchMode", "contains") + .param("queryValue", "ali") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.records[0].username").value(LoginTestSeeder.USER_OK)); + } + + @Test + void list_queryByDepartmentName_multiJoin() throws Exception { + mvc.perform(get("/api/v1/users") + .param("queryField", "departmentName") + .param("matchMode", "equals") + .param("queryValue", "技术部") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.records[0].departmentName").value("技术部")); + } + + @Test + void list_queryByIsDeletedTrue() throws Exception { + mvc.perform(get("/api/v1/users") + .param("queryField", "isDeleted") + .param("matchMode", "equals") + .param("queryValue", "true") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.records[0].username").value(LoginTestSeeder.USER_DELETED)); + } + + @Test + void list_queryFieldInvalid_returns400_40003() throws Exception { + mvc.perform(get("/api/v1/users") + .param("queryField", "badField") + .param("queryValue", "x") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_ENUM_PARAM)); + } + + @Test + void list_matchModeInvalid_returns400_40003() throws Exception { + mvc.perform(get("/api/v1/users") + .param("matchMode", "startsWith") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.INVALID_ENUM_PARAM)); + } + + @Test + void list_explicitUserTypeFilter() throws Exception { + mvc.perform(get("/api/v1/users") + .param("userType", "NORMAL") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(2)); // alice + bob_deleted + } + + @Test + void list_explicitUserTypeInvalid_returns400_40001() throws Exception { + mvc.perform(get("/api/v1/users") + .param("userType", "HACKER") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST)); + } + + @Test + void list_explicitIsDeletedFalse_filtersActive() throws Exception { + mvc.perform(get("/api/v1/users") + .param("isDeleted", "false") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(2)); // alice + admin + } + + @Test + void list_composedFilters_andSemantics() throws Exception { + mvc.perform(get("/api/v1/users") + .param("queryField", "username") + .param("queryValue", "a") + .param("userType", "NORMAL") + .param("isDeleted", "false") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(1)) + .andExpect(jsonPath("$.data.records[0].username").value(LoginTestSeeder.USER_OK)); + } + + @Test + void list_pageBeyondTotal_returnsLastPage() throws Exception { + mvc.perform(get("/api/v1/users") + .param("page", "999") + .param("size", "10") + .header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(3)) + .andExpect(jsonPath("$.data.page").value(1)) + .andExpect(jsonPath("$.data.records.length()").value(3)); + } + + @Test + void list_normalUserToken_returns403_40301() throws Exception { + mvc.perform(get("/api/v1/users").header("Authorization", "Bearer " + normalToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(ErrorCode.FORBIDDEN)); + } + + @Test + void list_noAuthHeader_returns401_40101() throws Exception { + mvc.perform(get("/api/v1/users")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_CREDENTIALS)); + } + + @Test + void list_responseRecordDoesNotIncludePasswordField() throws Exception { + mvc.perform(get("/api/v1/users").header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.records[0].password").doesNotExist()) + .andExpect(jsonPath("$.data.records[0].passwordHash").doesNotExist()) + .andExpect(jsonPath("$.data.records[0].sPasswordHash").doesNotExist()); + } + + @Test + void list_emptyTable_returnsZeroTotal() throws Exception { + jdbc.update("DELETE FROM sys_user_permission_category"); + jdbc.update("DELETE FROM sys_user"); + // 但鉴权需要 token 关联用户存在,所以保留 admin 用户即可 + // 重做:只删 alice / bob + // 重置后用一个独立 seed + seeder.reset(); + jdbc.update("DELETE FROM sys_user WHERE sUsername IN (?, ?)", + LoginTestSeeder.USER_OK, LoginTestSeeder.USER_DELETED); + + mvc.perform(get("/api/v1/users").header("Authorization", "Bearer " + adminToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(1)); // 只剩 admin + } +}