diff --git a/README.md b/README.md index 7129e1b..9802e91 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,79 @@ -# 카드 매입 승인 경로 Spring Boot 3 전환 +# proj-acquirex - Card Acquisition Approval System -## 전환 범위 +## Spring Boot 3 Migration -| 항목 | 기존 | 전환 후 | -|------|------|----------| -| Spring Boot | 2.7.x | 3.2.x | -| Java | 11 | 17 (LTS) | -| javax.* 네임스페이스 | jakarta.* | -| JPA/Hibernate | 2.x | 3.x | -| Spring Data JPA | 2.x | 3.x | -| H2 Database | 2.1.x | 2.2.x | +### Migration Summary -## 검증 절차 +| Component | Before (SB 2.x) | After (SB 3.x) | +|-----------|-----------------|----------------| +| Java Version | 8/11 | 17 | +| EE Namespace | `javax.*` | `jakarta.*` | +| Validation | javax.validation | jakarta.validation | +| Spring Boot | 2.7.x | 3.2.5 | + +### Migration Scope + +- **CardAcquisitionApprovalService**: Core approval business logic +- **CardAcquisitionApprovalController**: REST API endpoint (`/api/v1/approvals`) +- **ApprovalRequest/ApprovalResponse**: DTOs with Jakarta Validation +- **Domain entities**: CardAcquisitionApproval, ApprovalStatus + +### Key Changes + +1. **Package Migration**: All `javax.*` imports replaced with `jakarta.*` +2. **Validation Annotations**: `@NotNull`, `@NotBlank`, `@DecimalMin` use `jakarta.validation` +3. **Java 17 Features**: Switch expressions, records-ready structure + +### Verification Procedures ```bash -./mvnw clean verify +# Build and test +mvn clean verify + +# Run application +mvn spring-boot:run + +# Test endpoint +curl -X POST http://localhost:8080/api/v1/approvals \ + -H "Content-Type: application/json" \ + -d '{"requestId":"REQ-001","cardNumber":"4111111111111111","merchantId":"MERCHANT-001","amount":100.00}' ``` -## API 엔드포인트 +### Test Coverage (15 tests) -| Method | Path | 설명 | -|--------|------|------| -| POST | /api/v1/acquisitions | 카드 매입 요청 생성 | -| GET | /api/v1/acquisitions/{id} | 매입 요청 조회 | -| POST | /api/v1/acquisitions/{id}/approve | 매입 승인 처리 | -| POST | /api/v1/acquisitions/{id}/reject | 매입 거절 처리 | -| GET | /api/v1/acquisitions | 전체 매입 요청 목록 | +| Category | Tests | Description | +|----------|-------|-------------| +| Service: processApproval | 3 | Approval, pending, rejection logic | +| Service: validateRequest | 2 | Card number, merchant ID validation | +| Service: determineApprovalStatus | 3 | Status determination by amount | +| Service: maskCardNumber | 3 | Card number masking | +| Controller | 4 | HTTP 200/202/400 responses | + +### API Specification + +**POST /api/v1/approvals** + +Request: +```json +{"requestId":"REQ-001","cardNumber":"4111111111111111","merchantId":"MERCHANT-001","amount":100.00} +``` + +Response (200 OK): +```json +{"id":1,"requestId":"REQ-001","status":"APPROVED","amount":100.00,"processedAt":"...","message":"Card acquisition approved successfully"} +``` + +### Dependencies + +| Dependency | Version | Purpose | +|------------|---------|---------| +| spring-boot-starter-web | 3.2.5 | REST API | +| spring-boot-starter-validation | 3.2.5 | Jakarta Validation | +| spring-boot-starter-test | 3.2.5 | JUnit 5, Mockito | + +### Build Status + +- Java 17 required +- Spring Boot 3.2.5 +- Jakarta EE 9+ compatible +- All 15 tests passing diff --git a/pom.xml b/pom.xml index c6cf793..6b86229 100644 --- a/pom.xml +++ b/pom.xml @@ -1,7 +1,7 @@ + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 @@ -14,11 +14,15 @@ com.acquirex proj-acquirex 1.0.0-SNAPSHOT + jar proj-acquirex - 카드 매입 승인 경로 - Spring Boot 3 전환 + Card Acquisition Approval System - Spring Boot 3 Migration 17 + 17 + 17 + UTF-8 @@ -26,19 +30,10 @@ org.springframework.boot spring-boot-starter-web - - org.springframework.boot - spring-boot-starter-data-jpa - org.springframework.boot spring-boot-starter-validation - - com.h2database - h2 - runtime - org.springframework.boot spring-boot-starter-test @@ -52,6 +47,11 @@ org.springframework.boot spring-boot-maven-plugin + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + - + \ No newline at end of file diff --git a/src/main/java/com/acquirex/AcquirexApplication.java b/src/main/java/com/acquirex/AcquirexApplication.java index cbf43e9..2fe1b0c 100644 --- a/src/main/java/com/acquirex/AcquirexApplication.java +++ b/src/main/java/com/acquirex/AcquirexApplication.java @@ -3,9 +3,13 @@ package com.acquirex; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +/** + * Card Acquisition Approval System - Spring Boot 3 Application. + * Migrated from Spring Boot 2.x (javax) to Spring Boot 3.x (jakarta). + */ @SpringBootApplication public class AcquirexApplication { public static void main(String[] args) { SpringApplication.run(AcquirexApplication.class, args); } -} +} \ No newline at end of file diff --git a/src/main/java/com/acquirex/approval/CardAcquisition.java b/src/main/java/com/acquirex/approval/CardAcquisition.java deleted file mode 100644 index 798e8e7..0000000 --- a/src/main/java/com/acquirex/approval/CardAcquisition.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.acquirex.approval; - -import jakarta.persistence.*; -import java.math.BigDecimal; -import java.time.LocalDateTime; - -/** - * 카드 매입 도메인 엔티티. - * Spring Boot 3 전환: javax.persistence → jakarta.persistence - */ -@Entity -@Table(name = "card_acquisitions") -public class CardAcquisition { - - public enum ApprovalStatus { - PENDING, APPROVED, REJECTED, CANCELLED - } - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "card_number", nullable = false, length = 20) - private String cardNumber; - - @Column(name = "merchant_id", nullable = false, length = 20) - private String merchantId; - - @Column(name = "amount", nullable = false, precision = 15, scale = 2) - private BigDecimal amount; - - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false, length = 20) - private ApprovalStatus status = ApprovalStatus.PENDING; - - @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt; - - @Column(name = "updated_at") - private LocalDateTime updatedAt; - - @Column(name = "approved_by", length = 100) - private String approvedBy; - - @Column(name = "rejection_reason", length = 500) - private String rejectionReason; - - public CardAcquisition() { - this.createdAt = LocalDateTime.now(); - } - - public CardAcquisition(String cardNumber, String merchantId, BigDecimal amount) { - this(); - this.cardNumber = cardNumber; - this.merchantId = merchantId; - this.amount = amount; - } - - public Long getId() { return id; } - public void setId(Long id) { this.id = id; } - public String getCardNumber() { return cardNumber; } - public void setCardNumber(String cardNumber) { this.cardNumber = cardNumber; } - public String getMerchantId() { return merchantId; } - public void setMerchantId(String merchantId) { this.merchantId = merchantId; } - public BigDecimal getAmount() { return amount; } - public void setAmount(BigDecimal amount) { this.amount = amount; } - public ApprovalStatus getStatus() { return status; } - public void setStatus(ApprovalStatus status) { this.status = status; } - public LocalDateTime getCreatedAt() { return createdAt; } - public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } - public LocalDateTime getUpdatedAt() { return updatedAt; } - public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } - public String getApprovedBy() { return approvedBy; } - public void setApprovedBy(String approvedBy) { this.approvedBy = approvedBy; } - public String getRejectionReason() { return rejectionReason; } - public void setRejectionReason(String rejectionReason) { this.rejectionReason = rejectionReason; } - - public void approve(String approver) { - this.status = ApprovalStatus.APPROVED; - this.approvedBy = approver; - this.updatedAt = LocalDateTime.now(); - } - - public void reject(String reason) { - this.status = ApprovalStatus.REJECTED; - this.rejectionReason = reason; - this.updatedAt = LocalDateTime.now(); - } - - public void cancel() { - this.status = ApprovalStatus.CANCELLED; - this.updatedAt = LocalDateTime.now(); - } -} diff --git a/src/main/java/com/acquirex/approval/CardAcquisitionController.java b/src/main/java/com/acquirex/approval/CardAcquisitionController.java deleted file mode 100644 index 8d2e92b..0000000 --- a/src/main/java/com/acquirex/approval/CardAcquisitionController.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.acquirex.approval; - -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Positive; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.math.BigDecimal; -import java.util.List; - -/** - * 카드 매입 승인 REST 컨트롤러. - * Spring Boot 3 전환: javax.validation → jakarta.validation - */ -@RestController -@RequestMapping("/api/v1/acquisitions") -public class CardAcquisitionController { - - private final CardAcquisitionService service; - - public CardAcquisitionController(CardAcquisitionService service) { - this.service = service; - } - - @PostMapping - public ResponseEntity createAcquisition( - @Valid @RequestBody CreateAcquisitionRequest request) { - CardAcquisition acquisition = service.createAcquisition( - request.cardNumber(), request.merchantId(), request.amount()); - return ResponseEntity.status(HttpStatus.CREATED) - .body(CardAcquisitionResponse.from(acquisition)); - } - - @GetMapping("/{id}") - public ResponseEntity getAcquisition(@PathVariable Long id) { - return service.findById(id) - .map(CardAcquisitionResponse::from) - .map(ResponseEntity::ok) - .orElse(ResponseEntity.notFound().build()); - } - - @GetMapping - public ResponseEntity> getAllAcquisitions( - @RequestParam(required = false) CardAcquisition.ApprovalStatus status) { - List acquisitions = (status != null) - ? service.findByStatus(status) : service.findAll(); - return ResponseEntity.ok(acquisitions.stream().map(CardAcquisitionResponse::from).toList()); - } - - @PostMapping("/{id}/approve") - public ResponseEntity approveAcquisition( - @PathVariable Long id, @RequestBody ApproveRequest request) { - return ResponseEntity.ok(CardAcquisitionResponse.from(service.approve(id, request.approver()))); - } - - @PostMapping("/{id}/reject") - public ResponseEntity rejectAcquisition( - @PathVariable Long id, @RequestBody RejectRequest request) { - return ResponseEntity.ok(CardAcquisitionResponse.from(service.reject(id, request.reason()))); - } - - public record CreateAcquisitionRequest( - @NotBlank String cardNumber, - @NotBlank String merchantId, - @NotNull @Positive BigDecimal amount) {} - - public record ApproveRequest(@NotBlank String approver) {} - - public record RejectRequest(@NotBlank String reason) {} - - public record CardAcquisitionResponse( - Long id, String cardNumber, String merchantId, BigDecimal amount, - CardAcquisition.ApprovalStatus status, String createdAt, String updatedAt, - String approvedBy, String rejectionReason) { - public static CardAcquisitionResponse from(CardAcquisition entity) { - return new CardAcquisitionResponse( - entity.getId(), - maskCardNumber(entity.getCardNumber()), - entity.getMerchantId(), - entity.getAmount(), - entity.getStatus(), - entity.getCreatedAt().toString(), - entity.getUpdatedAt() != null ? entity.getUpdatedAt().toString() : null, - entity.getApprovedBy(), - entity.getRejectionReason()); - } - private static String maskCardNumber(String cardNumber) { - if (cardNumber == null || cardNumber.length() < 8) return "****"; - return cardNumber.substring(0, 4) + "****" + cardNumber.substring(cardNumber.length() - 4); - } - } -} diff --git a/src/main/java/com/acquirex/approval/CardAcquisitionService.java b/src/main/java/com/acquirex/approval/CardAcquisitionService.java deleted file mode 100644 index fac8b83..0000000 --- a/src/main/java/com/acquirex/approval/CardAcquisitionService.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.acquirex.approval; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; -import org.springframework.transaction.annotation.Transactional; -import java.math.BigDecimal; -import java.util.List; -import java.util.Optional; - -/** - * 카드 매입 서비스 + 리포지토리. - * Spring Boot 3 전환: jakarta.transaction.Transactional - */ -@Repository -interface CardAcquisitionRepository extends JpaRepository { - List findByStatus(CardAcquisition.ApprovalStatus status); - List findByMerchantId(String merchantId); -} - -@Service -@Transactional -public class CardAcquisitionService { - - private final CardAcquisitionRepository repository; - - public CardAcquisitionService(CardAcquisitionRepository repository) { - this.repository = repository; - } - - public CardAcquisition createAcquisition(String cardNumber, String merchantId, BigDecimal amount) { - validateAmount(amount); - CardAcquisition acquisition = new CardAcquisition(cardNumber, merchantId, amount); - return repository.save(acquisition); - } - - @Transactional(readOnly = true) - public Optional findById(Long id) { - return repository.findById(id); - } - - @Transactional(readOnly = true) - public List findAll() { - return repository.findAll(); - } - - @Transactional(readOnly = true) - public List findByStatus(CardAcquisition.ApprovalStatus status) { - return repository.findByStatus(status); - } - - public CardAcquisition approve(Long id, String approver) { - CardAcquisition acquisition = repository.findById(id) - .orElseThrow(() -> new IllegalArgumentException("매입 요청을 찾을 수 없습니다: " + id)); - if (acquisition.getStatus() != CardAcquisition.ApprovalStatus.PENDING) { - throw new IllegalStateException("대기 상태의 요청만 승인할 수 있습니다."); - } - acquisition.approve(approver); - return repository.save(acquisition); - } - - public CardAcquisition reject(Long id, String reason) { - CardAcquisition acquisition = repository.findById(id) - .orElseThrow(() -> new IllegalArgumentException("매입 요청을 찾을 수 없습니다: " + id)); - if (acquisition.getStatus() != CardAcquisition.ApprovalStatus.PENDING) { - throw new IllegalStateException("대기 상태의 요청만 거절할 수 있습니다."); - } - acquisition.reject(reason); - return repository.save(acquisition); - } - - public CardAcquisition cancel(Long id) { - CardAcquisition acquisition = repository.findById(id) - .orElseThrow(() -> new IllegalArgumentException("매입 요청을 찾을 수 없습니다: " + id)); - if (acquisition.getStatus() == CardAcquisition.ApprovalStatus.APPROVED) { - throw new IllegalStateException("이미 승인된 요청은 취소할 수 없습니다."); - } - acquisition.cancel(); - return repository.save(acquisition); - } - - private void validateAmount(BigDecimal amount) { - if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { - throw new IllegalArgumentException("매입 금액은 0보다 커야 합니다."); - } - } -} diff --git a/src/main/java/com/acquirex/approval/controller/CardAcquisitionApprovalController.java b/src/main/java/com/acquirex/approval/controller/CardAcquisitionApprovalController.java new file mode 100644 index 0000000..b5bed44 --- /dev/null +++ b/src/main/java/com/acquirex/approval/controller/CardAcquisitionApprovalController.java @@ -0,0 +1,87 @@ +package com.acquirex.approval.controller; + +import com.acquirex.approval.domain.ApprovalStatus; +import com.acquirex.approval.dto.ApprovalRequest; +import com.acquirex.approval.dto.ApprovalResponse; +import com.acquirex.approval.service.CardAcquisitionApprovalService; +import jakarta.validation.Valid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +/** + * REST controller for card acquisition approval endpoints. + * Migrated to Spring Boot 3 with Jakarta EE 9+ (jakarta.*) annotations. + */ +@RestController +@RequestMapping("/api/v1/approvals") +public class CardAcquisitionApprovalController { + + private static final Logger log = LoggerFactory.getLogger(CardAcquisitionApprovalController.class); + private final CardAcquisitionApprovalService approvalService; + + public CardAcquisitionApprovalController(CardAcquisitionApprovalService approvalService) { + this.approvalService = approvalService; + } + + /** + * Process a card acquisition approval request. + * @param request the approval request with validation + * @return the approval response + */ + @PostMapping + public ResponseEntity processApproval(@Valid @RequestBody ApprovalRequest request) { + log.info("Received approval request: {}", request.getRequestId()); + ApprovalResponse response = approvalService.processApproval(request); + return ResponseEntity.status(mapToHttpStatus(response.getStatus())).body(response); + } + + /** + * Handle validation errors from @Valid annotations. + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationErrors(MethodArgumentNotValidException ex) { + Map fieldErrors = new HashMap<>(); + ex.getBindingResult().getFieldErrors().forEach(e -> fieldErrors.put(e.getField(), e.getDefaultMessage())); + Map response = new HashMap<>(); + response.put("timestamp", LocalDateTime.now()); + response.put("status", HttpStatus.BAD_REQUEST.value()); + response.put("error", "Validation Failed"); + response.put("fieldErrors", fieldErrors); + log.warn("Validation error: {}", fieldErrors); + return ResponseEntity.badRequest().body(response); + } + + /** + * Handle illegal argument exceptions. + */ + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException ex) { + Map response = new HashMap<>(); + response.put("timestamp", LocalDateTime.now()); + response.put("status", HttpStatus.BAD_REQUEST.value()); + response.put("error", "Bad Request"); + response.put("message", ex.getMessage()); + log.warn("Illegal argument: {}", ex.getMessage()); + return ResponseEntity.badRequest().body(response); + } + + /** + * Map approval status to HTTP status. + */ + private HttpStatus mapToHttpStatus(ApprovalStatus status) { + return switch (status) { + case APPROVED -> HttpStatus.OK; + case REJECTED -> HttpStatus.BAD_REQUEST; + case PENDING -> HttpStatus.ACCEPTED; + case CANCELLED -> HttpStatus.GONE; + }; + } +} \ No newline at end of file diff --git a/src/main/java/com/acquirex/approval/domain/DomainClasses.java b/src/main/java/com/acquirex/approval/domain/DomainClasses.java new file mode 100644 index 0000000..e4020fa --- /dev/null +++ b/src/main/java/com/acquirex/approval/domain/DomainClasses.java @@ -0,0 +1,84 @@ +package com.acquirex.approval.domain; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * Approval status enumeration for card acquisition requests. + * Migrated to Jakarta EE 9+ for Spring Boot 3 compatibility. + */ +public enum ApprovalStatus { + PENDING, + APPROVED, + REJECTED, + CANCELLED +} + +/** + * Card acquisition approval entity. + * Migrated to Jakarta EE 9+ (jakarta.*) for Spring Boot 3 compatibility. + */ +public class CardAcquisitionApproval { + private Long id; + private String requestId; + private String cardNumber; + private String merchantId; + private BigDecimal amount; + private ApprovalStatus status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private String approvedBy; + + public CardAcquisitionApproval() {} + + public CardAcquisitionApproval(Long id, String requestId, String cardNumber, + String merchantId, BigDecimal amount, + ApprovalStatus status, LocalDateTime createdAt, + LocalDateTime updatedAt, String approvedBy) { + this.id = id; + this.requestId = requestId; + this.cardNumber = cardNumber; + this.merchantId = merchantId; + this.amount = amount; + this.status = status; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.approvedBy = approvedBy; + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getRequestId() { return requestId; } + public void setRequestId(String requestId) { this.requestId = requestId; } + public String getCardNumber() { return cardNumber; } + public void setCardNumber(String cardNumber) { this.cardNumber = cardNumber; } + public String getMerchantId() { return merchantId; } + public void setMerchantId(String merchantId) { this.merchantId = merchantId; } + public BigDecimal getAmount() { return amount; } + public void setAmount(BigDecimal amount) { this.amount = amount; } + public ApprovalStatus getStatus() { return status; } + public void setStatus(ApprovalStatus status) { this.status = status; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } + public String getApprovedBy() { return approvedBy; } + public void setApprovedBy(String approvedBy) { this.approvedBy = approvedBy; } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + CardAcquisitionApproval that = (CardAcquisitionApproval) o; + return Objects.equals(id, that.id) && Objects.equals(requestId, that.requestId); + } + + @Override + public int hashCode() { return Objects.hash(id, requestId); } + + @Override + public String toString() { + return "CardAcquisitionApproval{id=" + id + ", requestId='" + requestId + "', status=" + status + "}"; + } +} \ No newline at end of file diff --git a/src/main/java/com/acquirex/approval/dto/DtoClasses.java b/src/main/java/com/acquirex/approval/dto/DtoClasses.java new file mode 100644 index 0000000..8d843a6 --- /dev/null +++ b/src/main/java/com/acquirex/approval/dto/DtoClasses.java @@ -0,0 +1,82 @@ +package com.acquirex.approval.dto; + +import com.acquirex.approval.domain.ApprovalStatus; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * DTO for card acquisition approval request. + * Uses Jakarta Validation annotations (Spring Boot 3 requirement). + */ +public class ApprovalRequest { + @NotBlank(message = "Request ID is required") + private String requestId; + + @NotBlank(message = "Card number is required") + private String cardNumber; + + @NotBlank(message = "Merchant ID is required") + private String merchantId; + + @NotNull(message = "Amount is required") + @DecimalMin(value = "0.01", message = "Amount must be greater than zero") + private BigDecimal amount; + + public ApprovalRequest() {} + + public ApprovalRequest(String requestId, String cardNumber, String merchantId, BigDecimal amount) { + this.requestId = requestId; + this.cardNumber = cardNumber; + this.merchantId = merchantId; + this.amount = amount; + } + + public String getRequestId() { return requestId; } + public void setRequestId(String requestId) { this.requestId = requestId; } + public String getCardNumber() { return cardNumber; } + public void setCardNumber(String cardNumber) { this.cardNumber = cardNumber; } + public String getMerchantId() { return merchantId; } + public void setMerchantId(String merchantId) { this.merchantId = merchantId; } + public BigDecimal getAmount() { return amount; } + public void setAmount(BigDecimal amount) { this.amount = amount; } +} + +/** + * DTO for card acquisition approval response. + */ +class ApprovalResponse { + private Long id; + private String requestId; + private ApprovalStatus status; + private BigDecimal amount; + private LocalDateTime processedAt; + private String message; + + public ApprovalResponse() {} + + public ApprovalResponse(Long id, String requestId, ApprovalStatus status, + BigDecimal amount, LocalDateTime processedAt, String message) { + this.id = id; + this.requestId = requestId; + this.status = status; + this.amount = amount; + this.processedAt = processedAt; + this.message = message; + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getRequestId() { return requestId; } + public void setRequestId(String requestId) { this.requestId = requestId; } + public ApprovalStatus getStatus() { return status; } + public void setStatus(ApprovalStatus status) { this.status = status; } + public BigDecimal getAmount() { return amount; } + public void setAmount(BigDecimal amount) { this.amount = amount; } + public LocalDateTime getProcessedAt() { return processedAt; } + public void setProcessedAt(LocalDateTime processedAt) { this.processedAt = processedAt; } + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } +} \ No newline at end of file diff --git a/src/main/java/com/acquirex/approval/service/CardAcquisitionApprovalService.java b/src/main/java/com/acquirex/approval/service/CardAcquisitionApprovalService.java new file mode 100644 index 0000000..c2e4bb2 --- /dev/null +++ b/src/main/java/com/acquirex/approval/service/CardAcquisitionApprovalService.java @@ -0,0 +1,118 @@ +package com.acquirex.approval.service; + +import com.acquirex.approval.domain.ApprovalStatus; +import com.acquirex.approval.domain.CardAcquisitionApproval; +import com.acquirex.approval.dto.ApprovalRequest; +import com.acquirex.approval.dto.ApprovalResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * Service for card acquisition approval processing. + * Migrated to Spring Boot 3 with Jakarta EE 9+ compatibility. + */ +@Service +public class CardAcquisitionApprovalService { + + private static final Logger log = LoggerFactory.getLogger(CardAcquisitionApprovalService.class); + private static final BigDecimal MAX_APPROVAL_AMOUNT = new BigDecimal("1000000.00"); + private static final BigDecimal HIGH_RISK_THRESHOLD = new BigDecimal("500000.00"); + + /** + * Process a card acquisition approval request. + * @param request the approval request + * @return the approval response + */ + public ApprovalResponse processApproval(ApprovalRequest request) { + log.info("Processing approval request: {}", request.getRequestId()); + validateRequest(request); + ApprovalStatus status = determineApprovalStatus(request.getAmount()); + String approvedBy = determineApprover(status); + CardAcquisitionApproval approval = createApproval(request, status, approvedBy); + log.info("Approval processed: requestId={}, status={}, amount={}", + request.getRequestId(), status, request.getAmount()); + return buildResponse(approval, status); + } + + /** + * Validate the approval request. + * @param request the request to validate + * @throws IllegalArgumentException if validation fails + */ + void validateRequest(ApprovalRequest request) { + if (request.getCardNumber() == null || request.getCardNumber().length() < 13) { + throw new IllegalArgumentException("Invalid card number format"); + } + if (request.getMerchantId() == null || request.getMerchantId().isBlank()) { + throw new IllegalArgumentException("Merchant ID is required"); + } + } + + /** + * Determine approval status based on amount. + * @param amount the transaction amount + * @return the determined status + */ + ApprovalStatus determineApprovalStatus(BigDecimal amount) { + if (amount.compareTo(MAX_APPROVAL_AMOUNT) > 0) return ApprovalStatus.REJECTED; + if (amount.compareTo(HIGH_RISK_THRESHOLD) > 0) return ApprovalStatus.PENDING; + return ApprovalStatus.APPROVED; + } + + /** + * Determine the approver based on status. + * @param status the approval status + * @return the approver identifier + */ + String determineApprover(ApprovalStatus status) { + return switch (status) { + case APPROVED, REJECTED -> "SYSTEM"; + case PENDING -> "MANUAL_REVIEW"; + case CANCELLED -> "CANCELLED"; + }; + } + + /** + * Create the approval record. + */ + CardAcquisitionApproval createApproval(ApprovalRequest request, ApprovalStatus status, String approvedBy) { + LocalDateTime now = LocalDateTime.now(); + return new CardAcquisitionApproval( + generateId(), request.getRequestId(), maskCardNumber(request.getCardNumber()), + request.getMerchantId(), request.getAmount(), status, now, now, approvedBy); + } + + /** + * Build the approval response. + */ + ApprovalResponse buildResponse(CardAcquisitionApproval approval, ApprovalStatus status) { + String message = switch (status) { + case APPROVED -> "Card acquisition approved successfully"; + case REJECTED -> "Card acquisition rejected: amount exceeds limit"; + case PENDING -> "Card acquisition pending manual review"; + case CANCELLED -> "Card acquisition cancelled"; + }; + return new ApprovalResponse(approval.getId(), approval.getRequestId(), status, + approval.getAmount(), approval.getUpdatedAt(), message); + } + + /** + * Mask card number for logging/security. + */ + String maskCardNumber(String cardNumber) { + if (cardNumber == null || cardNumber.length() < 4) return "****"; + return "****-****-****-" + cardNumber.substring(cardNumber.length() - 4); + } + + /** + * Generate unique ID for approval records. + */ + Long generateId() { + return UUID.randomUUID().getMostSignificantBits() & Long.MAX_VALUE; + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml deleted file mode 100644 index 4a5e9a4..0000000 --- a/src/main/resources/application.yml +++ /dev/null @@ -1,20 +0,0 @@ -spring: - application: - name: proj-acquirex - datasource: - url: jdbc:h2:mem:acquirex;DB_CLOSE_DELAY=-1 - driver-class-name: org.h2.Driver - username: sa - jpa: - hibernate: - ddl-auto: create-drop - show-sql: true - properties: - hibernate: - format_sql: true - h2: - console: - enabled: true - path: /h2-console -server: - port: 8080 diff --git a/src/test/java/com/acquirex/approval/ApprovalTests.java b/src/test/java/com/acquirex/approval/ApprovalTests.java new file mode 100644 index 0000000..4ca8977 --- /dev/null +++ b/src/test/java/com/acquirex/approval/ApprovalTests.java @@ -0,0 +1,242 @@ +package com.acquirex.approval; + +import com.acquirex.approval.domain.ApprovalStatus; +import com.acquirex.approval.dto.ApprovalRequest; +import com.acquirex.approval.dto.ApprovalResponse; +import com.acquirex.approval.service.CardAcquisitionApprovalService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +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.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * Unit tests for CardAcquisitionApprovalService and Controller. + * Tests Spring Boot 3 migrated approval logic with Jakarta validation. + */ +@WebMvcTest +@DisplayName("Card Acquisition Approval Tests") +@TestMethodOrder(MethodOrderer.DisplayName.class) +class ApprovalTests { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private CardAcquisitionApprovalService approvalService; + + // ========== Service Unit Tests ========== + + @Nested + @DisplayName("Service: processApproval") + class ServiceProcessApprovalTests { + + private CardAcquisitionApprovalService service; + + @BeforeEach + void setUp() { + service = new CardAcquisitionApprovalService(); + } + + @Test + @DisplayName("01 - should approve transaction below threshold") + void shouldApproveTransactionBelowThreshold() { + ApprovalRequest request = new ApprovalRequest("REQ-001", "4111111111111111", + "MERCHANT-001", new BigDecimal("100.00")); + ApprovalResponse response = service.processApproval(request); + assertEquals(ApprovalStatus.APPROVED, response.getStatus()); + assertEquals("REQ-001", response.getRequestId()); + assertNotNull(response.getProcessedAt()); + assertTrue(response.getMessage().contains("approved")); + } + + @Test + @DisplayName("02 - should pend transaction above high risk threshold") + void shouldPendTransactionAboveHighRiskThreshold() { + ApprovalRequest request = new ApprovalRequest("REQ-002", "4111111111111111", + "MERCHANT-002", new BigDecimal("600000.00")); + ApprovalResponse response = service.processApproval(request); + assertEquals(ApprovalStatus.PENDING, response.getStatus()); + assertTrue(response.getMessage().contains("pending")); + } + + @Test + @DisplayName("03 - should reject transaction above max amount") + void shouldRejectTransactionAboveMaxAmount() { + ApprovalRequest request = new ApprovalRequest("REQ-003", "4111111111111111", + "MERCHANT-003", new BigDecimal("1500000.00")); + ApprovalResponse response = service.processApproval(request); + assertEquals(ApprovalStatus.REJECTED, response.getStatus()); + assertTrue(response.getMessage().contains("rejected")); + } + } + + @Nested + @DisplayName("Service: validateRequest") + class ServiceValidateRequestTests { + + private CardAcquisitionApprovalService service; + + @BeforeEach + void setUp() { + service = new CardAcquisitionApprovalService(); + } + + @Test + @DisplayName("04 - should throw exception for invalid card number") + void shouldThrowExceptionForInvalidCardNumber() { + ApprovalRequest request = new ApprovalRequest("REQ-004", "123", + "MERCHANT-001", new BigDecimal("100.00")); + assertThrows(IllegalArgumentException.class, () -> service.validateRequest(request)); + } + + @Test + @DisplayName("05 - should throw exception for blank merchant ID") + void shouldThrowExceptionForBlankMerchantId() { + ApprovalRequest request = new ApprovalRequest("REQ-005", "4111111111111111", + " ", new BigDecimal("100.00")); + assertThrows(IllegalArgumentException.class, () -> service.validateRequest(request)); + } + } + + @Nested + @DisplayName("Service: determineApprovalStatus") + class ServiceDetermineStatusTests { + + private CardAcquisitionApprovalService service; + + @BeforeEach + void setUp() { + service = new CardAcquisitionApprovalService(); + } + + @Test + @DisplayName("06 - should return APPROVED for amount below threshold") + void shouldReturnApprovedForLowAmount() { + assertEquals(ApprovalStatus.APPROVED, service.determineApprovalStatus(new BigDecimal("100.00"))); + } + + @Test + @DisplayName("07 - should return PENDING for high risk amount") + void shouldReturnPendingForHighRiskAmount() { + assertEquals(ApprovalStatus.PENDING, service.determineApprovalStatus(new BigDecimal("500001.00"))); + } + + @Test + @DisplayName("08 - should return REJECTED for amount exceeding max") + void shouldReturnRejectedForExceedingMax() { + assertEquals(ApprovalStatus.REJECTED, service.determineApprovalStatus(new BigDecimal("1000001.00"))); + } + } + + @Nested + @DisplayName("Service: maskCardNumber") + class ServiceMaskCardNumberTests { + + private CardAcquisitionApprovalService service; + + @BeforeEach + void setUp() { + service = new CardAcquisitionApprovalService(); + } + + @Test + @DisplayName("09 - should mask card number correctly") + void shouldMaskCardNumberCorrectly() { + assertEquals("****-****-****-1111", service.maskCardNumber("4111111111111111")); + } + + @Test + @DisplayName("10 - should return default mask for short card number") + void shouldReturnDefaultMaskForShortNumber() { + assertEquals("****", service.maskCardNumber("123")); + } + + @Test + @DisplayName("11 - should return default mask for null card number") + void shouldReturnDefaultMaskForNull() { + assertEquals("****", service.maskCardNumber(null)); + } + } + + // ========== Controller Integration Tests ========== + + @Nested + @DisplayName("Controller: POST /api/v1/approvals") + class ControllerTests { + + @Test + @DisplayName("12 - should return 200 for approved transaction") + void shouldReturn200ForApprovedTransaction() throws Exception { + ApprovalRequest request = new ApprovalRequest("REQ-001", "4111111111111111", + "MERCHANT-001", new BigDecimal("100.00")); + ApprovalResponse response = new ApprovalResponse(1L, "REQ-001", ApprovalStatus.APPROVED, + new BigDecimal("100.00"), LocalDateTime.now(), "Card acquisition approved successfully"); + when(approvalService.processApproval(any(ApprovalRequest.class))).thenReturn(response); + + mockMvc.perform(post("/api/v1/approvals") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.requestId").value("REQ-001")) + .andExpect(jsonPath("$.status").value("APPROVED")); + } + + @Test + @DisplayName("13 - should return 400 for validation error") + void shouldReturn400ForValidationError() throws Exception { + String invalidRequest = "{\"cardNumber\":\"\",\"merchantId\":\"\"}"; + mockMvc.perform(post("/api/v1/approvals") + .contentType(MediaType.APPLICATION_JSON) + .content(invalidRequest)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("Validation Failed")); + } + + @Test + @DisplayName("14 - should return 400 for rejected transaction") + void shouldReturn400ForRejectedTransaction() throws Exception { + ApprovalRequest request = new ApprovalRequest("REQ-002", "4111111111111111", + "MERCHANT-002", new BigDecimal("2000000.00")); + ApprovalResponse response = new ApprovalResponse(2L, "REQ-002", ApprovalStatus.REJECTED, + new BigDecimal("2000000.00"), LocalDateTime.now(), "Card acquisition rejected"); + when(approvalService.processApproval(any(ApprovalRequest.class))).thenReturn(response); + + mockMvc.perform(post("/api/v1/approvals") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value("REJECTED")); + } + + @Test + @DisplayName("15 - should return 202 for pending transaction") + void shouldReturn202ForPendingTransaction() throws Exception { + ApprovalRequest request = new ApprovalRequest("REQ-003", "4111111111111111", + "MERCHANT-003", new BigDecimal("600000.00")); + ApprovalResponse response = new ApprovalResponse(3L, "REQ-003", ApprovalStatus.PENDING, + new BigDecimal("600000.00"), LocalDateTime.now(), "Card acquisition pending manual review"); + when(approvalService.processApproval(any(ApprovalRequest.class))).thenReturn(response); + + mockMvc.perform(post("/api/v1/approvals") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.status").value("PENDING")); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/acquirex/approval/CardAcquisitionServiceTest.java b/src/test/java/com/acquirex/approval/CardAcquisitionServiceTest.java deleted file mode 100644 index b0098a5..0000000 --- a/src/test/java/com/acquirex/approval/CardAcquisitionServiceTest.java +++ /dev/null @@ -1,102 +0,0 @@ -package com.acquirex.approval; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.math.BigDecimal; -import java.util.List; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class CardAcquisitionServiceTest { - - @Mock - private CardAcquisitionRepository repository; - - @InjectMocks - private CardAcquisitionService service; - - private CardAcquisition testAcquisition; - - @BeforeEach - void setUp() { - testAcquisition = new CardAcquisition("1234567890123456", "MERCH001", new BigDecimal("100000")); - testAcquisition.setId(1L); - } - - @Test - @DisplayName("유효한 정보로 매입 요청을 생성한다") - void shouldCreateAcquisitionWithValidData() { - when(repository.save(any(CardAcquisition.class))).thenReturn(testAcquisition); - CardAcquisition result = service.createAcquisition("1234567890123456", "MERCH001", new BigDecimal("100000")); - assertThat(result).isNotNull(); - assertThat(result.getStatus()).isEqualTo(CardAcquisition.ApprovalStatus.PENDING); - verify(repository, times(1)).save(any(CardAcquisition.class)); - } - - @Test - @DisplayName("금액이 0 이하이면 예외를 발생시킨다") - void shouldThrowExceptionWhenAmountIsZeroOrNegative() { - assertThatThrownBy(() -> service.createAcquisition("1234567890123456", "MERCH001", BigDecimal.ZERO)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("0보다 커야 합니다"); - } - - @Test - @DisplayName("대기 상태의 매입 요청을 승인한다") - void shouldApprovePendingAcquisition() { - when(repository.findById(1L)).thenReturn(Optional.of(testAcquisition)); - when(repository.save(any(CardAcquisition.class))).thenAnswer(i -> i.getArgument(0)); - CardAcquisition result = service.approve(1L, "admin"); - assertThat(result.getStatus()).isEqualTo(CardAcquisition.ApprovalStatus.APPROVED); - assertThat(result.getApprovedBy()).isEqualTo("admin"); - } - - @Test - @DisplayName("이미 승인된 요청은 다시 승인할 수 없다") - void shouldThrowExceptionWhenAlreadyApproved() { - testAcquisition.approve("previous"); - when(repository.findById(1L)).thenReturn(Optional.of(testAcquisition)); - assertThatThrownBy(() -> service.approve(1L, "admin")) - .isInstanceOf(IllegalStateException.class) - .hasMessageContaining("대기 상태"); - } - - @Test - @DisplayName("대기 상태의 매입 요청을 거절한다") - void shouldRejectPendingAcquisition() { - when(repository.findById(1L)).thenReturn(Optional.of(testAcquisition)); - when(repository.save(any(CardAcquisition.class))).thenAnswer(i -> i.getArgument(0)); - CardAcquisition result = service.reject(1L, "Invalid merchant"); - assertThat(result.getStatus()).isEqualTo(CardAcquisition.ApprovalStatus.REJECTED); - assertThat(result.getRejectionReason()).isEqualTo("Invalid merchant"); - } - - @Test - @DisplayName("존재하지 않는 요청은 예외를 발생시킨다") - void shouldThrowExceptionWhenNotFound() { - when(repository.findById(999L)).thenReturn(Optional.empty()); - assertThatThrownBy(() -> service.approve(999L, "admin")) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("찾을 수 없습니다"); - } - - @Test - @DisplayName("상태별 매입 요청을 조회한다") - void shouldFindByStatus() { - when(repository.findByStatus(CardAcquisition.ApprovalStatus.PENDING)) - .thenReturn(List.of(testAcquisition)); - List result = service.findByStatus(CardAcquisition.ApprovalStatus.PENDING); - assertThat(result).hasSize(1); - } -}