调整结构

This commit is contained in:
马宝龙
2026-07-28 12:52:36 +08:00
parent f9dcf890b2
commit 94dd560331
61 changed files with 4243 additions and 906 deletions

View File

@@ -0,0 +1,17 @@
package com.example.training.common;
/**
* 统一接口响应结构。
*
* @param <T> 响应数据类型
*/
public record ApiResponse<T>(int code, String message, T data) {
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(200, "success", data);
}
public static <T> ApiResponse<T> failure(int code, String message) {
return new ApiResponse<>(code, message, null);
}
}

View File

@@ -15,4 +15,5 @@ public class MybatisPlusConfig {
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}

View File

@@ -0,0 +1,33 @@
package com.example.training.config;
import java.io.IOException;
import java.util.UUID;
import java.util.regex.Pattern;
import com.example.training.util.RequestIdContext;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.filter.OncePerRequestFilter;
public class RequestIdFilter extends OncePerRequestFilter {
private static final Pattern SAFE_REQUEST_ID = Pattern.compile("[A-Za-z0-9._-]{1,64}");
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String requestId = request.getHeader(RequestIdContext.HEADER_NAME);
if (requestId == null || !SAFE_REQUEST_ID.matcher(requestId).matches()) {
requestId = UUID.randomUUID().toString();
}
RequestIdContext.set(requestId);
response.setHeader(RequestIdContext.HEADER_NAME, requestId);
try {
filterChain.doFilter(request, response);
} finally {
RequestIdContext.clear();
}
}
}

View File

@@ -0,0 +1,18 @@
package com.example.training.config;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class WebConfig {
@Bean
public FilterRegistrationBean<RequestIdFilter> requestIdFilter() {
FilterRegistrationBean<RequestIdFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new RequestIdFilter());
registration.addUrlPatterns("/*");
registration.setOrder(-100);
return registration;
}
}

View File

@@ -0,0 +1,25 @@
package com.example.training.constant;
public enum ErrorCode {
VALIDATION_FAILED(400001, "请求参数校验失败"),
USER_NOT_FOUND(404001, "用户不存在"),
DUPLICATE_EMAIL(409001, "该邮箱已被使用,请更换后重试"),
INTERNAL_ERROR(500001, "服务暂时不可用,请稍后重试");
private final int code;
private final String message;
ErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
public int getCode() {
return code;
}
public String getMessage() {
return message;
}
}

View File

@@ -3,17 +3,18 @@ package com.example.training.controller;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import com.example.training.common.ApiResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/health")
@RequestMapping("/api/v1/health")
public class HealthController {
@GetMapping
public HealthResponse health() {
return new HealthResponse("UP", "training-backend", OffsetDateTime.now(ZoneOffset.UTC));
public ApiResponse<HealthResponse> health() {
return ApiResponse.success(new HealthResponse("UP", "training-backend", OffsetDateTime.now(ZoneOffset.UTC)));
}
public record HealthResponse(String status, String service, OffsetDateTime timestamp) {

View File

@@ -1,22 +1,22 @@
package com.example.training.controller;
import java.time.LocalDateTime;
import java.util.List;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.training.domain.User;
import com.example.training.common.ApiResponse;
import com.example.training.constant.ErrorCode;
import com.example.training.domain.dto.BulkStatusRequest;
import com.example.training.domain.dto.UserRequest;
import com.example.training.domain.entity.User;
import com.example.training.domain.vo.BulkStatusVo;
import com.example.training.domain.vo.UserPageVo;
import com.example.training.domain.vo.UserSummaryVo;
import com.example.training.domain.vo.UserVo;
import com.example.training.service.UserService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -29,7 +29,7 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/users")
@RequestMapping("/api/v1/users")
public class UserController {
private final UserService userService;
@@ -39,91 +39,55 @@ public class UserController {
}
@GetMapping
public UserPage list(
public ApiResponse<UserPageVo> list(
@RequestParam(defaultValue = "") String keyword,
@RequestParam(defaultValue = "") String role,
@RequestParam(defaultValue = "") String status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
int safePage = Math.max(page, 0);
int safeSize = Math.min(Math.max(size, 1), 100);
Page<User> resultPage = userService.pageUsers(keyword, role, status, safePage, safeSize);
return new UserPage(resultPage.getRecords().stream().map(UserResponse::from).toList(),
resultPage.getTotal(), (int) resultPage.getCurrent() - 1, (int) resultPage.getSize());
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "50") int pageSize) {
int safePage = Math.max(page, 1);
int safePageSize = Math.min(Math.max(pageSize, 1), 100);
Page<User> resultPage = userService.pageUsers(keyword, role, status, safePage, safePageSize);
UserPageVo data = new UserPageVo(resultPage.getRecords().stream().map(UserVo::from).toList(),
resultPage.getTotal(), safePage, safePageSize);
return ApiResponse.success(data);
}
@GetMapping("/summary")
public UserSummary summary() {
public ApiResponse<UserSummaryVo> summary() {
UserService.UserSummary summary = userService.summarizeUsers();
return new UserSummary(summary.total(), summary.active(), summary.inactive(), summary.admins());
return ApiResponse.success(new UserSummaryVo(summary.total(), summary.active(),
summary.inactive(), summary.admins()));
}
@PatchMapping("/status")
public BulkStatusResponse updateStatuses(@Valid @RequestBody BulkStatusRequest request) {
public ApiResponse<BulkStatusVo> updateStatuses(@Valid @RequestBody BulkStatusRequest request) {
int updated = userService.updateStatuses(request.ids(), request.status());
return new BulkStatusResponse(updated, request.status());
return ApiResponse.success(new BulkStatusVo(updated, request.status()));
}
@PostMapping
public ResponseEntity<UserResponse> create(@Valid @RequestBody UserRequest request) {
public ResponseEntity<ApiResponse<UserVo>> create(@Valid @RequestBody UserRequest request) {
User user = userService.createUser(request.fullName(), request.email(),
request.role(), request.status());
return ResponseEntity.status(HttpStatus.CREATED).body(UserResponse.from(user));
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(UserVo.from(user)));
}
@PutMapping("/{id}")
public UserResponse update(@PathVariable Long id, @Valid @RequestBody UserRequest request) {
public ApiResponse<UserVo> update(@PathVariable Long id, @Valid @RequestBody UserRequest request) {
User user = userService.updateUser(id, request.fullName(), request.email(),
request.role(), request.status());
if (user == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "用户不存在");
throw new ResponseStatusException(HttpStatus.NOT_FOUND, ErrorCode.USER_NOT_FOUND.getMessage());
}
return UserResponse.from(user);
return ApiResponse.success(UserVo.from(user));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
public ApiResponse<Void> delete(@PathVariable Long id) {
if (!userService.deleteUser(id)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "用户不存在");
throw new ResponseStatusException(HttpStatus.NOT_FOUND, ErrorCode.USER_NOT_FOUND.getMessage());
}
return ResponseEntity.noContent().build();
}
@ExceptionHandler(DataIntegrityViolationException.class)
ResponseEntity<ApiError> handleDuplicateEmail() {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ApiError("该邮箱已被使用,请更换后重试"));
}
public record UserRequest(
@NotBlank @Size(max = 80) String fullName,
@NotBlank @Email @Size(max = 160) String email,
@NotBlank @Size(max = 40) String role,
@NotBlank @Size(max = 20) String status) {
}
public record BulkStatusRequest(
@NotEmpty @Size(max = 100) List<Long> ids,
@NotBlank @Pattern(regexp = "ACTIVE|INACTIVE") String status) {
}
public record UserPage(List<UserResponse> items, long total, int page, int size) {
}
public record UserSummary(long total, long active, long inactive, long admins) {
}
public record BulkStatusResponse(int updated, String status) {
}
public record UserResponse(Long id, String fullName, String email, String role,
String status, LocalDateTime createdAt, LocalDateTime updatedAt) {
static UserResponse from(User user) {
return new UserResponse(user.getId(), user.getFullName(), user.getEmail(),
user.getRole(), user.getStatus(), user.getCreatedAt(), user.getUpdatedAt());
}
}
public record ApiError(String message) {
return ApiResponse.success(null);
}
}

View File

@@ -1,57 +1,16 @@
package com.example.training.domain;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
@TableName("users")
public class User {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField("full_name")
private String fullName;
private String email;
private String role;
private String status;
@TableField("created_at")
private LocalDateTime createdAt;
@TableField("updated_at")
private LocalDateTime updatedAt;
/**
* 兼容旧包路径的过渡类型,业务代码统一使用 domain.entity.User。
*/
@Deprecated
public class User extends com.example.training.domain.entity.User {
public User() {
super();
}
public User(String fullName, String email, String role, String status) {
LocalDateTime now = LocalDateTime.now();
this.fullName = fullName;
this.email = email;
this.role = role;
this.status = status;
this.createdAt = now;
this.updatedAt = now;
}
public Long getId() { return id; }
public String getFullName() { return fullName; }
public String getEmail() { return email; }
public String getRole() { return role; }
public String getStatus() { return status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void update(String fullName, String email, String role, String status) {
this.fullName = fullName;
this.email = email;
this.role = role;
this.status = status;
this.updatedAt = LocalDateTime.now();
super(fullName, email, role, status);
}
}

View File

@@ -0,0 +1,13 @@
package com.example.training.domain.dto;
import java.util.List;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record BulkStatusRequest(
@NotEmpty(message = "用户 ID 不能为空") @Size(max = 100, message = "单次最多更新 100 个用户") List<Long> ids,
@NotBlank(message = "状态不能为空") @Pattern(regexp = "ACTIVE|INACTIVE", message = "状态不合法") String status) {
}

View File

@@ -0,0 +1,13 @@
package com.example.training.domain.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
public record UserRequest(
@NotBlank(message = "姓名不能为空") @Size(max = 80, message = "姓名长度不能超过 80 个字符") String fullName,
@NotBlank(message = "邮箱不能为空") @Email(message = "邮箱格式不正确") @Size(max = 160, message = "邮箱长度不能超过 160 个字符") String email,
@NotBlank(message = "角色不能为空") @Pattern(regexp = "Admin|Manager|Member|Viewer", message = "角色不合法") String role,
@NotBlank(message = "状态不能为空") @Pattern(regexp = "ACTIVE|INACTIVE", message = "状态不合法") String status) {
}

View File

@@ -0,0 +1,77 @@
package com.example.training.domain.entity;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
@TableName("USERS")
public class User {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField("full_name")
private String fullName;
private String email;
private String role;
private String status;
@TableField("created_at")
private LocalDateTime createdAt;
@TableField("updated_at")
private LocalDateTime updatedAt;
public User() {
}
public User(String fullName, String email, String role, String status) {
LocalDateTime now = LocalDateTime.now();
this.fullName = fullName;
this.email = email;
this.role = role;
this.status = status;
this.createdAt = now;
this.updatedAt = now;
}
public Long getId() {
return id;
}
public String getFullName() {
return fullName;
}
public String getEmail() {
return email;
}
public String getRole() {
return role;
}
public String getStatus() {
return status;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void update(String fullName, String email, String role, String status) {
this.fullName = fullName;
this.email = email;
this.role = role;
this.status = status;
this.updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,4 @@
package com.example.training.domain.vo;
public record BulkStatusVo(int updated, String status) {
}

View File

@@ -0,0 +1,6 @@
package com.example.training.domain.vo;
import java.util.List;
public record UserPageVo(List<UserVo> items, long total, int page, int pageSize) {
}

View File

@@ -0,0 +1,4 @@
package com.example.training.domain.vo;
public record UserSummaryVo(long total, long active, long inactive, long admins) {
}

View File

@@ -0,0 +1,14 @@
package com.example.training.domain.vo;
import java.time.LocalDateTime;
import com.example.training.domain.entity.User;
public record UserVo(Long id, String fullName, String email, String role,
String status, LocalDateTime createdAt, LocalDateTime updatedAt) {
public static UserVo from(User user) {
return new UserVo(user.getId(), user.getFullName(), user.getEmail(), user.getRole(),
user.getStatus(), user.getCreatedAt(), user.getUpdatedAt());
}
}

View File

@@ -0,0 +1,60 @@
package com.example.training.exception;
import com.example.training.common.ApiResponse;
import com.example.training.constant.ErrorCode;
import com.example.training.util.RequestIdContext;
import jakarta.validation.ConstraintViolationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.server.ResponseStatusException;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException exception) {
String message = exception.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(error -> error.getDefaultMessage())
.orElse(ErrorCode.VALIDATION_FAILED.getMessage());
return ResponseEntity.badRequest().body(ApiResponse.failure(
ErrorCode.VALIDATION_FAILED.getCode(), message));
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiResponse<Void>> handleConstraintViolation() {
return ResponseEntity.badRequest().body(ApiResponse.failure(
ErrorCode.VALIDATION_FAILED.getCode(), ErrorCode.VALIDATION_FAILED.getMessage()));
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ApiResponse<Void>> handleDataIntegrityViolation() {
return ResponseEntity.status(409).body(ApiResponse.failure(
ErrorCode.DUPLICATE_EMAIL.getCode(), ErrorCode.DUPLICATE_EMAIL.getMessage()));
}
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ApiResponse<Void>> handleResponseStatus(ResponseStatusException exception) {
int code = exception.getStatusCode().value() == 404
? ErrorCode.USER_NOT_FOUND.getCode()
: ErrorCode.VALIDATION_FAILED.getCode();
String message = exception.getStatusCode().value() == 404
? ErrorCode.USER_NOT_FOUND.getMessage()
: exception.getReason();
return ResponseEntity.status(exception.getStatusCode()).body(ApiResponse.failure(code, message));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleUnexpectedException(Exception exception) {
log.error("请求处理失败requestId={}", RequestIdContext.current(), exception);
return ResponseEntity.internalServerError().body(ApiResponse.failure(
ErrorCode.INTERNAL_ERROR.getCode(), ErrorCode.INTERNAL_ERROR.getMessage()));
}
}

View File

@@ -1,7 +1,7 @@
package com.example.training.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.training.domain.User;
import com.example.training.domain.entity.User;
import org.apache.ibatis.annotations.Mapper;
@Mapper

View File

@@ -1,7 +1,7 @@
package com.example.training.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.training.domain.User;
import com.example.training.domain.entity.User;
import java.util.List;

View File

@@ -3,9 +3,12 @@ package com.example.training.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.training.domain.User;
import com.example.training.domain.entity.User;
import com.example.training.mapper.UserMapper;
import com.example.training.service.UserService;
import com.example.training.util.RequestIdContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -15,6 +18,8 @@ import java.util.List;
@Service
public class UserServiceImpl implements UserService {
private static final Logger log = LoggerFactory.getLogger(UserServiceImpl.class);
private final UserMapper userMapper;
public UserServiceImpl(UserMapper userMapper) {
@@ -24,7 +29,7 @@ public class UserServiceImpl implements UserService {
@Override
@Transactional(readOnly = true)
public Page<User> pageUsers(String keyword, String role, String status, int page, int size) {
Page<User> requestPage = new Page<>(page + 1L, size);
Page<User> requestPage = new Page<>(Math.max(page, 1), Math.min(Math.max(size, 1), 100));
LambdaQueryWrapper<User> query = new LambdaQueryWrapper<>();
if (keyword != null && !keyword.isBlank()) {
String trimmedKeyword = keyword.trim();
@@ -65,7 +70,10 @@ public class UserServiceImpl implements UserService {
update.in(User::getId, ids)
.set(User::getStatus, status)
.set(User::getUpdatedAt, LocalDateTime.now());
return userMapper.update(null, update);
int updated = userMapper.update(null, update);
log.info("批量更新用户状态requestId={}updated={}status={}",
RequestIdContext.current(), updated, status);
return updated;
}
@Override
@@ -73,6 +81,7 @@ public class UserServiceImpl implements UserService {
public User createUser(String fullName, String email, String role, String status) {
User user = new User(normalize(fullName), normalizeEmail(email), role, status);
userMapper.insert(user);
log.info("创建用户requestId={}userId={}", RequestIdContext.current(), user.getId());
return user;
}
@@ -85,6 +94,7 @@ public class UserServiceImpl implements UserService {
}
user.update(normalize(fullName), normalizeEmail(email), role, status);
userMapper.updateById(user);
log.info("更新用户requestId={}userId={}", RequestIdContext.current(), id);
return user;
}
@@ -94,7 +104,10 @@ public class UserServiceImpl implements UserService {
if (userMapper.selectById(id) == null) {
return false;
}
return userMapper.deleteById(id) > 0;
boolean deleted = userMapper.deleteById(id) > 0;
log.info("删除用户requestId={}userId={}deleted={}",
RequestIdContext.current(), id, deleted);
return deleted;
}
private static String normalize(String value) {

View File

@@ -0,0 +1,24 @@
package com.example.training.util;
import org.slf4j.MDC;
public final class RequestIdContext {
public static final String HEADER_NAME = "X-Request-Id";
private static final String MDC_KEY = "requestId";
private RequestIdContext() {
}
public static void set(String requestId) {
MDC.put(MDC_KEY, requestId);
}
public static String current() {
return MDC.get(MDC_KEY);
}
public static void clear() {
MDC.remove(MDC_KEY);
}
}

View File

@@ -15,8 +15,7 @@ spring:
validation-timeout: ${MYSQL_POOL_VALIDATION_TIMEOUT_MS:5000}
sql:
init:
mode: always
continue-on-error: false
mode: never
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
@@ -36,3 +35,5 @@ logging:
level:
com.example.training: ${LOGGING_LEVEL_COM_EXAMPLE_TRAINING:INFO}
com.baomidou.mybatisplus: INFO
pattern:
level: "%5p [requestId:%X{requestId}]"

View File

@@ -1,13 +0,0 @@
CREATE TABLE IF NOT EXISTS users (
id BIGINT NOT NULL AUTO_INCREMENT,
full_name VARCHAR(80) NOT NULL,
email VARCHAR(160) NOT NULL,
role VARCHAR(40) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_users_email (email),
KEY idx_users_full_name (full_name),
KEY idx_users_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -7,13 +7,17 @@ import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import com.example.training.controller.HealthController;
import com.example.training.config.WebConfig;
import org.springframework.context.annotation.Import;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
@WebMvcTest(HealthController.class)
@ActiveProfiles("test")
@Import(WebConfig.class)
class TrainingApplicationTests {
@Autowired
@@ -21,9 +25,12 @@ class TrainingApplicationTests {
@Test
void healthEndpointReturnsUp() throws Exception {
mockMvc.perform(get("/api/health"))
mockMvc.perform(get("/api/v1/health").header("X-Request-Id", "health-test-1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("UP"))
.andExpect(jsonPath("$.service").value("training-backend"));
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.message").value("success"))
.andExpect(jsonPath("$.data.status").value("UP"))
.andExpect(jsonPath("$.data.service").value("training-backend"))
.andExpect(header().string("X-Request-Id", "health-test-1"));
}
}

View File

@@ -31,39 +31,53 @@ class UserControllerTests {
@Test
void listPassesRoleAndStatusFilters() throws Exception {
when(userService.pageUsers("", "Admin", "ACTIVE", 0, 50)).thenReturn(new Page<>(1, 50));
when(userService.pageUsers("", "Admin", "ACTIVE", 1, 50)).thenReturn(new Page<>(1, 50));
mockMvc.perform(get("/api/users")
mockMvc.perform(get("/api/v1/users")
.param("keyword", "")
.param("role", "Admin")
.param("status", "ACTIVE"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.total").value(0));
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.total").value(0))
.andExpect(jsonPath("$.data.page").value(1))
.andExpect(jsonPath("$.data.pageSize").value(50));
verify(userService).pageUsers("", "Admin", "ACTIVE", 0, 50);
verify(userService).pageUsers("", "Admin", "ACTIVE", 1, 50);
}
@Test
void summaryReturnsOverviewCounts() throws Exception {
when(userService.summarizeUsers()).thenReturn(new UserService.UserSummary(12, 9, 3, 2));
mockMvc.perform(get("/api/users/summary"))
mockMvc.perform(get("/api/v1/users/summary"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.total").value(12))
.andExpect(jsonPath("$.active").value(9))
.andExpect(jsonPath("$.inactive").value(3))
.andExpect(jsonPath("$.admins").value(2));
.andExpect(jsonPath("$.data.total").value(12))
.andExpect(jsonPath("$.data.active").value(9))
.andExpect(jsonPath("$.data.inactive").value(3))
.andExpect(jsonPath("$.data.admins").value(2));
}
@Test
void bulkStatusUpdatesSelectedUsers() throws Exception {
when(userService.updateStatuses(anyList(), eq("INACTIVE"))).thenReturn(2);
mockMvc.perform(patch("/api/users/status")
mockMvc.perform(patch("/api/v1/users/status")
.contentType("application/json")
.content("{\"ids\":[4,7],\"status\":\"INACTIVE\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.updated").value(2))
.andExpect(jsonPath("$.status").value("INACTIVE"));
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.data.updated").value(2))
.andExpect(jsonPath("$.data.status").value("INACTIVE"));
}
@Test
void invalidUserRequestReturnsStandardError() throws Exception {
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/v1/users")
.contentType("application/json")
.content("{\"fullName\":\"\",\"email\":\"invalid\",\"role\":\"Unknown\",\"status\":\"BROKEN\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400001))
.andExpect(jsonPath("$.data").doesNotExist());
}
}