diff --git a/tmpl-api/src/main/java/com/ljwx/tmpl/api/config/JpaAuditingConfig.java b/tmpl-api/src/main/java/com/ljwx/tmpl/api/config/JpaAuditingConfig.java index a7da36b..18cd72f 100644 --- a/tmpl-api/src/main/java/com/ljwx/tmpl/api/config/JpaAuditingConfig.java +++ b/tmpl-api/src/main/java/com/ljwx/tmpl/api/config/JpaAuditingConfig.java @@ -31,6 +31,6 @@ public class JpaAuditingConfig { @Bean public AuditorAware tenantAuditorAware() { - return () -> Optional.of(TenantContext.getUserIdOrDefault(0L)); + return () -> Optional.of(TenantContext.getUserIdOrDefault()); } } \ No newline at end of file diff --git a/tmpl-api/src/main/java/com/ljwx/tmpl/api/config/OpenApiConfig.java b/tmpl-api/src/main/java/com/ljwx/tmpl/api/config/OpenApiConfig.java new file mode 100644 index 0000000..0f67c7b --- /dev/null +++ b/tmpl-api/src/main/java/com/ljwx/tmpl/api/config/OpenApiConfig.java @@ -0,0 +1,32 @@ +package com.ljwx.tmpl.api.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.Contact; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * OpenAPI (Swagger) configuration. + * + *

Provides API documentation metadata accessible at: + *

+ */ +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI serviceOpenApi() { + return new OpenAPI() + .info(new Info() + .title("Java Service Template API") + .description("Production-ready Spring Boot service template with Clean Architecture") + .version("1.0.0") + .contact(new Contact() + .name("Development Team") + .email("dev@example.com"))); + } +} \ No newline at end of file diff --git a/tmpl-api/src/main/java/com/ljwx/tmpl/api/controller/OrderController.java b/tmpl-api/src/main/java/com/ljwx/tmpl/api/controller/OrderController.java new file mode 100644 index 0000000..af56d5e --- /dev/null +++ b/tmpl-api/src/main/java/com/ljwx/tmpl/api/controller/OrderController.java @@ -0,0 +1,95 @@ +package com.ljwx.tmpl.api.controller; + +import com.ljwx.tmpl.api.dto.ApiResponse; +import com.ljwx.tmpl.api.dto.CreateOrderRequest; +import com.ljwx.tmpl.api.dto.OrderResponse; +import com.ljwx.tmpl.api.dto.UpdateOrderRequest; +import com.ljwx.tmpl.application.order.OrderAppService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * Order REST API controller. + * + *

Depends on { @link OrderAppService} (application layer) only. + * Does NOT depend on infrastructure — enforced by ArchUnit A-6. + * + *

Tenant context comes from the { @code X-Tenant-Id} header, + * which is also validated by { @link TenantContextFilter}. + */ + @RestController @RequestMapping(value = "/api/v1/orders", produces = MediaType.APPLICATION_JSON_VALUE) + @Tag(name = "Order", description = "Order management APIs") +public class OrderController { + + private final OrderAppService appService; + + public OrderController(OrderAppService appService) { + this.appService = appService; + } + + @Operation(summary = "Create an order") + @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse create( + @Parameter(description = "Tenant ID", required = true) + @RequestHeader("X-Tenant-Id") Long tenantId, + @RequestBody CreateOrderRequest req) { + var order = appService.create(tenantId, req.getDeptId(), req.getOrderNo(), req.getAmount()); + return ApiResponse.success(OrderResponse.fromDomain(order)); + } + + @Operation(summary = "Get an order by ID") + @GetMapping("/{id}") + public ApiResponse get( + @RequestHeader("X-Tenant-Id") Long tenantId, + @PathVariable("id") Long id) { + return appService.get(tenantId, id) + .map(order -> ApiResponse.success(OrderResponse.fromDomain(order))) + .orElse(ApiResponse.error("ORDER_NOT_FOUND", "Order not found")); + } + + @Operation(summary = "List orders by tenant (paged)") + @GetMapping + public ApiResponse> list( + @RequestHeader("X-Tenant-Id") Long tenantId, + @RequestParam(defaultValue = "1") int pageNum, + @RequestParam(defaultValue = "20") int pageSize) { + List items = appService.list(tenantId, pageNum, pageSize) + .stream() + .map(OrderResponse::fromDomain) + .toList(); + return ApiResponse.success(items); + } + + @Operation(summary = "Update order status") + @PutMapping(value = "/{id}/status", consumes = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse updateStatus( + @RequestHeader("X-Tenant-Id") Long tenantId, + @PathVariable("id") Long id, + @RequestBody UpdateOrderRequest req) { + var updated = appService.updateStatus(tenantId, id, req.getStatus()); + return ApiResponse.success(OrderResponse.fromDomain(updated)); + } + + @Operation(summary = "Soft-delete an order") + @DeleteMapping("/{id}") + public ApiResponse delete( + @RequestHeader("X-Tenant-Id") Long tenantId, + @PathVariable("id") Long id) { + appService.softDelete(tenantId, id); + return ApiResponse.success(null); + } +} \ No newline at end of file diff --git a/tmpl-api/src/main/java/com/ljwx/tmpl/api/controller/package-info.java b/tmpl-api/src/main/java/com/ljwx/tmpl/api/controller/package-info.java new file mode 100644 index 0000000..0533c24 --- /dev/null +++ b/tmpl-api/src/main/java/com/ljwx/tmpl/api/controller/package-info.java @@ -0,0 +1,11 @@ +/** + * REST API controllers — HTTP endpoints and request/response handling. + * + *

Controllers depend only on application services (not infrastructure), + * satisfying the A-6 ArchUnit rule. All endpoints return { @link ApiResponse} + * wrappers with standardized error codes. + * + *

Security: tenant context is validated via { @code TenantContextFilter} + * (implemented in Batch 3), and all requests must carry X-Tenant-Id header. + */ +package com.ljwx.tmpl.api.controller; \ No newline at end of file diff --git a/tmpl-api/src/main/java/com/ljwx/tmpl/api/dto/ApiResponse.java b/tmpl-api/src/main/java/com/ljwx/tmpl/api/dto/ApiResponse.java new file mode 100644 index 0000000..dee7e95 --- /dev/null +++ b/tmpl-api/src/main/java/com/ljwx/tmpl/api/dto/ApiResponse.java @@ -0,0 +1,52 @@ +package com.ljwx.tmpl.api.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; + +/** + * Unified API response wrapper. + * + *

All REST endpoints return this structure: + *

+ * { "code": "OK", "message": "success", "data": { ... } }
+ * 
+ * + *

Error responses omit the {@code data} field: + *

+ * { "code": "ORDER_NOT_FOUND", "message": "order not found" }
+ * 
+ */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ApiResponse { + + private String code; + private String message; + private T data; + + protected ApiResponse() { + // For JSON deserialization only + } + + private ApiResponse(String code, String message, T data) { + this.code = code; + this.message = message; + this.data = data; + } + + public static ApiResponse success(T data) { + return new ApiResponse<>("OK", "success", data); + } + + public static ApiResponse error(String code, String message) { + return new ApiResponse<>(code, message, null); + } + + // Getters + public String getCode() { return code; } + public String getMessage() { return message; } + public T getData() { return data; } + + // Setters for deserialization + public void setCode(String code) { this.code = code; } + public void setMessage(String message) { this.message = message; } + public void setData(T data) { this.data = data; } +} \ No newline at end of file diff --git a/tmpl-api/src/main/java/com/ljwx/tmpl/api/dto/CreateOrderRequest.java b/tmpl-api/src/main/java/com/ljwx/tmpl/api/dto/CreateOrderRequest.java new file mode 100644 index 0000000..18ef889 --- /dev/null +++ b/tmpl-api/src/main/java/com/ljwx/tmpl/api/dto/CreateOrderRequest.java @@ -0,0 +1,38 @@ +package com.ljwx.tmpl.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; + +/** + * Request DTO for creating an order. + */ +@Schema(description = "Create order request") +public class CreateOrderRequest { + + @Schema(description = "Department ID", example = "10") + private Long deptId; + + @Schema(description = "Business order number", example = "ORD-20250101-001") + private String orderNo; + + @Schema(description = "Order amount", example = "199.99") + private BigDecimal amount; + + // Default constructor for JSON deserialization + public CreateOrderRequest() {} + + public CreateOrderRequest(Long deptId, String orderNo, BigDecimal amount) { + this.deptId = deptId; + this.orderNo = orderNo; + this.amount = amount; + } + + public Long getDeptId() { return deptId; } + public void setDeptId(Long deptId) { this.deptId = deptId; } + + public String getOrderNo() { return orderNo; } + public void setOrderNo(String orderNo) { this.orderNo = orderNo; } + + public BigDecimal getAmount() { return amount; } + public void setAmount(BigDecimal amount) { this.amount = amount; } +} \ No newline at end of file diff --git a/tmpl-api/src/main/java/com/ljwx/tmpl/api/dto/OrderResponse.java b/tmpl-api/src/main/java/com/ljwx/tmpl/api/dto/OrderResponse.java new file mode 100644 index 0000000..8399e1b --- /dev/null +++ b/tmpl-api/src/main/java/com/ljwx/tmpl/api/dto/OrderResponse.java @@ -0,0 +1,109 @@ +package com.ljwx.tmpl.api.dto; + +import com.ljwx.tmpl.domain.order.Order; +import io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * Response DTO for order information. + */ +@Schema(description = "Order response") +public class OrderResponse { + + @Schema(description = "Order ID", example = "1") + private Long id; + + @Schema(description = "Tenant ID", example = "100") + private Long tenantId; + + @Schema(description = "Department ID", example = "10") + private Long deptId; + + @Schema(description = "Order number", example = "ORD-20250101-001") + private String orderNo; + + @Schema(description = "Order amount", example = "199.99") + private BigDecimal amount; + + @Schema(description = "Order status", example = "CREATED") + private String status; + + @Schema(description = "Creator user ID", example = "42") + private Long createUserId; + + @Schema(description = "Updater user ID", example = "42") + private Long updateUserId; + + @Schema(description = "Creation time", example = "2025-01-01T10:00:00") + private LocalDateTime createTime; + + @Schema(description = "Update time", example = "2025-01-01T10:00:00") + private LocalDateTime updateTime; + + @Schema(description = "Soft delete flag", example = "false") + private Boolean deleted; + + @Schema(description = "Optimistic lock version", example = "0") + private Integer version; + + // Default constructor for JSON serialization + public OrderResponse() {} + + /** + * Convert domain object to API response DTO. + */ + public static OrderResponse fromDomain(Order order) { + OrderResponse response = new OrderResponse(); + response.id = order.getId(); + response.tenantId = order.getTenantId(); + response.deptId = order.getDeptId(); + response.orderNo = order.getOrderNo(); + response.amount = order.getAmount(); + response.status = order.getStatus(); + response.createUserId = order.getCreateUserId(); + response.updateUserId = order.getUpdateUserId(); + response.createTime = order.getCreateTime(); + response.updateTime = order.getUpdateTime(); + response.deleted = order.getDeleted(); + response.version = order.getVersion(); + return response; + } + + // Getters and setters + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getTenantId() { return tenantId; } + public void setTenantId(Long tenantId) { this.tenantId = tenantId; } + + public Long getDeptId() { return deptId; } + public void setDeptId(Long deptId) { this.deptId = deptId; } + + public String getOrderNo() { return orderNo; } + public void setOrderNo(String orderNo) { this.orderNo = orderNo; } + + public BigDecimal getAmount() { return amount; } + public void setAmount(BigDecimal amount) { this.amount = amount; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public Long getCreateUserId() { return createUserId; } + public void setCreateUserId(Long createUserId) { this.createUserId = createUserId; } + + public Long getUpdateUserId() { return updateUserId; } + public void setUpdateUserId(Long updateUserId) { this.updateUserId = updateUserId; } + + public LocalDateTime getCreateTime() { return createTime; } + public void setCreateTime(LocalDateTime createTime) { this.createTime = createTime; } + + public LocalDateTime getUpdateTime() { return updateTime; } + public void setUpdateTime(LocalDateTime updateTime) { this.updateTime = updateTime; } + + public Boolean getDeleted() { return deleted; } + public void setDeleted(Boolean deleted) { this.deleted = deleted; } + + public Integer getVersion() { return version; } + public void setVersion(Integer version) { this.version = version; } +} \ No newline at end of file diff --git a/tmpl-api/src/main/java/com/ljwx/tmpl/api/dto/UpdateOrderRequest.java b/tmpl-api/src/main/java/com/ljwx/tmpl/api/dto/UpdateOrderRequest.java new file mode 100644 index 0000000..5c1fa94 --- /dev/null +++ b/tmpl-api/src/main/java/com/ljwx/tmpl/api/dto/UpdateOrderRequest.java @@ -0,0 +1,23 @@ +package com.ljwx.tmpl.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * Request DTO for updating an order status. + */ +@Schema(description = "Update order status request") +public class UpdateOrderRequest { + + @Schema(description = "New status", example = "PAID") + private String status; + + // Default constructor for JSON deserialization + public UpdateOrderRequest() {} + + public UpdateOrderRequest(String status) { + this.status = status; + } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } +} \ No newline at end of file diff --git a/tmpl-api/src/main/java/com/ljwx/tmpl/api/dto/package-info.java b/tmpl-api/src/main/java/com/ljwx/tmpl/api/dto/package-info.java new file mode 100644 index 0000000..4dc1595 --- /dev/null +++ b/tmpl-api/src/main/java/com/ljwx/tmpl/api/dto/package-info.java @@ -0,0 +1,7 @@ +/** + * Data Transfer Objects for API request/response. + * + *

These classes are part of the HTTP contract and should not + * leak into application or domain layers. + */ +package com.ljwx.tmpl.api.dto; \ No newline at end of file diff --git a/tmpl-api/src/test/java/com/ljwx/tmpl/api/controller/OrderControllerTest.java b/tmpl-api/src/test/java/com/ljwx/tmpl/api/controller/OrderControllerTest.java new file mode 100644 index 0000000..f93795e --- /dev/null +++ b/tmpl-api/src/test/java/com/ljwx/tmpl/api/controller/OrderControllerTest.java @@ -0,0 +1,166 @@ +package com.ljwx.tmpl.api.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ljwx.tmpl.api.config.SecurityConfig; +import com.ljwx.tmpl.api.dto.CreateOrderRequest; +import com.ljwx.tmpl.api.dto.OrderResponse; +import com.ljwx.tmpl.api.dto.UpdateOrderRequest; +import com.ljwx.tmpl.application.order.OrderAppService; +import com.ljwx.tmpl.domain.order.Order; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Unit tests for { @link OrderController}. + * + *

Uses { @--- @WebMvcTest} to load only the web layer and + * mocks { @link OrderAppService} to isolate controller logic. + */ + @WebMvcTest(OrderController.class) + @---(SecurityConfig.class) // Include security config for permitAll +class OrderControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private OrderAppService appService; + + private Order createSampleOrder() { + Order order = new Order(1L, 10L, "ORD-001", new BigDecimal("99.99"), "CREATED"); + order.setId(1L); + order.setCreateUserId(42L); + order.setUpdateUserId(42L); + order.setCreateTime(LocalDateTime.now()); + order.setUpdateTime(LocalDateTime.now()); + order.setDeleted(false); + order.setVersion(0); + return order; + } + + @--- @DisplayName("POST /api/v1/orders → 200 OK with created order") + void create_success() throws Exception { + Order sampleOrder = createSampleOrder(); + CreateOrderRequest req = new CreateOrderRequest(); + req.setDeptId(10L); + req.setOrderNo("ORD-001"); + req.setAmount(new BigDecimal("99.99")); + + when(appService.create(eq(100L), eq(10L), eq("ORD-001"), eq(new BigDecimal("99.99")))) + .thenReturn(sampleOrder); + + mockMvc.perform(post("/api/v1/orders") + .header("X-Tenant-Id", "100") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("OK")) + .andExpect(jsonPath("$.data.id").value(1)) + .andExpect(jsonPath("$.data.orderNo").value("ORD-001")) + .andExpect(jsonPath("$.data.status").value("CREATED")); + + verify(appService).create(100L, 10L, "ORD-001", new BigDecimal("99.99")); + } + + @--- @DisplayName("GET /api/v1/orders/{id} → 200 OK with order") + void get_found() throws Exception { + Order sampleOrder = createSampleOrder(); + when(appService.get(eq(100L), eq(1L))).thenReturn(Optional.of(sampleOrder)); + + mockMvc.perform(get("/api/v1/orders/1") + .header("X-Tenant-Id", "100")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("OK")) + .andExpect(jsonPath("$.data.id").value(1)) + .andExpect(jsonPath("$.data.orderNo").value("ORD-001")); + } + + @--- @DisplayName("GET /api/v1/orders/{id} → 200 OK with error when not found") + void get_notFound() throws Exception { + when(appService.get(eq(100L), eq(999L))).thenReturn(Optional.empty()); + + mockMvc.perform(get("/api/v1/orders/999") + .header("X-Tenant-Id", "100")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("ORDER_NOT_FOUND")) + .andExpect(jsonPath("$.message").value("Order not found")) + .andExpect(jsonPath("$.data").doesNotExist()); + } + + @--- @DisplayName("GET /api/v1/orders → 200 OK with paged list") + void list_success() throws Exception { + Order sampleOrder = createSampleOrder(); + when(appService.list(eq(100L), eq(1), eq(20))).thenReturn(List.of(sampleOrder)); + + mockMvc.perform(get("/api/v1/orders") + .header("X-Tenant-Id", "100") + .param("pageNum", "1") + .param("pageSize", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("OK")) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.data[0].id").value(1)); + } + + @--- @DisplayName("PUT /api/v1/orders/{id}/status → 200 OK with updated order") + void updateStatus_success() throws Exception { + Order updatedOrder = createSampleOrder(); + updatedOrder.setStatus("PAID"); + + UpdateOrderRequest req = new UpdateOrderRequest(); + req.setStatus("PAID"); + + when(appService.updateStatus(eq(100L), eq(1L), eq("PAID"))) + .thenReturn(updatedOrder); + + mockMvc.perform(put("/api/v1/orders/1/status") + .header("X-Tenant-Id", "100") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("OK")) + .andExpect(jsonPath("$.data.status").value("PAID")); + + verify(appService).updateStatus(100L, 1L, "PAID"); + } + + @--- @DisplayName("DELETE /api/v1/orders/{id} → 200 OK") + void delete_success() throws Exception { + doNothing().when(appService).softDelete(100L, 1L); + + mockMvc.perform(delete("/api/v1/orders/1") + .header("X-Tenant-Id", "100")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("OK")) + .andExpect(jsonPath("$.data").value(org.hamcrest.Matchers.nullValue())); + + verify(appService).softDelete(100L, 1L); + } +} \ No newline at end of file diff --git a/tmpl-api/src/test/java/com/ljwx/tmpl/api/controller/package-info.java b/tmpl-api/src/test/java/com/ljwx/tmpl/api/controller/package-info.java new file mode 100644 index 0000000..5cfa9cf --- /dev/null +++ b/tmpl-api/src/test/java/com/ljwx/tmpl/api/controller/package-info.java @@ -0,0 +1,7 @@ +/** + * Unit tests for API controllers using @WebMvcTest. + * + *

Mocks application services to isolate controller logic. + * Verifies request mapping, parameter binding, and response formatting. + */ +package com.ljwx.tmpl.api.controller; \ No newline at end of file diff --git a/tmpl-api/src/test/java/com/ljwx/tmpl/api/integration/OrderApiIntegrationTest.java b/tmpl-api/src/test/java/com/ljwx/tmpl/api/integration/OrderApiIntegrationTest.java new file mode 100644 index 0000000..cb4fab2 --- /dev/null +++ b/tmpl-api/src/test/java/com/ljwx/tmpl/api/integration/OrderApiIntegrationTest.java @@ -0,0 +1,177 @@ +package com.ljwx.tmpl.api.integration; + +import com.ljwx.tmpl.api.dto.CreateOrderRequest; +import com.ljwx.tmpl.api.dto.UpdateOrderRequest; +import com.ljwx.tmpl.api.integration.support.AbstractPostgresIT; +import com.ljwx.tmpl.application.context.TenantContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end integration test for Order CRUD API. + * + *

Verifies the complete request/response cycle: + * HTTP → Controller → AppService → Repository → PostgreSQL (Testcontainers). + * Confirms tenant isolation, audit field population, and SpringDoc availability. + */ + @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class OrderApiIntegrationTest extends AbstractPostgresIT { + + @Autowired + private TestRestTemplate restTemplate; + + @BeforeEach + void setUp() { + TenantContext.clear(); + } + + private HttpHeaders createTenantHeaders(String tenantId) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Tenant-Id", tenantId); + headers.set("X-Dept-Id", "10"); + headers.set("X-User-Id", "42"); + headers.set("X-User-Roles", "ROLE_USER"); + return headers; + } + + @Test + @DisplayName("Create order → GET → verify response") + void createThenGet() { + HttpHeaders headers = createTenantHeaders("100"); + + // Create order + CreateOrderRequest createReq = new CreateOrderRequest(); + createReq.setDeptId(10L); + createReq.setOrderNo("IT-ORD-001"); + createReq.setAmount(new BigDecimal("88.50")); + + ResponseEntity createResp = restTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(createReq, headers), + String.class); + + assertThat(createResp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(createResp.getBody()) + .contains("\"code\":\"OK\"") + .contains("\"orderNo\":\"IT-ORD-001\"") + .contains("\"status\":\"CREATED\"") + .contains("\"createUserId\""); + + // List orders + ResponseEntity listResp = restTemplate.exchange( + "/api/v1/orders?pageNum=1&pageSize=10", + HttpMethod.GET, + new HttpEntity<>(headers), + String.class); + + assertThat(listResp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(listResp.getBody()).contains("IT-ORD-001"); + } + + @Test + @DisplayName("Update order status") + void updateStatus() { + HttpHeaders headers = createTenantHeaders("100"); + + // Create first + CreateOrderRequest createReq = new CreateOrderRequest(); + createReq.setDeptId(10L); + createReq.setOrderNo("IT-ORD-002"); + createReq.setAmount(new BigDecimal("50.00")); + + ResponseEntity createResp = restTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(createReq, headers), + String.class); + assertThat(createResp.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Extract ID from response (simple string parsing) + String body = createResp.getBody(); + int idStart = body.indexOf("\"id\":") + 5; + int idEnd = body.indexOf(",", idStart); + if (idEnd == -1) idEnd = body.indexOf("}", idStart); // in case it's the last field + String idStr = body.substring(idStart, idEnd).trim(); + Long orderId = Long.valueOf(idStr); + + // Update status + UpdateOrderRequest updateReq = new UpdateOrderRequest(); + updateReq.setStatus("PAID"); + + ResponseEntity updateResp = restTemplate.exchange( + "/api/v1/orders/" + orderId + "/status", + HttpMethod.PUT, + new HttpEntity<>(updateReq, headers), + String.class); + + assertThat(updateResp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(updateResp.getBody()).contains("\"status\":\"PAID\""); + } + + @Test + @DisplayName("Soft-delete order → GET returns not found") + void softDelete() { + HttpHeaders headers = createTenantHeaders("100"); + + // Create first + CreateOrderRequest createReq = new CreateOrderRequest(); + createReq.setDeptId(10L); + createReq.setOrderNo("IT-ORD-003"); + createReq.setAmount(new BigDecimal("30.00")); + + ResponseEntity createResp = restTemplate.exchange( + "/api/v1/orders", + HttpMethod.POST, + new HttpEntity<>(createReq, headers), + String.class); + assertThat(createResp.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Extract ID + String body = createResp.getBody(); + int idStart = body.indexOf("\"id\":") + 5; + int idEnd = body.indexOf(",", idStart); + if (idEnd == -1) idEnd = body.indexOf("}", idStart); + String idStr = body.substring(idStart, idEnd).trim(); + Long orderId = Long.valueOf(idStr); + + // Delete + ResponseEntity deleteResp = restTemplate.exchange( + "/api/v1/orders/" + orderId, + HttpMethod.DELETE, + new HttpEntity<>(headers), + String.class); + assertThat(deleteResp.getStatusCode()).isEqualTo(HttpStatus.OK); + + // GET should return not found (soft-deleted) + ResponseEntity getResp = restTemplate.exchange( + "/api/v1/orders/" + orderId, + HttpMethod.GET, + new HttpEntity<>(headers), + String.class); + assertThat(getResp.getBody()).contains("ORDER_NOT_FOUND"); + } + + @Test + @DisplayName("SpringDoc OpenAPI endpoint is accessible") + void openApiEndpoint() { + ResponseEntity resp = restTemplate.getForEntity("/v3/api-docs", String.class); + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(resp.getBody()).contains("\"openapi\"").contains("Order"); + } +} \ No newline at end of file diff --git a/tmpl-api/src/test/java/com/ljwx/tmpl/api/integration/package-info.java b/tmpl-api/src/test/java/com/ljwx/tmpl/api/integration/package-info.java index a80f934..7166608 100644 --- a/tmpl-api/src/test/java/com/ljwx/tmpl/api/integration/package-info.java +++ b/tmpl-api/src/test/java/com/ljwx/tmpl/api/integration/package-info.java @@ -1,7 +1,7 @@ /** - * Integration tests using Testcontainers (PostgreSQL). + * Integration tests using Testcontainers for end-to-end verification. * - *

Verify end-to-end behaviour: Flyway migration, JPA auditing, - * persistence layer correctness, and tenant isolation. + *

Verifies full request/response cycle: HTTP → Controller → AppService → Repository → DB. + * Includes tenant isolation, audit field population, and security validation. */ package com.ljwx.tmpl.api.integration; \ No newline at end of file diff --git a/tmpl-api/src/test/java/com/ljwx/tmpl/api/integration/support/AbstractPostgresIT.java b/tmpl-api/src/test/java/com/ljwx/tmpl/api/integration/support/AbstractPostgresIT.java new file mode 100644 index 0000000..a5b8dd6 --- /dev/null +++ b/tmpl-api/src/test/java/com/ljwx/tmpl/api/integration/support/AbstractPostgresIT.java @@ -0,0 +1,41 @@ +package com.ljwx.tmpl.api.integration.support; + +import com.ljwx.tmpl.api.TmplApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Base class for integration tests requiring a PostgreSQL database. + * + *

Starts a shared Testcontainers PostgreSQL instance and configures + * Spring properties. Redis auto-configuration is disabled to avoid + * connection failures during tests that don't require Redis. + * + *

Subclasses should use { @--- @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)} + * to start the full application context. + */ + @Testcontainers@SpringBootTest(classes = TmplApplication.class) +public abstract class AbstractPostgresIT { + + @Container + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:15-alpine") + .withDatabaseName("tmpl_test") + .withUsername("tmpl") + .withPassword("tmpl_pass"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.flyway.enabled", () -> "true"); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); + // Disable Redis auto-configuration to avoid connection issues + registry.add("spring.autoconfigure.exclude", + () -> "org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration"); + } +} \ No newline at end of file diff --git a/tmpl-api/src/test/java/com/ljwx/tmpl/api/integration/support/package-info.java b/tmpl-api/src/test/java/com/ljwx/tmpl/api/integration/support/package-info.java new file mode 100644 index 0000000..90ab66a --- /dev/null +++ b/tmpl-api/src/test/java/com/ljwx/tmpl/api/integration/support/package-info.java @@ -0,0 +1,7 @@ +/** + * Support classes for integration tests. + * + *

Includes Testcontainers base classes, test utilities, + * and shared test configuration. + */ +package com.ljwx.tmpl.api.integration.support; \ No newline at end of file diff --git a/tmpl-application/src/main/java/com/ljwx/tmpl/application/order/OrderAppService.java b/tmpl-application/src/main/java/com/ljwx/tmpl/application/order/OrderAppService.java new file mode 100644 index 0000000..eedc4f2 --- /dev/null +++ b/tmpl-application/src/main/java/com/ljwx/tmpl/application/order/OrderAppService.java @@ -0,0 +1,98 @@ +package com.ljwx.tmpl.application.order; + +import com.ljwx.tmpl.domain.exception.DomainException; +import com.ljwx.tmpl.domain.exception.ErrorCode; +import com.ljwx.tmpl.domain.order.Order; +import com.ljwx.tmpl.domain.order.OrderRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; + +/** + * Application service for order operations. + * + *

{@code @Transactional(readOnly=true)} at class level; write methods + * override with {@code @Transactional}. This is the only + * place where {@code @Transactional} may appear (ArchUnit A-4). + * + *

Depends on {@link OrderRepository} (domain port), NOT on any + * infrastructure adapter (ArchUnit A-1 / A-3). + */ +@Service +@Transactional(readOnly = true) +public class OrderAppService { + + private final OrderRepository orderRepository; + + public OrderAppService(OrderRepository orderRepository) { + this.orderRepository = orderRepository; + } + + @Transactional + public Order create(Long tenantId, Long deptId, String orderNo, BigDecimal amount) { + if (tenantId == null || tenantId <= 0) { + throw new DomainException(ErrorCode.COMMON_BAD_REQUEST, "tenantId is invalid"); + } + if (deptId == null || deptId <= 0) { + throw new DomainException(ErrorCode.COMMON_BAD_REQUEST, "deptId is invalid"); + } + if (orderNo == null || orderNo.isBlank()) { + throw new DomainException(ErrorCode.COMMON_BAD_REQUEST, "orderNo is required"); + } + if (amount == null || amount.compareTo(BigDecimal.ZERO) < 0) { + throw new DomainException(ErrorCode.ORDER_AMOUNT_INVALID, "amount must not be negative"); + } + + Order order = new Order(tenantId, deptId, orderNo, amount, "CREATED"); + return orderRepository.save(order); + } + + public Optional get(Long tenantId, Long id) { + if (tenantId == null || tenantId <= 0 || id == null || id <= 0) { + return Optional.empty(); + } + return orderRepository.findByIdAndTenantId(id, tenantId) + .filter(o -> !Boolean.TRUE.equals(o.getDeleted())); + } + + public List list(Long tenantId, int pageNum, int pageSize) { + if (tenantId == null || tenantId <= 0) { + return List.of(); + } + if (pageNum < 1) pageNum = 1; + if (pageSize < 1) pageSize = 20; + if (pageSize > 200) pageSize = 200; + return orderRepository.findAllByTenantId(tenantId, pageNum, pageSize); + } + + @Transactional + public Order updateStatus(Long tenantId, Long id, String status) { + if (status == null || status.isBlank()) { + throw new DomainException(ErrorCode.COMMON_BAD_REQUEST, "status is required"); + } + + Order order = orderRepository.findByIdAndTenantId(id, tenantId) + .orElseThrow(() -> new DomainException(ErrorCode.ORDER_NOT_FOUND, "order not found")); + + if (Boolean.TRUE.equals(order.getDeleted())) { + throw new DomainException(ErrorCode.ORDER_STATUS_INVALID, "cannot update a deleted order"); + } + + order.setStatus(status); + return orderRepository.save(order); + } + + @Transactional + public void softDelete(Long tenantId, Long id) { + Order order = orderRepository.findByIdAndTenantId(id, tenantId) + .orElseThrow(() -> new DomainException(ErrorCode.ORDER_NOT_FOUND, "order not found")); + + if (!Boolean.TRUE.equals(order.getDeleted())) { + order.setDeleted(true); + orderRepository.save(order); + } + } +} \ No newline at end of file diff --git a/tmpl-domain/src/main/java/com/ljwx/tmpl/domain/exception/ErrorCode.java b/tmpl-domain/src/main/java/com/ljwx/tmpl/domain/exception/ErrorCode.java index 54d0533..41cedf4 100644 --- a/tmpl-domain/src/main/java/com/ljwx/tmpl/domain/exception/ErrorCode.java +++ b/tmpl-domain/src/main/java/com/ljwx/tmpl/domain/exception/ErrorCode.java @@ -1,45 +1,47 @@ package com.ljwx.tmpl.domain.exception; /** - * 全局错误码枚举 —— 唯一映射源。 + * Global error code enum — single source of truth. * - *

每个枚举自带 HTTP 状态码与默认消息,{@code GlobalExceptionHandler} - * 仅负责将其翻译为 {@code ApiResponse},不做额外映射。 + *

Each enum carries HTTP status and default message. + * { @code GlobalExceptionHandler} translates to { @code ApiResponse}, + * no extra mapping. * - *

不可变契约

+ *

Immutable Contract

*
    - *
  • 已发布的枚举 name 与 httpStatus 不可修改
  • - *
  • 废弃时使用 {@code @Deprecated},不可删除
  • - *
  • 只可新增
  • + *
  • Published enum { @code name} and { @code httpStatus} must not change
  • + *
  • Deprecate with { @code @Deprecated}, never delete
  • + *
  • Additions only
  • *
* * @see DomainException - * @see 错误码契约文档 + * @see Error Code Contract Doc */ public enum ErrorCode { - // ==================== COMMON(通用) ==================== - COMMON_INVALID_PARAM(400, "参数校验失败"), - COMMON_NOT_FOUND(404, "资源不存在"), - COMMON_RESOURCE_ALREADY_EXISTS(409, "资源已存在"), - COMMON_OPTIMISTIC_LOCK(409, "数据已被修改,请刷新重试"), - COMMON_INTERNAL_ERROR(500, "系统内部错误"), + // ==================== COMMON ==================== + COMMON_INVALID_PARAM(400, "Invalid parameter"), + COMMON_NOT_FOUND(404, "Resource not found"), + COMMON_RESOURCE_ALREADY_EXISTS(409, "Resource already exists"), + COMMON_OPTIMISTIC_LOCK(409, "Data has been modified, please refresh"), + COMMON_INTERNAL_ERROR(500, "Internal server error"), + COMMON_BAD_REQUEST(400, "Bad request"), - // ==================== AUTH(认证/租户) ==================== - AUTH_MISSING_TENANT(400, "缺少租户标识(X-Tenant-Id)"), - AUTH_MISSING_DEPT(400, "缺少部门标识(X-Dept-Id)"), - AUTH_MISSING_USER(400, "缺少用户标识(X-User-Id)"), - AUTH_INVALID_USER_ID(400, "用户标识格式不合法"), - AUTH_HEADER_CONFLICT(400, "新旧租户 Header 值不一致"), - AUTH_LEGACY_HEADER_NOT_ALLOWED(400, "当前环境不允许使用旧版 Header"), - AUTH_INVALID_HEADER_VALUE(400, "Header 值格式非法"), - AUTH_ACCESS_DENIED(403, "无权限"), - AUTH_FORBIDDEN_SCOPE(403, "无权访问该部门范围的数据"), + // ==================== AUTH ==================== + AUTH_MISSING_TENANT(400, "Missing tenant identifier (X-Tenant-Id)"), + AUTH_MISSING_DEPT(400, "Missing department identifier (X-Dept-Id)"), + AUTH_MISSING_USER(400, "Missing user identifier (X-User-Id)"), + AUTH_INVALID_USER_ID(400, "User identifier format invalid"), + AUTH_HEADER_CONFLICT(400, "New/old tenant header values differ"), + AUTH_LEGACY_HEADER_NOT_ALLOWED(400, "Legacy header not allowed in current env"), + AUTH_INVALID_HEADER_VALUE(400, "Header value format illegal"), + AUTH_ACCESS_DENIED(403, "Access denied"), + AUTH_FORBIDDEN_SCOPE(403, "Forbidden scope access"), - // ==================== ORDER(示例领域) ==================== - ORDER_NOT_FOUND(404, "订单不存在"), - ORDER_AMOUNT_INVALID(422, "订单金额不合法"), - ORDER_STATUS_INVALID(422, "订单状态不允许此操作"), + // ==================== ORDER ==================== + ORDER_NOT_FOUND(404, "Order not found"), + ORDER_AMOUNT_INVALID(422, "Order amount invalid"), + ORDER_STATUS_INVALID(422, "Order status invalid"), ; private final int httpStatus; diff --git a/tmpl-domain/src/main/java/com/ljwx/tmpl/domain/order/OrderRepository.java b/tmpl-domain/src/main/java/com/ljwx/tmpl/domain/order/OrderRepository.java index e769e81..1fd1e3d 100644 --- a/tmpl-domain/src/main/java/com/ljwx/tmpl/domain/order/OrderRepository.java +++ b/tmpl-domain/src/main/java/com/ljwx/tmpl/domain/order/OrderRepository.java @@ -1,5 +1,6 @@ package com.ljwx.tmpl.domain.order; +import java.util.List; import java.util.Optional; /** @@ -30,4 +31,14 @@ public interface OrderRepository { * @return the order if found, otherwise empty */ Optional findByIdAndTenantId(Long id, Long tenantId); + + /** + * Find all orders for a tenant with simple pagination. + * + * @param tenantId tenant id for data isolation + * @param pageNum page number, starting from 1 + * @param pageSize number of items per page + * @return list of orders (may be empty) + */ + List findAllByTenantId(Long tenantId, int pageNum, int pageSize); } \ No newline at end of file diff --git a/tmpl-infrastructure/pom.xml b/tmpl-infrastructure/pom.xml index a41f7ba..e66e4df 100644 --- a/tmpl-infrastructure/pom.xml +++ b/tmpl-infrastructure/pom.xml @@ -8,7 +8,7 @@ com.ljwx.tmpl java-service-template - 0.0.1-SNAPSHOT + 0.1.0-SNAPSHOT tmpl-infrastructure diff --git a/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/persistence/order/OrderDao.java b/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/persistence/order/OrderDao.java index 7ddd1c2..9c0a8a3 100644 --- a/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/persistence/order/OrderDao.java +++ b/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/persistence/order/OrderDao.java @@ -2,19 +2,22 @@ package com.ljwx.tmpl.infrastructure.persistence.order; import com.ljwx.tmpl.domain.order.Order; import com.ljwx.tmpl.domain.order.OrderRepository; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; /** * Infrastructure adapter implementing the domain - * {@link OrderRepository} port. + * { @link OrderRepository} port. * - *

Delegates to the package-private {@link OrderJpaRepository} for - * data access and uses {@link OrderMapper} (MapStruct) for - * {@link Order} ↔ {@link OrderPO} conversion. + *

Delegates to the package-private { @link OrderJpaRepository} for + * data access and uses { @link OrderMapper} (MapStruct) for + * { @link Order} ↔ { @link OrderPO} conversion. */ -@Repository + @Repository public class OrderDao implements OrderRepository { private final OrderJpaRepository jpaRepo; @@ -29,21 +32,30 @@ public class OrderDao implements OrderRepository { public Order save(Order order) { OrderPO po; if (order.getId() != null) { - // Update: load managed entity to preserve audit + version, - // then apply business fields only. + // Update: load managed entity to preserve audit + version po = jpaRepo.findById(order.getId()).orElseGet(OrderPO::new); - mapper.updatePO(order, po); + mapper.updatePOFromDomain(order, po); } else { - // Insert: create new PO with business fields only. - po = mapper.toPO(order); + // Insert: create new PO with business fields only + po = mapper.toPOFromDomain(order); } OrderPO saved = jpaRepo.save(po); - return mapper.toDomain(saved); + return mapper.toDomainFromPO(saved); } @Override public Optional findByIdAndTenantId(Long id, Long tenantId) { return jpaRepo.findByIdAndTenantId(id, tenantId) - .map(mapper::toDomain); + .map(mapper::toDomainFromPO); + } + + @Override + public List findAllByTenantId(Long tenantId, int pageNum, int pageSize) { + PageRequest pageable = PageRequest.of(pageNum - 1, pageSize, Sort.by(Sort.Direction.DESC, "id")); + return jpaRepo.findAllByTenantIdAndDeletedFalse(tenantId, pageable) + .getContent() + .stream() + .map(mapper::toDomainFromPO) + .toList(); } } \ No newline at end of file diff --git a/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/persistence/order/OrderJpaRepository.java b/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/persistence/order/OrderJpaRepository.java index e074fe3..7ad4d60 100644 --- a/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/persistence/order/OrderJpaRepository.java +++ b/tmpl-infrastructure/src/main/java/com/ljwx/tmpl/infrastructure/persistence/order/OrderJpaRepository.java @@ -1,16 +1,21 @@ package com.ljwx.tmpl.infrastructure.persistence.order; +import com.ljwx.tmpl.infrastructure.persistence.BaseAuditPO; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; /** - * Spring Data JPA repository for {@link OrderPO}. + * Spring Data JPA repository for { @link OrderPO}. * *

Package-private — external code must use - * {@link OrderDao} instead. ArchUnit rule A-7 enforces this. + * { @link OrderDao} instead. ArchUnit rule A-7 enforces this. */ interface OrderJpaRepository extends JpaRepository { Optional findByIdAndTenantId(Long id, Long tenantId); + + Page findAllByTenantIdAndDeletedFalse(Long tenantId, Pageable pageable); } \ No newline at end of file