diff --git a/README.md b/README.md index 9802e91..7129e1b 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,28 @@ -# proj-acquirex - Card Acquisition Approval System +# 카드 매입 승인 경로 Spring Boot 3 전환 -## Spring Boot 3 Migration +## 전환 범위 -### Migration Summary +| 항목 | 기존 | 전환 후 | +|------|------|----------| +| 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 | -| 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 -# 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}' +./mvnw clean verify ``` -### Test Coverage (15 tests) +## API 엔드포인트 -| 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 +| 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 | 전체 매입 요청 목록 | diff --git a/pom.xml b/pom.xml index 6b86229..c6cf793 100644 --- a/pom.xml +++ b/pom.xml @@ -1,7 +1,7 @@ + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 @@ -14,15 +14,11 @@ com.acquirex proj-acquirex 1.0.0-SNAPSHOT - jar proj-acquirex - Card Acquisition Approval System - Spring Boot 3 Migration + 카드 매입 승인 경로 - Spring Boot 3 전환 17 - 17 - 17 - UTF-8 @@ -30,10 +26,19 @@ 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 @@ -47,11 +52,6 @@ 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 2fe1b0c..cbf43e9 100644 --- a/src/main/java/com/acquirex/AcquirexApplication.java +++ b/src/main/java/com/acquirex/AcquirexApplication.java @@ -3,13 +3,9 @@ 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 new file mode 100644 index 0000000..798e8e7 --- /dev/null +++ b/src/main/java/com/acquirex/approval/CardAcquisition.java @@ -0,0 +1,94 @@ +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 new file mode 100644 index 0000000..8d2e92b --- /dev/null +++ b/src/main/java/com/acquirex/approval/CardAcquisitionController.java @@ -0,0 +1,95 @@ +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 new file mode 100644 index 0000000..fac8b83 --- /dev/null +++ b/src/main/java/com/acquirex/approval/CardAcquisitionService.java @@ -0,0 +1,86 @@ +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 deleted file mode 100644 index b5bed44..0000000 --- a/src/main/java/com/acquirex/approval/controller/CardAcquisitionApprovalController.java +++ /dev/null @@ -1,87 +0,0 @@ -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 deleted file mode 100644 index e4020fa..0000000 --- a/src/main/java/com/acquirex/approval/domain/DomainClasses.java +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100644 index 8d843a6..0000000 --- a/src/main/java/com/acquirex/approval/dto/DtoClasses.java +++ /dev/null @@ -1,82 +0,0 @@ -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 deleted file mode 100644 index c2e4bb2..0000000 --- a/src/main/java/com/acquirex/approval/service/CardAcquisitionApprovalService.java +++ /dev/null @@ -1,118 +0,0 @@ -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 new file mode 100644 index 0000000..4a5e9a4 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,20 @@ +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 deleted file mode 100644 index 4ca8977..0000000 --- a/src/test/java/com/acquirex/approval/ApprovalTests.java +++ /dev/null @@ -1,242 +0,0 @@ -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 new file mode 100644 index 0000000..b0098a5 --- /dev/null +++ b/src/test/java/com/acquirex/approval/CardAcquisitionServiceTest.java @@ -0,0 +1,102 @@ +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); + } +}