From 3310a3d56c1b923f66c5b96b98232816adeaf8eb Mon Sep 17 00:00:00 2001 From: madirony Date: Wed, 4 Feb 2026 01:27:01 +0900 Subject: [PATCH 01/20] =?UTF-8?q?fix=20:=20=EC=98=88=EC=A0=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=EC=9D=84=20=EC=9C=84=ED=95=9C=20testcontaine?= =?UTF-8?q?rs=20=EB=B2=84=EC=A0=84=20=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + gradle.properties | 1 + 2 files changed, 2 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 9c8490b8a..dc167f2e7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -42,6 +42,7 @@ subprojects { dependencyManagement { imports { mavenBom("org.springframework.cloud:spring-cloud-dependencies:${project.properties["springCloudDependenciesVersion"]}") + mavenBom("org.testcontainers:testcontainers-bom:${project.properties["testcontainersVersion"]}") } } diff --git a/gradle.properties b/gradle.properties index 142d7120f..5ae37ac99 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,6 +10,7 @@ springBootVersion=3.4.4 springDependencyManagementVersion=1.1.7 springCloudDependenciesVersion=2024.0.1 ### Library versions ### +testcontainersVersion=2.0.2 springDocOpenApiVersion=2.7.0 springMockkVersion=4.0.2 mockitoVersion=5.14.0 From 4c17f62d2ea8c900a62ff027cdfb3b9157e609a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Fri, 13 Feb 2026 13:03:38 +0900 Subject: [PATCH 02/20] =?UTF-8?q?refactor:=20Bean=20Validation=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B3=84?= =?UTF-8?q?=EC=B8=B5=20=EA=B2=80=EC=A6=9D=EC=9C=BC=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserRegisterRequest에서 @NotBlank, @NotNull, @Email 어노테이션 제거 - UserController에서 @Valid 제거 - GlobalExceptionHandler에서 MethodArgumentNotValidException 핸들러 제거 - AuthenticationService 에러 메시지 통일 및 미사용 findUser() 제거 - Password @ToString에서 value 필드 제외 (보안) - Email 정규식에 특수문자(._%+-) 허용 추가 - User.createdAt을 final로 변경 (불변성) - UserService.updatePassword에서 현재 비밀번호 검증 시 불필요한 Password VO 생성 제거 Co-Authored-By: Claude Opus 4.6 --- .../service/AuthenticationService.java | 9 +++------ .../application/service/UserService.java | 3 +-- .../java/com/loopers/domain/model/Email.java | 2 +- .../com/loopers/domain/model/Password.java | 2 +- .../java/com/loopers/domain/model/User.java | 2 +- .../interfaces/api/UserController.java | 6 +++--- .../api/dto/UserRegisterRequest.java | 14 +++++-------- .../support/error/GlobalExceptionHandler.java | 20 +++---------------- .../service/AuthenticationServiceTest.java | 4 ++-- .../interfaces/api/UserApiE2ETest.java | 4 ++-- 10 files changed, 22 insertions(+), 44 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/AuthenticationService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/AuthenticationService.java index f2f6fa6f6..ab156ff9f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/AuthenticationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/AuthenticationService.java @@ -19,18 +19,15 @@ public AuthenticationService(UserRepository userRepository, PasswordEncoder pass this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; } + private static final String AUTH_FAILURE_MESSAGE = "아이디 또는 비밀번호가 올바르지 않습니다."; @Override public void authenticate(UserId userId, String rawPassword) { - User user = findUser(userId); + User user = userRepository.findById(userId).orElseThrow(() -> new IllegalArgumentException(AUTH_FAILURE_MESSAGE)); if (!passwordEncoder.matches(rawPassword, user.getEncodedPassword())) { - throw new IllegalArgumentException("비밀번호가 일치하지 않습니다."); + throw new IllegalArgumentException(AUTH_FAILURE_MESSAGE); } } - private User findUser(UserId userId) { - return userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/UserService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/UserService.java index 46a014918..c9e93b0c5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/UserService.java @@ -52,10 +52,9 @@ public void updatePassword(UserId userId, String currentRawPassword, String newR User user = findUser(userId); LocalDate birthday = user.getBirth().getValue(); - Password currentPassword = Password.of(currentRawPassword, birthday); Password newPassword = Password.of(newRawPassword, birthday); - if (!passwordEncoder.matches(currentPassword.getValue(), user.getEncodedPassword())) { + if (!passwordEncoder.matches(currentRawPassword, user.getEncodedPassword())) { throw new IllegalArgumentException("현재 비밀번호가 일치하지 않습니다."); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/Email.java index 9b615d564..d833b6520 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/Email.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/Email.java @@ -8,7 +8,7 @@ public class Email { private static final Pattern PATTERN = Pattern.compile( - "^[a-zA-Z0-9]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" + "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" ); private final String value; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/Password.java index f39902cdc..8f91a631e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/Password.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/Password.java @@ -12,7 +12,7 @@ @Getter @EqualsAndHashCode -@ToString +@ToString(exclude = "value") public class Password { private static final Pattern ALLOWED_CHARS = Pattern.compile("^[a-zA-Z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?`~]{8,16}$"); private static final DateTimeFormatter FMT_YYYYMMDD = DateTimeFormatter.ofPattern("yyyyMMdd"); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/User.java index f321978e2..a789ca6eb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/User.java @@ -17,7 +17,7 @@ public class User { private final Birthday birth; // YYYYMMDD format with default value private final Email email; private final WrongPasswordCount wrongPasswordCount; - private LocalDateTime createdAt; + private final LocalDateTime createdAt; public static User register(UserId userId,UserName userName, String encodedPassword, Birthday birth, Email email, WrongPasswordCount wrongPasswordCount, LocalDateTime createdAt) { return new User(null,userId,userName,encodedPassword,birth,email, wrongPasswordCount,createdAt); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/UserController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/UserController.java index 312fc8d70..23305ea61 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/UserController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/UserController.java @@ -8,7 +8,7 @@ import com.loopers.interfaces.api.dto.UserInfoResponse; import com.loopers.interfaces.api.dto.UserRegisterRequest; import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.Valid; + import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -31,7 +31,7 @@ public UserController( } @PostMapping("/register") - public ResponseEntity register(@Valid @RequestBody UserRegisterRequest request) { + public ResponseEntity register(@RequestBody UserRegisterRequest request) { registerUseCase.register( request.loginId(), request.name(), @@ -53,7 +53,7 @@ public ResponseEntity getMyInfo(HttpServletRequest request) { @PutMapping("/me/password") public ResponseEntity updatePassword( HttpServletRequest request, - @Valid @RequestBody PasswordUpdateRequest passwordUpdateRequest + @RequestBody PasswordUpdateRequest passwordUpdateRequest ) { UserId userId = (UserId) request.getAttribute("authenticatedUserId"); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/UserRegisterRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/UserRegisterRequest.java index 86fbbed7e..576b84aa4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/UserRegisterRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/UserRegisterRequest.java @@ -1,15 +1,11 @@ package com.loopers.interfaces.api.dto; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; - import java.time.LocalDate; public record UserRegisterRequest( - @NotBlank String loginId, - @NotBlank String password, - @NotBlank String name, - @NotNull LocalDate birthday, - @NotBlank @Email String email + String loginId, + String password, + String name, + LocalDate birthday, + String email ) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/GlobalExceptionHandler.java b/apps/commerce-api/src/main/java/com/loopers/support/error/GlobalExceptionHandler.java index 105a897c7..54b1de7b7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/GlobalExceptionHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/GlobalExceptionHandler.java @@ -2,18 +2,19 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; - import java.util.Map; +import lombok.extern.slf4j.Slf4j; +@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(CoreException.class) public ResponseEntity> handleCoreException(CoreException e) { + log.error("Unhandled exception occurred", e); return ResponseEntity .status(e.getErrorType().getStatus()) .body(Map.of( @@ -32,21 +33,6 @@ public ResponseEntity> handleIllegalArgumentException(Illega )); } - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidationException(MethodArgumentNotValidException e) { - String message = e.getBindingResult().getFieldErrors().stream() - .findFirst() - .map(error -> error.getField() + ": " + error.getDefaultMessage()) - .orElse("유효성 검사 실패"); - - return ResponseEntity - .status(HttpStatus.BAD_REQUEST) - .body(Map.of( - "code", "VALIDATION_ERROR", - "message", message - )); - } - @ExceptionHandler(MissingRequestHeaderException.class) public ResponseEntity> handleMissingHeaderException(MissingRequestHeaderException e) { return ResponseEntity diff --git a/apps/commerce-api/src/test/java/com/loopers/application/service/AuthenticationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/service/AuthenticationServiceTest.java index 39af9240a..276cf38a2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/service/AuthenticationServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/service/AuthenticationServiceTest.java @@ -61,7 +61,7 @@ void authenticate_fail_userNotFound() { // when & then assertThatThrownBy(() -> service.authenticate(userId, "password")) .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("사용자를 찾을 수 없습니다"); + .hasMessageContaining("아이디 또는 비밀번호가 올바르지 않습니다"); } @Test @@ -79,7 +79,7 @@ void authenticate_fail_passwordMismatch() { // when & then assertThatThrownBy(() -> service.authenticate(userId, wrongPassword)) .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("비밀번호가 일치하지 않습니다"); + .hasMessageContaining("아이디 또는 비밀번호가 올바르지 않습니다"); } private User createUser(UserId userId, String encodedPassword) { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiE2ETest.java index 45201ede5..1c2d8d97f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiE2ETest.java @@ -292,6 +292,6 @@ private HttpHeaders createAuthHeaders(String loginId, String password) { private void registerUser(String loginId, String password, String name) { var request = createRegisterRequest(loginId, password, name); - restTemplate.postForEntity(BASE_URL + "/register", request, Void.class); - } + ResponseEntity response = restTemplate.postForEntity(BASE_URL + "/register", request, Void.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); } } From 0d607eff49714df14be94f141d92d418b06480db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Fri, 13 Feb 2026 14:22:47 +0900 Subject: [PATCH 03/20] =?UTF-8?q?docs:=20Notion=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=20=EB=AC=B8=EC=84=9C=20=EC=A0=95=ED=95=A9?= =?UTF-8?q?=EC=84=B1=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API endpoint 경로를 Notion 명세와 일치하도록 수정 (/api/v1 prefix 통일) - 좋아요 목록 endpoint: /users/me/likes → /users/{userId}/likes - 주문 목록 endpoint: /orders/me → /orders (startAt, endAt 기간 필터 추가) - Admin 조회 API 4개 추가 (브랜드 상세, 상품 목록/상세, 주문 상세) - Bean Validation 제거 반영: VALIDATION_ERROR 에러코드, handleValidationException, findUser 제거 - 클래스 다이어그램에 Admin 조회 메서드 추가 반영 Co-Authored-By: Claude Opus 4.6 --- .docs/design/01-requirements.md | 29 +++++++++++++++------------- .docs/design/02-sequence-diagrams.md | 4 ++-- .docs/design/03-class-diagram.md | 10 +++++++--- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/.docs/design/01-requirements.md b/.docs/design/01-requirements.md index 8bc92d09d..2009d080d 100644 --- a/.docs/design/01-requirements.md +++ b/.docs/design/01-requirements.md @@ -48,9 +48,9 @@ | Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | | :--- | :---: | :--- | :--- | :--- | -| Guest | `POST` | `/users/register` | **회원가입** | ID 중복 체크 필수 | -| User | `GET` | `/users/me` | **내 정보 조회** | 이름 마스킹 처리 | -| User | `PUT` | `/users/me/password` | **비밀번호 변경** | 기존 비밀번호 확인 로직 포함 | +| Guest | `POST` | `/api/v1/users` | **회원가입** | ID 중복 체크 필수 | +| User | `GET` | `/api/v1/users/me` | **내 정보 조회** | 이름 마스킹 처리 | +| User | `PUT` | `/api/v1/users/password` | **비밀번호 변경** | 기존 비밀번호 확인 로직 포함 | #### 상세 요구사항 * **회원가입 입력값**: ID, PW, 이름, 생년월일, 이메일 @@ -67,9 +67,9 @@ | Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | | :--- | :---: | :--- | :--- | :--- | -| Any | `GET` | `/brands/{brandId}` | **브랜드 조회** | 브랜드 정보 반환 | -| Any | `GET` | `/products` | **상품 목록** | 필터, 정렬, 페이징 | -| Any | `GET` | `/products/{productId}` | **상품 상세** | | +| Any | `GET` | `/api/v1/brands/{brandId}` | **브랜드 조회** | 브랜드 정보 반환 | +| Any | `GET` | `/api/v1/products` | **상품 목록** | 필터, 정렬, 페이징 | +| Any | `GET` | `/api/v1/products/{productId}` | **상품 상세** | | #### 상세 요구사항 * **목록 조회 쿼리 파라미터**: @@ -83,9 +83,9 @@ | Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | | :--- | :---: | :--- | :--- | :--- | -| User | `POST` | `/products/{id}/likes` | **좋아요 등록** | Idempotency 보장 | -| User | `DELETE` | `/products/{id}/likes` | **좋아요 취소** | | -| User | `GET` | `/users/me/likes` | **좋아요 목록** | 필터링 지원 | +| User | `POST` | `/api/v1/products/{id}/likes` | **좋아요 등록** | Idempotency 보장 | +| User | `DELETE` | `/api/v1/products/{id}/likes` | **좋아요 취소** | | +| User | `GET` | `/api/v1/users/{userId}/likes` | **좋아요 목록** | 필터링 지원 | #### 상세 요구사항 * **제약**: 유저당 1개의 상품에 1번만 좋아요 가능. @@ -99,9 +99,9 @@ | Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | | :--- | :---: | :--- | :--- | :--- | -| User | `POST` | `/orders` | **주문 요청** | 트랜잭션 처리 필수 | -| User | `GET` | `/orders/me` | **내 주문 목록** | 기간 조회 | -| User | `GET` | `/orders/{id}` | **주문 상세** | 영수증 데이터 포함 | +| User | `POST` | `/api/v1/orders` | **주문 요청** | 트랜잭션 처리 필수 | +| User | `GET` | `/api/v1/orders` | **내 주문 목록** | `startAt`, `endAt` 기간 필터 | +| User | `GET` | `/api/v1/orders/{id}` | **주문 상세** | 영수증 데이터 포함 | #### 상세 요구사항 1. **주문 요청**: @@ -122,13 +122,17 @@ | Role | Method | Endpoint | 기능 | 상세 로직 및 제약사항 | | :--- | :---: | :--- | :--- | :--- | | Admin | `GET` | `/api-admin/v1/brands` | 브랜드 목록 | | +| Admin | `GET` | `/api-admin/v1/brands/{brandId}` | 브랜드 상세 조회 | | | Admin | `POST` | `/api-admin/v1/brands` | 브랜드 등록 | | | Admin | `PUT` | `/api-admin/v1/brands/{id}` | 브랜드 수정 | | | Admin | `DELETE`| `/api-admin/v1/brands/{id}` | **브랜드 삭제** | **[Cascade]** 하위 상품 일괄 삭제 | +| Admin | `GET` | `/api-admin/v1/products` | **상품 목록 조회** | 페이징, `brandId` 필터 | +| Admin | `GET` | `/api-admin/v1/products/{productId}` | **상품 상세 조회** | | | Admin | `POST` | `/api-admin/v1/products` | **상품 등록** | 등록된 브랜드 ID만 허용 | | Admin | `PUT` | `/api-admin/v1/products/{id}`| **상품 수정** | **[Immutable]** 브랜드 변경 불가 | | Admin | `DELETE`| `/api-admin/v1/products/{id}`| 상품 삭제 | Soft Delete 권장 | | Admin | `GET` | `/api-admin/v1/orders` | 주문 목록 | 전체 유저 주문 조회 | +| Admin | `GET` | `/api-admin/v1/orders/{orderId}` | 주문 상세 조회 | | --- @@ -158,7 +162,6 @@ | 코드 | HTTP 상태 | 설명 | | :--- | :---: | :--- | | `BAD_REQUEST` | 400 | 유효성 검사 실패, 인증 실패, ID 중복 등 | -| `VALIDATION_ERROR` | 400 | DTO `@Valid` 어노테이션 검증 실패 | | `MISSING_HEADER` | 400 | 필수 헤더 누락 (`X-Loopers-LoginId` 등) | | `Not Found` | 404 | 존재하지 않는 리소스 | | `Conflict` | 409 | 비즈니스 로직 충돌 (리소스 중복 등) | diff --git a/.docs/design/02-sequence-diagrams.md b/.docs/design/02-sequence-diagrams.md index a7b525697..60f901868 100644 --- a/.docs/design/02-sequence-diagrams.md +++ b/.docs/design/02-sequence-diagrams.md @@ -39,7 +39,7 @@ sequenceDiagram participant Encoder as 🛡️ PasswordEncoder participant DB as 💾 UserRepository - User->>API: POST /api/v1/users/register (loginId, password, name, birthday, email) + User->>API: POST /api/v1/users (loginId, password, name, birthday, email) API->>Service: register(loginId, name, rawPassword, birthday, email) rect rgb(240, 248, 255) @@ -150,7 +150,7 @@ sequenceDiagram participant Encoder as 🛡️ PasswordEncoder participant DB as 💾 UserRepository - User->>API: PUT /api/v1/users/me/password (Header: X-Loopers-LoginId, X-Loopers-LoginPw, Body: currentPassword, newPassword) + User->>API: PUT /api/v1/users/password (Header: X-Loopers-LoginId, X-Loopers-LoginPw, Body: currentPassword, newPassword) rect rgb(255, 230, 230) Note right of Interceptor: [책임 1] Interceptor preHandle — 헤더 기반 인증 diff --git a/.docs/design/03-class-diagram.md b/.docs/design/03-class-diagram.md index a04288679..b6d38a7c3 100644 --- a/.docs/design/03-class-diagram.md +++ b/.docs/design/03-class-diagram.md @@ -131,7 +131,6 @@ classDiagram -UserRepository userRepository -PasswordEncoder passwordEncoder +authenticate(UserId, String) void - -findUser(UserId) User } %% --- Interceptor → UseCase --- @@ -183,7 +182,7 @@ classDiagram - **인증 관심사 분리**: `AuthenticationInterceptor`가 `/api/v1/users/me/**` 경로의 인증을 전담한다. Controller는 `AuthenticationUseCase`를 더 이상 알지 못하며, `HttpServletRequest`의 `authenticatedUserId` 속성에서 인증된 사용자를 꺼내 쓴다. - **Service 분리**: `UserService`는 Register, Query, PasswordUpdate만 구현하고, `AuthenticationService`가 인증만 전담한다. 향후 도메인(주문, 좋아요 등)이 추가되어도 각 도메인별 Service가 독립적으로 존재하는 패턴의 기반이 된다. -- **Interceptor 등록**: `WebMvcConfig`가 `AuthenticationInterceptor`를 인증이 필요한 경로에만 등록한다. `/api/v1/users/register`는 인증 없이 접근 가능하다. +- **Interceptor 등록**: `WebMvcConfig`가 `AuthenticationInterceptor`를 인증이 필요한 경로에만 등록한다. `POST /api/v1/users` (회원가입)는 인증 없이 접근 가능하다. ### 설계 의도 @@ -625,7 +624,6 @@ classDiagram <> +handleCoreException(CoreException) ResponseEntity +handleIllegalArgumentException(IllegalArgumentException) ResponseEntity - +handleValidationException(MethodArgumentNotValidException) ResponseEntity +handleMissingHeaderException(MissingRequestHeaderException) ResponseEntity +handleException(Exception) ResponseEntity } @@ -764,6 +762,7 @@ classDiagram +updateBrand(Long, BrandUpdateRequest) ResponseEntity +deleteBrand(Long) ResponseEntity +getBrands() ResponseEntity + +getBrand(Long) ResponseEntity } class BrandController { <> @@ -888,9 +887,12 @@ classDiagram -CreateProductUseCase createProductUseCase -UpdateProductUseCase updateProductUseCase -DeleteProductUseCase deleteProductUseCase + -ProductQueryUseCase productQueryUseCase +createProduct(ProductCreateRequest) ResponseEntity +updateProduct(Long, ProductUpdateRequest) ResponseEntity +deleteProduct(Long) ResponseEntity + +getProducts(ProductSearchCondition) ResponseEntity + +getProduct(Long) ResponseEntity } class ProductController { <> @@ -934,6 +936,7 @@ classDiagram ProductAdminController ..> CreateProductUseCase : uses ProductAdminController ..> UpdateProductUseCase : uses ProductAdminController ..> DeleteProductUseCase : uses + ProductAdminController ..> ProductQueryUseCase : uses ProductController ..> ProductQueryUseCase : uses ProductService ..|> CreateProductUseCase : implements @@ -1149,6 +1152,7 @@ classDiagram <> -OrderQueryUseCase orderQueryUseCase +getAllOrders(OrderSearchCondition) ResponseEntity + +getOrder(Long) ResponseEntity } class CreateOrderUseCase { From cdb174e678b8ecde9cc397a06990902baff6163b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Tue, 24 Feb 2026 15:04:06 +0900 Subject: [PATCH 04/20] =?UTF-8?q?refactor:=20User=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=ED=8C=8C=EC=9D=BC=EC=9D=84=20model/user/=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aggregate별 하위 패키지 구조 적용 (05-package-structure.md 설계 기반) - domain/model/*.java → domain/model/user/*.java - 모든 레이어의 import 경로 수정 (로직 변경 없음) - 빈 Product.java 스켈레톤 제거 Co-Authored-By: Claude Opus 4.6 --- .../com/loopers/application/AuthenticationUseCase.java | 2 +- .../com/loopers/application/PasswordUpdateUseCase.java | 2 +- .../java/com/loopers/application/UserQueryUseCase.java | 2 +- .../application/service/AuthenticationService.java | 4 ++-- .../java/com/loopers/application/service/UserService.java | 2 +- .../com/loopers/domain/model/{ => user}/Birthday.java | 2 +- .../java/com/loopers/domain/model/{ => user}/Email.java | 2 +- .../com/loopers/domain/model/{ => user}/Password.java | 2 +- .../java/com/loopers/domain/model/{ => user}/User.java | 2 +- .../java/com/loopers/domain/model/{ => user}/UserId.java | 2 +- .../com/loopers/domain/model/{ => user}/UserName.java | 2 +- .../domain/model/{ => user}/WrongPasswordCount.java | 2 +- .../com/loopers/domain/repository/UserRepository.java | 4 ++-- .../com/loopers/infrastructure/UserRepositoryImpl.java | 2 +- .../com/loopers/infrastructure/entity/UserJpaEntity.java | 8 ++++---- .../java/com/loopers/interfaces/api/UserController.java | 2 +- .../api/interceptor/AuthenticationInterceptor.java | 2 +- .../application/service/AuthenticationServiceTest.java | 2 +- .../com/loopers/application/service/UserServiceTest.java | 2 +- .../com/loopers/domain/model/{ => user}/BirthdayTest.java | 2 +- .../com/loopers/domain/model/{ => user}/EmailTest.java | 2 +- .../com/loopers/domain/model/{ => user}/PasswordTest.java | 2 +- .../com/loopers/domain/model/{ => user}/UserIdTest.java | 2 +- .../com/loopers/domain/model/{ => user}/UserNameTest.java | 2 +- .../domain/model/{ => user}/WrongPasswordCountTest.java | 2 +- claudedocs/process.md | 0 claudedocs/week3.md | 0 27 files changed, 30 insertions(+), 30 deletions(-) rename apps/commerce-api/src/main/java/com/loopers/domain/model/{ => user}/Birthday.java (95%) rename apps/commerce-api/src/main/java/com/loopers/domain/model/{ => user}/Email.java (95%) rename apps/commerce-api/src/main/java/com/loopers/domain/model/{ => user}/Password.java (98%) rename apps/commerce-api/src/main/java/com/loopers/domain/model/{ => user}/User.java (97%) rename apps/commerce-api/src/main/java/com/loopers/domain/model/{ => user}/UserId.java (94%) rename apps/commerce-api/src/main/java/com/loopers/domain/model/{ => user}/UserName.java (94%) rename apps/commerce-api/src/main/java/com/loopers/domain/model/{ => user}/WrongPasswordCount.java (95%) rename apps/commerce-api/src/test/java/com/loopers/domain/model/{ => user}/BirthdayTest.java (98%) rename apps/commerce-api/src/test/java/com/loopers/domain/model/{ => user}/EmailTest.java (98%) rename apps/commerce-api/src/test/java/com/loopers/domain/model/{ => user}/PasswordTest.java (98%) rename apps/commerce-api/src/test/java/com/loopers/domain/model/{ => user}/UserIdTest.java (98%) rename apps/commerce-api/src/test/java/com/loopers/domain/model/{ => user}/UserNameTest.java (98%) rename apps/commerce-api/src/test/java/com/loopers/domain/model/{ => user}/WrongPasswordCountTest.java (98%) create mode 100644 claudedocs/process.md create mode 100644 claudedocs/week3.md diff --git a/apps/commerce-api/src/main/java/com/loopers/application/AuthenticationUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/AuthenticationUseCase.java index 3d274cd6f..7b423c15c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/AuthenticationUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/AuthenticationUseCase.java @@ -1,6 +1,6 @@ package com.loopers.application; -import com.loopers.domain.model.UserId; +import com.loopers.domain.model.user.UserId; public interface AuthenticationUseCase { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/PasswordUpdateUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/PasswordUpdateUseCase.java index b4fbf5ee6..1fdada2eb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/PasswordUpdateUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/PasswordUpdateUseCase.java @@ -1,6 +1,6 @@ package com.loopers.application; -import com.loopers.domain.model.UserId; +import com.loopers.domain.model.user.UserId; public interface PasswordUpdateUseCase { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/UserQueryUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/UserQueryUseCase.java index 75eb713e9..a2f1c5f17 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/UserQueryUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/UserQueryUseCase.java @@ -1,6 +1,6 @@ package com.loopers.application; -import com.loopers.domain.model.UserId; +import com.loopers.domain.model.user.UserId; import java.time.LocalDate; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/AuthenticationService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/AuthenticationService.java index ab156ff9f..85a60af99 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/AuthenticationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/AuthenticationService.java @@ -1,8 +1,8 @@ package com.loopers.application.service; import com.loopers.application.AuthenticationUseCase; -import com.loopers.domain.model.User; -import com.loopers.domain.model.UserId; +import com.loopers.domain.model.user.User; +import com.loopers.domain.model.user.UserId; import com.loopers.domain.repository.UserRepository; import com.loopers.domain.service.PasswordEncoder; import org.springframework.stereotype.Service; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/UserService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/UserService.java index c9e93b0c5..52aa37d50 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/UserService.java @@ -3,7 +3,7 @@ import com.loopers.application.PasswordUpdateUseCase; import com.loopers.application.RegisterUseCase; import com.loopers.application.UserQueryUseCase; -import com.loopers.domain.model.*; +import com.loopers.domain.model.user.*; import com.loopers.domain.repository.UserRepository; import com.loopers.domain.service.PasswordEncoder; import org.springframework.dao.DataIntegrityViolationException; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/Birthday.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/Birthday.java similarity index 95% rename from apps/commerce-api/src/main/java/com/loopers/domain/model/Birthday.java rename to apps/commerce-api/src/main/java/com/loopers/domain/model/user/Birthday.java index 5ff4694b6..dcd592d4a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/Birthday.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/Birthday.java @@ -1,4 +1,4 @@ -package com.loopers.domain.model; +package com.loopers.domain.model.user; import lombok.AccessLevel; import lombok.AllArgsConstructor; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/Email.java similarity index 95% rename from apps/commerce-api/src/main/java/com/loopers/domain/model/Email.java rename to apps/commerce-api/src/main/java/com/loopers/domain/model/user/Email.java index d833b6520..edf1c0f9a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/Email.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/Email.java @@ -1,4 +1,4 @@ -package com.loopers.domain.model; +package com.loopers.domain.model.user; import lombok.Data; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/Password.java similarity index 98% rename from apps/commerce-api/src/main/java/com/loopers/domain/model/Password.java rename to apps/commerce-api/src/main/java/com/loopers/domain/model/user/Password.java index 8f91a631e..3096f8d1e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/Password.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/Password.java @@ -1,4 +1,4 @@ -package com.loopers.domain.model; +package com.loopers.domain.model.user; import lombok.Data; import lombok.EqualsAndHashCode; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/User.java similarity index 97% rename from apps/commerce-api/src/main/java/com/loopers/domain/model/User.java rename to apps/commerce-api/src/main/java/com/loopers/domain/model/user/User.java index a789ca6eb..190192c6e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/User.java @@ -1,4 +1,4 @@ -package com.loopers.domain.model; +package com.loopers.domain.model.user; import lombok.AccessLevel; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/UserId.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserId.java similarity index 94% rename from apps/commerce-api/src/main/java/com/loopers/domain/model/UserId.java rename to apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserId.java index 541f29d95..da5665999 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/UserId.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserId.java @@ -1,4 +1,4 @@ -package com.loopers.domain.model; +package com.loopers.domain.model.user; import lombok.Data; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/UserName.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserName.java similarity index 94% rename from apps/commerce-api/src/main/java/com/loopers/domain/model/UserName.java rename to apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserName.java index cd5e10ff1..3ea083fc4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/UserName.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserName.java @@ -1,4 +1,4 @@ -package com.loopers.domain.model; +package com.loopers.domain.model.user; import lombok.Data; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/WrongPasswordCount.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/WrongPasswordCount.java similarity index 95% rename from apps/commerce-api/src/main/java/com/loopers/domain/model/WrongPasswordCount.java rename to apps/commerce-api/src/main/java/com/loopers/domain/model/user/WrongPasswordCount.java index 9c2e182fb..81122dff7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/WrongPasswordCount.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/WrongPasswordCount.java @@ -1,4 +1,4 @@ -package com.loopers.domain.model; +package com.loopers.domain.model.user; import lombok.AccessLevel; import lombok.AllArgsConstructor; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/repository/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/repository/UserRepository.java index f2b4eae02..90e580d89 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/repository/UserRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/repository/UserRepository.java @@ -1,7 +1,7 @@ package com.loopers.domain.repository; -import com.loopers.domain.model.User; -import com.loopers.domain.model.UserId; +import com.loopers.domain.model.user.User; +import com.loopers.domain.model.user.UserId; import java.util.Optional; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java index 226376350..8807285d8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java @@ -1,6 +1,6 @@ package com.loopers.infrastructure; -import com.loopers.domain.model.*; +import com.loopers.domain.model.user.*; import com.loopers.domain.repository.UserRepository; import com.loopers.infrastructure.entity.UserJpaEntity; import com.loopers.infrastructure.repository.UserJpaRepository; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/UserJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/UserJpaEntity.java index faccc97ed..18085e9e0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/UserJpaEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/UserJpaEntity.java @@ -1,9 +1,9 @@ package com.loopers.infrastructure.entity; -import com.loopers.domain.model.Birthday; -import com.loopers.domain.model.Email; -import com.loopers.domain.model.UserId; -import com.loopers.domain.model.UserName; +import com.loopers.domain.model.user.Birthday; +import com.loopers.domain.model.user.Email; +import com.loopers.domain.model.user.UserId; +import com.loopers.domain.model.user.UserName; import jakarta.persistence.*; import lombok.Getter; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/UserController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/UserController.java index 23305ea61..f88503408 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/UserController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/UserController.java @@ -3,7 +3,7 @@ import com.loopers.application.PasswordUpdateUseCase; import com.loopers.application.RegisterUseCase; import com.loopers.application.UserQueryUseCase; -import com.loopers.domain.model.UserId; +import com.loopers.domain.model.user.UserId; import com.loopers.interfaces.api.dto.PasswordUpdateRequest; import com.loopers.interfaces.api.dto.UserInfoResponse; import com.loopers.interfaces.api.dto.UserRegisterRequest; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/interceptor/AuthenticationInterceptor.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/interceptor/AuthenticationInterceptor.java index 328f8e7d9..28b612568 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/interceptor/AuthenticationInterceptor.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/interceptor/AuthenticationInterceptor.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.interceptor; import com.loopers.application.AuthenticationUseCase; -import com.loopers.domain.model.UserId; +import com.loopers.domain.model.user.UserId; import com.loopers.support.error.ErrorType; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; diff --git a/apps/commerce-api/src/test/java/com/loopers/application/service/AuthenticationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/service/AuthenticationServiceTest.java index 276cf38a2..8db7d7998 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/service/AuthenticationServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/service/AuthenticationServiceTest.java @@ -1,6 +1,6 @@ package com.loopers.application.service; -import com.loopers.domain.model.*; +import com.loopers.domain.model.user.*; import com.loopers.domain.repository.UserRepository; import com.loopers.domain.service.PasswordEncoder; import org.junit.jupiter.api.BeforeEach; diff --git a/apps/commerce-api/src/test/java/com/loopers/application/service/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/service/UserServiceTest.java index 6e62421b6..a8fd8e02e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/service/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/service/UserServiceTest.java @@ -1,6 +1,6 @@ package com.loopers.application.service; -import com.loopers.domain.model.*; +import com.loopers.domain.model.user.*; import com.loopers.domain.repository.UserRepository; import com.loopers.domain.service.PasswordEncoder; import org.junit.jupiter.api.BeforeEach; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/BirthdayTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/BirthdayTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/model/BirthdayTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/model/user/BirthdayTest.java index a2e0ff907..dd1127ed6 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/BirthdayTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/BirthdayTest.java @@ -1,4 +1,4 @@ -package com.loopers.domain.model; +package com.loopers.domain.model.user; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/EmailTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/model/EmailTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/model/user/EmailTest.java index 3393a5c1d..387c900a6 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/EmailTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/EmailTest.java @@ -1,4 +1,4 @@ -package com.loopers.domain.model; +package com.loopers.domain.model.user; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/PasswordTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/model/PasswordTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/model/user/PasswordTest.java index d85452e5f..f09025f4f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/PasswordTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/PasswordTest.java @@ -1,4 +1,4 @@ -package com.loopers.domain.model; +package com.loopers.domain.model.user; import org.junit.jupiter.api.Test; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/UserIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/UserIdTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/model/UserIdTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/model/user/UserIdTest.java index 938ec0bfa..ccb4af72e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/UserIdTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/UserIdTest.java @@ -1,4 +1,4 @@ -package com.loopers.domain.model; +package com.loopers.domain.model.user; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/UserNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/UserNameTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/model/UserNameTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/model/user/UserNameTest.java index 2a8c28ee7..0a1b33eb2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/UserNameTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/UserNameTest.java @@ -1,4 +1,4 @@ -package com.loopers.domain.model; +package com.loopers.domain.model.user; import org.junit.jupiter.api.DisplayName; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/WrongPasswordCountTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/WrongPasswordCountTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/model/WrongPasswordCountTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/model/user/WrongPasswordCountTest.java index 88c2bfe5e..83fbb5251 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/WrongPasswordCountTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/WrongPasswordCountTest.java @@ -1,4 +1,4 @@ -package com.loopers.domain.model; +package com.loopers.domain.model.user; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/claudedocs/process.md b/claudedocs/process.md new file mode 100644 index 000000000..e69de29bb diff --git a/claudedocs/week3.md b/claudedocs/week3.md new file mode 100644 index 000000000..e69de29bb From b8330dafe82b1c52ac77bc22c9c168ae35b51b1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Tue, 24 Feb 2026 15:09:29 +0900 Subject: [PATCH 05/20] =?UTF-8?q?feat:=20Brand=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BrandName Value Object (self-validating, 1~50자) - Brand Aggregate Root (create/update/delete, Soft Delete) - BrandRepository 인터페이스 정의 - BrandName, Brand 단위 테스트 Co-Authored-By: Claude Opus 4.6 --- .../com/loopers/domain/model/brand/Brand.java | 45 +++++++++++++ .../loopers/domain/model/brand/BrandName.java | 29 ++++++++ .../domain/repository/BrandRepository.java | 18 +++++ .../domain/model/brand/BrandNameTest.java | 57 ++++++++++++++++ .../loopers/domain/model/brand/BrandTest.java | 67 +++++++++++++++++++ 5 files changed, 216 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/brand/Brand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/brand/BrandName.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/repository/BrandRepository.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/model/brand/BrandNameTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/model/brand/BrandTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/Brand.java new file mode 100644 index 000000000..4ad00919a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/Brand.java @@ -0,0 +1,45 @@ +package com.loopers.domain.model.brand; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Brand { + + private final Long id; + private final BrandName name; + private final String description; + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; + private final LocalDateTime deletedAt; + + public static Brand create(BrandName name, String description) { + LocalDateTime now = LocalDateTime.now(); + return new Brand(null, name, description, now, now, null); + } + + public static Brand reconstitute(Long id, BrandName name, String description, + LocalDateTime createdAt, LocalDateTime updatedAt, + LocalDateTime deletedAt) { + return new Brand(id, name, description, createdAt, updatedAt, deletedAt); + } + + public Brand update(BrandName name, String description) { + return new Brand(this.id, name, description, this.createdAt, LocalDateTime.now(), this.deletedAt); + } + + public Brand delete() { + if (isDeleted()) { + throw new IllegalStateException("이미 삭제된 브랜드입니다."); + } + return new Brand(this.id, this.name, this.description, this.createdAt, this.updatedAt, LocalDateTime.now()); + } + + public boolean isDeleted() { + return this.deletedAt != null; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/BrandName.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/BrandName.java new file mode 100644 index 000000000..bb2ca8d71 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/BrandName.java @@ -0,0 +1,29 @@ +package com.loopers.domain.model.brand; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@EqualsAndHashCode +public class BrandName { + + private static final int MIN_LENGTH = 1; + private static final int MAX_LENGTH = 50; + + private final String value; + + private BrandName(String value) { + this.value = value; + } + + public static BrandName of(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("브랜드 이름은 필수 입력값입니다."); + } + String trimmed = value.trim(); + if (trimmed.length() < MIN_LENGTH || trimmed.length() > MAX_LENGTH) { + throw new IllegalArgumentException("브랜드 이름은 1~50자여야 합니다."); + } + return new BrandName(trimmed); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/repository/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/repository/BrandRepository.java new file mode 100644 index 000000000..ad151c62b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/repository/BrandRepository.java @@ -0,0 +1,18 @@ +package com.loopers.domain.repository; + +import com.loopers.domain.model.brand.Brand; +import com.loopers.domain.model.brand.BrandName; + +import java.util.List; +import java.util.Optional; + +public interface BrandRepository { + + Brand save(Brand brand); + + Optional findById(Long id); + + List findAll(); + + boolean existsByName(BrandName name); +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/brand/BrandNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/brand/BrandNameTest.java new file mode 100644 index 000000000..f8341215f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/brand/BrandNameTest.java @@ -0,0 +1,57 @@ +package com.loopers.domain.model.brand; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BrandNameTest { + + @Test + @DisplayName("유효한 브랜드 이름 생성 성공") + void create_success() { + BrandName name = BrandName.of("Nike"); + assertThat(name.getValue()).isEqualTo("Nike"); + } + + @Test + @DisplayName("브랜드 이름 null이면 예외") + void create_fail_null() { + assertThatThrownBy(() -> BrandName.of(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("브랜드 이름은 필수 입력값입니다."); + } + + @Test + @DisplayName("브랜드 이름 공백이면 예외") + void create_fail_blank() { + assertThatThrownBy(() -> BrandName.of(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("브랜드 이름은 필수 입력값입니다."); + } + + @Test + @DisplayName("브랜드 이름 50자 초과면 예외") + void create_fail_too_long() { + String longName = "a".repeat(51); + assertThatThrownBy(() -> BrandName.of(longName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("1~50자"); + } + + @Test + @DisplayName("브랜드 이름 공백 trim 처리") + void create_success_with_trim() { + BrandName name = BrandName.of(" Nike "); + assertThat(name.getValue()).isEqualTo("Nike"); + } + + @Test + @DisplayName("동일한 이름은 equals 동등") + void equals_consistency() { + BrandName name1 = BrandName.of("Nike"); + BrandName name2 = BrandName.of("Nike"); + assertThat(name1).isEqualTo(name2); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/brand/BrandTest.java new file mode 100644 index 000000000..ded6ebb13 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/brand/BrandTest.java @@ -0,0 +1,67 @@ +package com.loopers.domain.model.brand; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BrandTest { + + @Test + @DisplayName("브랜드 생성 성공") + void create_success() { + Brand brand = Brand.create(BrandName.of("Nike"), "스포츠 브랜드"); + + assertThat(brand.getId()).isNull(); + assertThat(brand.getName().getValue()).isEqualTo("Nike"); + assertThat(brand.getDescription()).isEqualTo("스포츠 브랜드"); + assertThat(brand.getCreatedAt()).isNotNull(); + assertThat(brand.isDeleted()).isFalse(); + } + + @Test + @DisplayName("브랜드 수정 시 새 객체 반환") + void update_returns_new_instance() { + Brand brand = Brand.create(BrandName.of("Nike"), "스포츠 브랜드"); + Brand updated = brand.update(BrandName.of("Adidas"), "독일 스포츠 브랜드"); + + assertThat(updated.getName().getValue()).isEqualTo("Adidas"); + assertThat(updated.getDescription()).isEqualTo("독일 스포츠 브랜드"); + assertThat(brand.getName().getValue()).isEqualTo("Nike"); + } + + @Test + @DisplayName("브랜드 삭제 시 deletedAt 설정") + void delete_success() { + Brand brand = Brand.create(BrandName.of("Nike"), "스포츠 브랜드"); + Brand deleted = brand.delete(); + + assertThat(deleted.isDeleted()).isTrue(); + assertThat(deleted.getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("이미 삭제된 브랜드 재삭제 시 예외") + void delete_already_deleted() { + Brand brand = Brand.create(BrandName.of("Nike"), "스포츠 브랜드"); + Brand deleted = brand.delete(); + + assertThatThrownBy(deleted::delete) + .isInstanceOf(IllegalStateException.class) + .hasMessage("이미 삭제된 브랜드입니다."); + } + + @Test + @DisplayName("reconstitute로 DB에서 복원") + void reconstitute_success() { + LocalDateTime now = LocalDateTime.now(); + Brand brand = Brand.reconstitute(1L, BrandName.of("Nike"), "스포츠 브랜드", now, now, null); + + assertThat(brand.getId()).isEqualTo(1L); + assertThat(brand.getName().getValue()).isEqualTo("Nike"); + assertThat(brand.isDeleted()).isFalse(); + } +} From a25261916e45dd744d1511cab12fcfb7ffd55f85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Tue, 24 Feb 2026 15:11:02 +0900 Subject: [PATCH 06/20] =?UTF-8?q?feat:=20Product=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProductName, Price, Stock Value Objects (self-validating) - Stock에 decrease/hasEnough 비즈니스 로직 캡슐화 (음수 방지) - Product Aggregate Root (create/update/delete, 재고 차감, 좋아요 증감) - brandId는 ID 참조 (Aggregate 직접 참조 아님), update에 brandId 없음 (변경 불가) - ProductRepository 인터페이스 정의 - 단위 테스트: Stock, Price, ProductName, Product Co-Authored-By: Claude Opus 4.6 --- .../loopers/domain/model/product/Price.java | 22 ++++ .../loopers/domain/model/product/Product.java | 71 +++++++++++ .../domain/model/product/ProductName.java | 28 +++++ .../loopers/domain/model/product/Stock.java | 36 ++++++ .../domain/repository/ProductRepository.java | 12 ++ .../domain/model/product/PriceTest.java | 32 +++++ .../domain/model/product/ProductNameTest.java | 41 ++++++ .../domain/model/product/ProductTest.java | 117 ++++++++++++++++++ .../domain/model/product/StockTest.java | 74 +++++++++++ 9 files changed, 433 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/product/Price.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/product/Product.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/product/ProductName.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/product/Stock.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/repository/ProductRepository.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/model/product/PriceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductNameTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/model/product/StockTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Price.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Price.java new file mode 100644 index 000000000..2ba2237d2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Price.java @@ -0,0 +1,22 @@ +package com.loopers.domain.model.product; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@EqualsAndHashCode +public class Price { + + private final int value; + + private Price(int value) { + this.value = value; + } + + public static Price of(int value) { + if (value < 0) { + throw new IllegalArgumentException("상품 가격은 0 이상이어야 합니다."); + } + return new Price(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Product.java new file mode 100644 index 000000000..74398f8b8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Product.java @@ -0,0 +1,71 @@ +package com.loopers.domain.model.product; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Product { + + private final Long id; + private final Long brandId; + private final ProductName name; + private final Price price; + private final Stock stock; + private final int likeCount; + private final String description; + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; + private final LocalDateTime deletedAt; + + public static Product create(Long brandId, ProductName name, Price price, Stock stock, String description) { + LocalDateTime now = LocalDateTime.now(); + return new Product(null, brandId, name, price, stock, 0, description, now, now, null); + } + + public static Product reconstitute(Long id, Long brandId, ProductName name, Price price, Stock stock, + int likeCount, String description, + LocalDateTime createdAt, LocalDateTime updatedAt, + LocalDateTime deletedAt) { + return new Product(id, brandId, name, price, stock, likeCount, description, createdAt, updatedAt, deletedAt); + } + + public Product update(ProductName name, Price price, Stock stock, String description) { + return new Product(this.id, this.brandId, name, price, stock, this.likeCount, + description, this.createdAt, LocalDateTime.now(), this.deletedAt); + } + + public Product delete() { + if (isDeleted()) { + throw new IllegalStateException("이미 삭제된 상품입니다."); + } + return new Product(this.id, this.brandId, this.name, this.price, this.stock, this.likeCount, + this.description, this.createdAt, this.updatedAt, LocalDateTime.now()); + } + + public Product decreaseStock(int quantity) { + Stock decreased = this.stock.decrease(quantity); + return new Product(this.id, this.brandId, this.name, this.price, decreased, this.likeCount, + this.description, this.createdAt, LocalDateTime.now(), this.deletedAt); + } + + public Product increaseLikeCount() { + return new Product(this.id, this.brandId, this.name, this.price, this.stock, this.likeCount + 1, + this.description, this.createdAt, this.updatedAt, this.deletedAt); + } + + public Product decreaseLikeCount() { + if (this.likeCount <= 0) { + throw new IllegalStateException("좋아요 수는 0 미만이 될 수 없습니다."); + } + return new Product(this.id, this.brandId, this.name, this.price, this.stock, this.likeCount - 1, + this.description, this.createdAt, this.updatedAt, this.deletedAt); + } + + public boolean isDeleted() { + return this.deletedAt != null; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/ProductName.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/ProductName.java new file mode 100644 index 000000000..3848e6493 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/ProductName.java @@ -0,0 +1,28 @@ +package com.loopers.domain.model.product; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@EqualsAndHashCode +public class ProductName { + + private static final int MAX_LENGTH = 100; + + private final String value; + + private ProductName(String value) { + this.value = value; + } + + public static ProductName of(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("상품 이름은 필수 입력값입니다."); + } + String trimmed = value.trim(); + if (trimmed.length() > MAX_LENGTH) { + throw new IllegalArgumentException("상품 이름은 100자 이하여야 합니다."); + } + return new ProductName(trimmed); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Stock.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Stock.java new file mode 100644 index 000000000..2daf9dc6b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Stock.java @@ -0,0 +1,36 @@ +package com.loopers.domain.model.product; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@EqualsAndHashCode +public class Stock { + + private final int value; + + private Stock(int value) { + this.value = value; + } + + public static Stock of(int value) { + if (value < 0) { + throw new IllegalArgumentException("재고 수량은 0 이상이어야 합니다."); + } + return new Stock(value); + } + + public Stock decrease(int quantity) { + if (quantity <= 0) { + throw new IllegalArgumentException("차감 수량은 1 이상이어야 합니다."); + } + if (!hasEnough(quantity)) { + throw new IllegalStateException("재고가 부족합니다. 현재 재고: " + this.value + ", 요청 수량: " + quantity); + } + return new Stock(this.value - quantity); + } + + public boolean hasEnough(int quantity) { + return this.value >= quantity; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/repository/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/repository/ProductRepository.java new file mode 100644 index 000000000..5c1810731 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/repository/ProductRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.repository; + +import com.loopers.domain.model.product.Product; + +import java.util.Optional; + +public interface ProductRepository { + + Product save(Product product); + + Optional findById(Long id); +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/product/PriceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/product/PriceTest.java new file mode 100644 index 000000000..55c109a12 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/product/PriceTest.java @@ -0,0 +1,32 @@ +package com.loopers.domain.model.product; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PriceTest { + + @Test + @DisplayName("유효한 가격 생성 성공") + void create_success() { + Price price = Price.of(10000); + assertThat(price.getValue()).isEqualTo(10000); + } + + @Test + @DisplayName("가격 0원 생성 성공") + void create_success_zero() { + Price price = Price.of(0); + assertThat(price.getValue()).isEqualTo(0); + } + + @Test + @DisplayName("음수 가격 생성 시 예외") + void create_fail_negative() { + assertThatThrownBy(() -> Price.of(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("0 이상"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductNameTest.java new file mode 100644 index 000000000..f541ccc75 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductNameTest.java @@ -0,0 +1,41 @@ +package com.loopers.domain.model.product; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ProductNameTest { + + @Test + @DisplayName("유효한 상품 이름 생성 성공") + void create_success() { + ProductName name = ProductName.of("에어맥스 90"); + assertThat(name.getValue()).isEqualTo("에어맥스 90"); + } + + @Test + @DisplayName("상품 이름 null이면 예외") + void create_fail_null() { + assertThatThrownBy(() -> ProductName.of(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("상품 이름은 필수 입력값입니다."); + } + + @Test + @DisplayName("상품 이름 100자 초과면 예외") + void create_fail_too_long() { + String longName = "a".repeat(101); + assertThatThrownBy(() -> ProductName.of(longName)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("100자"); + } + + @Test + @DisplayName("상품 이름 공백 trim 처리") + void create_success_with_trim() { + ProductName name = ProductName.of(" 에어맥스 90 "); + assertThat(name.getValue()).isEqualTo("에어맥스 90"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductTest.java new file mode 100644 index 000000000..0ef987d22 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductTest.java @@ -0,0 +1,117 @@ +package com.loopers.domain.model.product; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ProductTest { + + private Product createProduct() { + return Product.create( + 1L, + ProductName.of("에어맥스 90"), + Price.of(139000), + Stock.of(50), + "나이키 에어맥스 90" + ); + } + + @Test + @DisplayName("상품 생성 성공") + void create_success() { + Product product = createProduct(); + + assertThat(product.getId()).isNull(); + assertThat(product.getBrandId()).isEqualTo(1L); + assertThat(product.getName().getValue()).isEqualTo("에어맥스 90"); + assertThat(product.getPrice().getValue()).isEqualTo(139000); + assertThat(product.getStock().getValue()).isEqualTo(50); + assertThat(product.getLikeCount()).isEqualTo(0); + assertThat(product.isDeleted()).isFalse(); + } + + @Test + @DisplayName("상품 수정 시 brandId 변경 불가 (update에 brandId 파라미터 없음)") + void update_without_brandId() { + Product product = createProduct(); + Product updated = product.update( + ProductName.of("에어맥스 95"), + Price.of(159000), + Stock.of(30), + "나이키 에어맥스 95" + ); + + assertThat(updated.getBrandId()).isEqualTo(1L); + assertThat(updated.getName().getValue()).isEqualTo("에어맥스 95"); + assertThat(updated.getPrice().getValue()).isEqualTo(159000); + } + + @Test + @DisplayName("상품 삭제 (Soft Delete)") + void delete_success() { + Product product = createProduct(); + Product deleted = product.delete(); + + assertThat(deleted.isDeleted()).isTrue(); + } + + @Test + @DisplayName("이미 삭제된 상품 재삭제 시 예외") + void delete_already_deleted() { + Product product = createProduct(); + Product deleted = product.delete(); + + assertThatThrownBy(deleted::delete) + .isInstanceOf(IllegalStateException.class) + .hasMessage("이미 삭제된 상품입니다."); + } + + @Test + @DisplayName("재고 차감 성공") + void decreaseStock_success() { + Product product = createProduct(); + Product decreased = product.decreaseStock(5); + + assertThat(decreased.getStock().getValue()).isEqualTo(45); + } + + @Test + @DisplayName("재고 부족 시 차감 예외") + void decreaseStock_fail_insufficient() { + Product product = createProduct(); + + assertThatThrownBy(() -> product.decreaseStock(51)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("재고가 부족합니다"); + } + + @Test + @DisplayName("좋아요 수 증가") + void increaseLikeCount() { + Product product = createProduct(); + Product liked = product.increaseLikeCount(); + + assertThat(liked.getLikeCount()).isEqualTo(1); + } + + @Test + @DisplayName("좋아요 수 감소") + void decreaseLikeCount() { + Product product = createProduct().increaseLikeCount(); + Product unliked = product.decreaseLikeCount(); + + assertThat(unliked.getLikeCount()).isEqualTo(0); + } + + @Test + @DisplayName("좋아요 0에서 감소 시 예외") + void decreaseLikeCount_fail_zero() { + Product product = createProduct(); + + assertThatThrownBy(product::decreaseLikeCount) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("0 미만"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/product/StockTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/product/StockTest.java new file mode 100644 index 000000000..12612e898 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/product/StockTest.java @@ -0,0 +1,74 @@ +package com.loopers.domain.model.product; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class StockTest { + + @Test + @DisplayName("유효한 재고 생성 성공") + void create_success() { + Stock stock = Stock.of(10); + assertThat(stock.getValue()).isEqualTo(10); + } + + @Test + @DisplayName("재고 0 생성 성공") + void create_success_zero() { + Stock stock = Stock.of(0); + assertThat(stock.getValue()).isEqualTo(0); + } + + @Test + @DisplayName("음수 재고 생성 시 예외") + void create_fail_negative() { + assertThatThrownBy(() -> Stock.of(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("0 이상"); + } + + @Test + @DisplayName("재고 차감 성공") + void decrease_success() { + Stock stock = Stock.of(10); + Stock decreased = stock.decrease(3); + assertThat(decreased.getValue()).isEqualTo(7); + } + + @Test + @DisplayName("재고 전량 차감 성공") + void decrease_to_zero() { + Stock stock = Stock.of(5); + Stock decreased = stock.decrease(5); + assertThat(decreased.getValue()).isEqualTo(0); + } + + @Test + @DisplayName("재고 부족 시 차감 예외") + void decrease_fail_insufficient() { + Stock stock = Stock.of(3); + assertThatThrownBy(() -> stock.decrease(5)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("재고가 부족합니다"); + } + + @Test + @DisplayName("차감 수량 0 이하면 예외") + void decrease_fail_zero_quantity() { + Stock stock = Stock.of(10); + assertThatThrownBy(() -> stock.decrease(0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("1 이상"); + } + + @Test + @DisplayName("재고 충분 여부 확인") + void hasEnough() { + Stock stock = Stock.of(5); + assertThat(stock.hasEnough(5)).isTrue(); + assertThat(stock.hasEnough(6)).isFalse(); + } +} From bb1380100b9100de5a0861974a7690a13c0b6da4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Tue, 24 Feb 2026 15:11:56 +0900 Subject: [PATCH 07/20] =?UTF-8?q?feat:=20Like=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Like Entity (UserId import으로 Aggregate 간 ID 참조) - create 팩토리 메서드 (null 검증 포함) - LikeRepository 인터페이스 정의 (existsBy로 Idempotency 지원) - Like 단위 테스트 Co-Authored-By: Claude Opus 4.6 --- .../com/loopers/domain/model/like/Like.java | 32 +++++++++++ .../domain/repository/LikeRepository.java | 17 ++++++ .../loopers/domain/model/like/LikeTest.java | 54 +++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/like/Like.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/repository/LikeRepository.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/model/like/LikeTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/like/Like.java new file mode 100644 index 000000000..ff2cf762a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/like/Like.java @@ -0,0 +1,32 @@ +package com.loopers.domain.model.like; + +import com.loopers.domain.model.user.UserId; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Like { + + private final Long id; + private final UserId userId; + private final Long productId; + private final LocalDateTime createdAt; + + public static Like create(UserId userId, Long productId) { + if (userId == null) { + throw new IllegalArgumentException("사용자 ID는 필수입니다."); + } + if (productId == null) { + throw new IllegalArgumentException("상품 ID는 필수입니다."); + } + return new Like(null, userId, productId, LocalDateTime.now()); + } + + public static Like reconstitute(Long id, UserId userId, Long productId, LocalDateTime createdAt) { + return new Like(id, userId, productId, createdAt); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/repository/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/repository/LikeRepository.java new file mode 100644 index 000000000..89acfe971 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/repository/LikeRepository.java @@ -0,0 +1,17 @@ +package com.loopers.domain.repository; + +import com.loopers.domain.model.like.Like; +import com.loopers.domain.model.user.UserId; + +import java.util.Optional; + +public interface LikeRepository { + + Like save(Like like); + + Optional findByUserIdAndProductId(UserId userId, Long productId); + + void deleteByUserIdAndProductId(UserId userId, Long productId); + + boolean existsByUserIdAndProductId(UserId userId, Long productId); +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/like/LikeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/like/LikeTest.java new file mode 100644 index 000000000..6f91d2d96 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/like/LikeTest.java @@ -0,0 +1,54 @@ +package com.loopers.domain.model.like; + +import com.loopers.domain.model.user.UserId; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LikeTest { + + @Test + @DisplayName("좋아요 생성 성공") + void create_success() { + UserId userId = UserId.of("testuser1"); + Like like = Like.create(userId, 1L); + + assertThat(like.getId()).isNull(); + assertThat(like.getUserId()).isEqualTo(userId); + assertThat(like.getProductId()).isEqualTo(1L); + assertThat(like.getCreatedAt()).isNotNull(); + } + + @Test + @DisplayName("userId null이면 예외") + void create_fail_null_userId() { + assertThatThrownBy(() -> Like.create(null, 1L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("사용자 ID는 필수입니다."); + } + + @Test + @DisplayName("productId null이면 예외") + void create_fail_null_productId() { + assertThatThrownBy(() -> Like.create(UserId.of("testuser1"), null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("상품 ID는 필수입니다."); + } + + @Test + @DisplayName("reconstitute로 DB에서 복원") + void reconstitute_success() { + LocalDateTime now = LocalDateTime.now(); + UserId userId = UserId.of("testuser1"); + Like like = Like.reconstitute(1L, userId, 100L, now); + + assertThat(like.getId()).isEqualTo(1L); + assertThat(like.getUserId()).isEqualTo(userId); + assertThat(like.getProductId()).isEqualTo(100L); + assertThat(like.getCreatedAt()).isEqualTo(now); + } +} From 188f96b3b699d78f7d47dce1068b1c4cb2654778 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Tue, 24 Feb 2026 15:13:51 +0900 Subject: [PATCH 08/20] =?UTF-8?q?feat:=20Order=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Money, ReceiverName, Address Value Objects - OrderStatus, PaymentMethod Enums (상태 전이 규칙 포함) - OrderItem Entity (calculateAmount 로직) - OrderSnapshot Entity (주문 시점 데이터 보존) - Order Aggregate Root (create/cancel/updateDeliveryAddress) - 금액 자동 계산: totalAmount = SUM(unitPrice * quantity) - 상태별 취소/배송지 변경 가능 여부 도메인 레벨 검증 - OrderRepository 인터페이스 정의 - 단위 테스트: Money, OrderItem, Order (정상/예외/상태 전이) Co-Authored-By: Claude Opus 4.6 --- .../loopers/domain/model/order/Address.java | 22 +++ .../com/loopers/domain/model/order/Money.java | 45 ++++++ .../com/loopers/domain/model/order/Order.java | 94 +++++++++++ .../loopers/domain/model/order/OrderItem.java | 36 +++++ .../domain/model/order/OrderSnapshot.java | 27 ++++ .../domain/model/order/OrderStatus.java | 27 ++++ .../domain/model/order/PaymentMethod.java | 17 ++ .../domain/model/order/ReceiverName.java | 22 +++ .../domain/repository/OrderRepository.java | 16 ++ .../loopers/domain/model/order/MoneyTest.java | 80 ++++++++++ .../domain/model/order/OrderItemTest.java | 52 ++++++ .../loopers/domain/model/order/OrderTest.java | 151 ++++++++++++++++++ 12 files changed, 589 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/order/Address.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/order/Money.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/order/Order.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderItem.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderSnapshot.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/order/PaymentMethod.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/order/ReceiverName.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/repository/OrderRepository.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/model/order/MoneyTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderItemTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Address.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Address.java new file mode 100644 index 000000000..53a295772 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Address.java @@ -0,0 +1,22 @@ +package com.loopers.domain.model.order; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@EqualsAndHashCode +public class Address { + + private final String value; + + private Address(String value) { + this.value = value; + } + + public static Address of(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("배송지 주소는 필수 입력값입니다."); + } + return new Address(value.trim()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Money.java new file mode 100644 index 000000000..718451fe6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Money.java @@ -0,0 +1,45 @@ +package com.loopers.domain.model.order; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@EqualsAndHashCode +public class Money { + + private final int value; + + private Money(int value) { + this.value = value; + } + + public static Money of(int value) { + if (value < 0) { + throw new IllegalArgumentException("금액은 0 이상이어야 합니다."); + } + return new Money(value); + } + + public static Money zero() { + return new Money(0); + } + + public Money add(Money other) { + return new Money(this.value + other.value); + } + + public Money subtract(Money other) { + int result = this.value - other.value; + if (result < 0) { + throw new IllegalStateException("차감 결과 금액이 음수가 될 수 없습니다."); + } + return new Money(result); + } + + public Money multiply(int quantity) { + if (quantity < 0) { + throw new IllegalArgumentException("곱할 수량은 0 이상이어야 합니다."); + } + return new Money(this.value * quantity); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Order.java new file mode 100644 index 000000000..2bcc12871 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Order.java @@ -0,0 +1,94 @@ +package com.loopers.domain.model.order; + +import com.loopers.domain.model.user.UserId; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Order { + + private final Long id; + private final UserId userId; + private final List items; + private final OrderSnapshot snapshot; + private final ReceiverName receiverName; + private final Address address; + private final String deliveryRequest; + private final PaymentMethod paymentMethod; + private final Money totalAmount; + private final Money discountAmount; + private final Money paymentAmount; + private final OrderStatus status; + private final LocalDate desiredDeliveryDate; + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; + + public static Order create(UserId userId, List items, ReceiverName receiverName, + Address address, String deliveryRequest, PaymentMethod paymentMethod, + Money discountAmount, LocalDate desiredDeliveryDate, + OrderSnapshot snapshot) { + if (userId == null) { + throw new IllegalArgumentException("사용자 ID는 필수입니다."); + } + if (items == null || items.isEmpty()) { + throw new IllegalArgumentException("주문 항목은 1개 이상이어야 합니다."); + } + if (paymentMethod == null) { + throw new IllegalArgumentException("결제 수단은 필수입니다."); + } + + Money totalAmount = calculateTotalAmount(items); + Money paymentAmount = totalAmount.subtract(discountAmount); + LocalDateTime now = LocalDateTime.now(); + + return new Order(null, userId, items, snapshot, receiverName, address, deliveryRequest, + paymentMethod, totalAmount, discountAmount, paymentAmount, + OrderStatus.PAYMENT_COMPLETED, desiredDeliveryDate, now, now); + } + + public static Order reconstitute(Long id, UserId userId, List items, OrderSnapshot snapshot, + ReceiverName receiverName, Address address, String deliveryRequest, + PaymentMethod paymentMethod, Money totalAmount, Money discountAmount, + Money paymentAmount, OrderStatus status, LocalDate desiredDeliveryDate, + LocalDateTime createdAt, LocalDateTime updatedAt) { + return new Order(id, userId, items, snapshot, receiverName, address, deliveryRequest, + paymentMethod, totalAmount, discountAmount, paymentAmount, status, + desiredDeliveryDate, createdAt, updatedAt); + } + + public Order cancel() { + if (!isCancellable()) { + throw new IllegalStateException("현재 상태에서는 주문을 취소할 수 없습니다. 현재 상태: " + status.getDescription()); + } + return new Order(this.id, this.userId, this.items, this.snapshot, this.receiverName, + this.address, this.deliveryRequest, this.paymentMethod, this.totalAmount, + this.discountAmount, this.paymentAmount, OrderStatus.PAYMENT_COMPLETED, + this.desiredDeliveryDate, this.createdAt, LocalDateTime.now()); + } + + public Order updateDeliveryAddress(Address newAddress) { + if (!status.isAddressChangeable()) { + throw new IllegalStateException("현재 상태에서는 배송지를 변경할 수 없습니다. 현재 상태: " + status.getDescription()); + } + return new Order(this.id, this.userId, this.items, this.snapshot, this.receiverName, + newAddress, this.deliveryRequest, this.paymentMethod, this.totalAmount, + this.discountAmount, this.paymentAmount, this.status, + this.desiredDeliveryDate, this.createdAt, LocalDateTime.now()); + } + + public boolean isCancellable() { + return status.isCancellable(); + } + + private static Money calculateTotalAmount(List items) { + return items.stream() + .map(OrderItem::calculateAmount) + .reduce(Money.zero(), Money::add); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderItem.java new file mode 100644 index 000000000..41404a5a6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderItem.java @@ -0,0 +1,36 @@ +package com.loopers.domain.model.order; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class OrderItem { + + private final Long id; + private final Long productId; + private final int quantity; + private final Money unitPrice; + + public static OrderItem create(Long productId, int quantity, Money unitPrice) { + if (productId == null) { + throw new IllegalArgumentException("상품 ID는 필수입니다."); + } + if (quantity <= 0) { + throw new IllegalArgumentException("주문 수량은 1 이상이어야 합니다."); + } + if (unitPrice == null) { + throw new IllegalArgumentException("단가는 필수입니다."); + } + return new OrderItem(null, productId, quantity, unitPrice); + } + + public static OrderItem reconstitute(Long id, Long productId, int quantity, Money unitPrice) { + return new OrderItem(id, productId, quantity, unitPrice); + } + + public Money calculateAmount() { + return unitPrice.multiply(quantity); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderSnapshot.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderSnapshot.java new file mode 100644 index 000000000..8ad2843fa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderSnapshot.java @@ -0,0 +1,27 @@ +package com.loopers.domain.model.order; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class OrderSnapshot { + + private final Long id; + private final String snapshotData; + private final LocalDateTime createdAt; + + public static OrderSnapshot create(String snapshotData) { + if (snapshotData == null || snapshotData.isBlank()) { + throw new IllegalArgumentException("스냅샷 데이터는 필수입니다."); + } + return new OrderSnapshot(null, snapshotData, LocalDateTime.now()); + } + + public static OrderSnapshot reconstitute(Long id, String snapshotData, LocalDateTime createdAt) { + return new OrderSnapshot(id, snapshotData, createdAt); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderStatus.java new file mode 100644 index 000000000..962bef74c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderStatus.java @@ -0,0 +1,27 @@ +package com.loopers.domain.model.order; + +public enum OrderStatus { + + PAYMENT_COMPLETED("결제완료"), + PREPARING("상품준비중"), + SHIPPING("배송중"), + DELIVERED("배송완료"); + + private final String description; + + OrderStatus(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } + + public boolean isCancellable() { + return this == PAYMENT_COMPLETED || this == PREPARING; + } + + public boolean isAddressChangeable() { + return this == PAYMENT_COMPLETED || this == PREPARING; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/PaymentMethod.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/PaymentMethod.java new file mode 100644 index 000000000..9cd84223e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/PaymentMethod.java @@ -0,0 +1,17 @@ +package com.loopers.domain.model.order; + +public enum PaymentMethod { + + CARD("카드"), + BANK_TRANSFER("계좌이체"); + + private final String description; + + PaymentMethod(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/ReceiverName.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/ReceiverName.java new file mode 100644 index 000000000..2db5ae56c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/ReceiverName.java @@ -0,0 +1,22 @@ +package com.loopers.domain.model.order; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@EqualsAndHashCode +public class ReceiverName { + + private final String value; + + private ReceiverName(String value) { + this.value = value; + } + + public static ReceiverName of(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("수령인 이름은 필수 입력값입니다."); + } + return new ReceiverName(value.trim()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/repository/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/repository/OrderRepository.java new file mode 100644 index 000000000..3e4a3802e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/repository/OrderRepository.java @@ -0,0 +1,16 @@ +package com.loopers.domain.repository; + +import com.loopers.domain.model.order.Order; +import com.loopers.domain.model.user.UserId; + +import java.util.List; +import java.util.Optional; + +public interface OrderRepository { + + Order save(Order order); + + Optional findById(Long id); + + List findAllByUserId(UserId userId); +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/order/MoneyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/MoneyTest.java new file mode 100644 index 000000000..fb58c87c8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/MoneyTest.java @@ -0,0 +1,80 @@ +package com.loopers.domain.model.order; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MoneyTest { + + @Test + @DisplayName("유효한 금액 생성 성공") + void create_success() { + Money money = Money.of(10000); + assertThat(money.getValue()).isEqualTo(10000); + } + + @Test + @DisplayName("0원 생성 성공") + void create_success_zero() { + Money money = Money.of(0); + assertThat(money.getValue()).isEqualTo(0); + } + + @Test + @DisplayName("음수 금액 생성 시 예외") + void create_fail_negative() { + assertThatThrownBy(() -> Money.of(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("0 이상"); + } + + @Test + @DisplayName("금액 덧셈") + void add() { + Money a = Money.of(1000); + Money b = Money.of(2000); + assertThat(a.add(b).getValue()).isEqualTo(3000); + } + + @Test + @DisplayName("금액 뺄셈") + void subtract() { + Money a = Money.of(3000); + Money b = Money.of(1000); + assertThat(a.subtract(b).getValue()).isEqualTo(2000); + } + + @Test + @DisplayName("금액 뺄셈 결과 음수면 예외") + void subtract_fail_negative_result() { + Money a = Money.of(1000); + Money b = Money.of(2000); + assertThatThrownBy(() -> a.subtract(b)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("음수"); + } + + @Test + @DisplayName("금액 곱셈") + void multiply() { + Money money = Money.of(5000); + assertThat(money.multiply(3).getValue()).isEqualTo(15000); + } + + @Test + @DisplayName("금액 곱셈 음수 수량이면 예외") + void multiply_fail_negative() { + Money money = Money.of(5000); + assertThatThrownBy(() -> money.multiply(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("0 이상"); + } + + @Test + @DisplayName("zero 팩토리 메서드") + void zero() { + assertThat(Money.zero().getValue()).isEqualTo(0); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderItemTest.java new file mode 100644 index 000000000..a8ffdb1bf --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderItemTest.java @@ -0,0 +1,52 @@ +package com.loopers.domain.model.order; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OrderItemTest { + + @Test + @DisplayName("주문 항목 생성 성공") + void create_success() { + OrderItem item = OrderItem.create(1L, 2, Money.of(10000)); + + assertThat(item.getId()).isNull(); + assertThat(item.getProductId()).isEqualTo(1L); + assertThat(item.getQuantity()).isEqualTo(2); + assertThat(item.getUnitPrice().getValue()).isEqualTo(10000); + } + + @Test + @DisplayName("productId null이면 예외") + void create_fail_null_productId() { + assertThatThrownBy(() -> OrderItem.create(null, 2, Money.of(10000))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("상품 ID는 필수입니다."); + } + + @Test + @DisplayName("수량 0 이하면 예외") + void create_fail_zero_quantity() { + assertThatThrownBy(() -> OrderItem.create(1L, 0, Money.of(10000))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("1 이상"); + } + + @Test + @DisplayName("단가 null이면 예외") + void create_fail_null_unitPrice() { + assertThatThrownBy(() -> OrderItem.create(1L, 2, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("단가는 필수입니다."); + } + + @Test + @DisplayName("금액 계산 (단가 * 수량)") + void calculateAmount() { + OrderItem item = OrderItem.create(1L, 3, Money.of(10000)); + assertThat(item.calculateAmount().getValue()).isEqualTo(30000); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderTest.java new file mode 100644 index 000000000..b3f0341d9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderTest.java @@ -0,0 +1,151 @@ +package com.loopers.domain.model.order; + +import com.loopers.domain.model.user.UserId; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OrderTest { + + private Order createOrder() { + List items = List.of( + OrderItem.create(1L, 2, Money.of(10000)), + OrderItem.create(2L, 1, Money.of(20000)) + ); + OrderSnapshot snapshot = OrderSnapshot.create("{\"items\":[]}"); + + return Order.create( + UserId.of("testuser1"), + items, + ReceiverName.of("홍길동"), + Address.of("서울시 강남구"), + "부재시 문 앞에 놓아주세요", + PaymentMethod.CARD, + Money.zero(), + LocalDate.now().plusDays(3), + snapshot + ); + } + + @Test + @DisplayName("주문 생성 성공 - 금액 자동 계산") + void create_success() { + Order order = createOrder(); + + assertThat(order.getId()).isNull(); + assertThat(order.getStatus()).isEqualTo(OrderStatus.PAYMENT_COMPLETED); + assertThat(order.getTotalAmount().getValue()).isEqualTo(40000); // 10000*2 + 20000*1 + assertThat(order.getPaymentAmount().getValue()).isEqualTo(40000); + assertThat(order.getItems()).hasSize(2); + } + + @Test + @DisplayName("주문 생성 - 할인 적용") + void create_with_discount() { + List items = List.of(OrderItem.create(1L, 1, Money.of(50000))); + OrderSnapshot snapshot = OrderSnapshot.create("{\"items\":[]}"); + + Order order = Order.create( + UserId.of("testuser1"), items, + ReceiverName.of("홍길동"), Address.of("서울시"), + null, PaymentMethod.CARD, + Money.of(5000), null, snapshot + ); + + assertThat(order.getTotalAmount().getValue()).isEqualTo(50000); + assertThat(order.getDiscountAmount().getValue()).isEqualTo(5000); + assertThat(order.getPaymentAmount().getValue()).isEqualTo(45000); + } + + @Test + @DisplayName("userId null이면 예외") + void create_fail_null_userId() { + List items = List.of(OrderItem.create(1L, 1, Money.of(10000))); + OrderSnapshot snapshot = OrderSnapshot.create("{\"items\":[]}"); + + assertThatThrownBy(() -> Order.create(null, items, + ReceiverName.of("홍길동"), Address.of("서울시"), + null, PaymentMethod.CARD, Money.zero(), null, snapshot)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("사용자 ID는 필수입니다."); + } + + @Test + @DisplayName("주문 항목 비어있으면 예외") + void create_fail_empty_items() { + OrderSnapshot snapshot = OrderSnapshot.create("{\"items\":[]}"); + + assertThatThrownBy(() -> Order.create(UserId.of("testuser1"), List.of(), + ReceiverName.of("홍길동"), Address.of("서울시"), + null, PaymentMethod.CARD, Money.zero(), null, snapshot)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("1개 이상"); + } + + @Test + @DisplayName("PAYMENT_COMPLETED 상태에서 취소 가능") + void cancel_success() { + Order order = createOrder(); + assertThat(order.isCancellable()).isTrue(); + } + + @Test + @DisplayName("SHIPPING 상태에서 취소 불가") + void cancel_fail_shipping() { + Order order = Order.reconstitute( + 1L, UserId.of("testuser1"), List.of(OrderItem.create(1L, 1, Money.of(10000))), + null, ReceiverName.of("홍길동"), Address.of("서울시"), null, + PaymentMethod.CARD, Money.of(10000), Money.zero(), Money.of(10000), + OrderStatus.SHIPPING, null, LocalDateTime.now(), LocalDateTime.now() + ); + + assertThat(order.isCancellable()).isFalse(); + assertThatThrownBy(order::cancel) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("취소할 수 없습니다"); + } + + @Test + @DisplayName("DELIVERED 상태에서 취소 불가") + void cancel_fail_delivered() { + Order order = Order.reconstitute( + 1L, UserId.of("testuser1"), List.of(OrderItem.create(1L, 1, Money.of(10000))), + null, ReceiverName.of("홍길동"), Address.of("서울시"), null, + PaymentMethod.CARD, Money.of(10000), Money.zero(), Money.of(10000), + OrderStatus.DELIVERED, null, LocalDateTime.now(), LocalDateTime.now() + ); + + assertThatThrownBy(order::cancel) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("배송지 변경 성공 (PAYMENT_COMPLETED)") + void updateDeliveryAddress_success() { + Order order = createOrder(); + Order updated = order.updateDeliveryAddress(Address.of("부산시 해운대구")); + + assertThat(updated.getAddress().getValue()).isEqualTo("부산시 해운대구"); + } + + @Test + @DisplayName("SHIPPING 상태에서 배송지 변경 불가") + void updateDeliveryAddress_fail_shipping() { + Order order = Order.reconstitute( + 1L, UserId.of("testuser1"), List.of(OrderItem.create(1L, 1, Money.of(10000))), + null, ReceiverName.of("홍길동"), Address.of("서울시"), null, + PaymentMethod.CARD, Money.of(10000), Money.zero(), Money.of(10000), + OrderStatus.SHIPPING, null, LocalDateTime.now(), LocalDateTime.now() + ); + + assertThatThrownBy(() -> order.updateDeliveryAddress(Address.of("부산시"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("배송지를 변경할 수 없습니다"); + } +} From e114dc41ac0fff45155e46db3011862ae76dc16e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Tue, 24 Feb 2026 15:31:42 +0900 Subject: [PATCH 09/20] =?UTF-8?q?feat:=20Application=20Layer=20UseCase=20?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EB=B0=8F=20Ser?= =?UTF-8?q?vice=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brand, Product, Like, Order 도메인의 UseCase 인터페이스와 Service 구현체를 추가 - Brand: CRUD UseCase + BrandService (중복 이름 검증 포함) - Product: CRUD UseCase + ProductService/ProductQueryService (CQRS 분리) - Like: LikeUseCase/UnlikeUseCase + LikeService (멱등성 보장) - Order: CreateOrderUseCase/OrderQueryUseCase + OrderService/OrderQueryService (재고 차감, 가격 스냅샷) Co-Authored-By: Claude Opus 4.6 --- .../application/BrandQueryUseCase.java | 16 ++++ .../application/CreateBrandUseCase.java | 6 ++ .../application/CreateOrderUseCase.java | 25 +++++++ .../application/CreateProductUseCase.java | 6 ++ .../application/DeleteBrandUseCase.java | 6 ++ .../application/DeleteProductUseCase.java | 6 ++ .../com/loopers/application/LikeUseCase.java | 8 ++ .../application/OrderQueryUseCase.java | 40 ++++++++++ .../application/ProductQueryUseCase.java | 17 +++++ .../loopers/application/UnlikeUseCase.java | 8 ++ .../application/UpdateBrandUseCase.java | 6 ++ .../application/UpdateProductUseCase.java | 6 ++ .../application/service/BrandService.java | 75 +++++++++++++++++++ .../application/service/LikeService.java | 59 +++++++++++++++ .../service/OrderQueryService.java | 69 +++++++++++++++++ .../application/service/OrderService.java | 70 +++++++++++++++++ .../service/ProductQueryService.java | 43 +++++++++++ .../application/service/ProductService.java | 56 ++++++++++++++ 18 files changed, 522 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/BrandQueryUseCase.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/CreateBrandUseCase.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/CreateOrderUseCase.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/CreateProductUseCase.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/DeleteBrandUseCase.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/DeleteProductUseCase.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/LikeUseCase.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/OrderQueryUseCase.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/ProductQueryUseCase.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/UnlikeUseCase.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/UpdateBrandUseCase.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/UpdateProductUseCase.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/service/BrandService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/service/LikeService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/service/OrderQueryService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/service/OrderService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/service/ProductQueryService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/service/ProductService.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/BrandQueryUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/BrandQueryUseCase.java new file mode 100644 index 000000000..dc0263625 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/BrandQueryUseCase.java @@ -0,0 +1,16 @@ +package com.loopers.application; + +import java.util.List; + +public interface BrandQueryUseCase { + + BrandInfo getBrand(Long brandId); + + List getBrands(); + + record BrandInfo( + Long id, + String name, + String description + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/CreateBrandUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/CreateBrandUseCase.java new file mode 100644 index 000000000..b0459c44f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/CreateBrandUseCase.java @@ -0,0 +1,6 @@ +package com.loopers.application; + +public interface CreateBrandUseCase { + + void createBrand(String name, String description); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/CreateOrderUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/CreateOrderUseCase.java new file mode 100644 index 000000000..f9b72b4c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/CreateOrderUseCase.java @@ -0,0 +1,25 @@ +package com.loopers.application; + +import com.loopers.domain.model.user.UserId; + +import java.time.LocalDate; +import java.util.List; + +public interface CreateOrderUseCase { + + void createOrder(UserId userId, OrderCommand command); + + record OrderCommand( + List items, + String receiverName, + String address, + String deliveryRequest, + String paymentMethod, + LocalDate desiredDeliveryDate + ) {} + + record OrderItemCommand( + Long productId, + int quantity + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/CreateProductUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/CreateProductUseCase.java new file mode 100644 index 000000000..f7da32d44 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/CreateProductUseCase.java @@ -0,0 +1,6 @@ +package com.loopers.application; + +public interface CreateProductUseCase { + + void createProduct(Long brandId, String name, int price, int stock, String description); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/DeleteBrandUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/DeleteBrandUseCase.java new file mode 100644 index 000000000..0a708f790 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/DeleteBrandUseCase.java @@ -0,0 +1,6 @@ +package com.loopers.application; + +public interface DeleteBrandUseCase { + + void deleteBrand(Long brandId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/DeleteProductUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/DeleteProductUseCase.java new file mode 100644 index 000000000..63c1a0f51 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/DeleteProductUseCase.java @@ -0,0 +1,6 @@ +package com.loopers.application; + +public interface DeleteProductUseCase { + + void deleteProduct(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/LikeUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/LikeUseCase.java new file mode 100644 index 000000000..f6a81528d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/LikeUseCase.java @@ -0,0 +1,8 @@ +package com.loopers.application; + +import com.loopers.domain.model.user.UserId; + +public interface LikeUseCase { + + void like(UserId userId, Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/OrderQueryUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/OrderQueryUseCase.java new file mode 100644 index 000000000..fc8af858c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/OrderQueryUseCase.java @@ -0,0 +1,40 @@ +package com.loopers.application; + +import com.loopers.domain.model.user.UserId; + +import java.time.LocalDateTime; +import java.util.List; + +public interface OrderQueryUseCase { + + List getMyOrders(UserId userId); + + OrderDetail getOrder(UserId userId, Long orderId); + + record OrderSummary( + Long id, + String status, + int paymentAmount, + LocalDateTime createdAt + ) {} + + record OrderDetail( + Long id, + String receiverName, + String address, + String deliveryRequest, + String paymentMethod, + int totalAmount, + int discountAmount, + int paymentAmount, + String status, + List items, + LocalDateTime createdAt + ) {} + + record OrderItemDetail( + Long productId, + int quantity, + int unitPrice + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ProductQueryUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/ProductQueryUseCase.java new file mode 100644 index 000000000..b81d2f199 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/ProductQueryUseCase.java @@ -0,0 +1,17 @@ +package com.loopers.application; + +public interface ProductQueryUseCase { + + ProductDetailInfo getProduct(Long productId); + + record ProductDetailInfo( + Long id, + Long brandId, + String brandName, + String name, + int price, + int stock, + int likeCount, + String description + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/UnlikeUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/UnlikeUseCase.java new file mode 100644 index 000000000..1b5bac33d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/UnlikeUseCase.java @@ -0,0 +1,8 @@ +package com.loopers.application; + +import com.loopers.domain.model.user.UserId; + +public interface UnlikeUseCase { + + void unlike(UserId userId, Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/UpdateBrandUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/UpdateBrandUseCase.java new file mode 100644 index 000000000..bd3a3819d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/UpdateBrandUseCase.java @@ -0,0 +1,6 @@ +package com.loopers.application; + +public interface UpdateBrandUseCase { + + void updateBrand(Long brandId, String name, String description); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/UpdateProductUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/UpdateProductUseCase.java new file mode 100644 index 000000000..95ff47a82 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/UpdateProductUseCase.java @@ -0,0 +1,6 @@ +package com.loopers.application; + +public interface UpdateProductUseCase { + + void updateProduct(Long productId, String name, int price, int stock, String description); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/BrandService.java new file mode 100644 index 000000000..122db88b0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/BrandService.java @@ -0,0 +1,75 @@ +package com.loopers.application.service; + +import com.loopers.application.BrandQueryUseCase; +import com.loopers.application.CreateBrandUseCase; +import com.loopers.application.DeleteBrandUseCase; +import com.loopers.application.UpdateBrandUseCase; +import com.loopers.domain.model.brand.Brand; +import com.loopers.domain.model.brand.BrandName; +import com.loopers.domain.repository.BrandRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional(readOnly = true) +public class BrandService implements CreateBrandUseCase, UpdateBrandUseCase, DeleteBrandUseCase, BrandQueryUseCase { + + private final BrandRepository brandRepository; + + public BrandService(BrandRepository brandRepository) { + this.brandRepository = brandRepository; + } + + @Override + @Transactional + public void createBrand(String name, String description) { + BrandName brandName = BrandName.of(name); + if (brandRepository.existsByName(brandName)) { + throw new IllegalArgumentException("이미 존재하는 브랜드 이름입니다."); + } + Brand brand = Brand.create(brandName, description); + brandRepository.save(brand); + } + + @Override + @Transactional + public void updateBrand(Long brandId, String name, String description) { + Brand brand = findBrand(brandId); + Brand updated = brand.update(BrandName.of(name), description); + brandRepository.save(updated); + } + + @Override + @Transactional + public void deleteBrand(Long brandId) { + Brand brand = findBrand(brandId); + Brand deleted = brand.delete(); + brandRepository.save(deleted); + } + + @Override + public BrandInfo getBrand(Long brandId) { + Brand brand = findBrand(brandId); + return toBrandInfo(brand); + } + + @Override + public List getBrands() { + return brandRepository.findAll().stream() + .filter(brand -> !brand.isDeleted()) + .map(this::toBrandInfo) + .toList(); + } + + private Brand findBrand(Long brandId) { + return brandRepository.findById(brandId) + .filter(brand -> !brand.isDeleted()) + .orElseThrow(() -> new IllegalArgumentException("브랜드를 찾을 수 없습니다.")); + } + + private BrandInfo toBrandInfo(Brand brand) { + return new BrandInfo(brand.getId(), brand.getName().getValue(), brand.getDescription()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/LikeService.java new file mode 100644 index 000000000..e5b1d4b89 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/LikeService.java @@ -0,0 +1,59 @@ +package com.loopers.application.service; + +import com.loopers.application.LikeUseCase; +import com.loopers.application.UnlikeUseCase; +import com.loopers.domain.model.like.Like; +import com.loopers.domain.model.product.Product; +import com.loopers.domain.model.user.UserId; +import com.loopers.domain.repository.LikeRepository; +import com.loopers.domain.repository.ProductRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class LikeService implements LikeUseCase, UnlikeUseCase { + + private final LikeRepository likeRepository; + private final ProductRepository productRepository; + + public LikeService(LikeRepository likeRepository, ProductRepository productRepository) { + this.likeRepository = likeRepository; + this.productRepository = productRepository; + } + + @Override + public void like(UserId userId, Long productId) { + Product product = findProduct(productId); + + if (likeRepository.existsByUserIdAndProductId(userId, productId)) { + return; // Idempotency — 이미 좋아요한 경우 무시 + } + + Like like = Like.create(userId, productId); + likeRepository.save(like); + + Product updated = product.increaseLikeCount(); + productRepository.save(updated); + } + + @Override + public void unlike(UserId userId, Long productId) { + Product product = findProduct(productId); + + if (!likeRepository.existsByUserIdAndProductId(userId, productId)) { + return; // 좋아요하지 않은 경우 무시 + } + + likeRepository.deleteByUserIdAndProductId(userId, productId); + + Product updated = product.decreaseLikeCount(); + productRepository.save(updated); + } + + private Product findProduct(Long productId) { + return productRepository.findById(productId) + .filter(p -> !p.isDeleted()) + .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다.")); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/OrderQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/OrderQueryService.java new file mode 100644 index 000000000..329df27ec --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/OrderQueryService.java @@ -0,0 +1,69 @@ +package com.loopers.application.service; + +import com.loopers.application.OrderQueryUseCase; +import com.loopers.domain.model.order.Order; +import com.loopers.domain.model.order.OrderItem; +import com.loopers.domain.model.user.UserId; +import com.loopers.domain.repository.OrderRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional(readOnly = true) +public class OrderQueryService implements OrderQueryUseCase { + + private final OrderRepository orderRepository; + + public OrderQueryService(OrderRepository orderRepository) { + this.orderRepository = orderRepository; + } + + @Override + public List getMyOrders(UserId userId) { + List orders = orderRepository.findAllByUserId(userId); + + return orders.stream() + .map(order -> new OrderSummary( + order.getId(), + order.getStatus().name(), + order.getPaymentAmount().getValue(), + order.getCreatedAt() + )) + .toList(); + } + + @Override + public OrderDetail getOrder(UserId userId, Long orderId) { + Order order = orderRepository.findById(orderId) + .filter(o -> o.getUserId().equals(userId)) + .orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다.")); + + List itemDetails = order.getItems().stream() + .map(this::toOrderItemDetail) + .toList(); + + return new OrderDetail( + order.getId(), + order.getReceiverName().getValue(), + order.getAddress().getValue(), + order.getDeliveryRequest(), + order.getPaymentMethod().name(), + order.getTotalAmount().getValue(), + order.getDiscountAmount().getValue(), + order.getPaymentAmount().getValue(), + order.getStatus().name(), + itemDetails, + order.getCreatedAt() + ); + } + + private OrderItemDetail toOrderItemDetail(OrderItem item) { + return new OrderItemDetail( + item.getProductId(), + item.getQuantity(), + item.getUnitPrice().getValue() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/OrderService.java new file mode 100644 index 000000000..09bab9283 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/OrderService.java @@ -0,0 +1,70 @@ +package com.loopers.application.service; + +import com.loopers.application.CreateOrderUseCase; +import com.loopers.domain.model.order.*; +import com.loopers.domain.model.product.Product; +import com.loopers.domain.model.user.UserId; +import com.loopers.domain.repository.OrderRepository; +import com.loopers.domain.repository.ProductRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@Service +@Transactional +public class OrderService implements CreateOrderUseCase { + + private final OrderRepository orderRepository; + private final ProductRepository productRepository; + + public OrderService(OrderRepository orderRepository, ProductRepository productRepository) { + this.orderRepository = orderRepository; + this.productRepository = productRepository; + } + + @Override + public void createOrder(UserId userId, OrderCommand command) { + List orderItems = new ArrayList<>(); + StringBuilder snapshotBuilder = new StringBuilder(); + + for (OrderItemCommand itemCommand : command.items()) { + Product product = productRepository.findById(itemCommand.productId()) + .filter(p -> !p.isDeleted()) + .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다. ID: " + itemCommand.productId())); + + Product decreased = product.decreaseStock(itemCommand.quantity()); + productRepository.save(decreased); + + OrderItem orderItem = OrderItem.create( + product.getId(), + itemCommand.quantity(), + Money.of(product.getPrice().getValue()) + ); + orderItems.add(orderItem); + + snapshotBuilder.append(product.getName().getValue()) + .append(":") + .append(product.getPrice().getValue()) + .append(","); + } + + OrderSnapshot snapshot = OrderSnapshot.create(snapshotBuilder.toString()); + PaymentMethod paymentMethod = PaymentMethod.valueOf(command.paymentMethod()); + + Order order = Order.create( + userId, + orderItems, + ReceiverName.of(command.receiverName()), + Address.of(command.address()), + command.deliveryRequest(), + paymentMethod, + Money.zero(), + command.desiredDeliveryDate(), + snapshot + ); + + orderRepository.save(order); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/ProductQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/ProductQueryService.java new file mode 100644 index 000000000..d1287a7ee --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/ProductQueryService.java @@ -0,0 +1,43 @@ +package com.loopers.application.service; + +import com.loopers.application.ProductQueryUseCase; +import com.loopers.domain.model.brand.Brand; +import com.loopers.domain.model.product.Product; +import com.loopers.domain.repository.BrandRepository; +import com.loopers.domain.repository.ProductRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +public class ProductQueryService implements ProductQueryUseCase { + + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + + public ProductQueryService(ProductRepository productRepository, BrandRepository brandRepository) { + this.productRepository = productRepository; + this.brandRepository = brandRepository; + } + + @Override + public ProductDetailInfo getProduct(Long productId) { + Product product = productRepository.findById(productId) + .filter(p -> !p.isDeleted()) + .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다.")); + + Brand brand = brandRepository.findById(product.getBrandId()) + .orElseThrow(() -> new IllegalArgumentException("브랜드를 찾을 수 없습니다.")); + + return new ProductDetailInfo( + product.getId(), + brand.getId(), + brand.getName().getValue(), + product.getName().getValue(), + product.getPrice().getValue(), + product.getStock().getValue(), + product.getLikeCount(), + product.getDescription() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/service/ProductService.java new file mode 100644 index 000000000..c36b42b8e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/service/ProductService.java @@ -0,0 +1,56 @@ +package com.loopers.application.service; + +import com.loopers.application.CreateProductUseCase; +import com.loopers.application.DeleteProductUseCase; +import com.loopers.application.UpdateProductUseCase; +import com.loopers.domain.model.product.Price; +import com.loopers.domain.model.product.Product; +import com.loopers.domain.model.product.ProductName; +import com.loopers.domain.model.product.Stock; +import com.loopers.domain.repository.BrandRepository; +import com.loopers.domain.repository.ProductRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class ProductService implements CreateProductUseCase, UpdateProductUseCase, DeleteProductUseCase { + + private final ProductRepository productRepository; + private final BrandRepository brandRepository; + + public ProductService(ProductRepository productRepository, BrandRepository brandRepository) { + this.productRepository = productRepository; + this.brandRepository = brandRepository; + } + + @Override + public void createProduct(Long brandId, String name, int price, int stock, String description) { + brandRepository.findById(brandId) + .filter(brand -> !brand.isDeleted()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 브랜드입니다.")); + + Product product = Product.create(brandId, ProductName.of(name), Price.of(price), Stock.of(stock), description); + productRepository.save(product); + } + + @Override + public void updateProduct(Long productId, String name, int price, int stock, String description) { + Product product = findProduct(productId); + Product updated = product.update(ProductName.of(name), Price.of(price), Stock.of(stock), description); + productRepository.save(updated); + } + + @Override + public void deleteProduct(Long productId) { + Product product = findProduct(productId); + Product deleted = product.delete(); + productRepository.save(deleted); + } + + private Product findProduct(Long productId) { + return productRepository.findById(productId) + .filter(product -> !product.isDeleted()) + .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다.")); + } +} From d203aa2017ea567bd2b9416d35fe06dbd7fd1661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Tue, 24 Feb 2026 15:57:30 +0900 Subject: [PATCH 10/20] =?UTF-8?q?feat:=20Infrastructure=20Layer=20JPA=20En?= =?UTF-8?q?tity=20=EB=B0=8F=20Repository=20=EA=B5=AC=ED=98=84=EC=B2=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brand, Product, Like, Order 도메인의 JPA Entity와 Repository 구현체를 추가 - 기존 UserJpaEntity 패턴 답습 (BaseEntity 미상속, 자체 필드 관리) - toEntity/toDomain 매핑으로 도메인 모델과 JPA 엔티티 간 변환 - Order는 OneToMany(OrderItem), OneToOne(OrderSnapshot) 관계 매핑 - Like는 UNIQUE(userId, productId) 제약 설정 Co-Authored-By: Claude Opus 4.6 --- .../infrastructure/BrandRepositoryImpl.java | 68 +++++++++ .../infrastructure/LikeRepositoryImpl.java | 61 ++++++++ .../infrastructure/OrderRepositoryImpl.java | 131 ++++++++++++++++++ .../infrastructure/ProductRepositoryImpl.java | 65 +++++++++ .../infrastructure/entity/BrandJpaEntity.java | 41 ++++++ .../infrastructure/entity/LikeJpaEntity.java | 36 +++++ .../entity/OrderItemJpaEntity.java | 32 +++++ .../infrastructure/entity/OrderJpaEntity.java | 86 ++++++++++++ .../entity/OrderSnapshotJpaEntity.java | 30 ++++ .../entity/ProductJpaEntity.java | 58 ++++++++ .../repository/BrandJpaRepository.java | 13 ++ .../repository/LikeJpaRepository.java | 15 ++ .../repository/OrderJpaRepository.java | 11 ++ .../repository/ProductJpaRepository.java | 7 + 14 files changed, 654 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/BrandRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/LikeRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/OrderRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/ProductRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/BrandJpaEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/LikeJpaEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/OrderItemJpaEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/OrderJpaEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/OrderSnapshotJpaEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/ProductJpaEntity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/BrandJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/LikeJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/OrderJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/ProductJpaRepository.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/BrandRepositoryImpl.java new file mode 100644 index 000000000..8817b5c2b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/BrandRepositoryImpl.java @@ -0,0 +1,68 @@ +package com.loopers.infrastructure; + +import com.loopers.domain.model.brand.Brand; +import com.loopers.domain.model.brand.BrandName; +import com.loopers.domain.repository.BrandRepository; +import com.loopers.infrastructure.entity.BrandJpaEntity; +import com.loopers.infrastructure.repository.BrandJpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + public BrandRepositoryImpl(BrandJpaRepository brandJpaRepository) { + this.brandJpaRepository = brandJpaRepository; + } + + @Override + public Brand save(Brand brand) { + BrandJpaEntity entity = toEntity(brand); + BrandJpaEntity saved = brandJpaRepository.save(entity); + return toDomain(saved); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findById(id) + .map(this::toDomain); + } + + @Override + public List findAll() { + return brandJpaRepository.findAllByDeletedAtIsNull().stream() + .map(this::toDomain) + .toList(); + } + + @Override + public boolean existsByName(BrandName name) { + return brandJpaRepository.existsByName(name.getValue()); + } + + private BrandJpaEntity toEntity(Brand brand) { + return new BrandJpaEntity( + brand.getId(), + brand.getName().getValue(), + brand.getDescription(), + brand.getCreatedAt(), + brand.getUpdatedAt(), + brand.getDeletedAt() + ); + } + + private Brand toDomain(BrandJpaEntity entity) { + return Brand.reconstitute( + entity.getId(), + BrandName.of(entity.getName()), + entity.getDescription(), + entity.getCreatedAt(), + entity.getUpdatedAt(), + entity.getDeletedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/LikeRepositoryImpl.java new file mode 100644 index 000000000..77b515a47 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/LikeRepositoryImpl.java @@ -0,0 +1,61 @@ +package com.loopers.infrastructure; + +import com.loopers.domain.model.like.Like; +import com.loopers.domain.model.user.UserId; +import com.loopers.domain.repository.LikeRepository; +import com.loopers.infrastructure.entity.LikeJpaEntity; +import com.loopers.infrastructure.repository.LikeJpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + public LikeRepositoryImpl(LikeJpaRepository likeJpaRepository) { + this.likeJpaRepository = likeJpaRepository; + } + + @Override + public Like save(Like like) { + LikeJpaEntity entity = toEntity(like); + LikeJpaEntity saved = likeJpaRepository.save(entity); + return toDomain(saved); + } + + @Override + public Optional findByUserIdAndProductId(UserId userId, Long productId) { + return likeJpaRepository.findByUserIdAndProductId(userId.getValue(), productId) + .map(this::toDomain); + } + + @Override + public void deleteByUserIdAndProductId(UserId userId, Long productId) { + likeJpaRepository.deleteByUserIdAndProductId(userId.getValue(), productId); + } + + @Override + public boolean existsByUserIdAndProductId(UserId userId, Long productId) { + return likeJpaRepository.existsByUserIdAndProductId(userId.getValue(), productId); + } + + private LikeJpaEntity toEntity(Like like) { + return new LikeJpaEntity( + like.getId(), + like.getUserId().getValue(), + like.getProductId(), + like.getCreatedAt() + ); + } + + private Like toDomain(LikeJpaEntity entity) { + return Like.reconstitute( + entity.getId(), + UserId.of(entity.getUserId()), + entity.getProductId(), + entity.getCreatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/OrderRepositoryImpl.java new file mode 100644 index 000000000..09640a601 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/OrderRepositoryImpl.java @@ -0,0 +1,131 @@ +package com.loopers.infrastructure; + +import com.loopers.domain.model.order.*; +import com.loopers.domain.model.user.UserId; +import com.loopers.domain.repository.OrderRepository; +import com.loopers.infrastructure.entity.OrderItemJpaEntity; +import com.loopers.infrastructure.entity.OrderJpaEntity; +import com.loopers.infrastructure.entity.OrderSnapshotJpaEntity; +import com.loopers.infrastructure.repository.OrderJpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + public OrderRepositoryImpl(OrderJpaRepository orderJpaRepository) { + this.orderJpaRepository = orderJpaRepository; + } + + @Override + public Order save(Order order) { + OrderJpaEntity entity = toEntity(order); + OrderJpaEntity saved = orderJpaRepository.save(entity); + return toDomain(saved); + } + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findById(id) + .map(this::toDomain); + } + + @Override + public List findAllByUserId(UserId userId) { + return orderJpaRepository.findAllByUserId(userId.getValue()).stream() + .map(this::toDomain) + .toList(); + } + + private OrderJpaEntity toEntity(Order order) { + List itemEntities = order.getItems().stream() + .map(this::toItemEntity) + .toList(); + + OrderSnapshotJpaEntity snapshotEntity = null; + if (order.getSnapshot() != null) { + snapshotEntity = toSnapshotEntity(order.getSnapshot()); + } + + return new OrderJpaEntity( + order.getId(), + order.getUserId().getValue(), + itemEntities, + snapshotEntity, + order.getReceiverName().getValue(), + order.getAddress().getValue(), + order.getDeliveryRequest(), + order.getPaymentMethod().name(), + order.getTotalAmount().getValue(), + order.getDiscountAmount().getValue(), + order.getPaymentAmount().getValue(), + order.getStatus().name(), + order.getDesiredDeliveryDate(), + order.getCreatedAt(), + order.getUpdatedAt() + ); + } + + private OrderItemJpaEntity toItemEntity(OrderItem item) { + return new OrderItemJpaEntity( + item.getId(), + item.getProductId(), + item.getQuantity(), + item.getUnitPrice().getValue() + ); + } + + private OrderSnapshotJpaEntity toSnapshotEntity(OrderSnapshot snapshot) { + return new OrderSnapshotJpaEntity( + snapshot.getId(), + snapshot.getSnapshotData(), + snapshot.getCreatedAt() + ); + } + + private Order toDomain(OrderJpaEntity entity) { + List items = entity.getItems().stream() + .map(this::toItemDomain) + .toList(); + + OrderSnapshot snapshot = null; + if (entity.getSnapshot() != null) { + snapshot = OrderSnapshot.reconstitute( + entity.getSnapshot().getId(), + entity.getSnapshot().getSnapshotData(), + entity.getSnapshot().getCreatedAt() + ); + } + + return Order.reconstitute( + entity.getId(), + UserId.of(entity.getUserId()), + items, + snapshot, + ReceiverName.of(entity.getReceiverName()), + Address.of(entity.getAddress()), + entity.getDeliveryRequest(), + PaymentMethod.valueOf(entity.getPaymentMethod()), + Money.of(entity.getTotalAmount()), + Money.of(entity.getDiscountAmount()), + Money.of(entity.getPaymentAmount()), + OrderStatus.valueOf(entity.getStatus()), + entity.getDesiredDeliveryDate(), + entity.getCreatedAt(), + entity.getUpdatedAt() + ); + } + + private OrderItem toItemDomain(OrderItemJpaEntity entity) { + return OrderItem.reconstitute( + entity.getId(), + entity.getProductId(), + entity.getQuantity(), + Money.of(entity.getUnitPrice()) + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ProductRepositoryImpl.java new file mode 100644 index 000000000..ee5cbe6b8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/ProductRepositoryImpl.java @@ -0,0 +1,65 @@ +package com.loopers.infrastructure; + +import com.loopers.domain.model.product.Price; +import com.loopers.domain.model.product.Product; +import com.loopers.domain.model.product.ProductName; +import com.loopers.domain.model.product.Stock; +import com.loopers.domain.repository.ProductRepository; +import com.loopers.infrastructure.entity.ProductJpaEntity; +import com.loopers.infrastructure.repository.ProductJpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public class ProductRepositoryImpl implements ProductRepository { + + private final ProductJpaRepository productJpaRepository; + + public ProductRepositoryImpl(ProductJpaRepository productJpaRepository) { + this.productJpaRepository = productJpaRepository; + } + + @Override + public Product save(Product product) { + ProductJpaEntity entity = toEntity(product); + ProductJpaEntity saved = productJpaRepository.save(entity); + return toDomain(saved); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id) + .map(this::toDomain); + } + + private ProductJpaEntity toEntity(Product product) { + return new ProductJpaEntity( + product.getId(), + product.getBrandId(), + product.getName().getValue(), + product.getPrice().getValue(), + product.getStock().getValue(), + product.getLikeCount(), + product.getDescription(), + product.getCreatedAt(), + product.getUpdatedAt(), + product.getDeletedAt() + ); + } + + private Product toDomain(ProductJpaEntity entity) { + return Product.reconstitute( + entity.getId(), + entity.getBrandId(), + ProductName.of(entity.getName()), + Price.of(entity.getPrice()), + Stock.of(entity.getStockQuantity()), + entity.getLikeCount(), + entity.getDescription(), + entity.getCreatedAt(), + entity.getUpdatedAt(), + entity.getDeletedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/BrandJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/BrandJpaEntity.java new file mode 100644 index 000000000..1ffad0c78 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/BrandJpaEntity.java @@ -0,0 +1,41 @@ +package com.loopers.infrastructure.entity; + +import jakarta.persistence.*; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "brands") +public class BrandJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String name; + + private String description; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + private LocalDateTime deletedAt; + + protected BrandJpaEntity() {} + + public BrandJpaEntity(Long id, String name, String description, + LocalDateTime createdAt, LocalDateTime updatedAt, LocalDateTime deletedAt) { + this.id = id; + this.name = name; + this.description = description; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.deletedAt = deletedAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/LikeJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/LikeJpaEntity.java new file mode 100644 index 000000000..a23e30eff --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/LikeJpaEntity.java @@ -0,0 +1,36 @@ +package com.loopers.infrastructure.entity; + +import jakarta.persistence.*; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "likes", uniqueConstraints = { + @UniqueConstraint(columnNames = {"userId", "productId"}) +}) +public class LikeJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String userId; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + protected LikeJpaEntity() {} + + public LikeJpaEntity(Long id, String userId, Long productId, LocalDateTime createdAt) { + this.id = id; + this.userId = userId; + this.productId = productId; + this.createdAt = createdAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/OrderItemJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/OrderItemJpaEntity.java new file mode 100644 index 000000000..625c4c34b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/OrderItemJpaEntity.java @@ -0,0 +1,32 @@ +package com.loopers.infrastructure.entity; + +import jakarta.persistence.*; +import lombok.Getter; + +@Entity +@Getter +@Table(name = "order_items") +public class OrderItemJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private int quantity; + + @Column(nullable = false) + private int unitPrice; + + protected OrderItemJpaEntity() {} + + public OrderItemJpaEntity(Long id, Long productId, int quantity, int unitPrice) { + this.id = id; + this.productId = productId; + this.quantity = quantity; + this.unitPrice = unitPrice; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/OrderJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/OrderJpaEntity.java new file mode 100644 index 000000000..e7701224c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/OrderJpaEntity.java @@ -0,0 +1,86 @@ +package com.loopers.infrastructure.entity; + +import jakarta.persistence.*; +import lombok.Getter; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Table(name = "orders") +public class OrderJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String userId; + + @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "order_id", nullable = false) + private List items = new ArrayList<>(); + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "snapshot_id") + private OrderSnapshotJpaEntity snapshot; + + @Column(nullable = false) + private String receiverName; + + @Column(nullable = false) + private String address; + + private String deliveryRequest; + + @Column(nullable = false) + private String paymentMethod; + + @Column(nullable = false) + private int totalAmount; + + @Column(nullable = false) + private int discountAmount; + + @Column(nullable = false) + private int paymentAmount; + + @Column(nullable = false) + private String status; + + private LocalDate desiredDeliveryDate; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + protected OrderJpaEntity() {} + + public OrderJpaEntity(Long id, String userId, List items, + OrderSnapshotJpaEntity snapshot, String receiverName, String address, + String deliveryRequest, String paymentMethod, + int totalAmount, int discountAmount, int paymentAmount, + String status, LocalDate desiredDeliveryDate, + LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.userId = userId; + this.items = items; + this.snapshot = snapshot; + this.receiverName = receiverName; + this.address = address; + this.deliveryRequest = deliveryRequest; + this.paymentMethod = paymentMethod; + this.totalAmount = totalAmount; + this.discountAmount = discountAmount; + this.paymentAmount = paymentAmount; + this.status = status; + this.desiredDeliveryDate = desiredDeliveryDate; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/OrderSnapshotJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/OrderSnapshotJpaEntity.java new file mode 100644 index 000000000..1534778f6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/OrderSnapshotJpaEntity.java @@ -0,0 +1,30 @@ +package com.loopers.infrastructure.entity; + +import jakarta.persistence.*; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "order_snapshots") +public class OrderSnapshotJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, columnDefinition = "TEXT") + private String snapshotData; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + protected OrderSnapshotJpaEntity() {} + + public OrderSnapshotJpaEntity(Long id, String snapshotData, LocalDateTime createdAt) { + this.id = id; + this.snapshotData = snapshotData; + this.createdAt = createdAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/ProductJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/ProductJpaEntity.java new file mode 100644 index 000000000..9213893fa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/ProductJpaEntity.java @@ -0,0 +1,58 @@ +package com.loopers.infrastructure.entity; + +import jakarta.persistence.*; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "products") +public class ProductJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long brandId; + + @Column(nullable = false, length = 100) + private String name; + + @Column(nullable = false) + private int price; + + @Column(nullable = false) + private int stockQuantity; + + @Column(nullable = false) + private int likeCount; + + private String description; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(nullable = false) + private LocalDateTime updatedAt; + + private LocalDateTime deletedAt; + + protected ProductJpaEntity() {} + + public ProductJpaEntity(Long id, Long brandId, String name, int price, int stockQuantity, + int likeCount, String description, + LocalDateTime createdAt, LocalDateTime updatedAt, LocalDateTime deletedAt) { + this.id = id; + this.brandId = brandId; + this.name = name; + this.price = price; + this.stockQuantity = stockQuantity; + this.likeCount = likeCount; + this.description = description; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.deletedAt = deletedAt; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/BrandJpaRepository.java new file mode 100644 index 000000000..224417801 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/BrandJpaRepository.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.repository; + +import com.loopers.infrastructure.entity.BrandJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface BrandJpaRepository extends JpaRepository { + + boolean existsByName(String name); + + List findAllByDeletedAtIsNull(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/LikeJpaRepository.java new file mode 100644 index 000000000..bb6b8d1c4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/LikeJpaRepository.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.repository; + +import com.loopers.infrastructure.entity.LikeJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface LikeJpaRepository extends JpaRepository { + + Optional findByUserIdAndProductId(String userId, Long productId); + + boolean existsByUserIdAndProductId(String userId, Long productId); + + void deleteByUserIdAndProductId(String userId, Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/OrderJpaRepository.java new file mode 100644 index 000000000..3413902ea --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/OrderJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.repository; + +import com.loopers.infrastructure.entity.OrderJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface OrderJpaRepository extends JpaRepository { + + List findAllByUserId(String userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/ProductJpaRepository.java new file mode 100644 index 000000000..b01b48c58 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/ProductJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.repository; + +import com.loopers.infrastructure.entity.ProductJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductJpaRepository extends JpaRepository { +} From 8d1182be3554f291971068bf3dbe07dddfc715d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Tue, 24 Feb 2026 15:58:16 +0900 Subject: [PATCH 11/20] =?UTF-8?q?docs:=20=EC=84=A4=EA=B3=84=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=95=20=EA=B7=BC=EA=B1=B0=20=EB=AC=B8=EC=84=9C=20(Reason.m?= =?UTF-8?q?d)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 패키지 구조, VO 설계, 불변 객체, Aggregate 간 ID 참조, Soft Delete, 비즈니스 로직 배치, CQRS 분리, UseCase 패턴, 가격 스냅샷, 에러 메시지 배치, 멱등성, 금액 자동 계산 등 12가지 설계 결정의 근거 기록 Co-Authored-By: Claude Opus 4.6 --- .docs/Reason.md | 211 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 .docs/Reason.md diff --git a/.docs/Reason.md b/.docs/Reason.md new file mode 100644 index 000000000..0788392cc --- /dev/null +++ b/.docs/Reason.md @@ -0,0 +1,211 @@ +# 설계 결정 근거 (Design Decision Rationale) + +본 문서는 Volume 3 구현 과정에서 내린 주요 설계 결정과 그 근거를 기록합니다. + +--- + +## 1. 패키지 구조: Aggregate별 하위 패키지 채택 + +### 결정 +`domain/model/` 아래 Aggregate별 하위 패키지로 분리 (`model/user/`, `model/product/`, `model/brand/`, `model/like/`, `model/order/`) + +### 근거 +- **process.md 원칙**: "패키징 전략은 4개 레이어 패키지를 두고, 하위에 도메인 별로 패키징하는 형태" +- **05-package-structure.md**: 평탄한 구조에 20~30개 파일이 쌓이면 탐색성과 응집도 저하 +- Aggregate 경계가 패키지로 표현되어 `import`만으로 소속을 파악 가능 +- 기존 레이어 구조(`application/`, `infrastructure/`, `interfaces/`)를 깨지 않음 +- 변경 범위가 `import` 문 수정에 한정 + +### 기각한 대안 +- `domain/` 자체를 Aggregate 단위로 분리 → 변경 범위가 너무 크고, 현재 규모(5 Aggregate)에 과도한 구조 + +--- + +## 2. Value Object 설계: Self-Validating + 정적 팩토리 + +### 결정 +모든 VO는 `private` 생성자 + `of()` 정적 팩토리 메서드, 생성 시점에 검증 수행 + +### 근거 +- **process.md 원칙**: "도메인 객체는 비즈니스 규칙을 캡슐화해야 합니다" +- 기존 User 도메인의 `UserId.of()`, `Email.of()`, `Password.of()` 패턴과 일관성 유지 +- Bean Validation 제거 후 도메인 계층 검증으로 통일 (커밋 `4c17f62`) +- 유효하지 않은 상태의 객체가 존재할 수 없음 → "항상 유효한 도메인 모델" 보장 + +### 적용 예시 +```java +// Money.of(-1) → IllegalArgumentException +// Stock.of(-5) → IllegalArgumentException +// BrandName.of("") → IllegalArgumentException +``` + +--- + +## 3. 불변 도메인 객체: 상태 변경 시 새 인스턴스 반환 + +### 결정 +모든 Aggregate Root와 Entity의 상태 변경 메서드는 새 객체를 반환 (기존 객체 불변) + +### 근거 +- 기존 User 도메인의 패턴 답습: `User.updatePassword()` → 새 `User` 반환 +- 사이드 이펙트 방지: 한 참조를 수정해도 다른 참조에 영향 없음 +- 테스트 용이성: 입력과 출력이 명확하여 단위 테스트 작성이 단순 +- 동시성 안전: 불변 객체는 별도 동기화 없이 스레드 안전 + +### 적용 예시 +```java +Product updated = product.decreaseStock(3); // product는 변하지 않음 +productRepository.save(updated); // 새 인스턴스를 저장 +``` + +--- + +## 4. Aggregate 간 ID 참조 + +### 결정 +Aggregate 간에는 직접 참조 대신 ID(Long) 참조 사용. 단, 타입 안전한 식별자(`UserId`)는 해당 Aggregate 패키지에서 import + +### 근거 +- **03-class-diagram.md**: "Aggregate 간 ID 참조" 원칙 +- **05-package-structure.md**: "UserId는 user/ 패키지에 그대로 둔다. 다른 Aggregate가 import해서 사용" +- Aggregate 간 결합도 최소화 → 각 Aggregate를 독립적으로 변경 가능 +- JPA 레벨에서 Lazy Loading 이슈 원천 차단 + +### 적용 +| 도메인 | 참조 방식 | +|--------|----------| +| `Product.brandId` | `Long` (Brand Aggregate와 느슨한 결합) | +| `Like.userId` | `UserId` (타입 안전한 ID 참조) | +| `Like.productId` | `Long` | +| `Order.userId` | `UserId` | +| `OrderItem.productId` | `Long` | + +--- + +## 5. Soft Delete 패턴 + +### 결정 +Brand, Product에 `deletedAt` 필드를 두어 논리적 삭제 수행 + +### 근거 +- **01-requirements.md**: 상품/브랜드 삭제 시 기존 주문 데이터의 참조 무결성 유지 필요 +- 물리적 삭제 시 주문 내역에서 "삭제된 상품" 표시 불가 +- 조회 시 `isDeleted()` / `filter(p -> !p.isDeleted())` 로 간단히 필터링 +- 향후 데이터 복구, 감사 로그 활용 가능 + +--- + +## 6. 비즈니스 로직 위치: 도메인 객체 vs Application Service + +### 결정 +단일 Aggregate 내 규칙은 도메인 객체에, 여러 Aggregate 협력은 Application Service에 배치 + +### 근거 +- **process.md 원칙**: "규칙이 여러 서비스에 나타나면 도메인 객체에 속할 가능성이 높습니다" +- **process.md 원칙**: "애플리케이션 서비스는 서로 다른 도메인을 조립해, 도메인 로직을 조정하여 기능을 제공" + +### 구체적 배치 + +| 로직 | 위치 | 이유 | +|------|------|------| +| `Stock.decrease()` | Domain (VO) | 재고 차감은 Stock 자체의 규칙 | +| `Order.isCancellable()` | Domain (AR) | 상태 전이 규칙은 Order 자체의 불변식 | +| `Money.add/subtract` | Domain (VO) | 금액 연산은 Money 자체의 규칙 | +| Like 생성 + Product.likeCount 증가 | Application (LikeService) | 두 Aggregate(Like, Product) 협력 | +| 재고 차감 + 주문 생성 | Application (OrderService) | Product 재고차감 + Order 생성 협력 | +| Product + Brand 조합 조회 | Application (ProductQueryService) | 두 Aggregate 정보 조합 | + +--- + +## 7. Command/Query Service 분리 + +### 결정 +Product, Order 도메인은 Command Service와 Query Service를 분리 + +### 근거 +- **03-class-diagram.md**: 설계 문서에서 CUD와 R 서비스를 분리 명시 +- Command와 Query의 트랜잭션 특성이 다름 (`@Transactional` vs `@Transactional(readOnly = true)`) +- Query Service는 여러 Aggregate를 조합하여 읽기 전용 DTO를 반환 → Command와 관심사가 다름 +- Brand, Like는 규모가 작아 통합 Service로 유지 (과도한 분리 방지) + +| 도메인 | Command | Query | 분리 이유 | +|--------|---------|-------|----------| +| Brand | `BrandService` | (통합) | CRUD가 단순, 조합 조회 없음 | +| Product | `ProductService` | `ProductQueryService` | 상세 조회 시 Brand 정보 조합 필요 | +| Like | `LikeService` | (통합) | 조회 UseCase가 현재 없음 | +| Order | `OrderService` | `OrderQueryService` | 주문 생성(복잡한 트랜잭션) vs 조회(읽기 전용) | + +--- + +## 8. UseCase 인터페이스 패턴 + +### 결정 +각 유스케이스를 독립 인터페이스로 정의, Service가 필요한 UseCase를 구현 + +### 근거 +- 기존 User 도메인의 `RegisterUseCase`, `AuthenticationUseCase` 패턴 답습 +- **ISP (Interface Segregation Principle)**: Controller는 자신이 사용하는 UseCase만 의존 +- DIP 준수: Interfaces 레이어 → Application 레이어의 인터페이스에 의존 +- 테스트 시 필요한 UseCase만 Stub/Mock 가능 + +### 적용 +```java +// Controller는 필요한 UseCase만 의존 +public class ProductController { + private final CreateProductUseCase createProductUseCase; + private final ProductQueryUseCase productQueryUseCase; + // DeleteProductUseCase는 주입받지 않음 → 불필요한 의존 제거 +} +``` + +--- + +## 9. 주문 시점 가격 스냅샷 + +### 결정 +`OrderItem.unitPrice`에 주문 시점의 상품 가격을 저장, `OrderSnapshot`에 상품명:가격 형태로 기록 + +### 근거 +- **01-requirements.md**: 주문 시점의 가격이 보존되어야 함 +- 상품 가격이 변경되어도 기존 주문의 결제 금액에 영향 없음 +- 주문 상세 조회 시 주문 당시 가격 표시 가능 +- **04-erd.md**: `order_items.unit_price` 컬럼으로 스냅샷 가격 저장 + +--- + +## 10. 에러 메시지: 도메인 객체 내부 배치 + +### 결정 +각 도메인 객체의 검증 실패 메시지를 해당 객체 내부에 한국어로 직접 배치 + +### 근거 +- **YAGNI 원칙**: 현재 다국어 지원 요구사항 없음, 에러 메시지 중앙화의 실익 없음 +- 응집도: 검증 규칙과 에러 메시지가 같은 위치에 있어 수정 시 한 파일만 변경 +- 기존 User 도메인 패턴 답습: `UserId`, `Email`, `Password` 등 모두 내부에 메시지 보유 +- 향후 다국어/중앙화 필요 시 MessageSource 도입으로 마이그레이션 가능 + +--- + +## 11. Like 멱등성 (Idempotency) + +### 결정 +이미 좋아요한 상태에서 `like()` 호출 시 예외 대신 무시 (early return) + +### 근거 +- **01-requirements.md**: 중복 좋아요 방지 +- 네트워크 재시도, 프론트엔드 더블클릭 등 실무에서 중복 호출 빈번 +- 예외 발생 시 불필요한 에러 로그, 클라이언트 에러 핸들링 부담 +- `unlike()` 도 동일하게 멱등적 처리: 좋아요하지 않은 상태에서 호출 시 무시 + +--- + +## 12. Order.create()에서 totalAmount 자동 계산 + +### 결정 +`Order.create()` 내부에서 `OrderItem` 목록으로부터 `totalAmount`를 자동 계산 + +### 근거 +- 외부에서 totalAmount를 전달받으면 조작/불일치 가능성 존재 +- 도메인 불변식: `totalAmount = SUM(item.unitPrice * item.quantity)` 는 Order의 핵심 규칙 +- `paymentAmount = totalAmount - discountAmount` 도 내부에서 계산하여 정합성 보장 +- **process.md**: "도메인 객체는 비즈니스 규칙을 캡슐화해야 합니다" From ba4f22d9ae912e91be9fc5addff24c32c58fde44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Tue, 24 Feb 2026 16:07:15 +0900 Subject: [PATCH 12/20] =?UTF-8?q?feat:=20Interfaces=20Layer=20Controller?= =?UTF-8?q?=20=EB=B0=8F=20DTO=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brand, Product, Like, Order 도메인의 Controller와 Request/Response DTO 추가 - AdminAuthenticationInterceptor: X-Loopers-Ldap 헤더 기반 Admin 인증 - WebMvcConfig: User(/api/v1) 및 Admin(/api-admin/v1) 경로별 인증 매핑 - BrandAdminController: CRUD (Admin), BrandController: 조회 (Public) - ProductAdminController: CUD+조회 (Admin), ProductController: 조회 (Public) - LikeController: 좋아요 등록/취소 (User 인증) - OrderController: 주문 생성, 목록/상세 조회 (User 인증) - 모든 DTO는 Java Record, from() 팩토리 메서드로 레이어 간 변환 Co-Authored-By: Claude Opus 4.6 --- .../interfaces/api/BrandAdminController.java | 66 +++++++++++++++++++ .../interfaces/api/BrandController.java | 26 ++++++++ .../interfaces/api/LikeController.java | 35 ++++++++++ .../interfaces/api/OrderController.java | 51 ++++++++++++++ .../api/ProductAdminController.java | 60 +++++++++++++++++ .../interfaces/api/ProductController.java | 26 ++++++++ .../interfaces/api/config/WebMvcConfig.java | 13 +++- .../api/dto/BrandCreateRequest.java | 6 ++ .../interfaces/api/dto/BrandResponse.java | 17 +++++ .../api/dto/BrandUpdateRequest.java | 6 ++ .../api/dto/OrderCreateRequest.java | 30 +++++++++ .../api/dto/OrderDetailResponse.java | 46 +++++++++++++ .../api/dto/OrderSummaryResponse.java | 21 ++++++ .../api/dto/ProductCreateRequest.java | 9 +++ .../api/dto/ProductDetailResponse.java | 27 ++++++++ .../api/dto/ProductUpdateRequest.java | 8 +++ .../AdminAuthenticationInterceptor.java | 35 ++++++++++ 17 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/BrandAdminController.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/BrandController.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/LikeController.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/OrderController.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/ProductAdminController.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/ProductController.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/BrandCreateRequest.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/BrandResponse.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/BrandUpdateRequest.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/OrderCreateRequest.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/OrderDetailResponse.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/OrderSummaryResponse.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/ProductCreateRequest.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/ProductDetailResponse.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/ProductUpdateRequest.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/interceptor/AdminAuthenticationInterceptor.java diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/BrandAdminController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/BrandAdminController.java new file mode 100644 index 000000000..87edb5550 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/BrandAdminController.java @@ -0,0 +1,66 @@ +package com.loopers.interfaces.api; + +import com.loopers.application.BrandQueryUseCase; +import com.loopers.application.CreateBrandUseCase; +import com.loopers.application.DeleteBrandUseCase; +import com.loopers.application.UpdateBrandUseCase; +import com.loopers.interfaces.api.dto.BrandCreateRequest; +import com.loopers.interfaces.api.dto.BrandResponse; +import com.loopers.interfaces.api.dto.BrandUpdateRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api-admin/v1/brands") +public class BrandAdminController { + + private final CreateBrandUseCase createBrandUseCase; + private final UpdateBrandUseCase updateBrandUseCase; + private final DeleteBrandUseCase deleteBrandUseCase; + private final BrandQueryUseCase brandQueryUseCase; + + public BrandAdminController(CreateBrandUseCase createBrandUseCase, + UpdateBrandUseCase updateBrandUseCase, + DeleteBrandUseCase deleteBrandUseCase, + BrandQueryUseCase brandQueryUseCase) { + this.createBrandUseCase = createBrandUseCase; + this.updateBrandUseCase = updateBrandUseCase; + this.deleteBrandUseCase = deleteBrandUseCase; + this.brandQueryUseCase = brandQueryUseCase; + } + + @PostMapping + public ResponseEntity createBrand(@RequestBody BrandCreateRequest request) { + createBrandUseCase.createBrand(request.name(), request.description()); + return ResponseEntity.ok().build(); + } + + @PutMapping("/{brandId}") + public ResponseEntity updateBrand(@PathVariable Long brandId, + @RequestBody BrandUpdateRequest request) { + updateBrandUseCase.updateBrand(brandId, request.name(), request.description()); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{brandId}") + public ResponseEntity deleteBrand(@PathVariable Long brandId) { + deleteBrandUseCase.deleteBrand(brandId); + return ResponseEntity.ok().build(); + } + + @GetMapping + public ResponseEntity> getBrands() { + List brands = brandQueryUseCase.getBrands().stream() + .map(BrandResponse::from) + .toList(); + return ResponseEntity.ok(brands); + } + + @GetMapping("/{brandId}") + public ResponseEntity getBrand(@PathVariable Long brandId) { + BrandQueryUseCase.BrandInfo brandInfo = brandQueryUseCase.getBrand(brandId); + return ResponseEntity.ok(BrandResponse.from(brandInfo)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/BrandController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/BrandController.java new file mode 100644 index 000000000..70f506110 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/BrandController.java @@ -0,0 +1,26 @@ +package com.loopers.interfaces.api; + +import com.loopers.application.BrandQueryUseCase; +import com.loopers.interfaces.api.dto.BrandResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/brands") +public class BrandController { + + private final BrandQueryUseCase brandQueryUseCase; + + public BrandController(BrandQueryUseCase brandQueryUseCase) { + this.brandQueryUseCase = brandQueryUseCase; + } + + @GetMapping("/{brandId}") + public ResponseEntity getBrand(@PathVariable Long brandId) { + BrandQueryUseCase.BrandInfo brandInfo = brandQueryUseCase.getBrand(brandId); + return ResponseEntity.ok(BrandResponse.from(brandInfo)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/LikeController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/LikeController.java new file mode 100644 index 000000000..2b598dc1c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/LikeController.java @@ -0,0 +1,35 @@ +package com.loopers.interfaces.api; + +import com.loopers.application.LikeUseCase; +import com.loopers.application.UnlikeUseCase; +import com.loopers.domain.model.user.UserId; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/products") +public class LikeController { + + private final LikeUseCase likeUseCase; + private final UnlikeUseCase unlikeUseCase; + + public LikeController(LikeUseCase likeUseCase, UnlikeUseCase unlikeUseCase) { + this.likeUseCase = likeUseCase; + this.unlikeUseCase = unlikeUseCase; + } + + @PostMapping("/{productId}/likes") + public ResponseEntity like(HttpServletRequest request, @PathVariable Long productId) { + UserId userId = (UserId) request.getAttribute("authenticatedUserId"); + likeUseCase.like(userId, productId); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{productId}/likes") + public ResponseEntity unlike(HttpServletRequest request, @PathVariable Long productId) { + UserId userId = (UserId) request.getAttribute("authenticatedUserId"); + unlikeUseCase.unlike(userId, productId); + return ResponseEntity.ok().build(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/OrderController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/OrderController.java new file mode 100644 index 000000000..5cf800b0d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/OrderController.java @@ -0,0 +1,51 @@ +package com.loopers.interfaces.api; + +import com.loopers.application.CreateOrderUseCase; +import com.loopers.application.OrderQueryUseCase; +import com.loopers.domain.model.user.UserId; +import com.loopers.interfaces.api.dto.OrderCreateRequest; +import com.loopers.interfaces.api.dto.OrderDetailResponse; +import com.loopers.interfaces.api.dto.OrderSummaryResponse; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/orders") +public class OrderController { + + private final CreateOrderUseCase createOrderUseCase; + private final OrderQueryUseCase orderQueryUseCase; + + public OrderController(CreateOrderUseCase createOrderUseCase, OrderQueryUseCase orderQueryUseCase) { + this.createOrderUseCase = createOrderUseCase; + this.orderQueryUseCase = orderQueryUseCase; + } + + @PostMapping + public ResponseEntity createOrder(HttpServletRequest request, + @RequestBody OrderCreateRequest orderCreateRequest) { + UserId userId = (UserId) request.getAttribute("authenticatedUserId"); + createOrderUseCase.createOrder(userId, orderCreateRequest.toCommand()); + return ResponseEntity.ok().build(); + } + + @GetMapping + public ResponseEntity> getMyOrders(HttpServletRequest request) { + UserId userId = (UserId) request.getAttribute("authenticatedUserId"); + List orders = orderQueryUseCase.getMyOrders(userId).stream() + .map(OrderSummaryResponse::from) + .toList(); + return ResponseEntity.ok(orders); + } + + @GetMapping("/{orderId}") + public ResponseEntity getOrder(HttpServletRequest request, + @PathVariable Long orderId) { + UserId userId = (UserId) request.getAttribute("authenticatedUserId"); + OrderQueryUseCase.OrderDetail detail = orderQueryUseCase.getOrder(userId, orderId); + return ResponseEntity.ok(OrderDetailResponse.from(detail)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ProductAdminController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ProductAdminController.java new file mode 100644 index 000000000..3ac5b91ba --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ProductAdminController.java @@ -0,0 +1,60 @@ +package com.loopers.interfaces.api; + +import com.loopers.application.CreateProductUseCase; +import com.loopers.application.DeleteProductUseCase; +import com.loopers.application.ProductQueryUseCase; +import com.loopers.application.UpdateProductUseCase; +import com.loopers.interfaces.api.dto.ProductCreateRequest; +import com.loopers.interfaces.api.dto.ProductDetailResponse; +import com.loopers.interfaces.api.dto.ProductUpdateRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api-admin/v1/products") +public class ProductAdminController { + + private final CreateProductUseCase createProductUseCase; + private final UpdateProductUseCase updateProductUseCase; + private final DeleteProductUseCase deleteProductUseCase; + private final ProductQueryUseCase productQueryUseCase; + + public ProductAdminController(CreateProductUseCase createProductUseCase, + UpdateProductUseCase updateProductUseCase, + DeleteProductUseCase deleteProductUseCase, + ProductQueryUseCase productQueryUseCase) { + this.createProductUseCase = createProductUseCase; + this.updateProductUseCase = updateProductUseCase; + this.deleteProductUseCase = deleteProductUseCase; + this.productQueryUseCase = productQueryUseCase; + } + + @PostMapping + public ResponseEntity createProduct(@RequestBody ProductCreateRequest request) { + createProductUseCase.createProduct( + request.brandId(), request.name(), request.price(), request.stock(), request.description() + ); + return ResponseEntity.ok().build(); + } + + @PutMapping("/{productId}") + public ResponseEntity updateProduct(@PathVariable Long productId, + @RequestBody ProductUpdateRequest request) { + updateProductUseCase.updateProduct( + productId, request.name(), request.price(), request.stock(), request.description() + ); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{productId}") + public ResponseEntity deleteProduct(@PathVariable Long productId) { + deleteProductUseCase.deleteProduct(productId); + return ResponseEntity.ok().build(); + } + + @GetMapping("/{productId}") + public ResponseEntity getProduct(@PathVariable Long productId) { + ProductQueryUseCase.ProductDetailInfo info = productQueryUseCase.getProduct(productId); + return ResponseEntity.ok(ProductDetailResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ProductController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ProductController.java new file mode 100644 index 000000000..36e97b636 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ProductController.java @@ -0,0 +1,26 @@ +package com.loopers.interfaces.api; + +import com.loopers.application.ProductQueryUseCase; +import com.loopers.interfaces.api.dto.ProductDetailResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/products") +public class ProductController { + + private final ProductQueryUseCase productQueryUseCase; + + public ProductController(ProductQueryUseCase productQueryUseCase) { + this.productQueryUseCase = productQueryUseCase; + } + + @GetMapping("/{productId}") + public ResponseEntity getProduct(@PathVariable Long productId) { + ProductQueryUseCase.ProductDetailInfo info = productQueryUseCase.getProduct(productId); + return ResponseEntity.ok(ProductDetailResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/config/WebMvcConfig.java index 773e0928f..acff338cb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/config/WebMvcConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/config/WebMvcConfig.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.config; +import com.loopers.interfaces.api.interceptor.AdminAuthenticationInterceptor; import com.loopers.interfaces.api.interceptor.AuthenticationInterceptor; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; @@ -9,14 +10,22 @@ public class WebMvcConfig implements WebMvcConfigurer { private final AuthenticationInterceptor authenticationInterceptor; + private final AdminAuthenticationInterceptor adminAuthenticationInterceptor; - public WebMvcConfig(AuthenticationInterceptor authenticationInterceptor) { + public WebMvcConfig(AuthenticationInterceptor authenticationInterceptor, + AdminAuthenticationInterceptor adminAuthenticationInterceptor) { this.authenticationInterceptor = authenticationInterceptor; + this.adminAuthenticationInterceptor = adminAuthenticationInterceptor; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authenticationInterceptor) - .addPathPatterns("/api/v1/users/me", "/api/v1/users/me/**"); + .addPathPatterns("/api/v1/users/me", "/api/v1/users/me/**") + .addPathPatterns("/api/v1/products/*/likes") + .addPathPatterns("/api/v1/orders", "/api/v1/orders/**"); + + registry.addInterceptor(adminAuthenticationInterceptor) + .addPathPatterns("/api-admin/v1/**"); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/BrandCreateRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/BrandCreateRequest.java new file mode 100644 index 000000000..5d01b40b1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/BrandCreateRequest.java @@ -0,0 +1,6 @@ +package com.loopers.interfaces.api.dto; + +public record BrandCreateRequest( + String name, + String description +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/BrandResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/BrandResponse.java new file mode 100644 index 000000000..a6f624ae9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/BrandResponse.java @@ -0,0 +1,17 @@ +package com.loopers.interfaces.api.dto; + +import com.loopers.application.BrandQueryUseCase; + +public record BrandResponse( + Long id, + String name, + String description +) { + public static BrandResponse from(BrandQueryUseCase.BrandInfo brandInfo) { + return new BrandResponse( + brandInfo.id(), + brandInfo.name(), + brandInfo.description() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/BrandUpdateRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/BrandUpdateRequest.java new file mode 100644 index 000000000..87f1a7673 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/BrandUpdateRequest.java @@ -0,0 +1,6 @@ +package com.loopers.interfaces.api.dto; + +public record BrandUpdateRequest( + String name, + String description +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/OrderCreateRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/OrderCreateRequest.java new file mode 100644 index 000000000..e1f975a2b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/OrderCreateRequest.java @@ -0,0 +1,30 @@ +package com.loopers.interfaces.api.dto; + +import com.loopers.application.CreateOrderUseCase; + +import java.time.LocalDate; +import java.util.List; + +public record OrderCreateRequest( + List items, + String receiverName, + String address, + String deliveryRequest, + String paymentMethod, + LocalDate desiredDeliveryDate +) { + public CreateOrderUseCase.OrderCommand toCommand() { + List itemCommands = items.stream() + .map(item -> new CreateOrderUseCase.OrderItemCommand(item.productId(), item.quantity())) + .toList(); + + return new CreateOrderUseCase.OrderCommand( + itemCommands, receiverName, address, deliveryRequest, paymentMethod, desiredDeliveryDate + ); + } + + public record OrderItemRequest( + Long productId, + int quantity + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/OrderDetailResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/OrderDetailResponse.java new file mode 100644 index 000000000..55de036c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/OrderDetailResponse.java @@ -0,0 +1,46 @@ +package com.loopers.interfaces.api.dto; + +import com.loopers.application.OrderQueryUseCase; + +import java.time.LocalDateTime; +import java.util.List; + +public record OrderDetailResponse( + Long id, + String receiverName, + String address, + String deliveryRequest, + String paymentMethod, + int totalAmount, + int discountAmount, + int paymentAmount, + String status, + List items, + LocalDateTime createdAt +) { + public static OrderDetailResponse from(OrderQueryUseCase.OrderDetail detail) { + List itemResponses = detail.items().stream() + .map(item -> new OrderItemResponse(item.productId(), item.quantity(), item.unitPrice())) + .toList(); + + return new OrderDetailResponse( + detail.id(), + detail.receiverName(), + detail.address(), + detail.deliveryRequest(), + detail.paymentMethod(), + detail.totalAmount(), + detail.discountAmount(), + detail.paymentAmount(), + detail.status(), + itemResponses, + detail.createdAt() + ); + } + + public record OrderItemResponse( + Long productId, + int quantity, + int unitPrice + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/OrderSummaryResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/OrderSummaryResponse.java new file mode 100644 index 000000000..d5788a2ff --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/OrderSummaryResponse.java @@ -0,0 +1,21 @@ +package com.loopers.interfaces.api.dto; + +import com.loopers.application.OrderQueryUseCase; + +import java.time.LocalDateTime; + +public record OrderSummaryResponse( + Long id, + String status, + int paymentAmount, + LocalDateTime createdAt +) { + public static OrderSummaryResponse from(OrderQueryUseCase.OrderSummary summary) { + return new OrderSummaryResponse( + summary.id(), + summary.status(), + summary.paymentAmount(), + summary.createdAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/ProductCreateRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/ProductCreateRequest.java new file mode 100644 index 000000000..e816e3f77 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/ProductCreateRequest.java @@ -0,0 +1,9 @@ +package com.loopers.interfaces.api.dto; + +public record ProductCreateRequest( + Long brandId, + String name, + int price, + int stock, + String description +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/ProductDetailResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/ProductDetailResponse.java new file mode 100644 index 000000000..5ad70ee8a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/ProductDetailResponse.java @@ -0,0 +1,27 @@ +package com.loopers.interfaces.api.dto; + +import com.loopers.application.ProductQueryUseCase; + +public record ProductDetailResponse( + Long id, + Long brandId, + String brandName, + String name, + int price, + int stock, + int likeCount, + String description +) { + public static ProductDetailResponse from(ProductQueryUseCase.ProductDetailInfo info) { + return new ProductDetailResponse( + info.id(), + info.brandId(), + info.brandName(), + info.name(), + info.price(), + info.stock(), + info.likeCount(), + info.description() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/ProductUpdateRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/ProductUpdateRequest.java new file mode 100644 index 000000000..e7d09c043 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/ProductUpdateRequest.java @@ -0,0 +1,8 @@ +package com.loopers.interfaces.api.dto; + +public record ProductUpdateRequest( + String name, + int price, + int stock, + String description +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/interceptor/AdminAuthenticationInterceptor.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/interceptor/AdminAuthenticationInterceptor.java new file mode 100644 index 000000000..9f424cf5b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/interceptor/AdminAuthenticationInterceptor.java @@ -0,0 +1,35 @@ +package com.loopers.interfaces.api.interceptor; + +import com.loopers.support.error.ErrorType; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class AdminAuthenticationInterceptor implements HandlerInterceptor { + + private static final String ADMIN_LDAP_VALUE = "loopers.admin"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + String ldap = request.getHeader("X-Loopers-Ldap"); + + if (!ADMIN_LDAP_VALUE.equals(ldap)) { + sendUnauthorizedResponse(response); + return false; + } + return true; + } + + private void sendUnauthorizedResponse(HttpServletResponse response) throws Exception { + ErrorType errorType = ErrorType.UNAUTHORIZED; + response.setStatus(errorType.getStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write( + "{\"code\":\"" + errorType.getCode() + "\",\"message\":\"" + errorType.getMessage() + "\"}" + ); + } +} From 0562d27989f25ab7172a4439e0a4b73d61fa51fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Tue, 24 Feb 2026 16:51:53 +0900 Subject: [PATCH 13/20] =?UTF-8?q?refactor:=20=EC=A0=84=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=20=EB=8F=84=EB=A9=94=EC=9D=B8=EB=B3=84=20?= =?UTF-8?q?=ED=95=98=EC=9C=84=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=A1=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit process.md 원칙("패키징 전략은 4개 레이어 패키지를 두고, 하위에 도메인 별로 패키징")에 따라 Application, Infrastructure, Interfaces 레이어의 파일을 도메인별 하위 패키지로 분리 - application/{user,brand,product,like,order}/ 하위 구조화 - infrastructure/{user,brand,product,like,order}/ 하위 구조화 - interfaces/api/{user,brand,product,like,order}/ 하위 구조화 - 테스트 파일도 동일 구조로 이동 - 모든 package 선언 및 import 문 업데이트 - 66개 파일 이동, 컴파일 및 58개 단위 테스트 통과 확인 Co-Authored-By: Claude Opus 4.6 --- .../{ => brand}/BrandQueryUseCase.java | 2 +- .../{service => brand}/BrandService.java | 10 +++++----- .../{ => brand}/CreateBrandUseCase.java | 2 +- .../{ => brand}/DeleteBrandUseCase.java | 2 +- .../{ => brand}/UpdateBrandUseCase.java | 2 +- .../{service => like}/LikeService.java | 6 +++--- .../application/{ => like}/LikeUseCase.java | 2 +- .../application/{ => like}/UnlikeUseCase.java | 2 +- .../{ => order}/CreateOrderUseCase.java | 2 +- .../{service => order}/OrderQueryService.java | 4 ++-- .../{ => order}/OrderQueryUseCase.java | 2 +- .../{service => order}/OrderService.java | 4 ++-- .../{ => product}/CreateProductUseCase.java | 2 +- .../{ => product}/DeleteProductUseCase.java | 2 +- .../ProductQueryService.java | 4 ++-- .../{ => product}/ProductQueryUseCase.java | 2 +- .../{service => product}/ProductService.java | 8 ++++---- .../{ => product}/UpdateProductUseCase.java | 2 +- .../AuthenticationService.java | 4 ++-- .../{ => user}/AuthenticationUseCase.java | 2 +- .../{ => user}/PasswordUpdateUseCase.java | 2 +- .../{ => user}/RegisterUseCase.java | 2 +- .../{ => user}/UserQueryUseCase.java | 2 +- .../{service => user}/UserService.java | 8 ++++---- .../{entity => brand}/BrandJpaEntity.java | 2 +- .../BrandJpaRepository.java | 4 ++-- .../{ => brand}/BrandRepositoryImpl.java | 6 +++--- .../{entity => like}/LikeJpaEntity.java | 2 +- .../LikeJpaRepository.java | 4 ++-- .../{ => like}/LikeRepositoryImpl.java | 6 +++--- .../{entity => order}/OrderItemJpaEntity.java | 2 +- .../{entity => order}/OrderJpaEntity.java | 2 +- .../OrderJpaRepository.java | 4 ++-- .../{ => order}/OrderRepositoryImpl.java | 10 +++++----- .../OrderSnapshotJpaEntity.java | 2 +- .../{entity => product}/ProductJpaEntity.java | 2 +- .../ProductJpaRepository.java | 4 ++-- .../{ => product}/ProductRepositoryImpl.java | 6 +++--- .../{entity => user}/UserJpaEntity.java | 2 +- .../UserJpaRepository.java | 4 ++-- .../{ => user}/UserRepositoryImpl.java | 6 +++--- .../api/{ => brand}/BrandAdminController.java | 16 ++++++++-------- .../api/{ => brand}/BrandController.java | 6 +++--- .../{ => brand}/dto/BrandCreateRequest.java | 2 +- .../api/{ => brand}/dto/BrandResponse.java | 4 ++-- .../{ => brand}/dto/BrandUpdateRequest.java | 2 +- .../interceptor/AuthenticationInterceptor.java | 2 +- .../api/{ => like}/LikeController.java | 6 +++--- .../api/{ => order}/OrderController.java | 12 ++++++------ .../{ => order}/dto/OrderCreateRequest.java | 4 ++-- .../{ => order}/dto/OrderDetailResponse.java | 4 ++-- .../{ => order}/dto/OrderSummaryResponse.java | 4 ++-- .../{ => product}/ProductAdminController.java | 18 +++++++++--------- .../api/{ => product}/ProductController.java | 6 +++--- .../dto/ProductCreateRequest.java | 2 +- .../dto/ProductDetailResponse.java | 4 ++-- .../dto/ProductUpdateRequest.java | 2 +- .../api/{ => user}/UserController.java | 14 +++++++------- .../{ => user}/dto/PasswordUpdateRequest.java | 2 +- .../api/{ => user}/dto/UserInfoResponse.java | 4 ++-- .../{ => user}/dto/UserRegisterRequest.java | 2 +- .../AuthenticationServiceTest.java | 2 +- .../{service => user}/UserServiceTest.java | 2 +- .../api/{ => user}/UserApiE2ETest.java | 8 ++++---- .../api/{ => user}/UserApiIntegrationTest.java | 6 +++--- .../{ => user}/dto/UserInfoResponseTest.java | 4 ++-- 66 files changed, 143 insertions(+), 143 deletions(-) rename apps/commerce-api/src/main/java/com/loopers/application/{ => brand}/BrandQueryUseCase.java (86%) rename apps/commerce-api/src/main/java/com/loopers/application/{service => brand}/BrandService.java (89%) rename apps/commerce-api/src/main/java/com/loopers/application/{ => brand}/CreateBrandUseCase.java (71%) rename apps/commerce-api/src/main/java/com/loopers/application/{ => brand}/DeleteBrandUseCase.java (66%) rename apps/commerce-api/src/main/java/com/loopers/application/{ => brand}/UpdateBrandUseCase.java (74%) rename apps/commerce-api/src/main/java/com/loopers/application/{service => like}/LikeService.java (93%) rename apps/commerce-api/src/main/java/com/loopers/application/{ => like}/LikeUseCase.java (76%) rename apps/commerce-api/src/main/java/com/loopers/application/{ => like}/UnlikeUseCase.java (77%) rename apps/commerce-api/src/main/java/com/loopers/application/{ => order}/CreateOrderUseCase.java (93%) rename apps/commerce-api/src/main/java/com/loopers/application/{service => order}/OrderQueryService.java (96%) rename apps/commerce-api/src/main/java/com/loopers/application/{ => order}/OrderQueryUseCase.java (95%) rename apps/commerce-api/src/main/java/com/loopers/application/{service => order}/OrderService.java (96%) rename apps/commerce-api/src/main/java/com/loopers/application/{ => product}/CreateProductUseCase.java (76%) rename apps/commerce-api/src/main/java/com/loopers/application/{ => product}/DeleteProductUseCase.java (67%) rename apps/commerce-api/src/main/java/com/loopers/application/{service => product}/ProductQueryService.java (94%) rename apps/commerce-api/src/main/java/com/loopers/application/{ => product}/ProductQueryUseCase.java (89%) rename apps/commerce-api/src/main/java/com/loopers/application/{service => product}/ProductService.java (90%) rename apps/commerce-api/src/main/java/com/loopers/application/{ => product}/UpdateProductUseCase.java (77%) rename apps/commerce-api/src/main/java/com/loopers/application/{service => user}/AuthenticationService.java (92%) rename apps/commerce-api/src/main/java/com/loopers/application/{ => user}/AuthenticationUseCase.java (79%) rename apps/commerce-api/src/main/java/com/loopers/application/{ => user}/PasswordUpdateUseCase.java (82%) rename apps/commerce-api/src/main/java/com/loopers/application/{ => user}/RegisterUseCase.java (81%) rename apps/commerce-api/src/main/java/com/loopers/application/{ => user}/UserQueryUseCase.java (89%) rename apps/commerce-api/src/main/java/com/loopers/application/{service => user}/UserService.java (94%) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{entity => brand}/BrandJpaEntity.java (95%) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{repository => brand}/BrandJpaRepository.java (71%) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{ => brand}/BrandRepositoryImpl.java (92%) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{entity => like}/LikeJpaEntity.java (94%) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{repository => like}/LikeJpaRepository.java (79%) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{ => like}/LikeRepositoryImpl.java (91%) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{entity => order}/OrderItemJpaEntity.java (93%) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{entity => order}/OrderJpaEntity.java (98%) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{repository => order}/OrderJpaRepository.java (68%) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{ => order}/OrderRepositoryImpl.java (93%) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{entity => order}/OrderSnapshotJpaEntity.java (94%) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{entity => product}/ProductJpaEntity.java (96%) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{repository => product}/ProductJpaRepository.java (58%) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{ => product}/ProductRepositoryImpl.java (92%) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{entity => user}/UserJpaEntity.java (96%) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{repository => user}/UserJpaRepository.java (72%) rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{ => user}/UserRepositoryImpl.java (91%) rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{ => brand}/BrandAdminController.java (83%) rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{ => brand}/BrandController.java (84%) rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{ => brand}/dto/BrandCreateRequest.java (65%) rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{ => brand}/dto/BrandResponse.java (76%) rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{ => brand}/dto/BrandUpdateRequest.java (65%) rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{ => like}/LikeController.java (89%) rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{ => order}/OrderController.java (84%) rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{ => order}/dto/OrderCreateRequest.java (88%) rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{ => order}/dto/OrderDetailResponse.java (92%) rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{ => order}/dto/OrderSummaryResponse.java (81%) rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{ => product}/ProductAdminController.java (81%) rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{ => product}/ProductController.java (83%) rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{ => product}/dto/ProductCreateRequest.java (75%) rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{ => product}/dto/ProductDetailResponse.java (85%) rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{ => product}/dto/ProductUpdateRequest.java (72%) rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{ => user}/UserController.java (83%) rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{ => user}/dto/PasswordUpdateRequest.java (69%) rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{ => user}/dto/UserInfoResponse.java (85%) rename apps/commerce-api/src/main/java/com/loopers/interfaces/api/{ => user}/dto/UserRegisterRequest.java (80%) rename apps/commerce-api/src/test/java/com/loopers/application/{service => user}/AuthenticationServiceTest.java (98%) rename apps/commerce-api/src/test/java/com/loopers/application/{service => user}/UserServiceTest.java (99%) rename apps/commerce-api/src/test/java/com/loopers/interfaces/api/{ => user}/UserApiE2ETest.java (97%) rename apps/commerce-api/src/test/java/com/loopers/interfaces/api/{ => user}/UserApiIntegrationTest.java (98%) rename apps/commerce-api/src/test/java/com/loopers/interfaces/api/{ => user}/dto/UserInfoResponseTest.java (95%) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/BrandQueryUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandQueryUseCase.java similarity index 86% rename from apps/commerce-api/src/main/java/com/loopers/application/BrandQueryUseCase.java rename to apps/commerce-api/src/main/java/com/loopers/application/brand/BrandQueryUseCase.java index dc0263625..99c63e548 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/BrandQueryUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandQueryUseCase.java @@ -1,4 +1,4 @@ -package com.loopers.application; +package com.loopers.application.brand; import java.util.List; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java similarity index 89% rename from apps/commerce-api/src/main/java/com/loopers/application/service/BrandService.java rename to apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java index 122db88b0..9c2c2ab4a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -1,9 +1,9 @@ -package com.loopers.application.service; +package com.loopers.application.brand; -import com.loopers.application.BrandQueryUseCase; -import com.loopers.application.CreateBrandUseCase; -import com.loopers.application.DeleteBrandUseCase; -import com.loopers.application.UpdateBrandUseCase; +import com.loopers.application.brand.BrandQueryUseCase; +import com.loopers.application.brand.CreateBrandUseCase; +import com.loopers.application.brand.DeleteBrandUseCase; +import com.loopers.application.brand.UpdateBrandUseCase; import com.loopers.domain.model.brand.Brand; import com.loopers.domain.model.brand.BrandName; import com.loopers.domain.repository.BrandRepository; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/CreateBrandUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/CreateBrandUseCase.java similarity index 71% rename from apps/commerce-api/src/main/java/com/loopers/application/CreateBrandUseCase.java rename to apps/commerce-api/src/main/java/com/loopers/application/brand/CreateBrandUseCase.java index b0459c44f..cc0e7bc09 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/CreateBrandUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/CreateBrandUseCase.java @@ -1,4 +1,4 @@ -package com.loopers.application; +package com.loopers.application.brand; public interface CreateBrandUseCase { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/DeleteBrandUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/DeleteBrandUseCase.java similarity index 66% rename from apps/commerce-api/src/main/java/com/loopers/application/DeleteBrandUseCase.java rename to apps/commerce-api/src/main/java/com/loopers/application/brand/DeleteBrandUseCase.java index 0a708f790..ebd3872b1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/DeleteBrandUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/DeleteBrandUseCase.java @@ -1,4 +1,4 @@ -package com.loopers.application; +package com.loopers.application.brand; public interface DeleteBrandUseCase { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/UpdateBrandUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/UpdateBrandUseCase.java similarity index 74% rename from apps/commerce-api/src/main/java/com/loopers/application/UpdateBrandUseCase.java rename to apps/commerce-api/src/main/java/com/loopers/application/brand/UpdateBrandUseCase.java index bd3a3819d..5a0eb55cf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/UpdateBrandUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/UpdateBrandUseCase.java @@ -1,4 +1,4 @@ -package com.loopers.application; +package com.loopers.application.brand; public interface UpdateBrandUseCase { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java similarity index 93% rename from apps/commerce-api/src/main/java/com/loopers/application/service/LikeService.java rename to apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java index e5b1d4b89..c0b9da4eb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java @@ -1,7 +1,7 @@ -package com.loopers.application.service; +package com.loopers.application.like; -import com.loopers.application.LikeUseCase; -import com.loopers.application.UnlikeUseCase; +import com.loopers.application.like.LikeUseCase; +import com.loopers.application.like.UnlikeUseCase; import com.loopers.domain.model.like.Like; import com.loopers.domain.model.product.Product; import com.loopers.domain.model.user.UserId; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/LikeUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeUseCase.java similarity index 76% rename from apps/commerce-api/src/main/java/com/loopers/application/LikeUseCase.java rename to apps/commerce-api/src/main/java/com/loopers/application/like/LikeUseCase.java index f6a81528d..ebec687cf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/LikeUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeUseCase.java @@ -1,4 +1,4 @@ -package com.loopers.application; +package com.loopers.application.like; import com.loopers.domain.model.user.UserId; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/UnlikeUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/like/UnlikeUseCase.java similarity index 77% rename from apps/commerce-api/src/main/java/com/loopers/application/UnlikeUseCase.java rename to apps/commerce-api/src/main/java/com/loopers/application/like/UnlikeUseCase.java index 1b5bac33d..b95381919 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/UnlikeUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/UnlikeUseCase.java @@ -1,4 +1,4 @@ -package com.loopers.application; +package com.loopers.application.like; import com.loopers.domain.model.user.UserId; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/CreateOrderUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderUseCase.java similarity index 93% rename from apps/commerce-api/src/main/java/com/loopers/application/CreateOrderUseCase.java rename to apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderUseCase.java index f9b72b4c3..18f3e2e0a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/CreateOrderUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderUseCase.java @@ -1,4 +1,4 @@ -package com.loopers.application; +package com.loopers.application.order; import com.loopers.domain.model.user.UserId; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/OrderQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryService.java similarity index 96% rename from apps/commerce-api/src/main/java/com/loopers/application/service/OrderQueryService.java rename to apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryService.java index 329df27ec..8e797f211 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/OrderQueryService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryService.java @@ -1,6 +1,6 @@ -package com.loopers.application.service; +package com.loopers.application.order; -import com.loopers.application.OrderQueryUseCase; +import com.loopers.application.order.OrderQueryUseCase; import com.loopers.domain.model.order.Order; import com.loopers.domain.model.order.OrderItem; import com.loopers.domain.model.user.UserId; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/OrderQueryUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryUseCase.java similarity index 95% rename from apps/commerce-api/src/main/java/com/loopers/application/OrderQueryUseCase.java rename to apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryUseCase.java index fc8af858c..58a13b506 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/OrderQueryUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryUseCase.java @@ -1,4 +1,4 @@ -package com.loopers.application; +package com.loopers.application.order; import com.loopers.domain.model.user.UserId; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java similarity index 96% rename from apps/commerce-api/src/main/java/com/loopers/application/service/OrderService.java rename to apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java index 09bab9283..ef5a3b564 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java @@ -1,6 +1,6 @@ -package com.loopers.application.service; +package com.loopers.application.order; -import com.loopers.application.CreateOrderUseCase; +import com.loopers.application.order.CreateOrderUseCase; import com.loopers.domain.model.order.*; import com.loopers.domain.model.product.Product; import com.loopers.domain.model.user.UserId; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/CreateProductUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/product/CreateProductUseCase.java similarity index 76% rename from apps/commerce-api/src/main/java/com/loopers/application/CreateProductUseCase.java rename to apps/commerce-api/src/main/java/com/loopers/application/product/CreateProductUseCase.java index f7da32d44..3d9a120c8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/CreateProductUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/CreateProductUseCase.java @@ -1,4 +1,4 @@ -package com.loopers.application; +package com.loopers.application.product; public interface CreateProductUseCase { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/DeleteProductUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/product/DeleteProductUseCase.java similarity index 67% rename from apps/commerce-api/src/main/java/com/loopers/application/DeleteProductUseCase.java rename to apps/commerce-api/src/main/java/com/loopers/application/product/DeleteProductUseCase.java index 63c1a0f51..703e2626d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/DeleteProductUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/DeleteProductUseCase.java @@ -1,4 +1,4 @@ -package com.loopers.application; +package com.loopers.application.product; public interface DeleteProductUseCase { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/ProductQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java similarity index 94% rename from apps/commerce-api/src/main/java/com/loopers/application/service/ProductQueryService.java rename to apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java index d1287a7ee..2031ea170 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/ProductQueryService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java @@ -1,6 +1,6 @@ -package com.loopers.application.service; +package com.loopers.application.product; -import com.loopers.application.ProductQueryUseCase; +import com.loopers.application.product.ProductQueryUseCase; import com.loopers.domain.model.brand.Brand; import com.loopers.domain.model.product.Product; import com.loopers.domain.repository.BrandRepository; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/ProductQueryUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryUseCase.java similarity index 89% rename from apps/commerce-api/src/main/java/com/loopers/application/ProductQueryUseCase.java rename to apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryUseCase.java index b81d2f199..e9be58423 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/ProductQueryUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryUseCase.java @@ -1,4 +1,4 @@ -package com.loopers.application; +package com.loopers.application.product; public interface ProductQueryUseCase { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java similarity index 90% rename from apps/commerce-api/src/main/java/com/loopers/application/service/ProductService.java rename to apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index c36b42b8e..2ada77bf2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -1,8 +1,8 @@ -package com.loopers.application.service; +package com.loopers.application.product; -import com.loopers.application.CreateProductUseCase; -import com.loopers.application.DeleteProductUseCase; -import com.loopers.application.UpdateProductUseCase; +import com.loopers.application.product.CreateProductUseCase; +import com.loopers.application.product.DeleteProductUseCase; +import com.loopers.application.product.UpdateProductUseCase; import com.loopers.domain.model.product.Price; import com.loopers.domain.model.product.Product; import com.loopers.domain.model.product.ProductName; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/UpdateProductUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/product/UpdateProductUseCase.java similarity index 77% rename from apps/commerce-api/src/main/java/com/loopers/application/UpdateProductUseCase.java rename to apps/commerce-api/src/main/java/com/loopers/application/product/UpdateProductUseCase.java index 95ff47a82..8d228b9b0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/UpdateProductUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/UpdateProductUseCase.java @@ -1,4 +1,4 @@ -package com.loopers.application; +package com.loopers.application.product; public interface UpdateProductUseCase { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/AuthenticationService.java b/apps/commerce-api/src/main/java/com/loopers/application/user/AuthenticationService.java similarity index 92% rename from apps/commerce-api/src/main/java/com/loopers/application/service/AuthenticationService.java rename to apps/commerce-api/src/main/java/com/loopers/application/user/AuthenticationService.java index 85a60af99..8cc570431 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/AuthenticationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/AuthenticationService.java @@ -1,6 +1,6 @@ -package com.loopers.application.service; +package com.loopers.application.user; -import com.loopers.application.AuthenticationUseCase; +import com.loopers.application.user.AuthenticationUseCase; import com.loopers.domain.model.user.User; import com.loopers.domain.model.user.UserId; import com.loopers.domain.repository.UserRepository; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/AuthenticationUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/user/AuthenticationUseCase.java similarity index 79% rename from apps/commerce-api/src/main/java/com/loopers/application/AuthenticationUseCase.java rename to apps/commerce-api/src/main/java/com/loopers/application/user/AuthenticationUseCase.java index 7b423c15c..7a566f51b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/AuthenticationUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/AuthenticationUseCase.java @@ -1,4 +1,4 @@ -package com.loopers.application; +package com.loopers.application.user; import com.loopers.domain.model.user.UserId; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/PasswordUpdateUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/user/PasswordUpdateUseCase.java similarity index 82% rename from apps/commerce-api/src/main/java/com/loopers/application/PasswordUpdateUseCase.java rename to apps/commerce-api/src/main/java/com/loopers/application/user/PasswordUpdateUseCase.java index 1fdada2eb..a2c8a4b07 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/PasswordUpdateUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/PasswordUpdateUseCase.java @@ -1,4 +1,4 @@ -package com.loopers.application; +package com.loopers.application.user; import com.loopers.domain.model.user.UserId; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/RegisterUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/user/RegisterUseCase.java similarity index 81% rename from apps/commerce-api/src/main/java/com/loopers/application/RegisterUseCase.java rename to apps/commerce-api/src/main/java/com/loopers/application/user/RegisterUseCase.java index 2a9a803e5..6a3602ef7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/RegisterUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/RegisterUseCase.java @@ -1,4 +1,4 @@ -package com.loopers.application; +package com.loopers.application.user; import java.time.LocalDate; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/UserQueryUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserQueryUseCase.java similarity index 89% rename from apps/commerce-api/src/main/java/com/loopers/application/UserQueryUseCase.java rename to apps/commerce-api/src/main/java/com/loopers/application/user/UserQueryUseCase.java index a2f1c5f17..7531886b0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/UserQueryUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserQueryUseCase.java @@ -1,4 +1,4 @@ -package com.loopers.application; +package com.loopers.application.user; import com.loopers.domain.model.user.UserId; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/service/UserService.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java similarity index 94% rename from apps/commerce-api/src/main/java/com/loopers/application/service/UserService.java rename to apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java index 52aa37d50..586854207 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/service/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java @@ -1,8 +1,8 @@ -package com.loopers.application.service; +package com.loopers.application.user; -import com.loopers.application.PasswordUpdateUseCase; -import com.loopers.application.RegisterUseCase; -import com.loopers.application.UserQueryUseCase; +import com.loopers.application.user.PasswordUpdateUseCase; +import com.loopers.application.user.RegisterUseCase; +import com.loopers.application.user.UserQueryUseCase; import com.loopers.domain.model.user.*; import com.loopers.domain.repository.UserRepository; import com.loopers.domain.service.PasswordEncoder; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/BrandJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaEntity.java similarity index 95% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/BrandJpaEntity.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaEntity.java index 1ffad0c78..3b6d7c6ad 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/BrandJpaEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaEntity.java @@ -1,4 +1,4 @@ -package com.loopers.infrastructure.entity; +package com.loopers.infrastructure.brand; import jakarta.persistence.*; import lombok.Getter; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java similarity index 71% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/BrandJpaRepository.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java index 224417801..a64697e32 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/BrandJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -1,6 +1,6 @@ -package com.loopers.infrastructure.repository; +package com.loopers.infrastructure.brand; -import com.loopers.infrastructure.entity.BrandJpaEntity; +import com.loopers.infrastructure.brand.BrandJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java similarity index 92% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/BrandRepositoryImpl.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java index 8817b5c2b..08378326c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/BrandRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -1,10 +1,10 @@ -package com.loopers.infrastructure; +package com.loopers.infrastructure.brand; import com.loopers.domain.model.brand.Brand; import com.loopers.domain.model.brand.BrandName; import com.loopers.domain.repository.BrandRepository; -import com.loopers.infrastructure.entity.BrandJpaEntity; -import com.loopers.infrastructure.repository.BrandJpaRepository; +import com.loopers.infrastructure.brand.BrandJpaEntity; +import com.loopers.infrastructure.brand.BrandJpaRepository; import org.springframework.stereotype.Repository; import java.util.List; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/LikeJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaEntity.java similarity index 94% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/LikeJpaEntity.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaEntity.java index a23e30eff..bf999609c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/LikeJpaEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaEntity.java @@ -1,4 +1,4 @@ -package com.loopers.infrastructure.entity; +package com.loopers.infrastructure.like; import jakarta.persistence.*; import lombok.Getter; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java similarity index 79% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/LikeJpaRepository.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java index bb6b8d1c4..c56fa505a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/LikeJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -1,6 +1,6 @@ -package com.loopers.infrastructure.repository; +package com.loopers.infrastructure.like; -import com.loopers.infrastructure.entity.LikeJpaEntity; +import com.loopers.infrastructure.like.LikeJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java similarity index 91% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/LikeRepositoryImpl.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java index 77b515a47..331548978 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/LikeRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -1,10 +1,10 @@ -package com.loopers.infrastructure; +package com.loopers.infrastructure.like; import com.loopers.domain.model.like.Like; import com.loopers.domain.model.user.UserId; import com.loopers.domain.repository.LikeRepository; -import com.loopers.infrastructure.entity.LikeJpaEntity; -import com.loopers.infrastructure.repository.LikeJpaRepository; +import com.loopers.infrastructure.like.LikeJpaEntity; +import com.loopers.infrastructure.like.LikeJpaRepository; import org.springframework.stereotype.Repository; import java.util.Optional; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/OrderItemJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaEntity.java similarity index 93% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/OrderItemJpaEntity.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaEntity.java index 625c4c34b..d616aae31 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/OrderItemJpaEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaEntity.java @@ -1,4 +1,4 @@ -package com.loopers.infrastructure.entity; +package com.loopers.infrastructure.order; import jakarta.persistence.*; import lombok.Getter; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/OrderJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaEntity.java similarity index 98% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/OrderJpaEntity.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaEntity.java index e7701224c..7ade164a6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/OrderJpaEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaEntity.java @@ -1,4 +1,4 @@ -package com.loopers.infrastructure.entity; +package com.loopers.infrastructure.order; import jakarta.persistence.*; import lombok.Getter; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java similarity index 68% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/OrderJpaRepository.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java index 3413902ea..b7ad65a1a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/OrderJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -1,6 +1,6 @@ -package com.loopers.infrastructure.repository; +package com.loopers.infrastructure.order; -import com.loopers.infrastructure.entity.OrderJpaEntity; +import com.loopers.infrastructure.order.OrderJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java similarity index 93% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/OrderRepositoryImpl.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java index 09640a601..d4d083e55 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/OrderRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -1,12 +1,12 @@ -package com.loopers.infrastructure; +package com.loopers.infrastructure.order; import com.loopers.domain.model.order.*; import com.loopers.domain.model.user.UserId; import com.loopers.domain.repository.OrderRepository; -import com.loopers.infrastructure.entity.OrderItemJpaEntity; -import com.loopers.infrastructure.entity.OrderJpaEntity; -import com.loopers.infrastructure.entity.OrderSnapshotJpaEntity; -import com.loopers.infrastructure.repository.OrderJpaRepository; +import com.loopers.infrastructure.order.OrderItemJpaEntity; +import com.loopers.infrastructure.order.OrderJpaEntity; +import com.loopers.infrastructure.order.OrderSnapshotJpaEntity; +import com.loopers.infrastructure.order.OrderJpaRepository; import org.springframework.stereotype.Repository; import java.util.List; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/OrderSnapshotJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderSnapshotJpaEntity.java similarity index 94% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/OrderSnapshotJpaEntity.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderSnapshotJpaEntity.java index 1534778f6..082c7a2b0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/OrderSnapshotJpaEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderSnapshotJpaEntity.java @@ -1,4 +1,4 @@ -package com.loopers.infrastructure.entity; +package com.loopers.infrastructure.order; import jakarta.persistence.*; import lombok.Getter; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/ProductJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaEntity.java similarity index 96% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/ProductJpaEntity.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaEntity.java index 9213893fa..e89a37447 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/ProductJpaEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaEntity.java @@ -1,4 +1,4 @@ -package com.loopers.infrastructure.entity; +package com.loopers.infrastructure.product; import jakarta.persistence.*; import lombok.Getter; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java similarity index 58% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/ProductJpaRepository.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index b01b48c58..96b125689 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -1,6 +1,6 @@ -package com.loopers.infrastructure.repository; +package com.loopers.infrastructure.product; -import com.loopers.infrastructure.entity.ProductJpaEntity; +import com.loopers.infrastructure.product.ProductJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; public interface ProductJpaRepository extends JpaRepository { diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java similarity index 92% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/ProductRepositoryImpl.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index ee5cbe6b8..d3f4f2ef2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -1,12 +1,12 @@ -package com.loopers.infrastructure; +package com.loopers.infrastructure.product; import com.loopers.domain.model.product.Price; import com.loopers.domain.model.product.Product; import com.loopers.domain.model.product.ProductName; import com.loopers.domain.model.product.Stock; import com.loopers.domain.repository.ProductRepository; -import com.loopers.infrastructure.entity.ProductJpaEntity; -import com.loopers.infrastructure.repository.ProductJpaRepository; +import com.loopers.infrastructure.product.ProductJpaEntity; +import com.loopers.infrastructure.product.ProductJpaRepository; import org.springframework.stereotype.Repository; import java.util.Optional; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/UserJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaEntity.java similarity index 96% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/UserJpaEntity.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaEntity.java index 18085e9e0..29b39556b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/entity/UserJpaEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaEntity.java @@ -1,4 +1,4 @@ -package com.loopers.infrastructure.entity; +package com.loopers.infrastructure.user; import com.loopers.domain.model.user.Birthday; import com.loopers.domain.model.user.Email; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java similarity index 72% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/UserJpaRepository.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java index 6a49ba3e6..b09fc5342 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/repository/UserJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -1,6 +1,6 @@ -package com.loopers.infrastructure.repository; +package com.loopers.infrastructure.user; -import com.loopers.infrastructure.entity.UserJpaEntity; +import com.loopers.infrastructure.user.UserJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java similarity index 91% rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java index 8807285d8..117716935 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/UserRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -1,9 +1,9 @@ -package com.loopers.infrastructure; +package com.loopers.infrastructure.user; import com.loopers.domain.model.user.*; import com.loopers.domain.repository.UserRepository; -import com.loopers.infrastructure.entity.UserJpaEntity; -import com.loopers.infrastructure.repository.UserJpaRepository; +import com.loopers.infrastructure.user.UserJpaEntity; +import com.loopers.infrastructure.user.UserJpaRepository; import org.springframework.stereotype.Repository; import java.util.Optional; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/BrandAdminController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminController.java similarity index 83% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/BrandAdminController.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminController.java index 87edb5550..5ecdd3ee5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/BrandAdminController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminController.java @@ -1,12 +1,12 @@ -package com.loopers.interfaces.api; +package com.loopers.interfaces.api.brand; -import com.loopers.application.BrandQueryUseCase; -import com.loopers.application.CreateBrandUseCase; -import com.loopers.application.DeleteBrandUseCase; -import com.loopers.application.UpdateBrandUseCase; -import com.loopers.interfaces.api.dto.BrandCreateRequest; -import com.loopers.interfaces.api.dto.BrandResponse; -import com.loopers.interfaces.api.dto.BrandUpdateRequest; +import com.loopers.application.brand.BrandQueryUseCase; +import com.loopers.application.brand.CreateBrandUseCase; +import com.loopers.application.brand.DeleteBrandUseCase; +import com.loopers.application.brand.UpdateBrandUseCase; +import com.loopers.interfaces.api.brand.dto.BrandCreateRequest; +import com.loopers.interfaces.api.brand.dto.BrandResponse; +import com.loopers.interfaces.api.brand.dto.BrandUpdateRequest; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/BrandController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java similarity index 84% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/BrandController.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java index 70f506110..73c4cc329 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/BrandController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandController.java @@ -1,7 +1,7 @@ -package com.loopers.interfaces.api; +package com.loopers.interfaces.api.brand; -import com.loopers.application.BrandQueryUseCase; -import com.loopers.interfaces.api.dto.BrandResponse; +import com.loopers.application.brand.BrandQueryUseCase; +import com.loopers.interfaces.api.brand.dto.BrandResponse; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/BrandCreateRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandCreateRequest.java similarity index 65% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/BrandCreateRequest.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandCreateRequest.java index 5d01b40b1..c21a5fdfe 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/BrandCreateRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandCreateRequest.java @@ -1,4 +1,4 @@ -package com.loopers.interfaces.api.dto; +package com.loopers.interfaces.api.brand.dto; public record BrandCreateRequest( String name, diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/BrandResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandResponse.java similarity index 76% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/BrandResponse.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandResponse.java index a6f624ae9..d47aa68f7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/BrandResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandResponse.java @@ -1,6 +1,6 @@ -package com.loopers.interfaces.api.dto; +package com.loopers.interfaces.api.brand.dto; -import com.loopers.application.BrandQueryUseCase; +import com.loopers.application.brand.BrandQueryUseCase; public record BrandResponse( Long id, diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/BrandUpdateRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandUpdateRequest.java similarity index 65% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/BrandUpdateRequest.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandUpdateRequest.java index 87f1a7673..9c26945b1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/BrandUpdateRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/dto/BrandUpdateRequest.java @@ -1,4 +1,4 @@ -package com.loopers.interfaces.api.dto; +package com.loopers.interfaces.api.brand.dto; public record BrandUpdateRequest( String name, diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/interceptor/AuthenticationInterceptor.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/interceptor/AuthenticationInterceptor.java index 28b612568..aa79f6a48 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/interceptor/AuthenticationInterceptor.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/interceptor/AuthenticationInterceptor.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.interceptor; -import com.loopers.application.AuthenticationUseCase; +import com.loopers.application.user.AuthenticationUseCase; import com.loopers.domain.model.user.UserId; import com.loopers.support.error.ErrorType; import jakarta.servlet.http.HttpServletRequest; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/LikeController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java similarity index 89% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/LikeController.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java index 2b598dc1c..c6c69780a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/LikeController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeController.java @@ -1,7 +1,7 @@ -package com.loopers.interfaces.api; +package com.loopers.interfaces.api.like; -import com.loopers.application.LikeUseCase; -import com.loopers.application.UnlikeUseCase; +import com.loopers.application.like.LikeUseCase; +import com.loopers.application.like.UnlikeUseCase; import com.loopers.domain.model.user.UserId; import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.ResponseEntity; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/OrderController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java similarity index 84% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/OrderController.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java index 5cf800b0d..a4b6e23e6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/OrderController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java @@ -1,11 +1,11 @@ -package com.loopers.interfaces.api; +package com.loopers.interfaces.api.order; -import com.loopers.application.CreateOrderUseCase; -import com.loopers.application.OrderQueryUseCase; +import com.loopers.application.order.CreateOrderUseCase; +import com.loopers.application.order.OrderQueryUseCase; import com.loopers.domain.model.user.UserId; -import com.loopers.interfaces.api.dto.OrderCreateRequest; -import com.loopers.interfaces.api.dto.OrderDetailResponse; -import com.loopers.interfaces.api.dto.OrderSummaryResponse; +import com.loopers.interfaces.api.order.dto.OrderCreateRequest; +import com.loopers.interfaces.api.order.dto.OrderDetailResponse; +import com.loopers.interfaces.api.order.dto.OrderSummaryResponse; import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/OrderCreateRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderCreateRequest.java similarity index 88% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/OrderCreateRequest.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderCreateRequest.java index e1f975a2b..3b4396f40 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/OrderCreateRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderCreateRequest.java @@ -1,6 +1,6 @@ -package com.loopers.interfaces.api.dto; +package com.loopers.interfaces.api.order.dto; -import com.loopers.application.CreateOrderUseCase; +import com.loopers.application.order.CreateOrderUseCase; import java.time.LocalDate; import java.util.List; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/OrderDetailResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderDetailResponse.java similarity index 92% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/OrderDetailResponse.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderDetailResponse.java index 55de036c3..9a3ee5a8f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/OrderDetailResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderDetailResponse.java @@ -1,6 +1,6 @@ -package com.loopers.interfaces.api.dto; +package com.loopers.interfaces.api.order.dto; -import com.loopers.application.OrderQueryUseCase; +import com.loopers.application.order.OrderQueryUseCase; import java.time.LocalDateTime; import java.util.List; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/OrderSummaryResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderSummaryResponse.java similarity index 81% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/OrderSummaryResponse.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderSummaryResponse.java index d5788a2ff..6887d5048 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/OrderSummaryResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/OrderSummaryResponse.java @@ -1,6 +1,6 @@ -package com.loopers.interfaces.api.dto; +package com.loopers.interfaces.api.order.dto; -import com.loopers.application.OrderQueryUseCase; +import com.loopers.application.order.OrderQueryUseCase; import java.time.LocalDateTime; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ProductAdminController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminController.java similarity index 81% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/ProductAdminController.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminController.java index 3ac5b91ba..466ea5e13 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ProductAdminController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminController.java @@ -1,12 +1,12 @@ -package com.loopers.interfaces.api; - -import com.loopers.application.CreateProductUseCase; -import com.loopers.application.DeleteProductUseCase; -import com.loopers.application.ProductQueryUseCase; -import com.loopers.application.UpdateProductUseCase; -import com.loopers.interfaces.api.dto.ProductCreateRequest; -import com.loopers.interfaces.api.dto.ProductDetailResponse; -import com.loopers.interfaces.api.dto.ProductUpdateRequest; +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.CreateProductUseCase; +import com.loopers.application.product.DeleteProductUseCase; +import com.loopers.application.product.ProductQueryUseCase; +import com.loopers.application.product.UpdateProductUseCase; +import com.loopers.interfaces.api.product.dto.ProductCreateRequest; +import com.loopers.interfaces.api.product.dto.ProductDetailResponse; +import com.loopers.interfaces.api.product.dto.ProductUpdateRequest; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ProductController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java similarity index 83% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/ProductController.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java index 36e97b636..dfa606d2e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ProductController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java @@ -1,7 +1,7 @@ -package com.loopers.interfaces.api; +package com.loopers.interfaces.api.product; -import com.loopers.application.ProductQueryUseCase; -import com.loopers.interfaces.api.dto.ProductDetailResponse; +import com.loopers.application.product.ProductQueryUseCase; +import com.loopers.interfaces.api.product.dto.ProductDetailResponse; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/ProductCreateRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductCreateRequest.java similarity index 75% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/ProductCreateRequest.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductCreateRequest.java index e816e3f77..06bb27c27 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/ProductCreateRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductCreateRequest.java @@ -1,4 +1,4 @@ -package com.loopers.interfaces.api.dto; +package com.loopers.interfaces.api.product.dto; public record ProductCreateRequest( Long brandId, diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/ProductDetailResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductDetailResponse.java similarity index 85% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/ProductDetailResponse.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductDetailResponse.java index 5ad70ee8a..b20498562 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/ProductDetailResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductDetailResponse.java @@ -1,6 +1,6 @@ -package com.loopers.interfaces.api.dto; +package com.loopers.interfaces.api.product.dto; -import com.loopers.application.ProductQueryUseCase; +import com.loopers.application.product.ProductQueryUseCase; public record ProductDetailResponse( Long id, diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/ProductUpdateRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductUpdateRequest.java similarity index 72% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/ProductUpdateRequest.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductUpdateRequest.java index e7d09c043..f0ea08187 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/ProductUpdateRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductUpdateRequest.java @@ -1,4 +1,4 @@ -package com.loopers.interfaces.api.dto; +package com.loopers.interfaces.api.product.dto; public record ProductUpdateRequest( String name, diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/UserController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java similarity index 83% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/UserController.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java index f88503408..d3cbd90f8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/UserController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java @@ -1,12 +1,12 @@ -package com.loopers.interfaces.api; +package com.loopers.interfaces.api.user; -import com.loopers.application.PasswordUpdateUseCase; -import com.loopers.application.RegisterUseCase; -import com.loopers.application.UserQueryUseCase; +import com.loopers.application.user.PasswordUpdateUseCase; +import com.loopers.application.user.RegisterUseCase; +import com.loopers.application.user.UserQueryUseCase; import com.loopers.domain.model.user.UserId; -import com.loopers.interfaces.api.dto.PasswordUpdateRequest; -import com.loopers.interfaces.api.dto.UserInfoResponse; -import com.loopers.interfaces.api.dto.UserRegisterRequest; +import com.loopers.interfaces.api.user.dto.PasswordUpdateRequest; +import com.loopers.interfaces.api.user.dto.UserInfoResponse; +import com.loopers.interfaces.api.user.dto.UserRegisterRequest; import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.ResponseEntity; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/PasswordUpdateRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/PasswordUpdateRequest.java similarity index 69% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/PasswordUpdateRequest.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/PasswordUpdateRequest.java index 24a38ea36..3944b86bb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/PasswordUpdateRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/PasswordUpdateRequest.java @@ -1,4 +1,4 @@ -package com.loopers.interfaces.api.dto; +package com.loopers.interfaces.api.user.dto; public record PasswordUpdateRequest( String currentPassword, diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/UserInfoResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/UserInfoResponse.java similarity index 85% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/UserInfoResponse.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/UserInfoResponse.java index e06b1acb1..562726436 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/UserInfoResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/UserInfoResponse.java @@ -1,6 +1,6 @@ -package com.loopers.interfaces.api.dto; +package com.loopers.interfaces.api.user.dto; -import com.loopers.application.UserQueryUseCase; +import com.loopers.application.user.UserQueryUseCase; import java.time.format.DateTimeFormatter; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/UserRegisterRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/UserRegisterRequest.java similarity index 80% rename from apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/UserRegisterRequest.java rename to apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/UserRegisterRequest.java index 576b84aa4..7c1e4bbe7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/dto/UserRegisterRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/UserRegisterRequest.java @@ -1,4 +1,4 @@ -package com.loopers.interfaces.api.dto; +package com.loopers.interfaces.api.user.dto; import java.time.LocalDate; diff --git a/apps/commerce-api/src/test/java/com/loopers/application/service/AuthenticationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/AuthenticationServiceTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/application/service/AuthenticationServiceTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/user/AuthenticationServiceTest.java index 8db7d7998..b62c53715 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/service/AuthenticationServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/AuthenticationServiceTest.java @@ -1,4 +1,4 @@ -package com.loopers.application.service; +package com.loopers.application.user; import com.loopers.domain.model.user.*; import com.loopers.domain.repository.UserRepository; diff --git a/apps/commerce-api/src/test/java/com/loopers/application/service/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceTest.java similarity index 99% rename from apps/commerce-api/src/test/java/com/loopers/application/service/UserServiceTest.java rename to apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceTest.java index a8fd8e02e..e3131705c 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/service/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceTest.java @@ -1,4 +1,4 @@ -package com.loopers.application.service; +package com.loopers.application.user; import com.loopers.domain.model.user.*; import com.loopers.domain.repository.UserRepository; diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java similarity index 97% rename from apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiE2ETest.java rename to apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java index 1c2d8d97f..0485f9284 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java @@ -1,8 +1,8 @@ -package com.loopers.interfaces.api; +package com.loopers.interfaces.api.user; -import com.loopers.interfaces.api.dto.PasswordUpdateRequest; -import com.loopers.interfaces.api.dto.UserInfoResponse; -import com.loopers.interfaces.api.dto.UserRegisterRequest; +import com.loopers.interfaces.api.user.dto.PasswordUpdateRequest; +import com.loopers.interfaces.api.user.dto.UserInfoResponse; +import com.loopers.interfaces.api.user.dto.UserRegisterRequest; import com.loopers.testcontainers.MySqlTestContainersConfig; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.BeforeEach; diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiIntegrationTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiIntegrationTest.java rename to apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiIntegrationTest.java index 87994f717..fa1d2eb35 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserApiIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiIntegrationTest.java @@ -1,8 +1,8 @@ -package com.loopers.interfaces.api; +package com.loopers.interfaces.api.user; import com.fasterxml.jackson.databind.ObjectMapper; -import com.loopers.interfaces.api.dto.PasswordUpdateRequest; -import com.loopers.interfaces.api.dto.UserRegisterRequest; +import com.loopers.interfaces.api.user.dto.PasswordUpdateRequest; +import com.loopers.interfaces.api.user.dto.UserRegisterRequest; import com.loopers.testcontainers.MySqlTestContainersConfig; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.BeforeEach; diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/dto/UserInfoResponseTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/dto/UserInfoResponseTest.java similarity index 95% rename from apps/commerce-api/src/test/java/com/loopers/interfaces/api/dto/UserInfoResponseTest.java rename to apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/dto/UserInfoResponseTest.java index 6125054a3..9f6df066a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/dto/UserInfoResponseTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/dto/UserInfoResponseTest.java @@ -1,6 +1,6 @@ -package com.loopers.interfaces.api.dto; +package com.loopers.interfaces.api.user.dto; -import com.loopers.application.UserQueryUseCase; +import com.loopers.application.user.UserQueryUseCase; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; From ed87f75b96f8552c94719cd09c79cc6667f9c310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Tue, 24 Feb 2026 16:52:00 +0900 Subject: [PATCH 14/20] =?UTF-8?q?docs:=20=EC=84=A4=EA=B3=84=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=95=20=EA=B7=BC=EA=B1=B0=20=EB=AC=B8=EC=84=9C=EC=97=90=20?= =?UTF-8?q?Interfaces=20Layer=20=EA=B4=80=EB=A0=A8=20=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Admin/User Interceptor 분리 근거 - Controller별 역할 분리 (Admin vs User) 근거 - Interfaces DTO와 Application DTO 분리 근거 - Infrastructure Layer BaseEntity 미상속 근거 Co-Authored-By: Claude Opus 4.6 --- .docs/Reason.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/.docs/Reason.md b/.docs/Reason.md index 0788392cc..6558ceabd 100644 --- a/.docs/Reason.md +++ b/.docs/Reason.md @@ -209,3 +209,62 @@ public class ProductController { - 도메인 불변식: `totalAmount = SUM(item.unitPrice * item.quantity)` 는 Order의 핵심 규칙 - `paymentAmount = totalAmount - discountAmount` 도 내부에서 계산하여 정합성 보장 - **process.md**: "도메인 객체는 비즈니스 규칙을 캡슐화해야 합니다" + +--- + +## 13. Admin/User Interceptor 분리 + +### 결정 +`AuthenticationInterceptor`(User)와 `AdminAuthenticationInterceptor`(Admin)를 별도 컴포넌트로 구현 + +### 근거 +- **01-requirements.md 2.2절**: Admin(`X-Loopers-Ldap`)과 User(`X-Loopers-LoginId` + `X-Loopers-LoginPw`)는 완전히 다른 인증 체계 +- **06-admin-authentication.md**: Admin은 DB 조회 없이 헤더 값 일치만 확인, User 테이블 변경 불필요 +- 단일 책임 원칙: 각 Interceptor가 하나의 인증 방식만 담당 +- `WebMvcConfig`에서 경로 패턴으로 분리 등록: `/api/v1/**` → User, `/api-admin/v1/**` → Admin + +--- + +## 14. Controller별 역할 분리 (Admin vs User) + +### 결정 +같은 도메인이라도 Admin Controller와 User Controller를 분리 + +### 근거 +- **03-class-diagram Part E~H**: Brand, Product, Order 모두 Admin/User Controller 분리 설계 +- Admin은 CRUD 전체 접근, User는 조회만 접근 → 하나의 Controller에 혼재 시 인증 경로 관리 복잡 +- 엔드포인트 경로가 다름: `/api-admin/v1/brands` vs `/api/v1/brands` +- 각 Controller가 필요한 UseCase만 의존하여 결합도 최소화 + +| 도메인 | Admin Controller | User Controller | +|--------|-----------------|-----------------| +| Brand | `BrandAdminController` (CRUD) | `BrandController` (조회) | +| Product | `ProductAdminController` (CUD+조회) | `ProductController` (조회) | +| Like | - | `LikeController` (등록/취소) | +| Order | - | `OrderController` (생성/조회) | + +--- + +## 15. Interfaces DTO와 Application DTO 분리 + +### 결정 +Request/Response DTO를 `interfaces/api/dto/`에 별도 정의, Application 레이어의 record와 `from()` 메서드로 변환 + +### 근거 +- **process.md**: "API request, response DTO와 응용 레이어의 DTO는 분리해 작성" +- Interfaces 레이어 변경(필드 추가/제거, 포맷 변경)이 Application 레이어에 전파되지 않음 +- 기존 `UserInfoResponse.from(UserQueryUseCase.UserInfoResponse)` 패턴 답습 +- `OrderCreateRequest.toCommand()`: DTO → Application Command 변환을 DTO 자체에 캡슐화 + +--- + +## 16. Infrastructure Layer: BaseEntity 미상속 + +### 결정 +새로운 JPA Entity들이 `BaseEntity`를 상속하지 않고 자체 필드로 관리 + +### 근거 +- 기존 `UserJpaEntity` 패턴 답습: 프로젝트 내 일관성 유지 +- `BaseEntity`는 `ZonedDateTime` 사용, 도메인 모델은 `LocalDateTime` 사용 → 타입 불일치 +- Like, OrderItem 등 `updated_at`/`deleted_at`가 불필요한 엔티티에 불필요한 컬럼 생성 방지 +- 각 Entity가 자신에게 필요한 필드만 정확히 가짐 → 명시적이고 예측 가능 From 17cfa12ee6f5e463d2804dc001f213efff28e7a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Tue, 24 Feb 2026 18:09:33 +0900 Subject: [PATCH 15/20] =?UTF-8?q?feat:=20=EC=84=A4=EA=B3=84=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EB=AF=B8=EA=B5=AC=ED=98=84=20=ED=95=AD=EB=AA=A9=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OrderStatus.CANCELLED 추가 및 Order.cancel() 버그 수정 - Stock.increase(), Product.increaseStock() 재고 복원 메서드 추가 - 상품 목록 조회 (필터/정렬/페이징) API 구현 - 좋아요 목록 조회 API 구현 (GET /api/v1/users/me/likes) - 주문 기간 필터 조회 (startAt/endAt) 구현 - Admin 주문 관리 API 구현 (OrderAdminController) - Brand 삭제 시 하위 상품 일괄 soft delete (Cascade) - 주문 취소 API (POST /orders/{id}/cancel) + 재고 복원 - 배송지 변경 API (PUT /orders/{id}/delivery-address) - PageResponse 공통 페이징 DTO 추가 Co-Authored-By: Claude Opus 4.6 --- .../application/brand/BrandService.java | 15 ++++-- .../application/like/LikeQueryUseCase.java | 18 ++++++++ .../loopers/application/like/LikeService.java | 24 ++++++++-- .../application/order/CancelOrderUseCase.java | 8 ++++ .../application/order/OrderQueryService.java | 44 +++++++++++++++--- .../application/order/OrderQueryUseCase.java | 7 +++ .../application/order/OrderService.java | 30 +++++++++++- .../order/UpdateDeliveryAddressUseCase.java | 8 ++++ .../product/ProductQueryService.java | 38 ++++++++++++++- .../product/ProductQueryUseCase.java | 13 ++++++ .../com/loopers/domain/model/order/Order.java | 2 +- .../domain/model/order/OrderStatus.java | 3 +- .../loopers/domain/model/product/Product.java | 6 +++ .../loopers/domain/model/product/Stock.java | 7 +++ .../domain/repository/LikeRepository.java | 3 ++ .../domain/repository/OrderRepository.java | 5 ++ .../domain/repository/ProductRepository.java | 7 +++ .../like/LikeJpaRepository.java | 4 +- .../like/LikeRepositoryImpl.java | 10 +++- .../order/OrderJpaRepository.java | 4 +- .../order/OrderRepositoryImpl.java | 19 ++++++-- .../product/ProductJpaRepository.java | 11 ++++- .../product/ProductRepositoryImpl.java | 23 +++++++++- .../interfaces/api/common/PageResponse.java | 22 +++++++++ .../interfaces/api/like/dto/LikeResponse.java | 21 +++++++++ .../api/order/OrderAdminController.java | 37 +++++++++++++++ .../interfaces/api/order/OrderController.java | 46 +++++++++++++++++-- .../dto/DeliveryAddressUpdateRequest.java | 5 ++ .../api/product/ProductAdminController.java | 13 ++++++ .../api/product/ProductController.java | 19 ++++++-- .../product/dto/ProductSummaryResponse.java | 23 ++++++++++ .../interfaces/api/user/UserController.java | 18 +++++++- 32 files changed, 474 insertions(+), 39 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeQueryUseCase.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/CancelOrderUseCase.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/UpdateDeliveryAddressUseCase.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/PageResponse.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/dto/LikeResponse.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminController.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/DeliveryAddressUpdateRequest.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductSummaryResponse.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java index 9c2c2ab4a..f4a2e66d3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -1,12 +1,10 @@ package com.loopers.application.brand; -import com.loopers.application.brand.BrandQueryUseCase; -import com.loopers.application.brand.CreateBrandUseCase; -import com.loopers.application.brand.DeleteBrandUseCase; -import com.loopers.application.brand.UpdateBrandUseCase; import com.loopers.domain.model.brand.Brand; import com.loopers.domain.model.brand.BrandName; +import com.loopers.domain.model.product.Product; import com.loopers.domain.repository.BrandRepository; +import com.loopers.domain.repository.ProductRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,9 +15,11 @@ public class BrandService implements CreateBrandUseCase, UpdateBrandUseCase, DeleteBrandUseCase, BrandQueryUseCase { private final BrandRepository brandRepository; + private final ProductRepository productRepository; - public BrandService(BrandRepository brandRepository) { + public BrandService(BrandRepository brandRepository, ProductRepository productRepository) { this.brandRepository = brandRepository; + this.productRepository = productRepository; } @Override @@ -47,6 +47,11 @@ public void deleteBrand(Long brandId) { Brand brand = findBrand(brandId); Brand deleted = brand.delete(); brandRepository.save(deleted); + + List products = productRepository.findAllByBrandId(brandId); + for (Product product : products) { + productRepository.save(product.delete()); + } } @Override diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeQueryUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeQueryUseCase.java new file mode 100644 index 000000000..f5d8dc6ea --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeQueryUseCase.java @@ -0,0 +1,18 @@ +package com.loopers.application.like; + +import com.loopers.domain.model.user.UserId; + +import java.time.LocalDateTime; +import java.util.List; + +public interface LikeQueryUseCase { + + List getMyLikes(UserId userId); + + record LikeInfo( + Long productId, + String productName, + int price, + LocalDateTime likedAt + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java index c0b9da4eb..59deef641 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java @@ -1,7 +1,5 @@ package com.loopers.application.like; -import com.loopers.application.like.LikeUseCase; -import com.loopers.application.like.UnlikeUseCase; import com.loopers.domain.model.like.Like; import com.loopers.domain.model.product.Product; import com.loopers.domain.model.user.UserId; @@ -10,9 +8,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service @Transactional -public class LikeService implements LikeUseCase, UnlikeUseCase { +public class LikeService implements LikeUseCase, UnlikeUseCase, LikeQueryUseCase { private final LikeRepository likeRepository; private final ProductRepository productRepository; @@ -51,6 +51,24 @@ public void unlike(UserId userId, Long productId) { productRepository.save(updated); } + @Override + @Transactional(readOnly = true) + public List getMyLikes(UserId userId) { + List likes = likeRepository.findAllByUserId(userId); + + return likes.stream() + .flatMap(like -> productRepository.findById(like.getProductId()) + .filter(p -> !p.isDeleted()) + .map(product -> new LikeInfo( + product.getId(), + product.getName().getValue(), + product.getPrice().getValue(), + like.getCreatedAt() + )) + .stream()) + .toList(); + } + private Product findProduct(Long productId) { return productRepository.findById(productId) .filter(p -> !p.isDeleted()) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/CancelOrderUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/order/CancelOrderUseCase.java new file mode 100644 index 000000000..a7115aef0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/CancelOrderUseCase.java @@ -0,0 +1,8 @@ +package com.loopers.application.order; + +import com.loopers.domain.model.user.UserId; + +public interface CancelOrderUseCase { + + void cancelOrder(UserId userId, Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryService.java index 8e797f211..1fe75d492 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryService.java @@ -1,6 +1,5 @@ package com.loopers.application.order; -import com.loopers.application.order.OrderQueryUseCase; import com.loopers.domain.model.order.Order; import com.loopers.domain.model.order.OrderItem; import com.loopers.domain.model.user.UserId; @@ -8,6 +7,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.time.LocalTime; import java.util.List; @Service @@ -23,7 +24,41 @@ public OrderQueryService(OrderRepository orderRepository) { @Override public List getMyOrders(UserId userId) { List orders = orderRepository.findAllByUserId(userId); + return toSummaries(orders); + } + + @Override + public List getMyOrders(UserId userId, LocalDate startAt, LocalDate endAt) { + List orders = orderRepository.findAllByUserIdAndDateRange( + userId, + startAt.atStartOfDay(), + endAt.atTime(LocalTime.MAX) + ); + return toSummaries(orders); + } + + @Override + public List getAllOrders() { + List orders = orderRepository.findAll(); + return toSummaries(orders); + } + + @Override + public OrderDetail getOrderDetail(Long orderId) { + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다.")); + return toOrderDetail(order); + } + @Override + public OrderDetail getOrder(UserId userId, Long orderId) { + Order order = orderRepository.findById(orderId) + .filter(o -> o.getUserId().equals(userId)) + .orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다.")); + return toOrderDetail(order); + } + + private List toSummaries(List orders) { return orders.stream() .map(order -> new OrderSummary( order.getId(), @@ -34,12 +69,7 @@ public List getMyOrders(UserId userId) { .toList(); } - @Override - public OrderDetail getOrder(UserId userId, Long orderId) { - Order order = orderRepository.findById(orderId) - .filter(o -> o.getUserId().equals(userId)) - .orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다.")); - + private OrderDetail toOrderDetail(Order order) { List itemDetails = order.getItems().stream() .map(this::toOrderItemDetail) .toList(); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryUseCase.java index 58a13b506..fadc9e0c7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryUseCase.java @@ -2,6 +2,7 @@ import com.loopers.domain.model.user.UserId; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -9,6 +10,12 @@ public interface OrderQueryUseCase { List getMyOrders(UserId userId); + List getMyOrders(UserId userId, LocalDate startAt, LocalDate endAt); + + List getAllOrders(); + + OrderDetail getOrderDetail(Long orderId); + OrderDetail getOrder(UserId userId, Long orderId); record OrderSummary( diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java index ef5a3b564..6084c9f3b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java @@ -1,6 +1,5 @@ package com.loopers.application.order; -import com.loopers.application.order.CreateOrderUseCase; import com.loopers.domain.model.order.*; import com.loopers.domain.model.product.Product; import com.loopers.domain.model.user.UserId; @@ -14,7 +13,7 @@ @Service @Transactional -public class OrderService implements CreateOrderUseCase { +public class OrderService implements CreateOrderUseCase, CancelOrderUseCase, UpdateDeliveryAddressUseCase { private final OrderRepository orderRepository; private final ProductRepository productRepository; @@ -67,4 +66,31 @@ public void createOrder(UserId userId, OrderCommand command) { orderRepository.save(order); } + + @Override + public void cancelOrder(UserId userId, Long orderId) { + Order order = orderRepository.findById(orderId) + .filter(o -> o.getUserId().equals(userId)) + .orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다.")); + + Order cancelled = order.cancel(); + orderRepository.save(cancelled); + + for (OrderItem item : order.getItems()) { + Product product = productRepository.findById(item.getProductId()) + .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다.")); + Product restored = product.increaseStock(item.getQuantity()); + productRepository.save(restored); + } + } + + @Override + public void updateDeliveryAddress(UserId userId, Long orderId, String newAddress) { + Order order = orderRepository.findById(orderId) + .filter(o -> o.getUserId().equals(userId)) + .orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다.")); + + Order updated = order.updateDeliveryAddress(Address.of(newAddress)); + orderRepository.save(updated); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/UpdateDeliveryAddressUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/order/UpdateDeliveryAddressUseCase.java new file mode 100644 index 000000000..a01ed6734 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/UpdateDeliveryAddressUseCase.java @@ -0,0 +1,8 @@ +package com.loopers.application.order; + +import com.loopers.domain.model.user.UserId; + +public interface UpdateDeliveryAddressUseCase { + + void updateDeliveryAddress(UserId userId, Long orderId, String newAddress); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java index 2031ea170..360e6e725 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java @@ -1,10 +1,12 @@ package com.loopers.application.product; -import com.loopers.application.product.ProductQueryUseCase; import com.loopers.domain.model.brand.Brand; import com.loopers.domain.model.product.Product; import com.loopers.domain.repository.BrandRepository; import com.loopers.domain.repository.ProductRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -40,4 +42,38 @@ public ProductDetailInfo getProduct(Long productId) { product.getDescription() ); } + + @Override + public Page getProducts(Long brandId, String sort, int page, int size) { + Sort sorting = resolveSort(sort); + PageRequest pageRequest = PageRequest.of(page, size, sorting); + + Page products = productRepository.findAllByDeletedAtIsNull(brandId, pageRequest); + + return products.map(product -> { + String brandName = brandRepository.findById(product.getBrandId()) + .map(b -> b.getName().getValue()) + .orElse(""); + return new ProductSummaryInfo( + product.getId(), + product.getBrandId(), + brandName, + product.getName().getValue(), + product.getPrice().getValue(), + product.getLikeCount() + ); + }); + } + + private Sort resolveSort(String sort) { + if (sort == null) { + return Sort.by(Sort.Direction.DESC, "createdAt"); + } + return switch (sort) { + case "price_asc" -> Sort.by(Sort.Direction.ASC, "price"); + case "price_desc" -> Sort.by(Sort.Direction.DESC, "price"); + case "likes_desc" -> Sort.by(Sort.Direction.DESC, "likeCount"); + default -> Sort.by(Sort.Direction.DESC, "createdAt"); + }; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryUseCase.java index e9be58423..10f4bb31e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryUseCase.java @@ -1,9 +1,13 @@ package com.loopers.application.product; +import org.springframework.data.domain.Page; + public interface ProductQueryUseCase { ProductDetailInfo getProduct(Long productId); + Page getProducts(Long brandId, String sort, int page, int size); + record ProductDetailInfo( Long id, Long brandId, @@ -14,4 +18,13 @@ record ProductDetailInfo( int likeCount, String description ) {} + + record ProductSummaryInfo( + Long id, + Long brandId, + String brandName, + String name, + int price, + int likeCount + ) {} } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Order.java index 2bcc12871..59135ede1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Order.java @@ -68,7 +68,7 @@ public Order cancel() { } return new Order(this.id, this.userId, this.items, this.snapshot, this.receiverName, this.address, this.deliveryRequest, this.paymentMethod, this.totalAmount, - this.discountAmount, this.paymentAmount, OrderStatus.PAYMENT_COMPLETED, + this.discountAmount, this.paymentAmount, OrderStatus.CANCELLED, this.desiredDeliveryDate, this.createdAt, LocalDateTime.now()); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderStatus.java index 962bef74c..352d71de7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderStatus.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderStatus.java @@ -5,7 +5,8 @@ public enum OrderStatus { PAYMENT_COMPLETED("결제완료"), PREPARING("상품준비중"), SHIPPING("배송중"), - DELIVERED("배송완료"); + DELIVERED("배송완료"), + CANCELLED("주문취소"); private final String description; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Product.java index 74398f8b8..5ec747187 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Product.java @@ -52,6 +52,12 @@ public Product decreaseStock(int quantity) { this.description, this.createdAt, LocalDateTime.now(), this.deletedAt); } + public Product increaseStock(int quantity) { + Stock increased = this.stock.increase(quantity); + return new Product(this.id, this.brandId, this.name, this.price, increased, this.likeCount, + this.description, this.createdAt, LocalDateTime.now(), this.deletedAt); + } + public Product increaseLikeCount() { return new Product(this.id, this.brandId, this.name, this.price, this.stock, this.likeCount + 1, this.description, this.createdAt, this.updatedAt, this.deletedAt); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Stock.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Stock.java index 2daf9dc6b..cbd2c7862 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Stock.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Stock.java @@ -30,6 +30,13 @@ public Stock decrease(int quantity) { return new Stock(this.value - quantity); } + public Stock increase(int quantity) { + if (quantity <= 0) { + throw new IllegalArgumentException("증가 수량은 1 이상이어야 합니다."); + } + return new Stock(this.value + quantity); + } + public boolean hasEnough(int quantity) { return this.value >= quantity; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/repository/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/repository/LikeRepository.java index 89acfe971..3430e99fb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/repository/LikeRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/repository/LikeRepository.java @@ -3,6 +3,7 @@ import com.loopers.domain.model.like.Like; import com.loopers.domain.model.user.UserId; +import java.util.List; import java.util.Optional; public interface LikeRepository { @@ -14,4 +15,6 @@ public interface LikeRepository { void deleteByUserIdAndProductId(UserId userId, Long productId); boolean existsByUserIdAndProductId(UserId userId, Long productId); + + List findAllByUserId(UserId userId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/repository/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/repository/OrderRepository.java index 3e4a3802e..d50f11b66 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/repository/OrderRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/repository/OrderRepository.java @@ -3,6 +3,7 @@ import com.loopers.domain.model.order.Order; import com.loopers.domain.model.user.UserId; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -13,4 +14,8 @@ public interface OrderRepository { Optional findById(Long id); List findAllByUserId(UserId userId); + + List findAllByUserIdAndDateRange(UserId userId, LocalDateTime startAt, LocalDateTime endAt); + + List findAll(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/repository/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/repository/ProductRepository.java index 5c1810731..a2c1840ee 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/repository/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/repository/ProductRepository.java @@ -1,7 +1,10 @@ package com.loopers.domain.repository; import com.loopers.domain.model.product.Product; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import java.util.List; import java.util.Optional; public interface ProductRepository { @@ -9,4 +12,8 @@ public interface ProductRepository { Product save(Product product); Optional findById(Long id); + + Page findAllByDeletedAtIsNull(Long brandId, Pageable pageable); + + List findAllByBrandId(Long brandId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java index c56fa505a..77928a0b3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -1,8 +1,8 @@ package com.loopers.infrastructure.like; -import com.loopers.infrastructure.like.LikeJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface LikeJpaRepository extends JpaRepository { @@ -12,4 +12,6 @@ public interface LikeJpaRepository extends JpaRepository { boolean existsByUserIdAndProductId(String userId, Long productId); void deleteByUserIdAndProductId(String userId, Long productId); + + List findAllByUserId(String userId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java index 331548978..38b9d5d74 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -3,10 +3,9 @@ import com.loopers.domain.model.like.Like; import com.loopers.domain.model.user.UserId; import com.loopers.domain.repository.LikeRepository; -import com.loopers.infrastructure.like.LikeJpaEntity; -import com.loopers.infrastructure.like.LikeJpaRepository; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -41,6 +40,13 @@ public boolean existsByUserIdAndProductId(UserId userId, Long productId) { return likeJpaRepository.existsByUserIdAndProductId(userId.getValue(), productId); } + @Override + public List findAllByUserId(UserId userId) { + return likeJpaRepository.findAllByUserId(userId.getValue()).stream() + .map(this::toDomain) + .toList(); + } + private LikeJpaEntity toEntity(Like like) { return new LikeJpaEntity( like.getId(), diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java index b7ad65a1a..38ca87f9b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -1,11 +1,13 @@ package com.loopers.infrastructure.order; -import com.loopers.infrastructure.order.OrderJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.LocalDateTime; import java.util.List; public interface OrderJpaRepository extends JpaRepository { List findAllByUserId(String userId); + + List findAllByUserIdAndCreatedAtBetween(String userId, LocalDateTime startAt, LocalDateTime endAt); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java index d4d083e55..28d441088 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -3,12 +3,9 @@ import com.loopers.domain.model.order.*; import com.loopers.domain.model.user.UserId; import com.loopers.domain.repository.OrderRepository; -import com.loopers.infrastructure.order.OrderItemJpaEntity; -import com.loopers.infrastructure.order.OrderJpaEntity; -import com.loopers.infrastructure.order.OrderSnapshotJpaEntity; -import com.loopers.infrastructure.order.OrderJpaRepository; import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -41,6 +38,20 @@ public List findAllByUserId(UserId userId) { .toList(); } + @Override + public List findAllByUserIdAndDateRange(UserId userId, LocalDateTime startAt, LocalDateTime endAt) { + return orderJpaRepository.findAllByUserIdAndCreatedAtBetween(userId.getValue(), startAt, endAt).stream() + .map(this::toDomain) + .toList(); + } + + @Override + public List findAll() { + return orderJpaRepository.findAll().stream() + .map(this::toDomain) + .toList(); + } + private OrderJpaEntity toEntity(Order order) { List itemEntities = order.getItems().stream() .map(this::toItemEntity) diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index 96b125689..ee5fe15f9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -1,7 +1,16 @@ package com.loopers.infrastructure.product; -import com.loopers.infrastructure.product.ProductJpaEntity; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + public interface ProductJpaRepository extends JpaRepository { + + Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); + + Page findAllByDeletedAtIsNull(Pageable pageable); + + List findAllByBrandIdAndDeletedAtIsNull(Long brandId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index d3f4f2ef2..02d049fe8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -5,10 +5,11 @@ import com.loopers.domain.model.product.ProductName; import com.loopers.domain.model.product.Stock; import com.loopers.domain.repository.ProductRepository; -import com.loopers.infrastructure.product.ProductJpaEntity; -import com.loopers.infrastructure.product.ProductJpaRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -33,6 +34,24 @@ public Optional findById(Long id) { .map(this::toDomain); } + @Override + public Page findAllByDeletedAtIsNull(Long brandId, Pageable pageable) { + Page page; + if (brandId != null) { + page = productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, pageable); + } else { + page = productJpaRepository.findAllByDeletedAtIsNull(pageable); + } + return page.map(this::toDomain); + } + + @Override + public List findAllByBrandId(Long brandId) { + return productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId).stream() + .map(this::toDomain) + .toList(); + } + private ProductJpaEntity toEntity(Product product) { return new ProductJpaEntity( product.getId(), diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/PageResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/PageResponse.java new file mode 100644 index 000000000..8ef86a2cd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/PageResponse.java @@ -0,0 +1,22 @@ +package com.loopers.interfaces.api.common; + +import org.springframework.data.domain.Page; + +import java.util.List; +import java.util.function.Function; + +public record PageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages +) { + public static PageResponse from(Page page, Function mapper) { + List content = page.getContent().stream() + .map(mapper) + .toList(); + return new PageResponse<>(content, page.getNumber(), page.getSize(), + page.getTotalElements(), page.getTotalPages()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/dto/LikeResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/dto/LikeResponse.java new file mode 100644 index 000000000..acd3586b1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/dto/LikeResponse.java @@ -0,0 +1,21 @@ +package com.loopers.interfaces.api.like.dto; + +import com.loopers.application.like.LikeQueryUseCase; + +import java.time.LocalDateTime; + +public record LikeResponse( + Long productId, + String productName, + int price, + LocalDateTime likedAt +) { + public static LikeResponse from(LikeQueryUseCase.LikeInfo info) { + return new LikeResponse( + info.productId(), + info.productName(), + info.price(), + info.likedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminController.java new file mode 100644 index 000000000..74afcf1eb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminController.java @@ -0,0 +1,37 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderQueryUseCase; +import com.loopers.interfaces.api.order.dto.OrderDetailResponse; +import com.loopers.interfaces.api.order.dto.OrderSummaryResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api-admin/v1/orders") +public class OrderAdminController { + + private final OrderQueryUseCase orderQueryUseCase; + + public OrderAdminController(OrderQueryUseCase orderQueryUseCase) { + this.orderQueryUseCase = orderQueryUseCase; + } + + @GetMapping + public ResponseEntity> getAllOrders() { + List orders = orderQueryUseCase.getAllOrders().stream() + .map(OrderSummaryResponse::from) + .toList(); + return ResponseEntity.ok(orders); + } + + @GetMapping("/{orderId}") + public ResponseEntity getOrderDetail(@PathVariable Long orderId) { + OrderQueryUseCase.OrderDetail detail = orderQueryUseCase.getOrderDetail(orderId); + return ResponseEntity.ok(OrderDetailResponse.from(detail)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java index a4b6e23e6..5cb4c5fe9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java @@ -1,15 +1,20 @@ package com.loopers.interfaces.api.order; +import com.loopers.application.order.CancelOrderUseCase; import com.loopers.application.order.CreateOrderUseCase; import com.loopers.application.order.OrderQueryUseCase; +import com.loopers.application.order.UpdateDeliveryAddressUseCase; import com.loopers.domain.model.user.UserId; +import com.loopers.interfaces.api.order.dto.DeliveryAddressUpdateRequest; import com.loopers.interfaces.api.order.dto.OrderCreateRequest; import com.loopers.interfaces.api.order.dto.OrderDetailResponse; import com.loopers.interfaces.api.order.dto.OrderSummaryResponse; import jakarta.servlet.http.HttpServletRequest; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; import java.util.List; @RestController @@ -18,10 +23,17 @@ public class OrderController { private final CreateOrderUseCase createOrderUseCase; private final OrderQueryUseCase orderQueryUseCase; + private final CancelOrderUseCase cancelOrderUseCase; + private final UpdateDeliveryAddressUseCase updateDeliveryAddressUseCase; - public OrderController(CreateOrderUseCase createOrderUseCase, OrderQueryUseCase orderQueryUseCase) { + public OrderController(CreateOrderUseCase createOrderUseCase, + OrderQueryUseCase orderQueryUseCase, + CancelOrderUseCase cancelOrderUseCase, + UpdateDeliveryAddressUseCase updateDeliveryAddressUseCase) { this.createOrderUseCase = createOrderUseCase; this.orderQueryUseCase = orderQueryUseCase; + this.cancelOrderUseCase = cancelOrderUseCase; + this.updateDeliveryAddressUseCase = updateDeliveryAddressUseCase; } @PostMapping @@ -33,9 +45,20 @@ public ResponseEntity createOrder(HttpServletRequest request, } @GetMapping - public ResponseEntity> getMyOrders(HttpServletRequest request) { + public ResponseEntity> getMyOrders( + HttpServletRequest request, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startAt, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endAt) { UserId userId = (UserId) request.getAttribute("authenticatedUserId"); - List orders = orderQueryUseCase.getMyOrders(userId).stream() + + List summaries; + if (startAt != null && endAt != null) { + summaries = orderQueryUseCase.getMyOrders(userId, startAt, endAt); + } else { + summaries = orderQueryUseCase.getMyOrders(userId); + } + + List orders = summaries.stream() .map(OrderSummaryResponse::from) .toList(); return ResponseEntity.ok(orders); @@ -48,4 +71,21 @@ public ResponseEntity getOrder(HttpServletRequest request, OrderQueryUseCase.OrderDetail detail = orderQueryUseCase.getOrder(userId, orderId); return ResponseEntity.ok(OrderDetailResponse.from(detail)); } + + @PostMapping("/{orderId}/cancel") + public ResponseEntity cancelOrder(HttpServletRequest request, + @PathVariable Long orderId) { + UserId userId = (UserId) request.getAttribute("authenticatedUserId"); + cancelOrderUseCase.cancelOrder(userId, orderId); + return ResponseEntity.ok().build(); + } + + @PutMapping("/{orderId}/delivery-address") + public ResponseEntity updateDeliveryAddress(HttpServletRequest request, + @PathVariable Long orderId, + @RequestBody DeliveryAddressUpdateRequest addressRequest) { + UserId userId = (UserId) request.getAttribute("authenticatedUserId"); + updateDeliveryAddressUseCase.updateDeliveryAddress(userId, orderId, addressRequest.address()); + return ResponseEntity.ok().build(); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/DeliveryAddressUpdateRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/DeliveryAddressUpdateRequest.java new file mode 100644 index 000000000..1b030252f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/dto/DeliveryAddressUpdateRequest.java @@ -0,0 +1,5 @@ +package com.loopers.interfaces.api.order.dto; + +public record DeliveryAddressUpdateRequest( + String address +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminController.java index 466ea5e13..f4e267f45 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminController.java @@ -4,9 +4,12 @@ import com.loopers.application.product.DeleteProductUseCase; import com.loopers.application.product.ProductQueryUseCase; import com.loopers.application.product.UpdateProductUseCase; +import com.loopers.interfaces.api.common.PageResponse; import com.loopers.interfaces.api.product.dto.ProductCreateRequest; import com.loopers.interfaces.api.product.dto.ProductDetailResponse; +import com.loopers.interfaces.api.product.dto.ProductSummaryResponse; import com.loopers.interfaces.api.product.dto.ProductUpdateRequest; +import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -29,6 +32,16 @@ public ProductAdminController(CreateProductUseCase createProductUseCase, this.productQueryUseCase = productQueryUseCase; } + @GetMapping + public ResponseEntity> getProducts( + @RequestParam(required = false) Long brandId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + Page products = + productQueryUseCase.getProducts(brandId, null, page, size); + return ResponseEntity.ok(PageResponse.from(products, ProductSummaryResponse::from)); + } + @PostMapping public ResponseEntity createProduct(@RequestBody ProductCreateRequest request) { createProductUseCase.createProduct( diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java index dfa606d2e..f2c01cab5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java @@ -1,12 +1,12 @@ package com.loopers.interfaces.api.product; import com.loopers.application.product.ProductQueryUseCase; +import com.loopers.interfaces.api.common.PageResponse; import com.loopers.interfaces.api.product.dto.ProductDetailResponse; +import com.loopers.interfaces.api.product.dto.ProductSummaryResponse; +import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/v1/products") @@ -18,6 +18,17 @@ public ProductController(ProductQueryUseCase productQueryUseCase) { this.productQueryUseCase = productQueryUseCase; } + @GetMapping + public ResponseEntity> getProducts( + @RequestParam(required = false) Long brandId, + @RequestParam(required = false) String sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + Page products = + productQueryUseCase.getProducts(brandId, sort, page, size); + return ResponseEntity.ok(PageResponse.from(products, ProductSummaryResponse::from)); + } + @GetMapping("/{productId}") public ResponseEntity getProduct(@PathVariable Long productId) { ProductQueryUseCase.ProductDetailInfo info = productQueryUseCase.getProduct(productId); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductSummaryResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductSummaryResponse.java new file mode 100644 index 000000000..19b205bc4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductSummaryResponse.java @@ -0,0 +1,23 @@ +package com.loopers.interfaces.api.product.dto; + +import com.loopers.application.product.ProductQueryUseCase; + +public record ProductSummaryResponse( + Long id, + Long brandId, + String brandName, + String name, + int price, + int likeCount +) { + public static ProductSummaryResponse from(ProductQueryUseCase.ProductSummaryInfo info) { + return new ProductSummaryResponse( + info.id(), + info.brandId(), + info.brandName(), + info.name(), + info.price(), + info.likeCount() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java index d3cbd90f8..b035aab67 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java @@ -1,9 +1,11 @@ package com.loopers.interfaces.api.user; +import com.loopers.application.like.LikeQueryUseCase; import com.loopers.application.user.PasswordUpdateUseCase; import com.loopers.application.user.RegisterUseCase; import com.loopers.application.user.UserQueryUseCase; import com.loopers.domain.model.user.UserId; +import com.loopers.interfaces.api.like.dto.LikeResponse; import com.loopers.interfaces.api.user.dto.PasswordUpdateRequest; import com.loopers.interfaces.api.user.dto.UserInfoResponse; import com.loopers.interfaces.api.user.dto.UserRegisterRequest; @@ -12,6 +14,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.List; + @RestController @RequestMapping("/api/v1/users") public class UserController { @@ -19,15 +23,18 @@ public class UserController { private final RegisterUseCase registerUseCase; private final UserQueryUseCase userQueryUseCase; private final PasswordUpdateUseCase passwordUpdateUseCase; + private final LikeQueryUseCase likeQueryUseCase; public UserController( RegisterUseCase registerUseCase, UserQueryUseCase userQueryUseCase, - PasswordUpdateUseCase passwordUpdateUseCase + PasswordUpdateUseCase passwordUpdateUseCase, + LikeQueryUseCase likeQueryUseCase ) { this.registerUseCase = registerUseCase; this.userQueryUseCase = userQueryUseCase; this.passwordUpdateUseCase = passwordUpdateUseCase; + this.likeQueryUseCase = likeQueryUseCase; } @PostMapping("/register") @@ -50,6 +57,15 @@ public ResponseEntity getMyInfo(HttpServletRequest request) { return ResponseEntity.ok(UserInfoResponse.from(userInfo)); } + @GetMapping("/me/likes") + public ResponseEntity> getMyLikes(HttpServletRequest request) { + UserId userId = (UserId) request.getAttribute("authenticatedUserId"); + List likes = likeQueryUseCase.getMyLikes(userId).stream() + .map(LikeResponse::from) + .toList(); + return ResponseEntity.ok(likes); + } + @PutMapping("/me/password") public ResponseEntity updatePassword( HttpServletRequest request, From 3f6c71f24967d0f5d4d8caaeba8d9d9af07ae85a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Tue, 24 Feb 2026 18:09:49 +0900 Subject: [PATCH 16/20] =?UTF-8?q?test:=20=EC=A0=84=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20Unit/Integration/E2E=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(237=EA=B0=9C=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=ED=86=B5=EA=B3=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brand, Product, Like, Order 4개 도메인에 대해 3계층 테스트 작성: - Unit: BrandServiceTest, ProductServiceTest, ProductQueryServiceTest, LikeServiceTest, OrderServiceTest, OrderQueryServiceTest - Integration (MockMvc): BrandApiIntegrationTest, ProductApiIntegrationTest, LikeApiIntegrationTest, OrderApiIntegrationTest - E2E (TestRestTemplate): BrandApiE2ETest, ProductApiE2ETest, LikeApiE2ETest, OrderApiE2ETest Co-Authored-By: Claude Opus 4.6 --- .../application/brand/BrandServiceTest.java | 199 +++++++++++++ .../application/like/LikeServiceTest.java | 201 +++++++++++++ .../order/OrderQueryServiceTest.java | 194 +++++++++++++ .../application/order/OrderServiceTest.java | 229 +++++++++++++++ .../product/ProductQueryServiceTest.java | 158 +++++++++++ .../product/ProductServiceTest.java | 154 ++++++++++ .../interfaces/api/brand/BrandApiE2ETest.java | 156 ++++++++++ .../api/brand/BrandApiIntegrationTest.java | 181 ++++++++++++ .../interfaces/api/like/LikeApiE2ETest.java | 145 ++++++++++ .../api/like/LikeApiIntegrationTest.java | 174 ++++++++++++ .../interfaces/api/order/OrderApiE2ETest.java | 238 ++++++++++++++++ .../api/order/OrderApiIntegrationTest.java | 266 ++++++++++++++++++ .../api/product/ProductApiE2ETest.java | 180 ++++++++++++ .../product/ProductApiIntegrationTest.java | 205 ++++++++++++++ 14 files changed, 2680 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/order/OrderQueryServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/product/ProductQueryServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiIntegrationTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java new file mode 100644 index 000000000..a002bdd43 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java @@ -0,0 +1,199 @@ +package com.loopers.application.brand; + +import com.loopers.domain.model.brand.Brand; +import com.loopers.domain.model.brand.BrandName; +import com.loopers.domain.model.product.Price; +import com.loopers.domain.model.product.Product; +import com.loopers.domain.model.product.ProductName; +import com.loopers.domain.model.product.Stock; +import com.loopers.domain.repository.BrandRepository; +import com.loopers.domain.repository.ProductRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class BrandServiceTest { + + private BrandRepository brandRepository; + private ProductRepository productRepository; + private BrandService service; + + @BeforeEach + void setUp() { + brandRepository = mock(BrandRepository.class); + productRepository = mock(ProductRepository.class); + service = new BrandService(brandRepository, productRepository); + } + + @Nested + @DisplayName("브랜드 생성") + class CreateBrand { + + @Test + @DisplayName("브랜드 생성 성공") + void createBrand_success() { + // given + when(brandRepository.existsByName(any(BrandName.class))).thenReturn(false); + + // when & then + assertThatNoException() + .isThrownBy(() -> service.createBrand("나이키", "스포츠 브랜드")); + + verify(brandRepository).save(any(Brand.class)); + } + + @Test + @DisplayName("중복 이름으로 생성시 예외") + void createBrand_fail_duplicateName() { + // given + when(brandRepository.existsByName(any(BrandName.class))).thenReturn(true); + + // when & then + assertThatThrownBy(() -> service.createBrand("나이키", "스포츠 브랜드")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("이미 존재하는 브랜드 이름"); + + verify(brandRepository, never()).save(any(Brand.class)); + } + } + + @Nested + @DisplayName("브랜드 수정") + class UpdateBrand { + + @Test + @DisplayName("브랜드 수정 성공") + void updateBrand_success() { + // given + Brand brand = createBrand(1L, "나이키"); + when(brandRepository.findById(1L)).thenReturn(Optional.of(brand)); + + // when & then + assertThatNoException() + .isThrownBy(() -> service.updateBrand(1L, "아디다스", "변경된 설명")); + + verify(brandRepository).save(any(Brand.class)); + } + + @Test + @DisplayName("존재하지 않는 브랜드 수정시 예외") + void updateBrand_fail_notFound() { + // given + when(brandRepository.findById(999L)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.updateBrand(999L, "아디다스", "설명")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("브랜드를 찾을 수 없습니다"); + } + } + + @Nested + @DisplayName("브랜드 삭제") + class DeleteBrand { + + @Test + @DisplayName("브랜드 삭제 성공 - 하위 상품도 일괄 삭제") + void deleteBrand_success_cascadeProducts() { + // given + Brand brand = createBrand(1L, "나이키"); + Product product1 = createProduct(1L, 1L); + Product product2 = createProduct(2L, 1L); + + when(brandRepository.findById(1L)).thenReturn(Optional.of(brand)); + when(productRepository.findAllByBrandId(1L)).thenReturn(List.of(product1, product2)); + + // when + service.deleteBrand(1L); + + // then + verify(brandRepository).save(any(Brand.class)); + verify(productRepository, times(2)).save(any(Product.class)); + } + + @Test + @DisplayName("브랜드 삭제 - 하위 상품 없는 경우") + void deleteBrand_success_noProducts() { + // given + Brand brand = createBrand(1L, "나이키"); + when(brandRepository.findById(1L)).thenReturn(Optional.of(brand)); + when(productRepository.findAllByBrandId(1L)).thenReturn(List.of()); + + // when + service.deleteBrand(1L); + + // then + verify(brandRepository).save(any(Brand.class)); + verify(productRepository, never()).save(any(Product.class)); + } + + @Test + @DisplayName("존재하지 않는 브랜드 삭제시 예외") + void deleteBrand_fail_notFound() { + // given + when(brandRepository.findById(999L)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.deleteBrand(999L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("브랜드를 찾을 수 없습니다"); + } + } + + @Nested + @DisplayName("브랜드 조회") + class QueryBrand { + + @Test + @DisplayName("단건 조회 성공") + void getBrand_success() { + // given + Brand brand = createBrand(1L, "나이키"); + when(brandRepository.findById(1L)).thenReturn(Optional.of(brand)); + + // when + var result = service.getBrand(1L); + + // then + assertThat(result.id()).isEqualTo(1L); + assertThat(result.name()).isEqualTo("나이키"); + } + + @Test + @DisplayName("목록 조회 - 삭제된 브랜드 제외") + void getBrands_excludeDeleted() { + // given + Brand active = createBrand(1L, "나이키"); + Brand deleted = Brand.reconstitute(2L, BrandName.of("삭제됨"), "설명", + LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now()); + + when(brandRepository.findAll()).thenReturn(List.of(active, deleted)); + + // when + var result = service.getBrands(); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).name()).isEqualTo("나이키"); + } + } + + private Brand createBrand(Long id, String name) { + return Brand.reconstitute(id, BrandName.of(name), "설명", + LocalDateTime.now(), LocalDateTime.now(), null); + } + + private Product createProduct(Long id, Long brandId) { + return Product.reconstitute(id, brandId, ProductName.of("상품" + id), Price.of(10000), + Stock.of(100), 0, "설명", LocalDateTime.now(), LocalDateTime.now(), null); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java new file mode 100644 index 000000000..bab118662 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java @@ -0,0 +1,201 @@ +package com.loopers.application.like; + +import com.loopers.domain.model.like.Like; +import com.loopers.domain.model.product.Price; +import com.loopers.domain.model.product.Product; +import com.loopers.domain.model.product.ProductName; +import com.loopers.domain.model.product.Stock; +import com.loopers.domain.model.user.UserId; +import com.loopers.domain.repository.LikeRepository; +import com.loopers.domain.repository.ProductRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class LikeServiceTest { + + private LikeRepository likeRepository; + private ProductRepository productRepository; + private LikeService service; + + @BeforeEach + void setUp() { + likeRepository = mock(LikeRepository.class); + productRepository = mock(ProductRepository.class); + service = new LikeService(likeRepository, productRepository); + } + + @Nested + @DisplayName("좋아요") + class LikeTest { + + @Test + @DisplayName("좋아요 성공") + void like_success() { + // given + UserId userId = UserId.of("test1234"); + Product product = createProduct(1L, 0); + + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + when(likeRepository.existsByUserIdAndProductId(userId, 1L)).thenReturn(false); + + // when + service.like(userId, 1L); + + // then + verify(likeRepository).save(any(Like.class)); + verify(productRepository).save(any(Product.class)); + } + + @Test + @DisplayName("이미 좋아요한 경우 무시 (멱등성)") + void like_alreadyLiked_ignored() { + // given + UserId userId = UserId.of("test1234"); + Product product = createProduct(1L, 1); + + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + when(likeRepository.existsByUserIdAndProductId(userId, 1L)).thenReturn(true); + + // when + service.like(userId, 1L); + + // then + verify(likeRepository, never()).save(any(Like.class)); + verify(productRepository, never()).save(any(Product.class)); + } + + @Test + @DisplayName("존재하지 않는 상품에 좋아요시 예외") + void like_fail_productNotFound() { + // given + UserId userId = UserId.of("test1234"); + when(productRepository.findById(999L)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.like(userId, 999L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("상품을 찾을 수 없습니다"); + } + } + + @Nested + @DisplayName("좋아요 취소") + class UnlikeTest { + + @Test + @DisplayName("좋아요 취소 성공") + void unlike_success() { + // given + UserId userId = UserId.of("test1234"); + Product product = createProduct(1L, 1); + + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + when(likeRepository.existsByUserIdAndProductId(userId, 1L)).thenReturn(true); + + // when + service.unlike(userId, 1L); + + // then + verify(likeRepository).deleteByUserIdAndProductId(userId, 1L); + verify(productRepository).save(any(Product.class)); + } + + @Test + @DisplayName("좋아요하지 않은 경우 무시") + void unlike_notLiked_ignored() { + // given + UserId userId = UserId.of("test1234"); + Product product = createProduct(1L, 0); + + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + when(likeRepository.existsByUserIdAndProductId(userId, 1L)).thenReturn(false); + + // when + service.unlike(userId, 1L); + + // then + verify(likeRepository, never()).deleteByUserIdAndProductId(any(), any()); + verify(productRepository, never()).save(any(Product.class)); + } + } + + @Nested + @DisplayName("좋아요 목록 조회") + class GetMyLikes { + + @Test + @DisplayName("좋아요 목록 조회 성공") + void getMyLikes_success() { + // given + UserId userId = UserId.of("test1234"); + Like like1 = Like.reconstitute(1L, userId, 1L, LocalDateTime.now()); + Like like2 = Like.reconstitute(2L, userId, 2L, LocalDateTime.now()); + Product product1 = createProduct(1L, 5); + Product product2 = createProduct(2L, 3); + + when(likeRepository.findAllByUserId(userId)).thenReturn(List.of(like1, like2)); + when(productRepository.findById(1L)).thenReturn(Optional.of(product1)); + when(productRepository.findById(2L)).thenReturn(Optional.of(product2)); + + // when + var result = service.getMyLikes(userId); + + // then + assertThat(result).hasSize(2); + } + + @Test + @DisplayName("삭제된 상품은 목록에서 제외") + void getMyLikes_excludeDeletedProducts() { + // given + UserId userId = UserId.of("test1234"); + Like like1 = Like.reconstitute(1L, userId, 1L, LocalDateTime.now()); + Like like2 = Like.reconstitute(2L, userId, 2L, LocalDateTime.now()); + + Product activeProduct = createProduct(1L, 5); + Product deletedProduct = Product.reconstitute(2L, 1L, ProductName.of("삭제됨"), + Price.of(10000), Stock.of(0), 0, "설명", + LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now()); + + when(likeRepository.findAllByUserId(userId)).thenReturn(List.of(like1, like2)); + when(productRepository.findById(1L)).thenReturn(Optional.of(activeProduct)); + when(productRepository.findById(2L)).thenReturn(Optional.of(deletedProduct)); + + // when + var result = service.getMyLikes(userId); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).productId()).isEqualTo(1L); + } + + @Test + @DisplayName("좋아요 목록이 비어있는 경우") + void getMyLikes_empty() { + // given + UserId userId = UserId.of("test1234"); + when(likeRepository.findAllByUserId(userId)).thenReturn(List.of()); + + // when + var result = service.getMyLikes(userId); + + // then + assertThat(result).isEmpty(); + } + } + + private Product createProduct(Long id, int likeCount) { + return Product.reconstitute(id, 1L, ProductName.of("상품" + id), Price.of(10000), + Stock.of(100), likeCount, "설명", LocalDateTime.now(), LocalDateTime.now(), null); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderQueryServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderQueryServiceTest.java new file mode 100644 index 000000000..b554dc4a7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderQueryServiceTest.java @@ -0,0 +1,194 @@ +package com.loopers.application.order; + +import com.loopers.domain.model.order.*; +import com.loopers.domain.model.user.UserId; +import com.loopers.domain.repository.OrderRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +class OrderQueryServiceTest { + + private OrderRepository orderRepository; + private OrderQueryService service; + + @BeforeEach + void setUp() { + orderRepository = mock(OrderRepository.class); + service = new OrderQueryService(orderRepository); + } + + @Nested + @DisplayName("내 주문 목록 조회") + class GetMyOrders { + + @Test + @DisplayName("주문 목록 조회 성공") + void getMyOrders_success() { + // given + UserId userId = UserId.of("test1234"); + Order order1 = createOrder(1L, userId, OrderStatus.PAYMENT_COMPLETED); + Order order2 = createOrder(2L, userId, OrderStatus.SHIPPING); + + when(orderRepository.findAllByUserId(userId)).thenReturn(List.of(order1, order2)); + + // when + var result = service.getMyOrders(userId); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).id()).isEqualTo(1L); + assertThat(result.get(0).status()).isEqualTo("PAYMENT_COMPLETED"); + } + + @Test + @DisplayName("기간 필터 조회 성공") + void getMyOrders_withDateRange() { + // given + UserId userId = UserId.of("test1234"); + LocalDate start = LocalDate.of(2025, 1, 1); + LocalDate end = LocalDate.of(2025, 12, 31); + + Order order = createOrder(1L, userId, OrderStatus.PAYMENT_COMPLETED); + when(orderRepository.findAllByUserIdAndDateRange(eq(userId), any(), any())) + .thenReturn(List.of(order)); + + // when + var result = service.getMyOrders(userId, start, end); + + // then + assertThat(result).hasSize(1); + verify(orderRepository).findAllByUserIdAndDateRange(eq(userId), any(), any()); + } + + @Test + @DisplayName("주문 없는 경우 빈 목록") + void getMyOrders_empty() { + // given + UserId userId = UserId.of("test1234"); + when(orderRepository.findAllByUserId(userId)).thenReturn(List.of()); + + // when + var result = service.getMyOrders(userId); + + // then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("주문 상세 조회") + class GetOrder { + + @Test + @DisplayName("내 주문 상세 조회 성공") + void getOrder_success() { + // given + UserId userId = UserId.of("test1234"); + Order order = createOrder(1L, userId, OrderStatus.PAYMENT_COMPLETED); + + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); + + // when + var result = service.getOrder(userId, 1L); + + // then + assertThat(result.id()).isEqualTo(1L); + assertThat(result.receiverName()).isEqualTo("홍길동"); + assertThat(result.status()).isEqualTo("PAYMENT_COMPLETED"); + assertThat(result.items()).hasSize(1); + } + + @Test + @DisplayName("다른 사용자 주문 조회시 예외") + void getOrder_fail_notOwner() { + // given + UserId userId = UserId.of("test1234"); + UserId otherUser = UserId.of("other123"); + Order order = createOrder(1L, otherUser, OrderStatus.PAYMENT_COMPLETED); + + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); + + // when & then + assertThatThrownBy(() -> service.getOrder(userId, 1L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("주문을 찾을 수 없습니다"); + } + + @Test + @DisplayName("존재하지 않는 주문 조회시 예외") + void getOrder_fail_notFound() { + // given + UserId userId = UserId.of("test1234"); + when(orderRepository.findById(999L)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.getOrder(userId, 999L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("주문을 찾을 수 없습니다"); + } + } + + @Nested + @DisplayName("관리자 주문 조회") + class AdminQuery { + + @Test + @DisplayName("전체 주문 목록 조회") + void getAllOrders_success() { + // given + UserId user1 = UserId.of("user0001"); + UserId user2 = UserId.of("user0002"); + Order order1 = createOrder(1L, user1, OrderStatus.PAYMENT_COMPLETED); + Order order2 = createOrder(2L, user2, OrderStatus.SHIPPING); + + when(orderRepository.findAll()).thenReturn(List.of(order1, order2)); + + // when + var result = service.getAllOrders(); + + // then + assertThat(result).hasSize(2); + } + + @Test + @DisplayName("관리자 주문 상세 조회 (userId 검증 없음)") + void getOrderDetail_success() { + // given + UserId userId = UserId.of("test1234"); + Order order = createOrder(1L, userId, OrderStatus.DELIVERED); + + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); + + // when + var result = service.getOrderDetail(1L); + + // then + assertThat(result.id()).isEqualTo(1L); + assertThat(result.status()).isEqualTo("DELIVERED"); + } + } + + private Order createOrder(Long id, UserId userId, OrderStatus status) { + List items = List.of( + OrderItem.reconstitute(1L, 1L, 2, Money.of(50000)) + ); + return Order.reconstitute(id, userId, items, null, + ReceiverName.of("홍길동"), Address.of("서울시 강남구"), + "배송 요청", PaymentMethod.CARD, + Money.of(100000), Money.zero(), Money.of(100000), + status, LocalDate.now().plusDays(3), + LocalDateTime.now(), LocalDateTime.now()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java new file mode 100644 index 000000000..0c3b79d02 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java @@ -0,0 +1,229 @@ +package com.loopers.application.order; + +import com.loopers.domain.model.order.*; +import com.loopers.domain.model.product.Price; +import com.loopers.domain.model.product.Product; +import com.loopers.domain.model.product.ProductName; +import com.loopers.domain.model.product.Stock; +import com.loopers.domain.model.user.UserId; +import com.loopers.domain.repository.OrderRepository; +import com.loopers.domain.repository.ProductRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class OrderServiceTest { + + private OrderRepository orderRepository; + private ProductRepository productRepository; + private OrderService service; + + @BeforeEach + void setUp() { + orderRepository = mock(OrderRepository.class); + productRepository = mock(ProductRepository.class); + service = new OrderService(orderRepository, productRepository); + } + + @Nested + @DisplayName("주문 생성") + class CreateOrder { + + @Test + @DisplayName("주문 생성 성공") + void createOrder_success() { + // given + UserId userId = UserId.of("test1234"); + Product product = createProduct(1L, 50000, 100); + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + when(productRepository.save(any(Product.class))).thenReturn(product); + + var command = new CreateOrderUseCase.OrderCommand( + List.of(new CreateOrderUseCase.OrderItemCommand(1L, 2)), + "홍길동", + "서울시 강남구", + "문 앞에 놓아주세요", + "CARD", + LocalDate.now().plusDays(3) + ); + + // when & then + assertThatNoException() + .isThrownBy(() -> service.createOrder(userId, command)); + + verify(productRepository).save(any(Product.class)); + verify(orderRepository).save(any(Order.class)); + } + + @Test + @DisplayName("존재하지 않는 상품으로 주문시 예외") + void createOrder_fail_productNotFound() { + // given + UserId userId = UserId.of("test1234"); + when(productRepository.findById(999L)).thenReturn(Optional.empty()); + + var command = new CreateOrderUseCase.OrderCommand( + List.of(new CreateOrderUseCase.OrderItemCommand(999L, 1)), + "홍길동", "서울시", "요청사항", "CARD", LocalDate.now() + ); + + // when & then + assertThatThrownBy(() -> service.createOrder(userId, command)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("상품을 찾을 수 없습니다"); + } + + @Test + @DisplayName("재고 부족시 예외") + void createOrder_fail_insufficientStock() { + // given + UserId userId = UserId.of("test1234"); + Product product = createProduct(1L, 50000, 1); + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + + var command = new CreateOrderUseCase.OrderCommand( + List.of(new CreateOrderUseCase.OrderItemCommand(1L, 100)), + "홍길동", "서울시", "요청사항", "CARD", LocalDate.now() + ); + + // when & then + assertThatThrownBy(() -> service.createOrder(userId, command)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("재고가 부족합니다"); + } + } + + @Nested + @DisplayName("주문 취소") + class CancelOrder { + + @Test + @DisplayName("주문 취소 성공 - 재고 복원") + void cancelOrder_success() { + // given + UserId userId = UserId.of("test1234"); + Order order = createOrder(1L, userId, OrderStatus.PAYMENT_COMPLETED); + Product product = createProduct(1L, 50000, 98); + + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + + // when + service.cancelOrder(userId, 1L); + + // then + verify(orderRepository).save(any(Order.class)); + verify(productRepository).save(any(Product.class)); + } + + @Test + @DisplayName("다른 사용자 주문 취소시 예외") + void cancelOrder_fail_notOwner() { + // given + UserId userId = UserId.of("test1234"); + UserId otherUser = UserId.of("other123"); + Order order = createOrder(1L, otherUser, OrderStatus.PAYMENT_COMPLETED); + + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); + + // when & then + assertThatThrownBy(() -> service.cancelOrder(userId, 1L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("주문을 찾을 수 없습니다"); + } + + @Test + @DisplayName("배송중 주문 취소시 예외") + void cancelOrder_fail_shipping() { + // given + UserId userId = UserId.of("test1234"); + Order order = createOrder(1L, userId, OrderStatus.SHIPPING); + + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); + + // when & then + assertThatThrownBy(() -> service.cancelOrder(userId, 1L)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("주문을 취소할 수 없습니다"); + } + } + + @Nested + @DisplayName("배송지 변경") + class UpdateDeliveryAddress { + + @Test + @DisplayName("배송지 변경 성공") + void updateDeliveryAddress_success() { + // given + UserId userId = UserId.of("test1234"); + Order order = createOrder(1L, userId, OrderStatus.PAYMENT_COMPLETED); + + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); + + // when & then + assertThatNoException() + .isThrownBy(() -> service.updateDeliveryAddress(userId, 1L, "새로운 주소")); + + verify(orderRepository).save(any(Order.class)); + } + + @Test + @DisplayName("다른 사용자 주문 배송지 변경시 예외") + void updateDeliveryAddress_fail_notOwner() { + // given + UserId userId = UserId.of("test1234"); + UserId otherUser = UserId.of("other123"); + Order order = createOrder(1L, otherUser, OrderStatus.PAYMENT_COMPLETED); + + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); + + // when & then + assertThatThrownBy(() -> service.updateDeliveryAddress(userId, 1L, "새 주소")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("주문을 찾을 수 없습니다"); + } + + @Test + @DisplayName("배송중 주문 배송지 변경시 예외") + void updateDeliveryAddress_fail_shipping() { + // given + UserId userId = UserId.of("test1234"); + Order order = createOrder(1L, userId, OrderStatus.SHIPPING); + + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); + + // when & then + assertThatThrownBy(() -> service.updateDeliveryAddress(userId, 1L, "새 주소")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("배송지를 변경할 수 없습니다"); + } + } + + private Product createProduct(Long id, int price, int stock) { + return Product.reconstitute(id, 1L, ProductName.of("상품" + id), Price.of(price), + Stock.of(stock), 0, "설명", LocalDateTime.now(), LocalDateTime.now(), null); + } + + private Order createOrder(Long id, UserId userId, OrderStatus status) { + List items = List.of( + OrderItem.reconstitute(1L, 1L, 2, Money.of(50000)) + ); + return Order.reconstitute(id, userId, items, null, + ReceiverName.of("홍길동"), Address.of("서울시 강남구"), + "문 앞에 놓아주세요", PaymentMethod.CARD, + Money.of(100000), Money.zero(), Money.of(100000), + status, LocalDate.now().plusDays(3), + LocalDateTime.now(), LocalDateTime.now()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductQueryServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductQueryServiceTest.java new file mode 100644 index 000000000..3d7423849 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductQueryServiceTest.java @@ -0,0 +1,158 @@ +package com.loopers.application.product; + +import com.loopers.domain.model.brand.Brand; +import com.loopers.domain.model.brand.BrandName; +import com.loopers.domain.model.product.Price; +import com.loopers.domain.model.product.Product; +import com.loopers.domain.model.product.ProductName; +import com.loopers.domain.model.product.Stock; +import com.loopers.domain.repository.BrandRepository; +import com.loopers.domain.repository.ProductRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +class ProductQueryServiceTest { + + private ProductRepository productRepository; + private BrandRepository brandRepository; + private ProductQueryService service; + + @BeforeEach + void setUp() { + productRepository = mock(ProductRepository.class); + brandRepository = mock(BrandRepository.class); + service = new ProductQueryService(productRepository, brandRepository); + } + + @Nested + @DisplayName("상품 단건 조회") + class GetProduct { + + @Test + @DisplayName("상품 상세 조회 성공") + void getProduct_success() { + // given + Product product = createProduct(1L, 1L, "운동화", 50000); + Brand brand = createBrand(1L, "나이키"); + + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + when(brandRepository.findById(1L)).thenReturn(Optional.of(brand)); + + // when + var result = service.getProduct(1L); + + // then + assertThat(result.id()).isEqualTo(1L); + assertThat(result.brandName()).isEqualTo("나이키"); + assertThat(result.name()).isEqualTo("운동화"); + assertThat(result.price()).isEqualTo(50000); + } + + @Test + @DisplayName("삭제된 상품 조회시 예외") + void getProduct_fail_deleted() { + // given + Product deleted = Product.reconstitute(1L, 1L, ProductName.of("삭제됨"), Price.of(10000), + Stock.of(0), 0, "설명", LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now()); + when(productRepository.findById(1L)).thenReturn(Optional.of(deleted)); + + // when & then + assertThatThrownBy(() -> service.getProduct(1L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("상품을 찾을 수 없습니다"); + } + + @Test + @DisplayName("존재하지 않는 상품 조회시 예외") + void getProduct_fail_notFound() { + // given + when(productRepository.findById(999L)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.getProduct(999L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("상품을 찾을 수 없습니다"); + } + } + + @Nested + @DisplayName("상품 목록 조회") + class GetProducts { + + @Test + @DisplayName("전체 목록 조회 성공") + void getProducts_success() { + // given + Product product1 = createProduct(1L, 1L, "운동화", 50000); + Product product2 = createProduct(2L, 1L, "슬리퍼", 30000); + Brand brand = createBrand(1L, "나이키"); + + Page page = new PageImpl<>(List.of(product1, product2), PageRequest.of(0, 20), 2); + when(productRepository.findAllByDeletedAtIsNull(eq(null), any())).thenReturn(page); + when(brandRepository.findById(1L)).thenReturn(Optional.of(brand)); + + // when + var result = service.getProducts(null, null, 0, 20); + + // then + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent().get(0).brandName()).isEqualTo("나이키"); + } + + @Test + @DisplayName("브랜드 필터 조회") + void getProducts_withBrandFilter() { + // given + Product product = createProduct(1L, 1L, "운동화", 50000); + Brand brand = createBrand(1L, "나이키"); + + Page page = new PageImpl<>(List.of(product), PageRequest.of(0, 20), 1); + when(productRepository.findAllByDeletedAtIsNull(eq(1L), any())).thenReturn(page); + when(brandRepository.findById(1L)).thenReturn(Optional.of(brand)); + + // when + var result = service.getProducts(1L, null, 0, 20); + + // then + assertThat(result.getContent()).hasSize(1); + } + + @Test + @DisplayName("가격 오름차순 정렬") + void getProducts_sortByPriceAsc() { + // given + Page page = new PageImpl<>(List.of(), PageRequest.of(0, 20), 0); + when(productRepository.findAllByDeletedAtIsNull(eq(null), any())).thenReturn(page); + + // when + service.getProducts(null, "price_asc", 0, 20); + + // then + verify(productRepository).findAllByDeletedAtIsNull(eq(null), any()); + } + } + + private Product createProduct(Long id, Long brandId, String name, int price) { + return Product.reconstitute(id, brandId, ProductName.of(name), Price.of(price), + Stock.of(100), 5, "설명", LocalDateTime.now(), LocalDateTime.now(), null); + } + + private Brand createBrand(Long id, String name) { + return Brand.reconstitute(id, BrandName.of(name), "설명", + LocalDateTime.now(), LocalDateTime.now(), null); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java new file mode 100644 index 000000000..062757cf9 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java @@ -0,0 +1,154 @@ +package com.loopers.application.product; + +import com.loopers.domain.model.brand.Brand; +import com.loopers.domain.model.brand.BrandName; +import com.loopers.domain.model.product.Price; +import com.loopers.domain.model.product.Product; +import com.loopers.domain.model.product.ProductName; +import com.loopers.domain.model.product.Stock; +import com.loopers.domain.repository.BrandRepository; +import com.loopers.domain.repository.ProductRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class ProductServiceTest { + + private ProductRepository productRepository; + private BrandRepository brandRepository; + private ProductService service; + + @BeforeEach + void setUp() { + productRepository = mock(ProductRepository.class); + brandRepository = mock(BrandRepository.class); + service = new ProductService(productRepository, brandRepository); + } + + @Nested + @DisplayName("상품 생성") + class CreateProduct { + + @Test + @DisplayName("상품 생성 성공") + void createProduct_success() { + // given + Brand brand = createBrand(1L); + when(brandRepository.findById(1L)).thenReturn(Optional.of(brand)); + + // when & then + assertThatNoException() + .isThrownBy(() -> service.createProduct(1L, "운동화", 50000, 100, "좋은 운동화")); + + verify(productRepository).save(any(Product.class)); + } + + @Test + @DisplayName("존재하지 않는 브랜드로 생성시 예외") + void createProduct_fail_brandNotFound() { + // given + when(brandRepository.findById(999L)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.createProduct(999L, "운동화", 50000, 100, "좋은 운동화")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("존재하지 않는 브랜드"); + + verify(productRepository, never()).save(any(Product.class)); + } + + @Test + @DisplayName("삭제된 브랜드로 생성시 예외") + void createProduct_fail_deletedBrand() { + // given + Brand deleted = Brand.reconstitute(1L, BrandName.of("삭제됨"), "설명", + LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now()); + when(brandRepository.findById(1L)).thenReturn(Optional.of(deleted)); + + // when & then + assertThatThrownBy(() -> service.createProduct(1L, "운동화", 50000, 100, "좋은 운동화")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("존재하지 않는 브랜드"); + } + } + + @Nested + @DisplayName("상품 수정") + class UpdateProduct { + + @Test + @DisplayName("상품 수정 성공") + void updateProduct_success() { + // given + Product product = createProduct(1L, 1L); + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + + // when & then + assertThatNoException() + .isThrownBy(() -> service.updateProduct(1L, "새 이름", 60000, 200, "변경된 설명")); + + verify(productRepository).save(any(Product.class)); + } + + @Test + @DisplayName("존재하지 않는 상품 수정시 예외") + void updateProduct_fail_notFound() { + // given + when(productRepository.findById(999L)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.updateProduct(999L, "새 이름", 60000, 200, "설명")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("상품을 찾을 수 없습니다"); + } + } + + @Nested + @DisplayName("상품 삭제") + class DeleteProduct { + + @Test + @DisplayName("상품 삭제 성공") + void deleteProduct_success() { + // given + Product product = createProduct(1L, 1L); + when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + + // when + service.deleteProduct(1L); + + // then + verify(productRepository).save(any(Product.class)); + } + + @Test + @DisplayName("존재하지 않는 상품 삭제시 예외") + void deleteProduct_fail_notFound() { + // given + when(productRepository.findById(999L)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.deleteProduct(999L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("상품을 찾을 수 없습니다"); + } + } + + private Brand createBrand(Long id) { + return Brand.reconstitute(id, BrandName.of("나이키"), "스포츠 브랜드", + LocalDateTime.now(), LocalDateTime.now(), null); + } + + private Product createProduct(Long id, Long brandId) { + return Product.reconstitute(id, brandId, ProductName.of("운동화"), Price.of(50000), + Stock.of(100), 0, "좋은 운동화", LocalDateTime.now(), LocalDateTime.now(), null); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java new file mode 100644 index 000000000..e542c199a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiE2ETest.java @@ -0,0 +1,156 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.interfaces.api.brand.dto.BrandCreateRequest; +import com.loopers.interfaces.api.brand.dto.BrandResponse; +import com.loopers.interfaces.api.brand.dto.BrandUpdateRequest; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.context.annotation.Import; +import org.springframework.http.*; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(MySqlTestContainersConfig.class) +class BrandApiE2ETest { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String ADMIN_BASE_URL = "/api-admin/v1/brands"; + private static final String PUBLIC_BASE_URL = "/api/v1/brands"; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("E2E: 브랜드 CRUD 시나리오") + class BrandCrudE2E { + + @Test + @DisplayName("브랜드 생성 → 조회 성공") + void create_then_get() { + // given + var request = new BrandCreateRequest("나이키", "스포츠 브랜드"); + + // when - 생성 + ResponseEntity createResponse = restTemplate.exchange( + ADMIN_BASE_URL, + HttpMethod.POST, + new HttpEntity<>(request, createAdminHeaders()), + Void.class + ); + + // then + assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // when - 조회 + ResponseEntity getResponse = restTemplate.getForEntity( + PUBLIC_BASE_URL + "/1", + BrandResponse.class + ); + + // then + assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(getResponse.getBody()).isNotNull(); + assertThat(getResponse.getBody().name()).isEqualTo("나이키"); + } + + @Test + @DisplayName("브랜드 생성 → 수정 → 조회 확인") + void create_update_then_get() { + // given + createBrand("나이키", "원래 설명"); + + // when - 수정 + var updateRequest = new BrandUpdateRequest("아디다스", "변경된 설명"); + ResponseEntity updateResponse = restTemplate.exchange( + ADMIN_BASE_URL + "/1", + HttpMethod.PUT, + new HttpEntity<>(updateRequest, createAdminHeaders()), + Void.class + ); + assertThat(updateResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // then - 조회 + ResponseEntity getResponse = restTemplate.getForEntity( + PUBLIC_BASE_URL + "/1", + BrandResponse.class + ); + assertThat(getResponse.getBody().name()).isEqualTo("아디다스"); + assertThat(getResponse.getBody().description()).isEqualTo("변경된 설명"); + } + + @Test + @DisplayName("브랜드 생성 → 삭제 → 조회 실패") + void create_delete_then_getFail() { + // given + createBrand("나이키", "스포츠 브랜드"); + + // when - 삭제 + ResponseEntity deleteResponse = restTemplate.exchange( + ADMIN_BASE_URL + "/1", + HttpMethod.DELETE, + new HttpEntity<>(createAdminHeaders()), + Void.class + ); + assertThat(deleteResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // then - 삭제된 브랜드 조회 시 실패 + ResponseEntity getResponse = restTemplate.getForEntity( + PUBLIC_BASE_URL + "/1", + String.class + ); + assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @Nested + @DisplayName("E2E: 관리자 인증 시나리오") + class AdminAuthE2E { + + @Test + @DisplayName("관리자 인증 없이 브랜드 생성 실패") + void createBrand_unauthorized() { + var request = new BrandCreateRequest("나이키", "스포츠 브랜드"); + + ResponseEntity response = restTemplate.postForEntity( + ADMIN_BASE_URL, + request, + String.class + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + private HttpHeaders createAdminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } + + private void createBrand(String name, String description) { + var request = new BrandCreateRequest(name, description); + ResponseEntity response = restTemplate.exchange( + ADMIN_BASE_URL, + HttpMethod.POST, + new HttpEntity<>(request, createAdminHeaders()), + Void.class + ); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiIntegrationTest.java new file mode 100644 index 000000000..ca1bf2f56 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandApiIntegrationTest.java @@ -0,0 +1,181 @@ +package com.loopers.interfaces.api.brand; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.interfaces.api.brand.dto.BrandCreateRequest; +import com.loopers.interfaces.api.brand.dto.BrandUpdateRequest; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(MySqlTestContainersConfig.class) +class BrandApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String ADMIN_BASE_URL = "/api-admin/v1/brands"; + private static final String PUBLIC_BASE_URL = "/api/v1/brands"; + private static final String ADMIN_HEADER = "X-Loopers-Ldap"; + private static final String ADMIN_VALUE = "loopers.admin"; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("브랜드 생성 API") + class CreateBrandApi { + + @Test + @DisplayName("브랜드 생성 성공") + void createBrand_success() throws Exception { + var request = new BrandCreateRequest("나이키", "스포츠 브랜드"); + + mockMvc.perform(post(ADMIN_BASE_URL) + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("중복 이름으로 생성 시 실패") + void createBrand_fail_duplicate() throws Exception { + var request = new BrandCreateRequest("나이키", "스포츠 브랜드"); + + mockMvc.perform(post(ADMIN_BASE_URL) + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + mockMvc.perform(post(ADMIN_BASE_URL) + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("관리자 인증 없이 생성 시 실패") + void createBrand_fail_unauthorized() throws Exception { + var request = new BrandCreateRequest("나이키", "스포츠 브랜드"); + + mockMvc.perform(post(ADMIN_BASE_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("브랜드 수정 API") + class UpdateBrandApi { + + @Test + @DisplayName("브랜드 수정 성공") + void updateBrand_success() throws Exception { + createBrand("나이키", "스포츠 브랜드"); + + var updateRequest = new BrandUpdateRequest("아디다스", "변경된 설명"); + + mockMvc.perform(put(ADMIN_BASE_URL + "/1") + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isOk()); + + // 변경 확인 + mockMvc.perform(get(ADMIN_BASE_URL + "/1") + .header(ADMIN_HEADER, ADMIN_VALUE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("아디다스")) + .andExpect(jsonPath("$.description").value("변경된 설명")); + } + } + + @Nested + @DisplayName("브랜드 삭제 API") + class DeleteBrandApi { + + @Test + @DisplayName("브랜드 삭제 성공") + void deleteBrand_success() throws Exception { + createBrand("나이키", "스포츠 브랜드"); + + mockMvc.perform(delete(ADMIN_BASE_URL + "/1") + .header(ADMIN_HEADER, ADMIN_VALUE)) + .andExpect(status().isOk()); + + // 삭제 확인 + mockMvc.perform(get(ADMIN_BASE_URL + "/1") + .header(ADMIN_HEADER, ADMIN_VALUE)) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("브랜드 조회 API") + class QueryBrandApi { + + @Test + @DisplayName("브랜드 단건 조회 성공") + void getBrand_success() throws Exception { + createBrand("나이키", "스포츠 브랜드"); + + mockMvc.perform(get(PUBLIC_BASE_URL + "/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("나이키")) + .andExpect(jsonPath("$.description").value("스포츠 브랜드")); + } + + @Test + @DisplayName("브랜드 목록 조회 성공") + void getBrands_success() throws Exception { + createBrand("나이키", "스포츠 브랜드"); + createBrand("아디다스", "독일 스포츠 브랜드"); + + mockMvc.perform(get(ADMIN_BASE_URL) + .header(ADMIN_HEADER, ADMIN_VALUE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)); + } + + @Test + @DisplayName("존재하지 않는 브랜드 조회 시 실패") + void getBrand_fail_notFound() throws Exception { + mockMvc.perform(get(PUBLIC_BASE_URL + "/999")) + .andExpect(status().isBadRequest()); + } + } + + private void createBrand(String name, String description) throws Exception { + var request = new BrandCreateRequest(name, description); + mockMvc.perform(post(ADMIN_BASE_URL) + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java new file mode 100644 index 000000000..5e7deeebf --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java @@ -0,0 +1,145 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.interfaces.api.brand.dto.BrandCreateRequest; +import com.loopers.interfaces.api.product.dto.ProductCreateRequest; +import com.loopers.interfaces.api.product.dto.ProductDetailResponse; +import com.loopers.interfaces.api.user.dto.UserRegisterRequest; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.context.annotation.Import; +import org.springframework.http.*; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(MySqlTestContainersConfig.class) +class LikeApiE2ETest { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String LOGIN_ID = "e2euser1"; + private static final String PASSWORD = "Password1!"; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + registerUser(LOGIN_ID, PASSWORD, "홍길동"); + createBrand("나이키", "스포츠"); + createProduct(1L, "운동화", 50000, 100); + } + + @Nested + @DisplayName("E2E: 좋아요 전체 플로우") + class LikeFlowE2E { + + @Test + @DisplayName("좋아요 → 목록 조회 → 좋아요 취소 → 빈 목록 확인") + void fullLikeFlow() { + // Step 1: 좋아요 + HttpHeaders authHeaders = createAuthHeaders(LOGIN_ID, PASSWORD); + ResponseEntity likeResponse = restTemplate.exchange( + "/api/v1/products/1/likes", + HttpMethod.POST, + new HttpEntity<>(authHeaders), + Void.class + ); + assertThat(likeResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Step 2: likeCount 확인 + ResponseEntity productResponse = restTemplate.getForEntity( + "/api/v1/products/1", + ProductDetailResponse.class + ); + assertThat(productResponse.getBody().likeCount()).isEqualTo(1); + + // Step 3: 좋아요 목록 조회 + ResponseEntity likesResponse = restTemplate.exchange( + "/api/v1/users/me/likes", + HttpMethod.GET, + new HttpEntity<>(authHeaders), + String.class + ); + assertThat(likesResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(likesResponse.getBody()).contains("운동화"); + + // Step 4: 좋아요 취소 + ResponseEntity unlikeResponse = restTemplate.exchange( + "/api/v1/products/1/likes", + HttpMethod.DELETE, + new HttpEntity<>(authHeaders), + Void.class + ); + assertThat(unlikeResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Step 5: likeCount 0 확인 + ResponseEntity productAfter = restTemplate.getForEntity( + "/api/v1/products/1", + ProductDetailResponse.class + ); + assertThat(productAfter.getBody().likeCount()).isEqualTo(0); + } + + @Test + @DisplayName("좋아요 멱등성 - 중복 좋아요 시 likeCount 1 유지") + void like_idempotent() { + // given + HttpHeaders authHeaders = createAuthHeaders(LOGIN_ID, PASSWORD); + + // when - 두 번 좋아요 + restTemplate.exchange("/api/v1/products/1/likes", HttpMethod.POST, + new HttpEntity<>(authHeaders), Void.class); + restTemplate.exchange("/api/v1/products/1/likes", HttpMethod.POST, + new HttpEntity<>(authHeaders), Void.class); + + // then - likeCount는 1 + ResponseEntity productResponse = restTemplate.getForEntity( + "/api/v1/products/1", ProductDetailResponse.class); + assertThat(productResponse.getBody().likeCount()).isEqualTo(1); + } + } + + private HttpHeaders createAuthHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + return headers; + } + + private HttpHeaders createAdminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } + + private void registerUser(String loginId, String password, String name) { + var request = new UserRegisterRequest(loginId, password, name, + LocalDate.of(1990, 5, 15), "test@example.com"); + restTemplate.postForEntity("/api/v1/users/register", request, Void.class); + } + + private void createBrand(String name, String description) { + var request = new BrandCreateRequest(name, description); + restTemplate.exchange("/api-admin/v1/brands", HttpMethod.POST, + new HttpEntity<>(request, createAdminHeaders()), Void.class); + } + + private void createProduct(Long brandId, String name, int price, int stock) { + var request = new ProductCreateRequest(brandId, name, price, stock, "설명"); + restTemplate.exchange("/api-admin/v1/products", HttpMethod.POST, + new HttpEntity<>(request, createAdminHeaders()), Void.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiIntegrationTest.java new file mode 100644 index 000000000..44b13290b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiIntegrationTest.java @@ -0,0 +1,174 @@ +package com.loopers.interfaces.api.like; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.interfaces.api.brand.dto.BrandCreateRequest; +import com.loopers.interfaces.api.product.dto.ProductCreateRequest; +import com.loopers.interfaces.api.user.dto.UserRegisterRequest; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDate; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(MySqlTestContainersConfig.class) +class LikeApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String LIKE_URL = "/api/v1/products"; + private static final String MY_LIKES_URL = "/api/v1/users/me/likes"; + private static final String ADMIN_HEADER = "X-Loopers-Ldap"; + private static final String ADMIN_VALUE = "loopers.admin"; + private static final String LOGIN_ID = "testuser1"; + private static final String PASSWORD = "Password1!"; + + @BeforeEach + void setUp() throws Exception { + databaseCleanUp.truncateAllTables(); + registerUser(LOGIN_ID, PASSWORD, "홍길동"); + createBrand("나이키", "스포츠"); + createProduct(1L, "운동화", 50000, 100); + } + + @Nested + @DisplayName("좋아요 API") + class LikeApi { + + @Test + @DisplayName("좋아요 성공") + void like_success() throws Exception { + mockMvc.perform(post(LIKE_URL + "/1/likes") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("좋아요 후 상품 likeCount 증가 확인") + void like_then_checkLikeCount() throws Exception { + mockMvc.perform(post(LIKE_URL + "/1/likes") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()); + + mockMvc.perform(get("/api/v1/products/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.likeCount").value(1)); + } + + @Test + @DisplayName("인증 없이 좋아요 시 실패") + void like_fail_unauthorized() throws Exception { + mockMvc.perform(post(LIKE_URL + "/1/likes")) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("좋아요 취소 API") + class UnlikeApi { + + @Test + @DisplayName("좋아요 취소 성공") + void unlike_success() throws Exception { + // 먼저 좋아요 + mockMvc.perform(post(LIKE_URL + "/1/likes") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()); + + // 좋아요 취소 + mockMvc.perform(delete(LIKE_URL + "/1/likes") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()); + + // likeCount 0 확인 + mockMvc.perform(get("/api/v1/products/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.likeCount").value(0)); + } + } + + @Nested + @DisplayName("좋아요 목록 조회 API") + class GetMyLikesApi { + + @Test + @DisplayName("좋아요 목록 조회 성공") + void getMyLikes_success() throws Exception { + // 좋아요 + mockMvc.perform(post(LIKE_URL + "/1/likes") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()); + + // 목록 조회 + mockMvc.perform(get(MY_LIKES_URL) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].productId").value(1)) + .andExpect(jsonPath("$[0].productName").value("운동화")); + } + + @Test + @DisplayName("좋아요 없는 경우 빈 목록") + void getMyLikes_empty() throws Exception { + mockMvc.perform(get(MY_LIKES_URL) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(0)); + } + } + + private void registerUser(String loginId, String password, String name) throws Exception { + var request = new UserRegisterRequest(loginId, password, name, + LocalDate.of(1990, 5, 15), "test@example.com"); + mockMvc.perform(post("/api/v1/users/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + private void createBrand(String name, String description) throws Exception { + var request = new BrandCreateRequest(name, description); + mockMvc.perform(post("/api-admin/v1/brands") + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + private void createProduct(Long brandId, String name, int price, int stock) throws Exception { + var request = new ProductCreateRequest(brandId, name, price, stock, "설명"); + mockMvc.perform(post("/api-admin/v1/products") + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java new file mode 100644 index 000000000..92c78826f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java @@ -0,0 +1,238 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.interfaces.api.brand.dto.BrandCreateRequest; +import com.loopers.interfaces.api.order.dto.DeliveryAddressUpdateRequest; +import com.loopers.interfaces.api.order.dto.OrderCreateRequest; +import com.loopers.interfaces.api.order.dto.OrderDetailResponse; +import com.loopers.interfaces.api.order.dto.OrderSummaryResponse; +import com.loopers.interfaces.api.product.dto.ProductCreateRequest; +import com.loopers.interfaces.api.product.dto.ProductDetailResponse; +import com.loopers.interfaces.api.user.dto.UserRegisterRequest; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.context.annotation.Import; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.*; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(MySqlTestContainersConfig.class) +class OrderApiE2ETest { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String ORDER_URL = "/api/v1/orders"; + private static final String LOGIN_ID = "e2euser1"; + private static final String PASSWORD = "Password1!"; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + registerUser(LOGIN_ID, PASSWORD, "홍길동"); + createBrand("나이키", "스포츠"); + createProduct(1L, "운동화", 50000, 100); + } + + @Nested + @DisplayName("E2E: 주문 전체 플로우") + class OrderFlowE2E { + + @Test + @DisplayName("주문 생성 → 조회 → 배송지 변경 → 취소 → 재고 복원 확인") + void fullOrderFlow() { + // Step 1: 주문 생성 + var orderRequest = new OrderCreateRequest( + List.of(new OrderCreateRequest.OrderItemRequest(1L, 2)), + "홍길동", "서울시 강남구", "문 앞에 놓아주세요", + "CARD", LocalDate.now().plusDays(3) + ); + + HttpHeaders authHeaders = createAuthHeaders(LOGIN_ID, PASSWORD); + authHeaders.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity createResponse = restTemplate.exchange( + ORDER_URL, + HttpMethod.POST, + new HttpEntity<>(orderRequest, authHeaders), + Void.class + ); + assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Step 2: 주문 목록 조회 + ResponseEntity listResponse = restTemplate.exchange( + ORDER_URL, + HttpMethod.GET, + new HttpEntity<>(createAuthHeaders(LOGIN_ID, PASSWORD)), + String.class + ); + assertThat(listResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(listResponse.getBody()).contains("PAYMENT_COMPLETED"); + + // Step 3: 주문 상세 조회 + ResponseEntity detailResponse = restTemplate.exchange( + ORDER_URL + "/1", + HttpMethod.GET, + new HttpEntity<>(createAuthHeaders(LOGIN_ID, PASSWORD)), + OrderDetailResponse.class + ); + assertThat(detailResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(detailResponse.getBody().receiverName()).isEqualTo("홍길동"); + assertThat(detailResponse.getBody().address()).isEqualTo("서울시 강남구"); + + // Step 4: 배송지 변경 + var addressRequest = new DeliveryAddressUpdateRequest("부산시 해운대구"); + HttpHeaders addressHeaders = createAuthHeaders(LOGIN_ID, PASSWORD); + addressHeaders.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity addressResponse = restTemplate.exchange( + ORDER_URL + "/1/delivery-address", + HttpMethod.PUT, + new HttpEntity<>(addressRequest, addressHeaders), + Void.class + ); + assertThat(addressResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Step 5: 변경된 배송지 확인 + ResponseEntity updatedDetail = restTemplate.exchange( + ORDER_URL + "/1", + HttpMethod.GET, + new HttpEntity<>(createAuthHeaders(LOGIN_ID, PASSWORD)), + OrderDetailResponse.class + ); + assertThat(updatedDetail.getBody().address()).isEqualTo("부산시 해운대구"); + + // Step 6: 주문 취소 + ResponseEntity cancelResponse = restTemplate.exchange( + ORDER_URL + "/1/cancel", + HttpMethod.POST, + new HttpEntity<>(createAuthHeaders(LOGIN_ID, PASSWORD)), + Void.class + ); + assertThat(cancelResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // Step 7: 취소 상태 확인 + ResponseEntity cancelledDetail = restTemplate.exchange( + ORDER_URL + "/1", + HttpMethod.GET, + new HttpEntity<>(createAuthHeaders(LOGIN_ID, PASSWORD)), + OrderDetailResponse.class + ); + assertThat(cancelledDetail.getBody().status()).isEqualTo("CANCELLED"); + + // Step 8: 재고 복원 확인 (원래 100, 2개 주문 → 98, 취소 → 100) + ResponseEntity productResponse = restTemplate.getForEntity( + "/api/v1/products/1", + ProductDetailResponse.class + ); + assertThat(productResponse.getBody().stock()).isEqualTo(100); + } + } + + @Nested + @DisplayName("E2E: 관리자 주문 관리") + class AdminOrderE2E { + + @Test + @DisplayName("관리자 전체 주문 조회") + void admin_getAllOrders() { + // given - 주문 생성 + createOrder(); + + // when + HttpHeaders adminHeaders = new HttpHeaders(); + adminHeaders.set("X-Loopers-Ldap", "loopers.admin"); + + ResponseEntity response = restTemplate.exchange( + "/api-admin/v1/orders", + HttpMethod.GET, + new HttpEntity<>(adminHeaders), + String.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).contains("PAYMENT_COMPLETED"); + } + + @Test + @DisplayName("관리자 주문 상세 조회") + void admin_getOrderDetail() { + // given + createOrder(); + + // when + HttpHeaders adminHeaders = new HttpHeaders(); + adminHeaders.set("X-Loopers-Ldap", "loopers.admin"); + + ResponseEntity response = restTemplate.exchange( + "/api-admin/v1/orders/1", + HttpMethod.GET, + new HttpEntity<>(adminHeaders), + OrderDetailResponse.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().receiverName()).isEqualTo("홍길동"); + } + } + + private HttpHeaders createAuthHeaders(String loginId, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", loginId); + headers.set("X-Loopers-LoginPw", password); + return headers; + } + + private HttpHeaders createAdminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } + + private void registerUser(String loginId, String password, String name) { + var request = new UserRegisterRequest(loginId, password, name, + LocalDate.of(1990, 5, 15), "test@example.com"); + restTemplate.postForEntity("/api/v1/users/register", request, Void.class); + } + + private void createBrand(String name, String description) { + var request = new BrandCreateRequest(name, description); + restTemplate.exchange("/api-admin/v1/brands", HttpMethod.POST, + new HttpEntity<>(request, createAdminHeaders()), Void.class); + } + + private void createProduct(Long brandId, String name, int price, int stock) { + var request = new ProductCreateRequest(brandId, name, price, stock, "설명"); + restTemplate.exchange("/api-admin/v1/products", HttpMethod.POST, + new HttpEntity<>(request, createAdminHeaders()), Void.class); + } + + private void createOrder() { + var request = new OrderCreateRequest( + List.of(new OrderCreateRequest.OrderItemRequest(1L, 2)), + "홍길동", "서울시 강남구", "문 앞에 놓아주세요", + "CARD", LocalDate.now().plusDays(3) + ); + HttpHeaders headers = createAuthHeaders(LOGIN_ID, PASSWORD); + headers.setContentType(MediaType.APPLICATION_JSON); + restTemplate.exchange(ORDER_URL, HttpMethod.POST, + new HttpEntity<>(request, headers), Void.class); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiIntegrationTest.java new file mode 100644 index 000000000..9b9c2f400 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiIntegrationTest.java @@ -0,0 +1,266 @@ +package com.loopers.interfaces.api.order; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.interfaces.api.brand.dto.BrandCreateRequest; +import com.loopers.interfaces.api.order.dto.DeliveryAddressUpdateRequest; +import com.loopers.interfaces.api.order.dto.OrderCreateRequest; +import com.loopers.interfaces.api.product.dto.ProductCreateRequest; +import com.loopers.interfaces.api.user.dto.UserRegisterRequest; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDate; +import java.util.List; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(MySqlTestContainersConfig.class) +class OrderApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String ORDER_URL = "/api/v1/orders"; + private static final String ADMIN_ORDER_URL = "/api-admin/v1/orders"; + private static final String ADMIN_HEADER = "X-Loopers-Ldap"; + private static final String ADMIN_VALUE = "loopers.admin"; + private static final String LOGIN_ID = "testuser1"; + private static final String PASSWORD = "Password1!"; + + @BeforeEach + void setUp() throws Exception { + databaseCleanUp.truncateAllTables(); + registerUser(LOGIN_ID, PASSWORD, "홍길동"); + createBrand("나이키", "스포츠"); + createProduct(1L, "운동화", 50000, 100); + } + + @Nested + @DisplayName("주문 생성 API") + class CreateOrderApi { + + @Test + @DisplayName("주문 생성 성공") + void createOrder_success() throws Exception { + var request = new OrderCreateRequest( + List.of(new OrderCreateRequest.OrderItemRequest(1L, 2)), + "홍길동", "서울시 강남구", "문 앞에 놓아주세요", + "CARD", LocalDate.now().plusDays(3) + ); + + mockMvc.perform(post(ORDER_URL) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("인증 없이 주문 생성 시 실패") + void createOrder_fail_unauthorized() throws Exception { + var request = new OrderCreateRequest( + List.of(new OrderCreateRequest.OrderItemRequest(1L, 2)), + "홍길동", "서울시", "요청", "CARD", LocalDate.now() + ); + + mockMvc.perform(post(ORDER_URL) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("주문 조회 API") + class QueryOrderApi { + + @Test + @DisplayName("내 주문 목록 조회 성공") + void getMyOrders_success() throws Exception { + createOrder(); + + mockMvc.perform(get(ORDER_URL) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].status").value("PAYMENT_COMPLETED")); + } + + @Test + @DisplayName("주문 상세 조회 성공") + void getOrderDetail_success() throws Exception { + createOrder(); + + mockMvc.perform(get(ORDER_URL + "/1") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.receiverName").value("홍길동")) + .andExpect(jsonPath("$.status").value("PAYMENT_COMPLETED")) + .andExpect(jsonPath("$.items.length()").value(1)); + } + + @Test + @DisplayName("기간 필터 조회") + void getMyOrders_withDateRange() throws Exception { + createOrder(); + + String startAt = LocalDate.now().minusDays(1).toString(); + String endAt = LocalDate.now().plusDays(1).toString(); + + mockMvc.perform(get(ORDER_URL) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD) + .param("startAt", startAt) + .param("endAt", endAt)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)); + } + } + + @Nested + @DisplayName("주문 취소 API") + class CancelOrderApi { + + @Test + @DisplayName("주문 취소 성공") + void cancelOrder_success() throws Exception { + createOrder(); + + mockMvc.perform(post(ORDER_URL + "/1/cancel") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()); + + // 취소 상태 확인 + mockMvc.perform(get(ORDER_URL + "/1") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("CANCELLED")); + } + } + + @Nested + @DisplayName("배송지 변경 API") + class UpdateDeliveryAddressApi { + + @Test + @DisplayName("배송지 변경 성공") + void updateDeliveryAddress_success() throws Exception { + createOrder(); + + var request = new DeliveryAddressUpdateRequest("부산시 해운대구"); + + mockMvc.perform(put(ORDER_URL + "/1/delivery-address") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + // 변경 확인 + mockMvc.perform(get(ORDER_URL + "/1") + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.address").value("부산시 해운대구")); + } + } + + @Nested + @DisplayName("관리자 주문 조회 API") + class AdminOrderApi { + + @Test + @DisplayName("관리자 전체 주문 목록 조회") + void getAllOrders_success() throws Exception { + createOrder(); + + mockMvc.perform(get(ADMIN_ORDER_URL) + .header(ADMIN_HEADER, ADMIN_VALUE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)); + } + + @Test + @DisplayName("관리자 주문 상세 조회") + void getOrderDetail_admin() throws Exception { + createOrder(); + + mockMvc.perform(get(ADMIN_ORDER_URL + "/1") + .header(ADMIN_HEADER, ADMIN_VALUE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.receiverName").value("홍길동")); + } + + @Test + @DisplayName("관리자 인증 없이 조회 시 실패") + void getAllOrders_fail_unauthorized() throws Exception { + mockMvc.perform(get(ADMIN_ORDER_URL)) + .andExpect(status().isUnauthorized()); + } + } + + private void registerUser(String loginId, String password, String name) throws Exception { + var request = new UserRegisterRequest(loginId, password, name, + LocalDate.of(1990, 5, 15), "test@example.com"); + mockMvc.perform(post("/api/v1/users/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + private void createBrand(String name, String description) throws Exception { + var request = new BrandCreateRequest(name, description); + mockMvc.perform(post("/api-admin/v1/brands") + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + private void createProduct(Long brandId, String name, int price, int stock) throws Exception { + var request = new ProductCreateRequest(brandId, name, price, stock, "설명"); + mockMvc.perform(post("/api-admin/v1/products") + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + private void createOrder() throws Exception { + var request = new OrderCreateRequest( + List.of(new OrderCreateRequest.OrderItemRequest(1L, 2)), + "홍길동", "서울시 강남구", "문 앞에 놓아주세요", + "CARD", LocalDate.now().plusDays(3) + ); + mockMvc.perform(post(ORDER_URL) + .header("X-Loopers-LoginId", LOGIN_ID) + .header("X-Loopers-LoginPw", PASSWORD) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java new file mode 100644 index 000000000..f4747936a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java @@ -0,0 +1,180 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.brand.dto.BrandCreateRequest; +import com.loopers.interfaces.api.common.PageResponse; +import com.loopers.interfaces.api.product.dto.ProductCreateRequest; +import com.loopers.interfaces.api.product.dto.ProductDetailResponse; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.context.annotation.Import; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.*; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(MySqlTestContainersConfig.class) +class ProductApiE2ETest { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String ADMIN_URL = "/api-admin/v1/products"; + private static final String PUBLIC_URL = "/api/v1/products"; + private static final String BRAND_ADMIN_URL = "/api-admin/v1/brands"; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("E2E: 상품 CRUD 시나리오") + class ProductCrudE2E { + + @Test + @DisplayName("상품 생성 → 상세 조회 성공") + void create_then_getDetail() { + // given + createBrand("나이키", "스포츠"); + var request = new ProductCreateRequest(1L, "운동화", 50000, 100, "좋은 운동화"); + + // when - 생성 + ResponseEntity createResponse = restTemplate.exchange( + ADMIN_URL, + HttpMethod.POST, + new HttpEntity<>(request, createAdminHeaders()), + Void.class + ); + assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // when - 조회 + ResponseEntity getResponse = restTemplate.getForEntity( + PUBLIC_URL + "/1", + ProductDetailResponse.class + ); + + // then + assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(getResponse.getBody()).isNotNull(); + assertThat(getResponse.getBody().name()).isEqualTo("운동화"); + assertThat(getResponse.getBody().brandName()).isEqualTo("나이키"); + assertThat(getResponse.getBody().price()).isEqualTo(50000); + } + + @Test + @DisplayName("상품 목록 조회 (페이징)") + void getProductList() { + // given + createBrand("나이키", "스포츠"); + createProduct(1L, "운동화", 50000, 100); + createProduct(1L, "슬리퍼", 30000, 200); + + // when + ResponseEntity response = restTemplate.getForEntity( + PUBLIC_URL + "?page=0&size=20", + String.class + ); + + // then + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).contains("운동화"); + assertThat(response.getBody()).contains("슬리퍼"); + } + + @Test + @DisplayName("상품 생성 → 삭제 → 조회 실패") + void create_delete_then_getFail() { + // given + createBrand("나이키", "스포츠"); + createProduct(1L, "운동화", 50000, 100); + + // when - 삭제 + ResponseEntity deleteResponse = restTemplate.exchange( + ADMIN_URL + "/1", + HttpMethod.DELETE, + new HttpEntity<>(createAdminHeaders()), + Void.class + ); + assertThat(deleteResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // then - 삭제된 상품 조회 실패 + ResponseEntity getResponse = restTemplate.getForEntity( + PUBLIC_URL + "/1", + String.class + ); + assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @Nested + @DisplayName("E2E: 브랜드 삭제 cascade 시나리오") + class BrandDeleteCascadeE2E { + + @Test + @DisplayName("브랜드 삭제 시 하위 상품도 삭제됨") + void deleteBrand_cascadeProducts() { + // given + createBrand("나이키", "스포츠"); + createProduct(1L, "운동화", 50000, 100); + createProduct(1L, "슬리퍼", 30000, 200); + + // when - 브랜드 삭제 + ResponseEntity deleteResponse = restTemplate.exchange( + BRAND_ADMIN_URL + "/1", + HttpMethod.DELETE, + new HttpEntity<>(createAdminHeaders()), + Void.class + ); + assertThat(deleteResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + // then - 상품 조회 실패 + ResponseEntity product1Response = restTemplate.getForEntity( + PUBLIC_URL + "/1", String.class); + assertThat(product1Response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + + ResponseEntity product2Response = restTemplate.getForEntity( + PUBLIC_URL + "/2", String.class); + assertThat(product2Response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + private HttpHeaders createAdminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.setContentType(MediaType.APPLICATION_JSON); + return headers; + } + + private void createBrand(String name, String description) { + var request = new BrandCreateRequest(name, description); + ResponseEntity response = restTemplate.exchange( + BRAND_ADMIN_URL, + HttpMethod.POST, + new HttpEntity<>(request, createAdminHeaders()), + Void.class + ); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + private void createProduct(Long brandId, String name, int price, int stock) { + var request = new ProductCreateRequest(brandId, name, price, stock, "설명"); + ResponseEntity response = restTemplate.exchange( + ADMIN_URL, + HttpMethod.POST, + new HttpEntity<>(request, createAdminHeaders()), + Void.class + ); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiIntegrationTest.java new file mode 100644 index 000000000..b960625b6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiIntegrationTest.java @@ -0,0 +1,205 @@ +package com.loopers.interfaces.api.product; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.interfaces.api.brand.dto.BrandCreateRequest; +import com.loopers.interfaces.api.product.dto.ProductCreateRequest; +import com.loopers.interfaces.api.product.dto.ProductUpdateRequest; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(MySqlTestContainersConfig.class) +class ProductApiIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private static final String ADMIN_URL = "/api-admin/v1/products"; + private static final String PUBLIC_URL = "/api/v1/products"; + private static final String BRAND_ADMIN_URL = "/api-admin/v1/brands"; + private static final String ADMIN_HEADER = "X-Loopers-Ldap"; + private static final String ADMIN_VALUE = "loopers.admin"; + + @BeforeEach + void setUp() { + databaseCleanUp.truncateAllTables(); + } + + @Nested + @DisplayName("상품 생성 API") + class CreateProductApi { + + @Test + @DisplayName("상품 생성 성공") + void createProduct_success() throws Exception { + createBrand("나이키", "스포츠"); + + var request = new ProductCreateRequest(1L, "운동화", 50000, 100, "좋은 운동화"); + + mockMvc.perform(post(ADMIN_URL) + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("존재하지 않는 브랜드로 상품 생성시 실패") + void createProduct_fail_brandNotFound() throws Exception { + var request = new ProductCreateRequest(999L, "운동화", 50000, 100, "좋은 운동화"); + + mockMvc.perform(post(ADMIN_URL) + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("상품 수정 API") + class UpdateProductApi { + + @Test + @DisplayName("상품 수정 성공") + void updateProduct_success() throws Exception { + createBrand("나이키", "스포츠"); + createProduct(1L, "운동화", 50000, 100); + + var updateRequest = new ProductUpdateRequest("슬리퍼", 30000, 200, "변경된 설명"); + + mockMvc.perform(put(ADMIN_URL + "/1") + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateRequest))) + .andExpect(status().isOk()); + + // 변경 확인 + mockMvc.perform(get(PUBLIC_URL + "/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("슬리퍼")) + .andExpect(jsonPath("$.price").value(30000)); + } + } + + @Nested + @DisplayName("상품 삭제 API") + class DeleteProductApi { + + @Test + @DisplayName("상품 삭제 성공") + void deleteProduct_success() throws Exception { + createBrand("나이키", "스포츠"); + createProduct(1L, "운동화", 50000, 100); + + mockMvc.perform(delete(ADMIN_URL + "/1") + .header(ADMIN_HEADER, ADMIN_VALUE)) + .andExpect(status().isOk()); + + // 삭제 확인 + mockMvc.perform(get(PUBLIC_URL + "/1")) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("상품 조회 API") + class QueryProductApi { + + @Test + @DisplayName("상품 상세 조회 성공") + void getProduct_success() throws Exception { + createBrand("나이키", "스포츠"); + createProduct(1L, "운동화", 50000, 100); + + mockMvc.perform(get(PUBLIC_URL + "/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("운동화")) + .andExpect(jsonPath("$.price").value(50000)) + .andExpect(jsonPath("$.brandName").value("나이키")); + } + + @Test + @DisplayName("상품 목록 조회 성공 (페이징)") + void getProducts_success() throws Exception { + createBrand("나이키", "스포츠"); + createProduct(1L, "운동화", 50000, 100); + createProduct(1L, "슬리퍼", 30000, 200); + + mockMvc.perform(get(PUBLIC_URL) + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(2)) + .andExpect(jsonPath("$.totalElements").value(2)) + .andExpect(jsonPath("$.page").value(0)); + } + + @Test + @DisplayName("브랜드 필터링 조회") + void getProducts_withBrandFilter() throws Exception { + createBrand("나이키", "스포츠"); + createBrand("아디다스", "독일"); + createProduct(1L, "나이키 운동화", 50000, 100); + createProduct(2L, "아디다스 운동화", 60000, 50); + + mockMvc.perform(get(PUBLIC_URL) + .param("brandId", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].brandName").value("나이키")); + } + + @Test + @DisplayName("관리자 상품 목록 조회") + void getProducts_admin() throws Exception { + createBrand("나이키", "스포츠"); + createProduct(1L, "운동화", 50000, 100); + + mockMvc.perform(get(ADMIN_URL) + .header(ADMIN_HEADER, ADMIN_VALUE) + .param("page", "0") + .param("size", "20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content.length()").value(1)); + } + } + + private void createBrand(String name, String description) throws Exception { + var request = new BrandCreateRequest(name, description); + mockMvc.perform(post(BRAND_ADMIN_URL) + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + private void createProduct(Long brandId, String name, int price, int stock) throws Exception { + var request = new ProductCreateRequest(brandId, name, price, stock, "설명"); + mockMvc.perform(post(ADMIN_URL) + .header(ADMIN_HEADER, ADMIN_VALUE) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } +} From 22ecece585f7c5e6391b7787e7c6d24f9a44baf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Tue, 24 Feb 2026 18:48:51 +0900 Subject: [PATCH 17/20] =?UTF-8?q?refactor:=20DDD=20=ED=95=B5=EC=8B=AC=20?= =?UTF-8?q?=EC=9B=90=EC=B9=99=EC=97=90=20=EB=94=B0=EB=9D=BC=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=EC=9D=84=20=EB=B9=84?= =?UTF-8?q?=EC=A6=88=EB=8B=88=EC=8A=A4=20=EA=B7=9C=EC=B9=99=EC=9D=98=20?= =?UTF-8?q?=EC=A4=91=EC=8B=AC=EC=9C=BC=EB=A1=9C=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Application Service가 수행하던 도메인 로직을 Aggregate 내부로 이동하고, Cross-Aggregate 간 결합을 Domain Event로 디커플링하며, Primitive Obsession을 해소한다. - Domain Event 인프라: DomainEvent 인터페이스, AggregateRoot 추상 클래스, SpringDomainEventPublisher - Value Object 보강: Quantity(주문수량), LikeCount(좋아요수), Description(상품설명) VO 추가 - Order Aggregate 강화: OrderLine 도입으로 Order.create() 내부에서 스냅샷/아이템 조립 - 주문취소→재고복원: OrderCancelledEvent + EventHandler로 디커플링 - 좋아요→likeCount: ProductLikedEvent/ProductUnlikedEvent + LikeEventHandler로 디커플링 - 브랜드삭제→상품삭제: BrandDeletedEvent + BrandDeletedEventHandler로 디커플링 (BrandService에서 ProductRepository 의존성 완전 제거) - 전체 테스트 통과 확인 (Unit/Integration/E2E) Co-Authored-By: Claude Opus 4.6 --- .../brand/BrandDeletedEventHandler.java | 32 ++++++++++ .../application/brand/BrandService.java | 15 ++--- .../application/like/LikeEventHandler.java | 38 +++++++++++ .../loopers/application/like/LikeService.java | 27 ++++---- .../order/OrderCancelledEventHandler.java | 30 +++++++++ .../application/order/OrderQueryService.java | 2 +- .../application/order/OrderService.java | 63 ++++++++----------- .../product/ProductQueryService.java | 6 +- .../com/loopers/domain/model/brand/Brand.java | 22 +++++-- .../model/brand/event/BrandDeletedEvent.java | 15 +++++ .../domain/model/common/AggregateRoot.java | 22 +++++++ .../domain/model/common/DomainEvent.java | 8 +++ .../com/loopers/domain/model/like/Like.java | 18 ++++-- .../model/like/event/ProductLikedEvent.java | 15 +++++ .../model/like/event/ProductUnlikedEvent.java | 15 +++++ .../com/loopers/domain/model/order/Order.java | 56 ++++++++++++++--- .../loopers/domain/model/order/OrderItem.java | 12 ++-- .../loopers/domain/model/order/OrderLine.java | 23 +++++++ .../loopers/domain/model/order/Quantity.java | 22 +++++++ .../order/event/OrderCancelledEvent.java | 19 ++++++ .../domain/model/product/Description.java | 35 +++++++++++ .../domain/model/product/LikeCount.java | 37 +++++++++++ .../loopers/domain/model/product/Product.java | 18 +++--- .../common/SpringDomainEventPublisher.java | 20 ++++++ .../order/OrderRepositoryImpl.java | 4 +- .../product/ProductRepositoryImpl.java | 13 ++-- .../application/brand/BrandServiceTest.java | 44 +++---------- .../application/like/LikeServiceTest.java | 27 ++++---- .../order/OrderQueryServiceTest.java | 2 +- .../application/order/OrderServiceTest.java | 18 +++--- .../product/ProductQueryServiceTest.java | 11 ++-- .../product/ProductServiceTest.java | 8 +-- .../domain/model/order/OrderItemTest.java | 15 +++-- .../loopers/domain/model/order/OrderTest.java | 48 +++++++------- .../domain/model/product/ProductTest.java | 6 +- 35 files changed, 560 insertions(+), 206 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandDeletedEventHandler.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderCancelledEventHandler.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/brand/event/BrandDeletedEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/common/AggregateRoot.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/common/DomainEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/like/event/ProductLikedEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/like/event/ProductUnlikedEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderLine.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/order/Quantity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/order/event/OrderCancelledEvent.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/product/Description.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/product/LikeCount.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/common/SpringDomainEventPublisher.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandDeletedEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandDeletedEventHandler.java new file mode 100644 index 000000000..bc4999cd4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandDeletedEventHandler.java @@ -0,0 +1,32 @@ +package com.loopers.application.brand; + +import com.loopers.domain.model.brand.event.BrandDeletedEvent; +import com.loopers.domain.model.product.Product; +import com.loopers.domain.repository.ProductRepository; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.List; + +@Component +public class BrandDeletedEventHandler { + + private final ProductRepository productRepository; + + public BrandDeletedEventHandler(ProductRepository productRepository) { + this.productRepository = productRepository; + } + + @TransactionalEventListener + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handle(BrandDeletedEvent event) { + List products = productRepository.findAllByBrandId(event.brandId()); + for (Product product : products) { + if (!product.isDeleted()) { + productRepository.save(product.delete()); + } + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java index f4a2e66d3..c9db91971 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -2,9 +2,8 @@ import com.loopers.domain.model.brand.Brand; import com.loopers.domain.model.brand.BrandName; -import com.loopers.domain.model.product.Product; import com.loopers.domain.repository.BrandRepository; -import com.loopers.domain.repository.ProductRepository; +import com.loopers.infrastructure.common.SpringDomainEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,11 +14,11 @@ public class BrandService implements CreateBrandUseCase, UpdateBrandUseCase, DeleteBrandUseCase, BrandQueryUseCase { private final BrandRepository brandRepository; - private final ProductRepository productRepository; + private final SpringDomainEventPublisher eventPublisher; - public BrandService(BrandRepository brandRepository, ProductRepository productRepository) { + public BrandService(BrandRepository brandRepository, SpringDomainEventPublisher eventPublisher) { this.brandRepository = brandRepository; - this.productRepository = productRepository; + this.eventPublisher = eventPublisher; } @Override @@ -47,11 +46,7 @@ public void deleteBrand(Long brandId) { Brand brand = findBrand(brandId); Brand deleted = brand.delete(); brandRepository.save(deleted); - - List products = productRepository.findAllByBrandId(brandId); - for (Product product : products) { - productRepository.save(product.delete()); - } + eventPublisher.publishEvents(deleted); } @Override diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java new file mode 100644 index 000000000..6b06ca5af --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java @@ -0,0 +1,38 @@ +package com.loopers.application.like; + +import com.loopers.domain.model.like.event.ProductLikedEvent; +import com.loopers.domain.model.like.event.ProductUnlikedEvent; +import com.loopers.domain.model.product.Product; +import com.loopers.domain.repository.ProductRepository; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +public class LikeEventHandler { + + private final ProductRepository productRepository; + + public LikeEventHandler(ProductRepository productRepository) { + this.productRepository = productRepository; + } + + @TransactionalEventListener + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handle(ProductLikedEvent event) { + Product product = productRepository.findById(event.productId()) + .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다.")); + Product updated = product.increaseLikeCount(); + productRepository.save(updated); + } + + @TransactionalEventListener + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handle(ProductUnlikedEvent event) { + Product product = productRepository.findById(event.productId()) + .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다.")); + Product updated = product.decreaseLikeCount(); + productRepository.save(updated); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java index 59deef641..6068838e8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java @@ -1,10 +1,13 @@ package com.loopers.application.like; import com.loopers.domain.model.like.Like; +import com.loopers.domain.model.like.event.ProductUnlikedEvent; import com.loopers.domain.model.product.Product; import com.loopers.domain.model.user.UserId; import com.loopers.domain.repository.LikeRepository; import com.loopers.domain.repository.ProductRepository; +import com.loopers.infrastructure.common.SpringDomainEventPublisher; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,39 +19,41 @@ public class LikeService implements LikeUseCase, UnlikeUseCase, LikeQueryUseCase private final LikeRepository likeRepository; private final ProductRepository productRepository; + private final SpringDomainEventPublisher domainEventPublisher; + private final ApplicationEventPublisher applicationEventPublisher; - public LikeService(LikeRepository likeRepository, ProductRepository productRepository) { + public LikeService(LikeRepository likeRepository, ProductRepository productRepository, + SpringDomainEventPublisher domainEventPublisher, + ApplicationEventPublisher applicationEventPublisher) { this.likeRepository = likeRepository; this.productRepository = productRepository; + this.domainEventPublisher = domainEventPublisher; + this.applicationEventPublisher = applicationEventPublisher; } @Override public void like(UserId userId, Long productId) { - Product product = findProduct(productId); + findProduct(productId); if (likeRepository.existsByUserIdAndProductId(userId, productId)) { - return; // Idempotency — 이미 좋아요한 경우 무시 + return; } Like like = Like.create(userId, productId); likeRepository.save(like); - - Product updated = product.increaseLikeCount(); - productRepository.save(updated); + domainEventPublisher.publishEvents(like); } @Override public void unlike(UserId userId, Long productId) { - Product product = findProduct(productId); + findProduct(productId); if (!likeRepository.existsByUserIdAndProductId(userId, productId)) { - return; // 좋아요하지 않은 경우 무시 + return; } likeRepository.deleteByUserIdAndProductId(userId, productId); - - Product updated = product.decreaseLikeCount(); - productRepository.save(updated); + applicationEventPublisher.publishEvent(new ProductUnlikedEvent(productId)); } @Override diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCancelledEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCancelledEventHandler.java new file mode 100644 index 000000000..21a07d9e7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCancelledEventHandler.java @@ -0,0 +1,30 @@ +package com.loopers.application.order; + +import com.loopers.domain.model.order.event.OrderCancelledEvent; +import com.loopers.domain.model.product.Product; +import com.loopers.domain.repository.ProductRepository; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +public class OrderCancelledEventHandler { + + private final ProductRepository productRepository; + + public OrderCancelledEventHandler(ProductRepository productRepository) { + this.productRepository = productRepository; + } + + @TransactionalEventListener + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handle(OrderCancelledEvent event) { + for (OrderCancelledEvent.CancelledItem item : event.cancelledItems()) { + Product product = productRepository.findById(item.productId()) + .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다.")); + Product restored = product.increaseStock(item.quantity()); + productRepository.save(restored); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryService.java index 1fe75d492..97d40f5fd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryService.java @@ -92,7 +92,7 @@ private OrderDetail toOrderDetail(Order order) { private OrderItemDetail toOrderItemDetail(OrderItem item) { return new OrderItemDetail( item.getProductId(), - item.getQuantity(), + item.getQuantity().getValue(), item.getUnitPrice().getValue() ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java index 6084c9f3b..dd69d847d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java @@ -5,10 +5,10 @@ import com.loopers.domain.model.user.UserId; import com.loopers.domain.repository.OrderRepository; import com.loopers.domain.repository.ProductRepository; +import com.loopers.infrastructure.common.SpringDomainEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; import java.util.List; @Service @@ -17,51 +17,44 @@ public class OrderService implements CreateOrderUseCase, CancelOrderUseCase, Upd private final OrderRepository orderRepository; private final ProductRepository productRepository; + private final SpringDomainEventPublisher eventPublisher; - public OrderService(OrderRepository orderRepository, ProductRepository productRepository) { + public OrderService(OrderRepository orderRepository, ProductRepository productRepository, + SpringDomainEventPublisher eventPublisher) { this.orderRepository = orderRepository; this.productRepository = productRepository; + this.eventPublisher = eventPublisher; } @Override public void createOrder(UserId userId, OrderCommand command) { - List orderItems = new ArrayList<>(); - StringBuilder snapshotBuilder = new StringBuilder(); + List orderLines = command.items().stream() + .map(itemCommand -> { + Product product = productRepository.findById(itemCommand.productId()) + .filter(p -> !p.isDeleted()) + .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다. ID: " + itemCommand.productId())); + + Product decreased = product.decreaseStock(itemCommand.quantity()); + productRepository.save(decreased); + + return new OrderLine( + product.getId(), + product.getName().getValue(), + Money.of(product.getPrice().getValue()), + Quantity.of(itemCommand.quantity()) + ); + }) + .toList(); - for (OrderItemCommand itemCommand : command.items()) { - Product product = productRepository.findById(itemCommand.productId()) - .filter(p -> !p.isDeleted()) - .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다. ID: " + itemCommand.productId())); - - Product decreased = product.decreaseStock(itemCommand.quantity()); - productRepository.save(decreased); - - OrderItem orderItem = OrderItem.create( - product.getId(), - itemCommand.quantity(), - Money.of(product.getPrice().getValue()) - ); - orderItems.add(orderItem); - - snapshotBuilder.append(product.getName().getValue()) - .append(":") - .append(product.getPrice().getValue()) - .append(","); - } - - OrderSnapshot snapshot = OrderSnapshot.create(snapshotBuilder.toString()); PaymentMethod paymentMethod = PaymentMethod.valueOf(command.paymentMethod()); - Order order = Order.create( - userId, - orderItems, + userId, orderLines, ReceiverName.of(command.receiverName()), Address.of(command.address()), command.deliveryRequest(), paymentMethod, Money.zero(), - command.desiredDeliveryDate(), - snapshot + command.desiredDeliveryDate() ); orderRepository.save(order); @@ -75,13 +68,7 @@ public void cancelOrder(UserId userId, Long orderId) { Order cancelled = order.cancel(); orderRepository.save(cancelled); - - for (OrderItem item : order.getItems()) { - Product product = productRepository.findById(item.getProductId()) - .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다.")); - Product restored = product.increaseStock(item.getQuantity()); - productRepository.save(restored); - } + eventPublisher.publishEvents(cancelled); } @Override diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java index 360e6e725..cf6ce87b2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java @@ -38,8 +38,8 @@ public ProductDetailInfo getProduct(Long productId) { product.getName().getValue(), product.getPrice().getValue(), product.getStock().getValue(), - product.getLikeCount(), - product.getDescription() + product.getLikeCount().getValue(), + product.getDescription() != null ? product.getDescription().getValue() : null ); } @@ -60,7 +60,7 @@ public Page getProducts(Long brandId, String sort, int page, brandName, product.getName().getValue(), product.getPrice().getValue(), - product.getLikeCount() + product.getLikeCount().getValue() ); }); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/Brand.java index 4ad00919a..6e5d860c8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/Brand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/Brand.java @@ -1,14 +1,13 @@ package com.loopers.domain.model.brand; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; +import com.loopers.domain.model.brand.event.BrandDeletedEvent; +import com.loopers.domain.model.common.AggregateRoot; import lombok.Getter; import java.time.LocalDateTime; @Getter -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class Brand { +public class Brand extends AggregateRoot { private final Long id; private final BrandName name; @@ -17,6 +16,16 @@ public class Brand { private final LocalDateTime updatedAt; private final LocalDateTime deletedAt; + private Brand(Long id, BrandName name, String description, + LocalDateTime createdAt, LocalDateTime updatedAt, LocalDateTime deletedAt) { + this.id = id; + this.name = name; + this.description = description; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.deletedAt = deletedAt; + } + public static Brand create(BrandName name, String description) { LocalDateTime now = LocalDateTime.now(); return new Brand(null, name, description, now, now, null); @@ -36,7 +45,10 @@ public Brand delete() { if (isDeleted()) { throw new IllegalStateException("이미 삭제된 브랜드입니다."); } - return new Brand(this.id, this.name, this.description, this.createdAt, this.updatedAt, LocalDateTime.now()); + Brand deleted = new Brand(this.id, this.name, this.description, + this.createdAt, this.updatedAt, LocalDateTime.now()); + deleted.registerEvent(new BrandDeletedEvent(this.id)); + return deleted; } public boolean isDeleted() { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/event/BrandDeletedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/event/BrandDeletedEvent.java new file mode 100644 index 000000000..8642c0650 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/event/BrandDeletedEvent.java @@ -0,0 +1,15 @@ +package com.loopers.domain.model.brand.event; + +import com.loopers.domain.model.common.DomainEvent; + +import java.time.LocalDateTime; + +public record BrandDeletedEvent( + Long brandId, + LocalDateTime occurredAt +) implements DomainEvent { + + public BrandDeletedEvent(Long brandId) { + this(brandId, LocalDateTime.now()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/common/AggregateRoot.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/common/AggregateRoot.java new file mode 100644 index 000000000..010fd01fc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/common/AggregateRoot.java @@ -0,0 +1,22 @@ +package com.loopers.domain.model.common; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public abstract class AggregateRoot { + + private final List domainEvents = new ArrayList<>(); + + protected void registerEvent(DomainEvent event) { + this.domainEvents.add(event); + } + + public List getDomainEvents() { + return Collections.unmodifiableList(domainEvents); + } + + public void clearDomainEvents() { + this.domainEvents.clear(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/common/DomainEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/common/DomainEvent.java new file mode 100644 index 000000000..c8a9efec6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/common/DomainEvent.java @@ -0,0 +1,8 @@ +package com.loopers.domain.model.common; + +import java.time.LocalDateTime; + +public interface DomainEvent { + + LocalDateTime occurredAt(); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/like/Like.java index ff2cf762a..4efba3726 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/like/Like.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/like/Like.java @@ -1,21 +1,27 @@ package com.loopers.domain.model.like; +import com.loopers.domain.model.common.AggregateRoot; +import com.loopers.domain.model.like.event.ProductLikedEvent; import com.loopers.domain.model.user.UserId; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Getter; import java.time.LocalDateTime; @Getter -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class Like { +public class Like extends AggregateRoot { private final Long id; private final UserId userId; private final Long productId; private final LocalDateTime createdAt; + private Like(Long id, UserId userId, Long productId, LocalDateTime createdAt) { + this.id = id; + this.userId = userId; + this.productId = productId; + this.createdAt = createdAt; + } + public static Like create(UserId userId, Long productId) { if (userId == null) { throw new IllegalArgumentException("사용자 ID는 필수입니다."); @@ -23,7 +29,9 @@ public static Like create(UserId userId, Long productId) { if (productId == null) { throw new IllegalArgumentException("상품 ID는 필수입니다."); } - return new Like(null, userId, productId, LocalDateTime.now()); + Like like = new Like(null, userId, productId, LocalDateTime.now()); + like.registerEvent(new ProductLikedEvent(productId)); + return like; } public static Like reconstitute(Long id, UserId userId, Long productId, LocalDateTime createdAt) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/like/event/ProductLikedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/like/event/ProductLikedEvent.java new file mode 100644 index 000000000..0b4f72443 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/like/event/ProductLikedEvent.java @@ -0,0 +1,15 @@ +package com.loopers.domain.model.like.event; + +import com.loopers.domain.model.common.DomainEvent; + +import java.time.LocalDateTime; + +public record ProductLikedEvent( + Long productId, + LocalDateTime occurredAt +) implements DomainEvent { + + public ProductLikedEvent(Long productId) { + this(productId, LocalDateTime.now()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/like/event/ProductUnlikedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/like/event/ProductUnlikedEvent.java new file mode 100644 index 000000000..a02f89f4e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/like/event/ProductUnlikedEvent.java @@ -0,0 +1,15 @@ +package com.loopers.domain.model.like.event; + +import com.loopers.domain.model.common.DomainEvent; + +import java.time.LocalDateTime; + +public record ProductUnlikedEvent( + Long productId, + LocalDateTime occurredAt +) implements DomainEvent { + + public ProductUnlikedEvent(Long productId) { + this(productId, LocalDateTime.now()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Order.java index 59135ede1..c0ef2405d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Order.java @@ -1,17 +1,17 @@ package com.loopers.domain.model.order; +import com.loopers.domain.model.common.AggregateRoot; +import com.loopers.domain.model.order.event.OrderCancelledEvent; import com.loopers.domain.model.user.UserId; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Getter; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +import java.util.stream.Collectors; @Getter -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class Order { +public class Order extends AggregateRoot { private final Long id; private final UserId userId; @@ -29,20 +29,50 @@ public class Order { private final LocalDateTime createdAt; private final LocalDateTime updatedAt; - public static Order create(UserId userId, List items, ReceiverName receiverName, + private Order(Long id, UserId userId, List items, OrderSnapshot snapshot, + ReceiverName receiverName, Address address, String deliveryRequest, + PaymentMethod paymentMethod, Money totalAmount, Money discountAmount, + Money paymentAmount, OrderStatus status, LocalDate desiredDeliveryDate, + LocalDateTime createdAt, LocalDateTime updatedAt) { + this.id = id; + this.userId = userId; + this.items = items; + this.snapshot = snapshot; + this.receiverName = receiverName; + this.address = address; + this.deliveryRequest = deliveryRequest; + this.paymentMethod = paymentMethod; + this.totalAmount = totalAmount; + this.discountAmount = discountAmount; + this.paymentAmount = paymentAmount; + this.status = status; + this.desiredDeliveryDate = desiredDeliveryDate; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public static Order create(UserId userId, List orderLines, ReceiverName receiverName, Address address, String deliveryRequest, PaymentMethod paymentMethod, - Money discountAmount, LocalDate desiredDeliveryDate, - OrderSnapshot snapshot) { + Money discountAmount, LocalDate desiredDeliveryDate) { if (userId == null) { throw new IllegalArgumentException("사용자 ID는 필수입니다."); } - if (items == null || items.isEmpty()) { + if (orderLines == null || orderLines.isEmpty()) { throw new IllegalArgumentException("주문 항목은 1개 이상이어야 합니다."); } if (paymentMethod == null) { throw new IllegalArgumentException("결제 수단은 필수입니다."); } + List items = orderLines.stream() + .map(line -> OrderItem.create(line.productId(), line.quantity(), line.unitPrice())) + .toList(); + + String snapshotData = orderLines.stream() + .map(line -> line.productName() + ":" + line.unitPrice().getValue()) + .collect(Collectors.joining(",")); + OrderSnapshot snapshot = OrderSnapshot.create(snapshotData + ","); + Money totalAmount = calculateTotalAmount(items); Money paymentAmount = totalAmount.subtract(discountAmount); LocalDateTime now = LocalDateTime.now(); @@ -66,10 +96,18 @@ public Order cancel() { if (!isCancellable()) { throw new IllegalStateException("현재 상태에서는 주문을 취소할 수 없습니다. 현재 상태: " + status.getDescription()); } - return new Order(this.id, this.userId, this.items, this.snapshot, this.receiverName, + + Order cancelled = new Order(this.id, this.userId, this.items, this.snapshot, this.receiverName, this.address, this.deliveryRequest, this.paymentMethod, this.totalAmount, this.discountAmount, this.paymentAmount, OrderStatus.CANCELLED, this.desiredDeliveryDate, this.createdAt, LocalDateTime.now()); + + List cancelledItems = this.items.stream() + .map(item -> new OrderCancelledEvent.CancelledItem(item.getProductId(), item.getQuantity().getValue())) + .toList(); + cancelled.registerEvent(new OrderCancelledEvent(this.id, cancelledItems)); + + return cancelled; } public Order updateDeliveryAddress(Address newAddress) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderItem.java index 41404a5a6..fdcca357c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderItem.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderItem.java @@ -10,15 +10,15 @@ public class OrderItem { private final Long id; private final Long productId; - private final int quantity; + private final Quantity quantity; private final Money unitPrice; - public static OrderItem create(Long productId, int quantity, Money unitPrice) { + public static OrderItem create(Long productId, Quantity quantity, Money unitPrice) { if (productId == null) { throw new IllegalArgumentException("상품 ID는 필수입니다."); } - if (quantity <= 0) { - throw new IllegalArgumentException("주문 수량은 1 이상이어야 합니다."); + if (quantity == null) { + throw new IllegalArgumentException("주문 수량은 필수입니다."); } if (unitPrice == null) { throw new IllegalArgumentException("단가는 필수입니다."); @@ -26,11 +26,11 @@ public static OrderItem create(Long productId, int quantity, Money unitPrice) { return new OrderItem(null, productId, quantity, unitPrice); } - public static OrderItem reconstitute(Long id, Long productId, int quantity, Money unitPrice) { + public static OrderItem reconstitute(Long id, Long productId, Quantity quantity, Money unitPrice) { return new OrderItem(id, productId, quantity, unitPrice); } public Money calculateAmount() { - return unitPrice.multiply(quantity); + return unitPrice.multiply(quantity.getValue()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderLine.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderLine.java new file mode 100644 index 000000000..57373b4b0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderLine.java @@ -0,0 +1,23 @@ +package com.loopers.domain.model.order; + +public record OrderLine( + Long productId, + String productName, + Money unitPrice, + Quantity quantity +) { + public OrderLine { + if (productId == null) { + throw new IllegalArgumentException("상품 ID는 필수입니다."); + } + if (productName == null || productName.isBlank()) { + throw new IllegalArgumentException("상품명은 필수입니다."); + } + if (unitPrice == null) { + throw new IllegalArgumentException("단가는 필수입니다."); + } + if (quantity == null) { + throw new IllegalArgumentException("수량은 필수입니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Quantity.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Quantity.java new file mode 100644 index 000000000..0f06adfb6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Quantity.java @@ -0,0 +1,22 @@ +package com.loopers.domain.model.order; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@EqualsAndHashCode +public class Quantity { + + private final int value; + + private Quantity(int value) { + this.value = value; + } + + public static Quantity of(int value) { + if (value < 1) { + throw new IllegalArgumentException("주문 수량은 1 이상이어야 합니다."); + } + return new Quantity(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/event/OrderCancelledEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/event/OrderCancelledEvent.java new file mode 100644 index 000000000..409e832b9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/event/OrderCancelledEvent.java @@ -0,0 +1,19 @@ +package com.loopers.domain.model.order.event; + +import com.loopers.domain.model.common.DomainEvent; + +import java.time.LocalDateTime; +import java.util.List; + +public record OrderCancelledEvent( + Long orderId, + List cancelledItems, + LocalDateTime occurredAt +) implements DomainEvent { + + public record CancelledItem(Long productId, int quantity) {} + + public OrderCancelledEvent(Long orderId, List cancelledItems) { + this(orderId, cancelledItems, LocalDateTime.now()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Description.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Description.java new file mode 100644 index 000000000..1f6dac012 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Description.java @@ -0,0 +1,35 @@ +package com.loopers.domain.model.product; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@EqualsAndHashCode +public class Description { + + private static final int MAX_LENGTH = 500; + + private final String value; + + private Description(String value) { + this.value = value; + } + + public static Description of(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("상품 설명은 필수 입력값입니다."); + } + String trimmed = value.trim(); + if (trimmed.length() > MAX_LENGTH) { + throw new IllegalArgumentException("상품 설명은 " + MAX_LENGTH + "자 이하여야 합니다."); + } + return new Description(trimmed); + } + + public static Description ofNullable(String value) { + if (value == null || value.isBlank()) { + return null; + } + return of(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/LikeCount.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/LikeCount.java new file mode 100644 index 000000000..061e81a60 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/LikeCount.java @@ -0,0 +1,37 @@ +package com.loopers.domain.model.product; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +@Getter +@EqualsAndHashCode +public class LikeCount { + + private final int value; + + private LikeCount(int value) { + this.value = value; + } + + public static LikeCount of(int value) { + if (value < 0) { + throw new IllegalArgumentException("좋아요 수는 0 이상이어야 합니다."); + } + return new LikeCount(value); + } + + public static LikeCount zero() { + return new LikeCount(0); + } + + public LikeCount increase() { + return new LikeCount(this.value + 1); + } + + public LikeCount decrease() { + if (this.value <= 0) { + throw new IllegalStateException("좋아요 수는 0 미만이 될 수 없습니다."); + } + return new LikeCount(this.value - 1); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Product.java index 5ec747187..4401c1f80 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Product.java @@ -15,19 +15,20 @@ public class Product { private final ProductName name; private final Price price; private final Stock stock; - private final int likeCount; - private final String description; + private final LikeCount likeCount; + private final Description description; private final LocalDateTime createdAt; private final LocalDateTime updatedAt; private final LocalDateTime deletedAt; public static Product create(Long brandId, ProductName name, Price price, Stock stock, String description) { LocalDateTime now = LocalDateTime.now(); - return new Product(null, brandId, name, price, stock, 0, description, now, now, null); + return new Product(null, brandId, name, price, stock, LikeCount.zero(), + Description.ofNullable(description), now, now, null); } public static Product reconstitute(Long id, Long brandId, ProductName name, Price price, Stock stock, - int likeCount, String description, + LikeCount likeCount, Description description, LocalDateTime createdAt, LocalDateTime updatedAt, LocalDateTime deletedAt) { return new Product(id, brandId, name, price, stock, likeCount, description, createdAt, updatedAt, deletedAt); @@ -35,7 +36,7 @@ public static Product reconstitute(Long id, Long brandId, ProductName name, Pric public Product update(ProductName name, Price price, Stock stock, String description) { return new Product(this.id, this.brandId, name, price, stock, this.likeCount, - description, this.createdAt, LocalDateTime.now(), this.deletedAt); + Description.ofNullable(description), this.createdAt, LocalDateTime.now(), this.deletedAt); } public Product delete() { @@ -59,15 +60,12 @@ public Product increaseStock(int quantity) { } public Product increaseLikeCount() { - return new Product(this.id, this.brandId, this.name, this.price, this.stock, this.likeCount + 1, + return new Product(this.id, this.brandId, this.name, this.price, this.stock, this.likeCount.increase(), this.description, this.createdAt, this.updatedAt, this.deletedAt); } public Product decreaseLikeCount() { - if (this.likeCount <= 0) { - throw new IllegalStateException("좋아요 수는 0 미만이 될 수 없습니다."); - } - return new Product(this.id, this.brandId, this.name, this.price, this.stock, this.likeCount - 1, + return new Product(this.id, this.brandId, this.name, this.price, this.stock, this.likeCount.decrease(), this.description, this.createdAt, this.updatedAt, this.deletedAt); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/common/SpringDomainEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/common/SpringDomainEventPublisher.java new file mode 100644 index 000000000..bfeaa7930 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/common/SpringDomainEventPublisher.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.common; + +import com.loopers.domain.model.common.AggregateRoot; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +@Component +public class SpringDomainEventPublisher { + + private final ApplicationEventPublisher eventPublisher; + + public SpringDomainEventPublisher(ApplicationEventPublisher eventPublisher) { + this.eventPublisher = eventPublisher; + } + + public void publishEvents(AggregateRoot aggregateRoot) { + aggregateRoot.getDomainEvents().forEach(eventPublisher::publishEvent); + aggregateRoot.clearDomainEvents(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java index 28d441088..63e7a689a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -85,7 +85,7 @@ private OrderItemJpaEntity toItemEntity(OrderItem item) { return new OrderItemJpaEntity( item.getId(), item.getProductId(), - item.getQuantity(), + item.getQuantity().getValue(), item.getUnitPrice().getValue() ); } @@ -135,7 +135,7 @@ private OrderItem toItemDomain(OrderItemJpaEntity entity) { return OrderItem.reconstitute( entity.getId(), entity.getProductId(), - entity.getQuantity(), + Quantity.of(entity.getQuantity()), Money.of(entity.getUnitPrice()) ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 02d049fe8..6840637ba 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -1,9 +1,6 @@ package com.loopers.infrastructure.product; -import com.loopers.domain.model.product.Price; -import com.loopers.domain.model.product.Product; -import com.loopers.domain.model.product.ProductName; -import com.loopers.domain.model.product.Stock; +import com.loopers.domain.model.product.*; import com.loopers.domain.repository.ProductRepository; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -59,8 +56,8 @@ private ProductJpaEntity toEntity(Product product) { product.getName().getValue(), product.getPrice().getValue(), product.getStock().getValue(), - product.getLikeCount(), - product.getDescription(), + product.getLikeCount().getValue(), + product.getDescription() != null ? product.getDescription().getValue() : null, product.getCreatedAt(), product.getUpdatedAt(), product.getDeletedAt() @@ -74,8 +71,8 @@ private Product toDomain(ProductJpaEntity entity) { ProductName.of(entity.getName()), Price.of(entity.getPrice()), Stock.of(entity.getStockQuantity()), - entity.getLikeCount(), - entity.getDescription(), + LikeCount.of(entity.getLikeCount()), + Description.ofNullable(entity.getDescription()), entity.getCreatedAt(), entity.getUpdatedAt(), entity.getDeletedAt() diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java index a002bdd43..ce8d20650 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java @@ -2,12 +2,9 @@ import com.loopers.domain.model.brand.Brand; import com.loopers.domain.model.brand.BrandName; -import com.loopers.domain.model.product.Price; -import com.loopers.domain.model.product.Product; -import com.loopers.domain.model.product.ProductName; -import com.loopers.domain.model.product.Stock; +import com.loopers.domain.model.product.*; import com.loopers.domain.repository.BrandRepository; -import com.loopers.domain.repository.ProductRepository; +import com.loopers.infrastructure.common.SpringDomainEventPublisher; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -24,14 +21,14 @@ class BrandServiceTest { private BrandRepository brandRepository; - private ProductRepository productRepository; + private SpringDomainEventPublisher eventPublisher; private BrandService service; @BeforeEach void setUp() { brandRepository = mock(BrandRepository.class); - productRepository = mock(ProductRepository.class); - service = new BrandService(brandRepository, productRepository); + eventPublisher = mock(SpringDomainEventPublisher.class); + service = new BrandService(brandRepository, eventPublisher); } @Nested @@ -102,38 +99,18 @@ void updateBrand_fail_notFound() { class DeleteBrand { @Test - @DisplayName("브랜드 삭제 성공 - 하위 상품도 일괄 삭제") - void deleteBrand_success_cascadeProducts() { + @DisplayName("브랜드 삭제 성공 - 이벤트 발행") + void deleteBrand_success_eventPublished() { // given Brand brand = createBrand(1L, "나이키"); - Product product1 = createProduct(1L, 1L); - Product product2 = createProduct(2L, 1L); - when(brandRepository.findById(1L)).thenReturn(Optional.of(brand)); - when(productRepository.findAllByBrandId(1L)).thenReturn(List.of(product1, product2)); // when service.deleteBrand(1L); // then verify(brandRepository).save(any(Brand.class)); - verify(productRepository, times(2)).save(any(Product.class)); - } - - @Test - @DisplayName("브랜드 삭제 - 하위 상품 없는 경우") - void deleteBrand_success_noProducts() { - // given - Brand brand = createBrand(1L, "나이키"); - when(brandRepository.findById(1L)).thenReturn(Optional.of(brand)); - when(productRepository.findAllByBrandId(1L)).thenReturn(List.of()); - - // when - service.deleteBrand(1L); - - // then - verify(brandRepository).save(any(Brand.class)); - verify(productRepository, never()).save(any(Product.class)); + verify(eventPublisher).publishEvents(any(Brand.class)); } @Test @@ -191,9 +168,4 @@ private Brand createBrand(Long id, String name) { return Brand.reconstitute(id, BrandName.of(name), "설명", LocalDateTime.now(), LocalDateTime.now(), null); } - - private Product createProduct(Long id, Long brandId) { - return Product.reconstitute(id, brandId, ProductName.of("상품" + id), Price.of(10000), - Stock.of(100), 0, "설명", LocalDateTime.now(), LocalDateTime.now(), null); - } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java index bab118662..9f510d676 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java @@ -1,17 +1,16 @@ package com.loopers.application.like; import com.loopers.domain.model.like.Like; -import com.loopers.domain.model.product.Price; -import com.loopers.domain.model.product.Product; -import com.loopers.domain.model.product.ProductName; -import com.loopers.domain.model.product.Stock; +import com.loopers.domain.model.product.*; import com.loopers.domain.model.user.UserId; import com.loopers.domain.repository.LikeRepository; import com.loopers.domain.repository.ProductRepository; +import com.loopers.infrastructure.common.SpringDomainEventPublisher; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; import java.time.LocalDateTime; import java.util.List; @@ -25,13 +24,18 @@ class LikeServiceTest { private LikeRepository likeRepository; private ProductRepository productRepository; + private SpringDomainEventPublisher domainEventPublisher; + private ApplicationEventPublisher applicationEventPublisher; private LikeService service; @BeforeEach void setUp() { likeRepository = mock(LikeRepository.class); productRepository = mock(ProductRepository.class); - service = new LikeService(likeRepository, productRepository); + domainEventPublisher = mock(SpringDomainEventPublisher.class); + applicationEventPublisher = mock(ApplicationEventPublisher.class); + service = new LikeService(likeRepository, productRepository, + domainEventPublisher, applicationEventPublisher); } @Nested @@ -53,7 +57,7 @@ void like_success() { // then verify(likeRepository).save(any(Like.class)); - verify(productRepository).save(any(Product.class)); + verify(domainEventPublisher).publishEvents(any(Like.class)); } @Test @@ -71,7 +75,7 @@ void like_alreadyLiked_ignored() { // then verify(likeRepository, never()).save(any(Like.class)); - verify(productRepository, never()).save(any(Product.class)); + verify(domainEventPublisher, never()).publishEvents(any()); } @Test @@ -107,7 +111,7 @@ void unlike_success() { // then verify(likeRepository).deleteByUserIdAndProductId(userId, 1L); - verify(productRepository).save(any(Product.class)); + verify(applicationEventPublisher).publishEvent(any(Object.class)); } @Test @@ -125,7 +129,7 @@ void unlike_notLiked_ignored() { // then verify(likeRepository, never()).deleteByUserIdAndProductId(any(), any()); - verify(productRepository, never()).save(any(Product.class)); + verify(applicationEventPublisher, never()).publishEvent(any(Object.class)); } } @@ -164,7 +168,7 @@ void getMyLikes_excludeDeletedProducts() { Product activeProduct = createProduct(1L, 5); Product deletedProduct = Product.reconstitute(2L, 1L, ProductName.of("삭제됨"), - Price.of(10000), Stock.of(0), 0, "설명", + Price.of(10000), Stock.of(0), LikeCount.zero(), Description.ofNullable("설명"), LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now()); when(likeRepository.findAllByUserId(userId)).thenReturn(List.of(like1, like2)); @@ -196,6 +200,7 @@ void getMyLikes_empty() { private Product createProduct(Long id, int likeCount) { return Product.reconstitute(id, 1L, ProductName.of("상품" + id), Price.of(10000), - Stock.of(100), likeCount, "설명", LocalDateTime.now(), LocalDateTime.now(), null); + Stock.of(100), LikeCount.of(likeCount), Description.ofNullable("설명"), + LocalDateTime.now(), LocalDateTime.now(), null); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderQueryServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderQueryServiceTest.java index b554dc4a7..5793ee0f1 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderQueryServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderQueryServiceTest.java @@ -182,7 +182,7 @@ void getOrderDetail_success() { private Order createOrder(Long id, UserId userId, OrderStatus status) { List items = List.of( - OrderItem.reconstitute(1L, 1L, 2, Money.of(50000)) + OrderItem.reconstitute(1L, 1L, Quantity.of(2), Money.of(50000)) ); return Order.reconstitute(id, userId, items, null, ReceiverName.of("홍길동"), Address.of("서울시 강남구"), diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java index 0c3b79d02..d8b9da1cb 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java @@ -5,9 +5,12 @@ import com.loopers.domain.model.product.Product; import com.loopers.domain.model.product.ProductName; import com.loopers.domain.model.product.Stock; +import com.loopers.domain.model.product.LikeCount; +import com.loopers.domain.model.product.Description; import com.loopers.domain.model.user.UserId; import com.loopers.domain.repository.OrderRepository; import com.loopers.domain.repository.ProductRepository; +import com.loopers.infrastructure.common.SpringDomainEventPublisher; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -26,13 +29,15 @@ class OrderServiceTest { private OrderRepository orderRepository; private ProductRepository productRepository; + private SpringDomainEventPublisher eventPublisher; private OrderService service; @BeforeEach void setUp() { orderRepository = mock(OrderRepository.class); productRepository = mock(ProductRepository.class); - service = new OrderService(orderRepository, productRepository); + eventPublisher = mock(SpringDomainEventPublisher.class); + service = new OrderService(orderRepository, productRepository, eventPublisher); } @Nested @@ -108,22 +113,20 @@ void createOrder_fail_insufficientStock() { class CancelOrder { @Test - @DisplayName("주문 취소 성공 - 재고 복원") + @DisplayName("주문 취소 성공 - 이벤트 발행") void cancelOrder_success() { // given UserId userId = UserId.of("test1234"); Order order = createOrder(1L, userId, OrderStatus.PAYMENT_COMPLETED); - Product product = createProduct(1L, 50000, 98); when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); - when(productRepository.findById(1L)).thenReturn(Optional.of(product)); // when service.cancelOrder(userId, 1L); // then verify(orderRepository).save(any(Order.class)); - verify(productRepository).save(any(Product.class)); + verify(eventPublisher).publishEvents(any(Order.class)); } @Test @@ -212,12 +215,13 @@ void updateDeliveryAddress_fail_shipping() { private Product createProduct(Long id, int price, int stock) { return Product.reconstitute(id, 1L, ProductName.of("상품" + id), Price.of(price), - Stock.of(stock), 0, "설명", LocalDateTime.now(), LocalDateTime.now(), null); + Stock.of(stock), LikeCount.zero(), Description.ofNullable("설명"), + LocalDateTime.now(), LocalDateTime.now(), null); } private Order createOrder(Long id, UserId userId, OrderStatus status) { List items = List.of( - OrderItem.reconstitute(1L, 1L, 2, Money.of(50000)) + OrderItem.reconstitute(1L, 1L, Quantity.of(2), Money.of(50000)) ); return Order.reconstitute(id, userId, items, null, ReceiverName.of("홍길동"), Address.of("서울시 강남구"), diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductQueryServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductQueryServiceTest.java index 3d7423849..3a36c6282 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductQueryServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductQueryServiceTest.java @@ -2,10 +2,7 @@ import com.loopers.domain.model.brand.Brand; import com.loopers.domain.model.brand.BrandName; -import com.loopers.domain.model.product.Price; -import com.loopers.domain.model.product.Product; -import com.loopers.domain.model.product.ProductName; -import com.loopers.domain.model.product.Stock; +import com.loopers.domain.model.product.*; import com.loopers.domain.repository.BrandRepository; import com.loopers.domain.repository.ProductRepository; import org.junit.jupiter.api.BeforeEach; @@ -67,7 +64,8 @@ void getProduct_success() { void getProduct_fail_deleted() { // given Product deleted = Product.reconstitute(1L, 1L, ProductName.of("삭제됨"), Price.of(10000), - Stock.of(0), 0, "설명", LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now()); + Stock.of(0), LikeCount.zero(), Description.ofNullable("설명"), + LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now()); when(productRepository.findById(1L)).thenReturn(Optional.of(deleted)); // when & then @@ -148,7 +146,8 @@ void getProducts_sortByPriceAsc() { private Product createProduct(Long id, Long brandId, String name, int price) { return Product.reconstitute(id, brandId, ProductName.of(name), Price.of(price), - Stock.of(100), 5, "설명", LocalDateTime.now(), LocalDateTime.now(), null); + Stock.of(100), LikeCount.of(5), Description.ofNullable("설명"), + LocalDateTime.now(), LocalDateTime.now(), null); } private Brand createBrand(Long id, String name) { diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java index 062757cf9..c6174b6a5 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java @@ -2,10 +2,7 @@ import com.loopers.domain.model.brand.Brand; import com.loopers.domain.model.brand.BrandName; -import com.loopers.domain.model.product.Price; -import com.loopers.domain.model.product.Product; -import com.loopers.domain.model.product.ProductName; -import com.loopers.domain.model.product.Stock; +import com.loopers.domain.model.product.*; import com.loopers.domain.repository.BrandRepository; import com.loopers.domain.repository.ProductRepository; import org.junit.jupiter.api.BeforeEach; @@ -149,6 +146,7 @@ private Brand createBrand(Long id) { private Product createProduct(Long id, Long brandId) { return Product.reconstitute(id, brandId, ProductName.of("운동화"), Price.of(50000), - Stock.of(100), 0, "좋은 운동화", LocalDateTime.now(), LocalDateTime.now(), null); + Stock.of(100), LikeCount.zero(), Description.ofNullable("좋은 운동화"), + LocalDateTime.now(), LocalDateTime.now(), null); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderItemTest.java index a8ffdb1bf..30c3fd753 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderItemTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderItemTest.java @@ -11,18 +11,18 @@ class OrderItemTest { @Test @DisplayName("주문 항목 생성 성공") void create_success() { - OrderItem item = OrderItem.create(1L, 2, Money.of(10000)); + OrderItem item = OrderItem.create(1L, Quantity.of(2), Money.of(10000)); assertThat(item.getId()).isNull(); assertThat(item.getProductId()).isEqualTo(1L); - assertThat(item.getQuantity()).isEqualTo(2); + assertThat(item.getQuantity().getValue()).isEqualTo(2); assertThat(item.getUnitPrice().getValue()).isEqualTo(10000); } @Test @DisplayName("productId null이면 예외") void create_fail_null_productId() { - assertThatThrownBy(() -> OrderItem.create(null, 2, Money.of(10000))) + assertThatThrownBy(() -> OrderItem.create(null, Quantity.of(2), Money.of(10000))) .isInstanceOf(IllegalArgumentException.class) .hasMessage("상품 ID는 필수입니다."); } @@ -30,7 +30,7 @@ void create_fail_null_productId() { @Test @DisplayName("수량 0 이하면 예외") void create_fail_zero_quantity() { - assertThatThrownBy(() -> OrderItem.create(1L, 0, Money.of(10000))) + assertThatThrownBy(() -> Quantity.of(0)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("1 이상"); } @@ -38,15 +38,14 @@ void create_fail_zero_quantity() { @Test @DisplayName("단가 null이면 예외") void create_fail_null_unitPrice() { - assertThatThrownBy(() -> OrderItem.create(1L, 2, null)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessage("단가는 필수입니다."); + assertThatThrownBy(() -> OrderItem.create(1L, Quantity.of(2), null)) + .isInstanceOf(IllegalArgumentException.class); } @Test @DisplayName("금액 계산 (단가 * 수량)") void calculateAmount() { - OrderItem item = OrderItem.create(1L, 3, Money.of(10000)); + OrderItem item = OrderItem.create(1L, Quantity.of(3), Money.of(10000)); assertThat(item.calculateAmount().getValue()).isEqualTo(30000); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderTest.java index b3f0341d9..32b3386fd 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderTest.java @@ -14,22 +14,20 @@ class OrderTest { private Order createOrder() { - List items = List.of( - OrderItem.create(1L, 2, Money.of(10000)), - OrderItem.create(2L, 1, Money.of(20000)) + List orderLines = List.of( + new OrderLine(1L, "상품A", Money.of(10000), Quantity.of(2)), + new OrderLine(2L, "상품B", Money.of(20000), Quantity.of(1)) ); - OrderSnapshot snapshot = OrderSnapshot.create("{\"items\":[]}"); return Order.create( UserId.of("testuser1"), - items, + orderLines, ReceiverName.of("홍길동"), Address.of("서울시 강남구"), "부재시 문 앞에 놓아주세요", PaymentMethod.CARD, Money.zero(), - LocalDate.now().plusDays(3), - snapshot + LocalDate.now().plusDays(3) ); } @@ -43,19 +41,21 @@ void create_success() { assertThat(order.getTotalAmount().getValue()).isEqualTo(40000); // 10000*2 + 20000*1 assertThat(order.getPaymentAmount().getValue()).isEqualTo(40000); assertThat(order.getItems()).hasSize(2); + assertThat(order.getSnapshot()).isNotNull(); } @Test @DisplayName("주문 생성 - 할인 적용") void create_with_discount() { - List items = List.of(OrderItem.create(1L, 1, Money.of(50000))); - OrderSnapshot snapshot = OrderSnapshot.create("{\"items\":[]}"); + List orderLines = List.of( + new OrderLine(1L, "상품A", Money.of(50000), Quantity.of(1)) + ); Order order = Order.create( - UserId.of("testuser1"), items, + UserId.of("testuser1"), orderLines, ReceiverName.of("홍길동"), Address.of("서울시"), null, PaymentMethod.CARD, - Money.of(5000), null, snapshot + Money.of(5000), null ); assertThat(order.getTotalAmount().getValue()).isEqualTo(50000); @@ -66,12 +66,13 @@ void create_with_discount() { @Test @DisplayName("userId null이면 예외") void create_fail_null_userId() { - List items = List.of(OrderItem.create(1L, 1, Money.of(10000))); - OrderSnapshot snapshot = OrderSnapshot.create("{\"items\":[]}"); + List orderLines = List.of( + new OrderLine(1L, "상품A", Money.of(10000), Quantity.of(1)) + ); - assertThatThrownBy(() -> Order.create(null, items, + assertThatThrownBy(() -> Order.create(null, orderLines, ReceiverName.of("홍길동"), Address.of("서울시"), - null, PaymentMethod.CARD, Money.zero(), null, snapshot)) + null, PaymentMethod.CARD, Money.zero(), null)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("사용자 ID는 필수입니다."); } @@ -79,11 +80,9 @@ void create_fail_null_userId() { @Test @DisplayName("주문 항목 비어있으면 예외") void create_fail_empty_items() { - OrderSnapshot snapshot = OrderSnapshot.create("{\"items\":[]}"); - assertThatThrownBy(() -> Order.create(UserId.of("testuser1"), List.of(), ReceiverName.of("홍길동"), Address.of("서울시"), - null, PaymentMethod.CARD, Money.zero(), null, snapshot)) + null, PaymentMethod.CARD, Money.zero(), null)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("1개 이상"); } @@ -93,13 +92,18 @@ void create_fail_empty_items() { void cancel_success() { Order order = createOrder(); assertThat(order.isCancellable()).isTrue(); + + Order cancelled = order.cancel(); + assertThat(cancelled.getStatus()).isEqualTo(OrderStatus.CANCELLED); + assertThat(cancelled.getDomainEvents()).hasSize(1); } @Test @DisplayName("SHIPPING 상태에서 취소 불가") void cancel_fail_shipping() { Order order = Order.reconstitute( - 1L, UserId.of("testuser1"), List.of(OrderItem.create(1L, 1, Money.of(10000))), + 1L, UserId.of("testuser1"), + List.of(OrderItem.create(1L, Quantity.of(1), Money.of(10000))), null, ReceiverName.of("홍길동"), Address.of("서울시"), null, PaymentMethod.CARD, Money.of(10000), Money.zero(), Money.of(10000), OrderStatus.SHIPPING, null, LocalDateTime.now(), LocalDateTime.now() @@ -115,7 +119,8 @@ void cancel_fail_shipping() { @DisplayName("DELIVERED 상태에서 취소 불가") void cancel_fail_delivered() { Order order = Order.reconstitute( - 1L, UserId.of("testuser1"), List.of(OrderItem.create(1L, 1, Money.of(10000))), + 1L, UserId.of("testuser1"), + List.of(OrderItem.create(1L, Quantity.of(1), Money.of(10000))), null, ReceiverName.of("홍길동"), Address.of("서울시"), null, PaymentMethod.CARD, Money.of(10000), Money.zero(), Money.of(10000), OrderStatus.DELIVERED, null, LocalDateTime.now(), LocalDateTime.now() @@ -138,7 +143,8 @@ void updateDeliveryAddress_success() { @DisplayName("SHIPPING 상태에서 배송지 변경 불가") void updateDeliveryAddress_fail_shipping() { Order order = Order.reconstitute( - 1L, UserId.of("testuser1"), List.of(OrderItem.create(1L, 1, Money.of(10000))), + 1L, UserId.of("testuser1"), + List.of(OrderItem.create(1L, Quantity.of(1), Money.of(10000))), null, ReceiverName.of("홍길동"), Address.of("서울시"), null, PaymentMethod.CARD, Money.of(10000), Money.zero(), Money.of(10000), OrderStatus.SHIPPING, null, LocalDateTime.now(), LocalDateTime.now() diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductTest.java index 0ef987d22..6b0870436 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductTest.java @@ -28,7 +28,7 @@ void create_success() { assertThat(product.getName().getValue()).isEqualTo("에어맥스 90"); assertThat(product.getPrice().getValue()).isEqualTo(139000); assertThat(product.getStock().getValue()).isEqualTo(50); - assertThat(product.getLikeCount()).isEqualTo(0); + assertThat(product.getLikeCount().getValue()).isEqualTo(0); assertThat(product.isDeleted()).isFalse(); } @@ -93,7 +93,7 @@ void increaseLikeCount() { Product product = createProduct(); Product liked = product.increaseLikeCount(); - assertThat(liked.getLikeCount()).isEqualTo(1); + assertThat(liked.getLikeCount().getValue()).isEqualTo(1); } @Test @@ -102,7 +102,7 @@ void decreaseLikeCount() { Product product = createProduct().increaseLikeCount(); Product unliked = product.decreaseLikeCount(); - assertThat(unliked.getLikeCount()).isEqualTo(0); + assertThat(unliked.getLikeCount().getValue()).isEqualTo(0); } @Test From d9b0551e542423f7b004165b5039ebc25fe23374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Tue, 24 Feb 2026 19:14:09 +0900 Subject: [PATCH 18/20] =?UTF-8?q?refactor:=20DDD=20=EC=95=84=ED=82=A4?= =?UTF-8?q?=ED=85=8D=EC=B2=98=204=EA=B0=80=EC=A7=80=20=EA=B0=9C=EC=84=A0?= =?UTF-8?q?=20(=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=9D=BC=EA=B4=80?= =?UTF-8?q?=EC=84=B1,=20Clean=20Architecture,=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=A8=ED=84=B4=20=ED=86=B5=EC=9D=BC,=20VO=20nul?= =?UTF-8?q?l=20=EC=A0=9C=EA=B1=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @TransactionalEventListener+REQUIRES_NEW → @EventListener로 변경하여 동일 트랜잭션 내 실행 보장 - DomainEventPublisher 인터페이스를 도메인 레이어에 도입하여 Application→Infrastructure 직접 참조 제거 - unlike() 이벤트 발행을 Like.markUnliked() Aggregate 패턴으로 통일 (ApplicationEventPublisher 제거) - Description VO에 empty() sentinel 도입하여 null 반환 제거 및 안전한 접근 메서드 제공 Co-Authored-By: Claude Opus 4.6 --- .../brand/BrandDeletedEventHandler.java | 7 ++----- .../application/brand/BrandService.java | 6 +++--- .../application/like/LikeEventHandler.java | 10 +++------- .../loopers/application/like/LikeService.java | 16 +++++++--------- .../order/OrderCancelledEventHandler.java | 7 ++----- .../application/order/OrderService.java | 6 +++--- .../product/ProductQueryService.java | 2 +- .../model/common/DomainEventPublisher.java | 6 ++++++ .../com/loopers/domain/model/like/Like.java | 6 ++++++ .../domain/model/product/Description.java | 15 ++++++++++++++- .../common/SpringDomainEventPublisher.java | 4 +++- .../product/ProductRepositoryImpl.java | 2 +- .../application/brand/BrandServiceTest.java | 6 +++--- .../application/like/LikeServiceTest.java | 18 ++++++++---------- .../application/order/OrderServiceTest.java | 6 +++--- 15 files changed, 65 insertions(+), 52 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/common/DomainEventPublisher.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandDeletedEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandDeletedEventHandler.java index bc4999cd4..0d7cd676c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandDeletedEventHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandDeletedEventHandler.java @@ -3,10 +3,8 @@ import com.loopers.domain.model.brand.event.BrandDeletedEvent; import com.loopers.domain.model.product.Product; import com.loopers.domain.repository.ProductRepository; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.event.TransactionalEventListener; import java.util.List; @@ -19,8 +17,7 @@ public BrandDeletedEventHandler(ProductRepository productRepository) { this.productRepository = productRepository; } - @TransactionalEventListener - @Transactional(propagation = Propagation.REQUIRES_NEW) + @EventListener public void handle(BrandDeletedEvent event) { List products = productRepository.findAllByBrandId(event.brandId()); for (Product product : products) { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java index c9db91971..6952bb1d4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -2,8 +2,8 @@ import com.loopers.domain.model.brand.Brand; import com.loopers.domain.model.brand.BrandName; +import com.loopers.domain.model.common.DomainEventPublisher; import com.loopers.domain.repository.BrandRepository; -import com.loopers.infrastructure.common.SpringDomainEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -14,9 +14,9 @@ public class BrandService implements CreateBrandUseCase, UpdateBrandUseCase, DeleteBrandUseCase, BrandQueryUseCase { private final BrandRepository brandRepository; - private final SpringDomainEventPublisher eventPublisher; + private final DomainEventPublisher eventPublisher; - public BrandService(BrandRepository brandRepository, SpringDomainEventPublisher eventPublisher) { + public BrandService(BrandRepository brandRepository, DomainEventPublisher eventPublisher) { this.brandRepository = brandRepository; this.eventPublisher = eventPublisher; } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java index 6b06ca5af..0ba0d8497 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeEventHandler.java @@ -4,10 +4,8 @@ import com.loopers.domain.model.like.event.ProductUnlikedEvent; import com.loopers.domain.model.product.Product; import com.loopers.domain.repository.ProductRepository; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.event.TransactionalEventListener; @Component public class LikeEventHandler { @@ -18,8 +16,7 @@ public LikeEventHandler(ProductRepository productRepository) { this.productRepository = productRepository; } - @TransactionalEventListener - @Transactional(propagation = Propagation.REQUIRES_NEW) + @EventListener public void handle(ProductLikedEvent event) { Product product = productRepository.findById(event.productId()) .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다.")); @@ -27,8 +24,7 @@ public void handle(ProductLikedEvent event) { productRepository.save(updated); } - @TransactionalEventListener - @Transactional(propagation = Propagation.REQUIRES_NEW) + @EventListener public void handle(ProductUnlikedEvent event) { Product product = productRepository.findById(event.productId()) .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다.")); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java index 6068838e8..7d0b1a87d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java @@ -1,13 +1,11 @@ package com.loopers.application.like; +import com.loopers.domain.model.common.DomainEventPublisher; import com.loopers.domain.model.like.Like; -import com.loopers.domain.model.like.event.ProductUnlikedEvent; import com.loopers.domain.model.product.Product; import com.loopers.domain.model.user.UserId; import com.loopers.domain.repository.LikeRepository; import com.loopers.domain.repository.ProductRepository; -import com.loopers.infrastructure.common.SpringDomainEventPublisher; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,16 +17,13 @@ public class LikeService implements LikeUseCase, UnlikeUseCase, LikeQueryUseCase private final LikeRepository likeRepository; private final ProductRepository productRepository; - private final SpringDomainEventPublisher domainEventPublisher; - private final ApplicationEventPublisher applicationEventPublisher; + private final DomainEventPublisher domainEventPublisher; public LikeService(LikeRepository likeRepository, ProductRepository productRepository, - SpringDomainEventPublisher domainEventPublisher, - ApplicationEventPublisher applicationEventPublisher) { + DomainEventPublisher domainEventPublisher) { this.likeRepository = likeRepository; this.productRepository = productRepository; this.domainEventPublisher = domainEventPublisher; - this.applicationEventPublisher = applicationEventPublisher; } @Override @@ -52,8 +47,11 @@ public void unlike(UserId userId, Long productId) { return; } + Like like = likeRepository.findByUserIdAndProductId(userId, productId) + .orElseThrow(() -> new IllegalArgumentException("좋아요를 찾을 수 없습니다.")); + like.markUnliked(); + domainEventPublisher.publishEvents(like); likeRepository.deleteByUserIdAndProductId(userId, productId); - applicationEventPublisher.publishEvent(new ProductUnlikedEvent(productId)); } @Override diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCancelledEventHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCancelledEventHandler.java index 21a07d9e7..3fc997653 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCancelledEventHandler.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCancelledEventHandler.java @@ -3,10 +3,8 @@ import com.loopers.domain.model.order.event.OrderCancelledEvent; import com.loopers.domain.model.product.Product; import com.loopers.domain.repository.ProductRepository; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.event.TransactionalEventListener; @Component public class OrderCancelledEventHandler { @@ -17,8 +15,7 @@ public OrderCancelledEventHandler(ProductRepository productRepository) { this.productRepository = productRepository; } - @TransactionalEventListener - @Transactional(propagation = Propagation.REQUIRES_NEW) + @EventListener public void handle(OrderCancelledEvent event) { for (OrderCancelledEvent.CancelledItem item : event.cancelledItems()) { Product product = productRepository.findById(item.productId()) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java index dd69d847d..9aeb06161 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java @@ -1,11 +1,11 @@ package com.loopers.application.order; +import com.loopers.domain.model.common.DomainEventPublisher; import com.loopers.domain.model.order.*; import com.loopers.domain.model.product.Product; import com.loopers.domain.model.user.UserId; import com.loopers.domain.repository.OrderRepository; import com.loopers.domain.repository.ProductRepository; -import com.loopers.infrastructure.common.SpringDomainEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,10 +17,10 @@ public class OrderService implements CreateOrderUseCase, CancelOrderUseCase, Upd private final OrderRepository orderRepository; private final ProductRepository productRepository; - private final SpringDomainEventPublisher eventPublisher; + private final DomainEventPublisher eventPublisher; public OrderService(OrderRepository orderRepository, ProductRepository productRepository, - SpringDomainEventPublisher eventPublisher) { + DomainEventPublisher eventPublisher) { this.orderRepository = orderRepository; this.productRepository = productRepository; this.eventPublisher = eventPublisher; diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java index cf6ce87b2..833c198eb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java @@ -39,7 +39,7 @@ public ProductDetailInfo getProduct(Long productId) { product.getPrice().getValue(), product.getStock().getValue(), product.getLikeCount().getValue(), - product.getDescription() != null ? product.getDescription().getValue() : null + product.getDescription().getValueOrNull() ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/common/DomainEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/common/DomainEventPublisher.java new file mode 100644 index 000000000..b4c71620b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/common/DomainEventPublisher.java @@ -0,0 +1,6 @@ +package com.loopers.domain.model.common; + +public interface DomainEventPublisher { + + void publishEvents(AggregateRoot aggregateRoot); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/like/Like.java index 4efba3726..7aa661852 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/like/Like.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/like/Like.java @@ -2,6 +2,7 @@ import com.loopers.domain.model.common.AggregateRoot; import com.loopers.domain.model.like.event.ProductLikedEvent; +import com.loopers.domain.model.like.event.ProductUnlikedEvent; import com.loopers.domain.model.user.UserId; import lombok.Getter; @@ -34,6 +35,11 @@ public static Like create(UserId userId, Long productId) { return like; } + public Like markUnliked() { + registerEvent(new ProductUnlikedEvent(this.productId)); + return this; + } + public static Like reconstitute(Long id, UserId userId, Long productId, LocalDateTime createdAt) { return new Like(id, userId, productId, createdAt); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Description.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Description.java index 1f6dac012..cd2b4f19f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Description.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Description.java @@ -8,6 +8,7 @@ public class Description { private static final int MAX_LENGTH = 500; + private static final Description EMPTY = new Description(null); private final String value; @@ -26,10 +27,22 @@ public static Description of(String value) { return new Description(trimmed); } + public static Description empty() { + return EMPTY; + } + public static Description ofNullable(String value) { if (value == null || value.isBlank()) { - return null; + return EMPTY; } return of(value); } + + public boolean isEmpty() { + return this.value == null; + } + + public String getValueOrNull() { + return this.value; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/common/SpringDomainEventPublisher.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/common/SpringDomainEventPublisher.java index bfeaa7930..aaf9f8353 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/common/SpringDomainEventPublisher.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/common/SpringDomainEventPublisher.java @@ -1,11 +1,12 @@ package com.loopers.infrastructure.common; import com.loopers.domain.model.common.AggregateRoot; +import com.loopers.domain.model.common.DomainEventPublisher; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; @Component -public class SpringDomainEventPublisher { +public class SpringDomainEventPublisher implements DomainEventPublisher { private final ApplicationEventPublisher eventPublisher; @@ -13,6 +14,7 @@ public SpringDomainEventPublisher(ApplicationEventPublisher eventPublisher) { this.eventPublisher = eventPublisher; } + @Override public void publishEvents(AggregateRoot aggregateRoot) { aggregateRoot.getDomainEvents().forEach(eventPublisher::publishEvent); aggregateRoot.clearDomainEvents(); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index 6840637ba..af33d2ac2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -57,7 +57,7 @@ private ProductJpaEntity toEntity(Product product) { product.getPrice().getValue(), product.getStock().getValue(), product.getLikeCount().getValue(), - product.getDescription() != null ? product.getDescription().getValue() : null, + product.getDescription().getValueOrNull(), product.getCreatedAt(), product.getUpdatedAt(), product.getDeletedAt() diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java index ce8d20650..49bd8d15d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java @@ -4,7 +4,7 @@ import com.loopers.domain.model.brand.BrandName; import com.loopers.domain.model.product.*; import com.loopers.domain.repository.BrandRepository; -import com.loopers.infrastructure.common.SpringDomainEventPublisher; +import com.loopers.domain.model.common.DomainEventPublisher; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -21,13 +21,13 @@ class BrandServiceTest { private BrandRepository brandRepository; - private SpringDomainEventPublisher eventPublisher; + private DomainEventPublisher eventPublisher; private BrandService service; @BeforeEach void setUp() { brandRepository = mock(BrandRepository.class); - eventPublisher = mock(SpringDomainEventPublisher.class); + eventPublisher = mock(DomainEventPublisher.class); service = new BrandService(brandRepository, eventPublisher); } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java index 9f510d676..3b784ff53 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java @@ -1,16 +1,15 @@ package com.loopers.application.like; +import com.loopers.domain.model.common.DomainEventPublisher; import com.loopers.domain.model.like.Like; import com.loopers.domain.model.product.*; import com.loopers.domain.model.user.UserId; import com.loopers.domain.repository.LikeRepository; import com.loopers.domain.repository.ProductRepository; -import com.loopers.infrastructure.common.SpringDomainEventPublisher; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.springframework.context.ApplicationEventPublisher; import java.time.LocalDateTime; import java.util.List; @@ -24,18 +23,15 @@ class LikeServiceTest { private LikeRepository likeRepository; private ProductRepository productRepository; - private SpringDomainEventPublisher domainEventPublisher; - private ApplicationEventPublisher applicationEventPublisher; + private DomainEventPublisher domainEventPublisher; private LikeService service; @BeforeEach void setUp() { likeRepository = mock(LikeRepository.class); productRepository = mock(ProductRepository.class); - domainEventPublisher = mock(SpringDomainEventPublisher.class); - applicationEventPublisher = mock(ApplicationEventPublisher.class); - service = new LikeService(likeRepository, productRepository, - domainEventPublisher, applicationEventPublisher); + domainEventPublisher = mock(DomainEventPublisher.class); + service = new LikeService(likeRepository, productRepository, domainEventPublisher); } @Nested @@ -102,16 +98,18 @@ void unlike_success() { // given UserId userId = UserId.of("test1234"); Product product = createProduct(1L, 1); + Like like = Like.reconstitute(1L, userId, 1L, LocalDateTime.now()); when(productRepository.findById(1L)).thenReturn(Optional.of(product)); when(likeRepository.existsByUserIdAndProductId(userId, 1L)).thenReturn(true); + when(likeRepository.findByUserIdAndProductId(userId, 1L)).thenReturn(Optional.of(like)); // when service.unlike(userId, 1L); // then + verify(domainEventPublisher).publishEvents(any(Like.class)); verify(likeRepository).deleteByUserIdAndProductId(userId, 1L); - verify(applicationEventPublisher).publishEvent(any(Object.class)); } @Test @@ -129,7 +127,7 @@ void unlike_notLiked_ignored() { // then verify(likeRepository, never()).deleteByUserIdAndProductId(any(), any()); - verify(applicationEventPublisher, never()).publishEvent(any(Object.class)); + verify(domainEventPublisher, never()).publishEvents(any()); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java index d8b9da1cb..4a18c3bf6 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java @@ -10,7 +10,7 @@ import com.loopers.domain.model.user.UserId; import com.loopers.domain.repository.OrderRepository; import com.loopers.domain.repository.ProductRepository; -import com.loopers.infrastructure.common.SpringDomainEventPublisher; +import com.loopers.domain.model.common.DomainEventPublisher; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -29,14 +29,14 @@ class OrderServiceTest { private OrderRepository orderRepository; private ProductRepository productRepository; - private SpringDomainEventPublisher eventPublisher; + private DomainEventPublisher eventPublisher; private OrderService service; @BeforeEach void setUp() { orderRepository = mock(OrderRepository.class); productRepository = mock(ProductRepository.class); - eventPublisher = mock(SpringDomainEventPublisher.class); + eventPublisher = mock(DomainEventPublisher.class); service = new OrderService(orderRepository, productRepository, eventPublisher); } From fa4f9ae2e6b83ad81434ea2dc101955dd76054d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Tue, 24 Feb 2026 19:19:38 +0900 Subject: [PATCH 19/20] =?UTF-8?q?refactor:=20unlike()=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=20=EC=BF=BC=EB=A6=AC=20=EC=A0=9C=EA=B1=B0=20(existsBy+findBy?= =?UTF-8?q?=20=E2=86=92=20findBy+ifPresent)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../com/loopers/application/like/LikeService.java | 15 ++++++--------- .../loopers/application/like/LikeServiceTest.java | 3 +-- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java index 7d0b1a87d..8077e721a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java @@ -43,15 +43,12 @@ public void like(UserId userId, Long productId) { public void unlike(UserId userId, Long productId) { findProduct(productId); - if (!likeRepository.existsByUserIdAndProductId(userId, productId)) { - return; - } - - Like like = likeRepository.findByUserIdAndProductId(userId, productId) - .orElseThrow(() -> new IllegalArgumentException("좋아요를 찾을 수 없습니다.")); - like.markUnliked(); - domainEventPublisher.publishEvents(like); - likeRepository.deleteByUserIdAndProductId(userId, productId); + likeRepository.findByUserIdAndProductId(userId, productId) + .ifPresent(like -> { + like.markUnliked(); + domainEventPublisher.publishEvents(like); + likeRepository.deleteByUserIdAndProductId(userId, productId); + }); } @Override diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java index 3b784ff53..608887e70 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java @@ -101,7 +101,6 @@ void unlike_success() { Like like = Like.reconstitute(1L, userId, 1L, LocalDateTime.now()); when(productRepository.findById(1L)).thenReturn(Optional.of(product)); - when(likeRepository.existsByUserIdAndProductId(userId, 1L)).thenReturn(true); when(likeRepository.findByUserIdAndProductId(userId, 1L)).thenReturn(Optional.of(like)); // when @@ -120,7 +119,7 @@ void unlike_notLiked_ignored() { Product product = createProduct(1L, 0); when(productRepository.findById(1L)).thenReturn(Optional.of(product)); - when(likeRepository.existsByUserIdAndProductId(userId, 1L)).thenReturn(false); + when(likeRepository.findByUserIdAndProductId(userId, 1L)).thenReturn(Optional.empty()); // when service.unlike(userId, 1L); From 537f66310348f7f105e80e62ffeff4cd4cd94145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=97=84=EC=9D=B8=EA=B5=AD?= Date: Thu, 26 Feb 2026 12:30:05 +0900 Subject: [PATCH 20/20] =?UTF-8?q?refactor:=20SOLID/DDD=20=EC=9B=90?= =?UTF-8?q?=EC=B9=99=20=EA=B8=B0=EB=B0=98=2012=EA=B0=80=EC=A7=80=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 할인율 계산 로직 도메인 위임 (ProductPricing.calculateDiscountRate) 2. isDeleted() 필터 Repository 통합 (findActiveById, findActiveByIdWithLock) 3. BrandService Command/Query 분리 (BrandQueryService 신규) 4. ProductQueryService N+1 쿼리 해소 (findAllByIds 배치 조회) 5. LikeWithProduct 조회 DTO를 Application 계층으로 이동 (LikeProductReadPort) 6. LikeService Command/Query 분리 (LikeQueryService 신규) 7. VO @Data → @Getter @EqualsAndHashCode (UserId, UserName, Email) 8. Repository 네이밍 도메인 용어 통일 (findAllActive, findActiveByIdWithLock) 9. UserRepositoryImpl wrongPasswordCount 매핑 수정 10. UserName 생성자 private 접근 제어 (VO 불변식 보호) 11. BrandRepository.findAll() → findAllActive() 네이밍 통일 12. domain Repository에서 infrastructure 용어(ForUpdate) 제거 Co-Authored-By: Claude Opus 4.6 --- .../application/brand/BrandQueryService.java | 37 ++++++ .../application/brand/BrandService.java | 30 +---- .../application/like/LikeProductReadPort.java | 21 ++++ .../application/like/LikeQueryService.java | 54 +++++++++ .../application/like/LikeQueryUseCase.java | 7 +- .../loopers/application/like/LikeService.java | 29 +---- .../application/order/OrderQueryService.java | 6 +- .../application/order/OrderService.java | 20 ++-- .../product/CreateProductUseCase.java | 7 +- .../product/ProductQueryService.java | 63 +++++----- .../product/ProductQueryUseCase.java | 8 +- .../application/product/ProductService.java | 20 ++-- .../product/UpdateProductUseCase.java | 7 +- .../application/user/RegisterUseCase.java | 7 +- .../loopers/application/user/UserService.java | 42 ++----- .../com/loopers/domain/model/brand/Brand.java | 25 +++- .../loopers/domain/model/brand/BrandData.java | 13 +++ .../domain/model/common/PageResult.java | 14 +++ .../com/loopers/domain/model/like/Like.java | 5 +- .../loopers/domain/model/order/Address.java | 22 ---- .../domain/model/order/DeliveryInfo.java | 37 ++++++ .../com/loopers/domain/model/order/Order.java | 86 ++++++-------- .../domain/model/order/OrderAmount.java | 41 +++++++ .../loopers/domain/model/order/OrderData.java | 19 +++ .../loopers/domain/model/order/OrderItem.java | 12 +- .../loopers/domain/model/order/OrderLine.java | 6 +- .../loopers/domain/model/order/Quantity.java | 22 ---- .../domain/model/order/ReceiverName.java | 22 ---- .../domain/model/product/Description.java | 48 -------- .../domain/model/product/LikeCount.java | 37 ------ .../loopers/domain/model/product/Product.java | 94 ++++++++++----- .../domain/model/product/ProductData.java | 18 +++ .../domain/model/product/ProductPricing.java | 36 ++++++ .../com/loopers/domain/model/user/Email.java | 6 +- .../com/loopers/domain/model/user/User.java | 26 +++-- .../loopers/domain/model/user/UserData.java | 15 +++ .../com/loopers/domain/model/user/UserId.java | 6 +- .../loopers/domain/model/user/UserName.java | 14 ++- .../domain/model/user/WrongPasswordCount.java | 39 ------- .../domain/repository/BrandRepository.java | 6 +- .../domain/repository/ProductRepository.java | 9 +- .../brand/BrandJpaRepository.java | 2 + .../brand/BrandRepositoryImpl.java | 21 +++- .../like/LikeJpaRepository.java | 8 ++ .../like/LikeRepositoryImpl.java | 27 ++++- .../order/OrderRepositoryImpl.java | 36 +++--- .../product/ProductJpaEntity.java | 7 +- .../product/ProductJpaRepository.java | 9 ++ .../product/ProductRepositoryImpl.java | 58 ++++++++-- .../infrastructure/user/UserJpaEntity.java | 7 +- .../user/UserRepositoryImpl.java | 14 +-- .../interfaces/api/common/PageResponse.java | 10 +- .../interfaces/api/config/WebMvcConfig.java | 1 + .../interfaces/api/like/dto/LikeResponse.java | 10 ++ .../api/product/ProductAdminController.java | 12 +- .../api/product/ProductController.java | 4 +- .../api/product/dto/ProductCreateRequest.java | 9 +- .../product/dto/ProductDetailResponse.java | 4 + .../product/dto/ProductSummaryResponse.java | 4 + .../api/product/dto/ProductUpdateRequest.java | 9 +- .../interfaces/api/user/UserController.java | 26 +++-- .../api/user/dto/UserRegisterRequest.java | 8 +- .../brand/BrandQueryServiceTest.java | 83 +++++++++++++ .../application/brand/BrandServiceTest.java | 53 ++------- .../like/LikeQueryServiceTest.java | 109 ++++++++++++++++++ .../application/like/LikeServiceTest.java | 82 ++----------- .../order/OrderQueryServiceTest.java | 16 +-- .../application/order/OrderServiceTest.java | 31 ++--- .../product/ProductQueryServiceTest.java | 49 ++++---- .../product/ProductServiceTest.java | 44 ++++--- .../user/AuthenticationServiceTest.java | 6 +- .../application/user/UserServiceTest.java | 25 ++-- .../loopers/domain/model/brand/BrandTest.java | 2 +- .../domain/model/order/DeliveryInfoTest.java | 79 +++++++++++++ .../domain/model/order/OrderAmountTest.java | 58 ++++++++++ .../domain/model/order/OrderItemTest.java | 12 +- .../loopers/domain/model/order/OrderTest.java | 108 +++++++++++------ .../model/product/ProductPricingTest.java | 36 ++++++ .../domain/model/product/ProductTest.java | 38 +++++- .../domain/model/user/UserNameTest.java | 20 ++++ .../loopers/domain/model/user/UserTest.java | 73 ++++++++++++ .../model/user/WrongPasswordCountTest.java | 97 ---------------- .../interfaces/api/like/LikeApiE2ETest.java | 6 +- .../api/like/LikeApiIntegrationTest.java | 6 +- .../interfaces/api/order/OrderApiE2ETest.java | 4 +- .../api/order/OrderApiIntegrationTest.java | 4 +- .../api/product/ProductApiE2ETest.java | 4 +- .../product/ProductApiIntegrationTest.java | 8 +- .../interfaces/api/user/UserApiE2ETest.java | 10 +- .../api/user/UserApiIntegrationTest.java | 12 +- 90 files changed, 1505 insertions(+), 892 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandQueryService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeProductReadPort.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeQueryService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/brand/BrandData.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/common/PageResult.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/order/Address.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/order/DeliveryInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderAmount.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderData.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/order/Quantity.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/order/ReceiverName.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/product/Description.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/product/LikeCount.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/product/ProductData.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/product/ProductPricing.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserData.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/model/user/WrongPasswordCount.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/brand/BrandQueryServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/like/LikeQueryServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/model/order/DeliveryInfoTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderAmountTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductPricingTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/model/user/UserTest.java delete mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/model/user/WrongPasswordCountTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandQueryService.java new file mode 100644 index 000000000..b5bd568a3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandQueryService.java @@ -0,0 +1,37 @@ +package com.loopers.application.brand; + +import com.loopers.domain.model.brand.Brand; +import com.loopers.domain.repository.BrandRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional(readOnly = true) +public class BrandQueryService implements BrandQueryUseCase { + + private final BrandRepository brandRepository; + + public BrandQueryService(BrandRepository brandRepository) { + this.brandRepository = brandRepository; + } + + @Override + public BrandInfo getBrand(Long brandId) { + Brand brand = brandRepository.findActiveById(brandId) + .orElseThrow(() -> new IllegalArgumentException("브랜드를 찾을 수 없습니다.")); + return toBrandInfo(brand); + } + + @Override + public List getBrands() { + return brandRepository.findAllActive().stream() + .map(this::toBrandInfo) + .toList(); + } + + private BrandInfo toBrandInfo(Brand brand) { + return new BrandInfo(brand.getId(), brand.getName().getValue(), brand.getDescription()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java index 6952bb1d4..47f67911e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -7,11 +7,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; - @Service -@Transactional(readOnly = true) -public class BrandService implements CreateBrandUseCase, UpdateBrandUseCase, DeleteBrandUseCase, BrandQueryUseCase { +@Transactional +public class BrandService implements CreateBrandUseCase, UpdateBrandUseCase, DeleteBrandUseCase { private final BrandRepository brandRepository; private final DomainEventPublisher eventPublisher; @@ -22,7 +20,6 @@ public BrandService(BrandRepository brandRepository, DomainEventPublisher eventP } @Override - @Transactional public void createBrand(String name, String description) { BrandName brandName = BrandName.of(name); if (brandRepository.existsByName(brandName)) { @@ -33,7 +30,6 @@ public void createBrand(String name, String description) { } @Override - @Transactional public void updateBrand(Long brandId, String name, String description) { Brand brand = findBrand(brandId); Brand updated = brand.update(BrandName.of(name), description); @@ -41,7 +37,6 @@ public void updateBrand(Long brandId, String name, String description) { } @Override - @Transactional public void deleteBrand(Long brandId) { Brand brand = findBrand(brandId); Brand deleted = brand.delete(); @@ -49,27 +44,8 @@ public void deleteBrand(Long brandId) { eventPublisher.publishEvents(deleted); } - @Override - public BrandInfo getBrand(Long brandId) { - Brand brand = findBrand(brandId); - return toBrandInfo(brand); - } - - @Override - public List getBrands() { - return brandRepository.findAll().stream() - .filter(brand -> !brand.isDeleted()) - .map(this::toBrandInfo) - .toList(); - } - private Brand findBrand(Long brandId) { - return brandRepository.findById(brandId) - .filter(brand -> !brand.isDeleted()) + return brandRepository.findActiveById(brandId) .orElseThrow(() -> new IllegalArgumentException("브랜드를 찾을 수 없습니다.")); } - - private BrandInfo toBrandInfo(Brand brand) { - return new BrandInfo(brand.getId(), brand.getName().getValue(), brand.getDescription()); - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeProductReadPort.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeProductReadPort.java new file mode 100644 index 000000000..0250d851b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeProductReadPort.java @@ -0,0 +1,21 @@ +package com.loopers.application.like; + +import com.loopers.domain.model.user.UserId; + +import java.time.LocalDateTime; +import java.util.List; + +public interface LikeProductReadPort { + + List findLikedProductsByUserId(UserId userId); + + record LikeProductView( + Long productId, + String productName, + int price, + Integer salePrice, + int stockQuantity, + String brandName, + LocalDateTime likedAt + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeQueryService.java new file mode 100644 index 000000000..1e7920159 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeQueryService.java @@ -0,0 +1,54 @@ +package com.loopers.application.like; + +import com.loopers.application.like.LikeProductReadPort.LikeProductView; +import com.loopers.domain.model.product.ProductPricing; +import com.loopers.domain.model.user.UserId; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Comparator; +import java.util.List; +import java.util.stream.Stream; + +@Service +@Transactional(readOnly = true) +public class LikeQueryService implements LikeQueryUseCase { + + private final LikeProductReadPort likeProductReadPort; + + public LikeQueryService(LikeProductReadPort likeProductReadPort) { + this.likeProductReadPort = likeProductReadPort; + } + + @Override + public List getMyLikes(UserId userId, String sort, Boolean saleYn, String status) { + List likes = likeProductReadPort.findLikedProductsByUserId(userId); + + Stream stream = likes.stream() + .map(lp -> { + boolean onSale = lp.salePrice() != null; + int discountRate = ProductPricing.calculateDiscountRate(lp.price(), lp.salePrice()); + boolean soldOut = lp.stockQuantity() == 0; + return new LikeInfo( + lp.productId(), lp.productName(), lp.price(), lp.salePrice(), + onSale, discountRate, lp.brandName(), soldOut, lp.likedAt() + ); + }); + + if (Boolean.TRUE.equals(saleYn)) { + stream = stream.filter(LikeInfo::onSale); + } + if ("selling".equals(status)) { + stream = stream.filter(info -> !info.soldOut()); + } + + Comparator comparator = switch (sort != null ? sort : "latest") { + case "price_asc" -> Comparator.comparingInt(LikeInfo::price); + case "discount_rate_desc" -> Comparator.comparingInt(LikeInfo::discountRate).reversed(); + case "brand_name_asc" -> Comparator.comparing(LikeInfo::brandName); + default -> Comparator.comparing(LikeInfo::likedAt).reversed(); + }; + + return stream.sorted(comparator).toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeQueryUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeQueryUseCase.java index f5d8dc6ea..967a0ce1c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeQueryUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeQueryUseCase.java @@ -7,12 +7,17 @@ public interface LikeQueryUseCase { - List getMyLikes(UserId userId); + List getMyLikes(UserId userId, String sort, Boolean saleYn, String status); record LikeInfo( Long productId, String productName, int price, + Integer salePrice, + boolean onSale, + int discountRate, + String brandName, + boolean soldOut, LocalDateTime likedAt ) {} } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java index 8077e721a..3f9a5558f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeService.java @@ -9,11 +9,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; - @Service @Transactional -public class LikeService implements LikeUseCase, UnlikeUseCase, LikeQueryUseCase { +public class LikeService implements LikeUseCase, UnlikeUseCase { private final LikeRepository likeRepository; private final ProductRepository productRepository; @@ -45,33 +43,14 @@ public void unlike(UserId userId, Long productId) { likeRepository.findByUserIdAndProductId(userId, productId) .ifPresent(like -> { - like.markUnliked(); - domainEventPublisher.publishEvents(like); + Like unliked = like.markUnliked(); + domainEventPublisher.publishEvents(unliked); likeRepository.deleteByUserIdAndProductId(userId, productId); }); } - @Override - @Transactional(readOnly = true) - public List getMyLikes(UserId userId) { - List likes = likeRepository.findAllByUserId(userId); - - return likes.stream() - .flatMap(like -> productRepository.findById(like.getProductId()) - .filter(p -> !p.isDeleted()) - .map(product -> new LikeInfo( - product.getId(), - product.getName().getValue(), - product.getPrice().getValue(), - like.getCreatedAt() - )) - .stream()) - .toList(); - } - private Product findProduct(Long productId) { - return productRepository.findById(productId) - .filter(p -> !p.isDeleted()) + return productRepository.findActiveById(productId) .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다.")); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryService.java index 97d40f5fd..319f13d6a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderQueryService.java @@ -76,8 +76,8 @@ private OrderDetail toOrderDetail(Order order) { return new OrderDetail( order.getId(), - order.getReceiverName().getValue(), - order.getAddress().getValue(), + order.getReceiverName(), + order.getAddress(), order.getDeliveryRequest(), order.getPaymentMethod().name(), order.getTotalAmount().getValue(), @@ -92,7 +92,7 @@ private OrderDetail toOrderDetail(Order order) { private OrderItemDetail toOrderItemDetail(OrderItem item) { return new OrderItemDetail( item.getProductId(), - item.getQuantity().getValue(), + item.getQuantity(), item.getUnitPrice().getValue() ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java index 9aeb06161..0613271be 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java @@ -30,8 +30,7 @@ public OrderService(OrderRepository orderRepository, ProductRepository productRe public void createOrder(UserId userId, OrderCommand command) { List orderLines = command.items().stream() .map(itemCommand -> { - Product product = productRepository.findById(itemCommand.productId()) - .filter(p -> !p.isDeleted()) + Product product = productRepository.findActiveByIdWithLock(itemCommand.productId()) .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다. ID: " + itemCommand.productId())); Product decreased = product.decreaseStock(itemCommand.quantity()); @@ -41,22 +40,21 @@ public void createOrder(UserId userId, OrderCommand command) { product.getId(), product.getName().getValue(), Money.of(product.getPrice().getValue()), - Quantity.of(itemCommand.quantity()) + itemCommand.quantity() ); }) .toList(); - PaymentMethod paymentMethod = PaymentMethod.valueOf(command.paymentMethod()); - Order order = Order.create( - userId, orderLines, - ReceiverName.of(command.receiverName()), - Address.of(command.address()), + DeliveryInfo deliveryInfo = DeliveryInfo.of( + command.receiverName(), + command.address(), command.deliveryRequest(), - paymentMethod, - Money.zero(), command.desiredDeliveryDate() ); + PaymentMethod paymentMethod = PaymentMethod.valueOf(command.paymentMethod()); + Order order = Order.create(userId, orderLines, deliveryInfo, paymentMethod, Money.zero()); + orderRepository.save(order); } @@ -77,7 +75,7 @@ public void updateDeliveryAddress(UserId userId, Long orderId, String newAddress .filter(o -> o.getUserId().equals(userId)) .orElseThrow(() -> new IllegalArgumentException("주문을 찾을 수 없습니다.")); - Order updated = order.updateDeliveryAddress(Address.of(newAddress)); + Order updated = order.updateDeliveryAddress(newAddress); orderRepository.save(updated); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/CreateProductUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/product/CreateProductUseCase.java index 3d9a120c8..9265bd4f0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/CreateProductUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/CreateProductUseCase.java @@ -2,5 +2,10 @@ public interface CreateProductUseCase { - void createProduct(Long brandId, String name, int price, int stock, String description); + void createProduct(ProductCreateCommand command); + + record ProductCreateCommand( + Long brandId, String name, int price, + Integer salePrice, int stock, String description + ) {} } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java index 833c198eb..e8faae10b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java @@ -1,15 +1,17 @@ package com.loopers.application.product; import com.loopers.domain.model.brand.Brand; +import com.loopers.domain.model.common.PageResult; import com.loopers.domain.model.product.Product; import com.loopers.domain.repository.BrandRepository; import com.loopers.domain.repository.ProductRepository; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + @Service @Transactional(readOnly = true) public class ProductQueryService implements ProductQueryUseCase { @@ -24,8 +26,7 @@ public ProductQueryService(ProductRepository productRepository, BrandRepository @Override public ProductDetailInfo getProduct(Long productId) { - Product product = productRepository.findById(productId) - .filter(p -> !p.isDeleted()) + Product product = productRepository.findActiveById(productId) .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다.")); Brand brand = brandRepository.findById(product.getBrandId()) @@ -37,43 +38,35 @@ public ProductDetailInfo getProduct(Long productId) { brand.getName().getValue(), product.getName().getValue(), product.getPrice().getValue(), + product.getSalePrice() != null ? product.getSalePrice().getValue() : null, + product.isOnSale(), product.getStock().getValue(), - product.getLikeCount().getValue(), - product.getDescription().getValueOrNull() + product.getLikeCount(), + product.getDescription() ); } @Override - public Page getProducts(Long brandId, String sort, int page, int size) { - Sort sorting = resolveSort(sort); - PageRequest pageRequest = PageRequest.of(page, size, sorting); + public PageResult getProducts(Long brandId, String sort, int page, int size) { + PageResult products = productRepository.findAllActive(brandId, sort, page, size); - Page products = productRepository.findAllByDeletedAtIsNull(brandId, pageRequest); + List brandIds = products.content().stream() + .map(Product::getBrandId) + .distinct() + .toList(); - return products.map(product -> { - String brandName = brandRepository.findById(product.getBrandId()) - .map(b -> b.getName().getValue()) - .orElse(""); - return new ProductSummaryInfo( - product.getId(), - product.getBrandId(), - brandName, - product.getName().getValue(), - product.getPrice().getValue(), - product.getLikeCount().getValue() - ); - }); - } + Map brandNameMap = brandRepository.findAllByIds(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, b -> b.getName().getValue())); - private Sort resolveSort(String sort) { - if (sort == null) { - return Sort.by(Sort.Direction.DESC, "createdAt"); - } - return switch (sort) { - case "price_asc" -> Sort.by(Sort.Direction.ASC, "price"); - case "price_desc" -> Sort.by(Sort.Direction.DESC, "price"); - case "likes_desc" -> Sort.by(Sort.Direction.DESC, "likeCount"); - default -> Sort.by(Sort.Direction.DESC, "createdAt"); - }; + return products.map(product -> new ProductSummaryInfo( + product.getId(), + product.getBrandId(), + brandNameMap.getOrDefault(product.getBrandId(), ""), + product.getName().getValue(), + product.getPrice().getValue(), + product.getSalePrice() != null ? product.getSalePrice().getValue() : null, + product.isOnSale(), + product.getLikeCount() + )); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryUseCase.java index 10f4bb31e..bc6c32b07 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryUseCase.java @@ -1,12 +1,12 @@ package com.loopers.application.product; -import org.springframework.data.domain.Page; +import com.loopers.domain.model.common.PageResult; public interface ProductQueryUseCase { ProductDetailInfo getProduct(Long productId); - Page getProducts(Long brandId, String sort, int page, int size); + PageResult getProducts(Long brandId, String sort, int page, int size); record ProductDetailInfo( Long id, @@ -14,6 +14,8 @@ record ProductDetailInfo( String brandName, String name, int price, + Integer salePrice, + boolean onSale, int stock, int likeCount, String description @@ -25,6 +27,8 @@ record ProductSummaryInfo( String brandName, String name, int price, + Integer salePrice, + boolean onSale, int likeCount ) {} } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index 2ada77bf2..fd32c1c03 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -25,19 +25,22 @@ public ProductService(ProductRepository productRepository, BrandRepository brand } @Override - public void createProduct(Long brandId, String name, int price, int stock, String description) { - brandRepository.findById(brandId) - .filter(brand -> !brand.isDeleted()) + public void createProduct(ProductCreateCommand command) { + brandRepository.findActiveById(command.brandId()) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 브랜드입니다.")); - Product product = Product.create(brandId, ProductName.of(name), Price.of(price), Stock.of(stock), description); + Price salePriceVo = command.salePrice() != null ? Price.of(command.salePrice()) : null; + Product product = Product.create(command.brandId(), ProductName.of(command.name()), + Price.of(command.price()), salePriceVo, Stock.of(command.stock()), command.description()); productRepository.save(product); } @Override - public void updateProduct(Long productId, String name, int price, int stock, String description) { - Product product = findProduct(productId); - Product updated = product.update(ProductName.of(name), Price.of(price), Stock.of(stock), description); + public void updateProduct(ProductUpdateCommand command) { + Product product = findProduct(command.productId()); + Price salePriceVo = command.salePrice() != null ? Price.of(command.salePrice()) : null; + Product updated = product.update(ProductName.of(command.name()), Price.of(command.price()), + salePriceVo, Stock.of(command.stock()), command.description()); productRepository.save(updated); } @@ -49,8 +52,7 @@ public void deleteProduct(Long productId) { } private Product findProduct(Long productId) { - return productRepository.findById(productId) - .filter(product -> !product.isDeleted()) + return productRepository.findActiveById(productId) .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다.")); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/UpdateProductUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/product/UpdateProductUseCase.java index 8d228b9b0..21dd42b0c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/UpdateProductUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/UpdateProductUseCase.java @@ -2,5 +2,10 @@ public interface UpdateProductUseCase { - void updateProduct(Long productId, String name, int price, int stock, String description); + void updateProduct(ProductUpdateCommand command); + + record ProductUpdateCommand( + Long productId, String name, int price, + Integer salePrice, int stock, String description + ) {} } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/RegisterUseCase.java b/apps/commerce-api/src/main/java/com/loopers/application/user/RegisterUseCase.java index 6a3602ef7..46d645572 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/RegisterUseCase.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/RegisterUseCase.java @@ -4,5 +4,10 @@ public interface RegisterUseCase { - void register(String loginId, String name, String rawPassword, LocalDate birthday, String email); + void register(RegisterCommand command); + + record RegisterCommand( + String loginId, String name, String rawPassword, + LocalDate birthday, String email + ) {} } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java index 586854207..c12bf11f9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserService.java @@ -10,7 +10,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDate; import java.time.LocalDateTime; @Service @@ -27,18 +26,18 @@ public UserService(UserRepository userRepository, PasswordEncoder passwordEncode @Override @Transactional - public void register(String loginId, String name, String rawPassword, LocalDate birthday, String email) { - UserId userId = UserId.of(loginId); - UserName userName = UserName.of(name); - Birthday birth = Birthday.of(birthday); - Email userEmail = Email.of(email); - Password password = Password.of(rawPassword, birthday); + public void register(RegisterCommand command) { + UserId userId = UserId.of(command.loginId()); + UserName userName = UserName.of(command.name()); + Birthday birth = Birthday.of(command.birthday()); + Email userEmail = Email.of(command.email()); + Password password = Password.of(command.rawPassword(), command.birthday()); String encodedPassword = passwordEncoder.encrypt(password.getValue()); try { User user = User.register( userId, userName, encodedPassword, birth, - userEmail, WrongPasswordCount.init(), LocalDateTime.now() + userEmail, LocalDateTime.now() ); userRepository.save(user); } catch (DataIntegrityViolationException ex) { @@ -50,20 +49,7 @@ public void register(String loginId, String name, String rawPassword, LocalDate @Transactional public void updatePassword(UserId userId, String currentRawPassword, String newRawPassword) { User user = findUser(userId); - - LocalDate birthday = user.getBirth().getValue(); - Password newPassword = Password.of(newRawPassword, birthday); - - if (!passwordEncoder.matches(currentRawPassword, user.getEncodedPassword())) { - throw new IllegalArgumentException("현재 비밀번호가 일치하지 않습니다."); - } - - if (passwordEncoder.matches(newPassword.getValue(), user.getEncodedPassword())) { - throw new IllegalArgumentException("현재 비밀번호는 사용할 수 없습니다."); - } - - String encodedNewPassword = passwordEncoder.encrypt(newPassword.getValue()); - User updatedUser = user.changePassword(encodedNewPassword); + User updatedUser = user.changePassword(currentRawPassword, newRawPassword, passwordEncoder); userRepository.save(updatedUser); } @@ -73,7 +59,7 @@ public UserInfoResponse getUserInfo(UserId userId) { return new UserInfoResponse( user.getUserId().getValue(), - maskName(user.getUserName().getValue()), + user.getUserName().maskedValue(), user.getBirth().getValue(), user.getEmail().getValue() ); @@ -83,14 +69,4 @@ private User findUser(UserId userId) { return userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); } - - private String maskName(String name) { - if (name == null || name.isEmpty()) { - return name; - } - if (name.length() == 1) { - return "*"; - } - return name.substring(0, name.length() - 1) + "*"; - } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/Brand.java index 6e5d860c8..8f20337ee 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/Brand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/Brand.java @@ -9,6 +9,8 @@ @Getter public class Brand extends AggregateRoot { + private static final int DESCRIPTION_MAX_LENGTH = 500; + private final Long id; private final BrandName name; private final String description; @@ -28,17 +30,17 @@ private Brand(Long id, BrandName name, String description, public static Brand create(BrandName name, String description) { LocalDateTime now = LocalDateTime.now(); - return new Brand(null, name, description, now, now, null); + return new Brand(null, name, validateDescription(description), now, now, null); } - public static Brand reconstitute(Long id, BrandName name, String description, - LocalDateTime createdAt, LocalDateTime updatedAt, - LocalDateTime deletedAt) { - return new Brand(id, name, description, createdAt, updatedAt, deletedAt); + public static Brand reconstitute(BrandData data) { + return new Brand(data.id(), data.name(), data.description(), + data.createdAt(), data.updatedAt(), data.deletedAt()); } public Brand update(BrandName name, String description) { - return new Brand(this.id, name, description, this.createdAt, LocalDateTime.now(), this.deletedAt); + return new Brand(this.id, name, validateDescription(description), + this.createdAt, LocalDateTime.now(), this.deletedAt); } public Brand delete() { @@ -54,4 +56,15 @@ public Brand delete() { public boolean isDeleted() { return this.deletedAt != null; } + + private static String validateDescription(String description) { + if (description == null || description.isBlank()) { + return null; + } + String trimmed = description.trim(); + if (trimmed.length() > DESCRIPTION_MAX_LENGTH) { + throw new IllegalArgumentException("설명은 " + DESCRIPTION_MAX_LENGTH + "자 이하여야 합니다."); + } + return trimmed; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/BrandData.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/BrandData.java new file mode 100644 index 000000000..58ec404d3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/brand/BrandData.java @@ -0,0 +1,13 @@ +package com.loopers.domain.model.brand; + +import java.time.LocalDateTime; + +public record BrandData( + Long id, + BrandName name, + String description, + LocalDateTime createdAt, + LocalDateTime updatedAt, + LocalDateTime deletedAt +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/common/PageResult.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/common/PageResult.java new file mode 100644 index 000000000..8fb3c7833 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/common/PageResult.java @@ -0,0 +1,14 @@ +package com.loopers.domain.model.common; + +import java.util.List; +import java.util.function.Function; + +public record PageResult( + List content, int page, int size, + long totalElements, int totalPages +) { + public PageResult map(Function mapper) { + List mapped = content.stream().map(mapper).toList(); + return new PageResult<>(mapped, page, size, totalElements, totalPages); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/like/Like.java index 7aa661852..ee94dfb62 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/like/Like.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/like/Like.java @@ -36,8 +36,9 @@ public static Like create(UserId userId, Long productId) { } public Like markUnliked() { - registerEvent(new ProductUnlikedEvent(this.productId)); - return this; + Like unliked = new Like(this.id, this.userId, this.productId, this.createdAt); + unliked.registerEvent(new ProductUnlikedEvent(this.productId)); + return unliked; } public static Like reconstitute(Long id, UserId userId, Long productId, LocalDateTime createdAt) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Address.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Address.java deleted file mode 100644 index 53a295772..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Address.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.loopers.domain.model.order; - -import lombok.EqualsAndHashCode; -import lombok.Getter; - -@Getter -@EqualsAndHashCode -public class Address { - - private final String value; - - private Address(String value) { - this.value = value; - } - - public static Address of(String value) { - if (value == null || value.isBlank()) { - throw new IllegalArgumentException("배송지 주소는 필수 입력값입니다."); - } - return new Address(value.trim()); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/DeliveryInfo.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/DeliveryInfo.java new file mode 100644 index 000000000..506866b22 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/DeliveryInfo.java @@ -0,0 +1,37 @@ +package com.loopers.domain.model.order; + +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +public class DeliveryInfo { + + private final String receiverName; + private final String address; + private final String deliveryRequest; + private final LocalDate desiredDeliveryDate; + + private DeliveryInfo(String receiverName, String address, + String deliveryRequest, LocalDate desiredDeliveryDate) { + if (receiverName == null || receiverName.isBlank()) { + throw new IllegalArgumentException("수령인 이름은 필수입니다."); + } + if (address == null || address.isBlank()) { + throw new IllegalArgumentException("배송 주소는 필수입니다."); + } + this.receiverName = receiverName.trim(); + this.address = address.trim(); + this.deliveryRequest = deliveryRequest; + this.desiredDeliveryDate = desiredDeliveryDate; + } + + public static DeliveryInfo of(String receiverName, String address, + String deliveryRequest, LocalDate desiredDeliveryDate) { + return new DeliveryInfo(receiverName, address, deliveryRequest, desiredDeliveryDate); + } + + public DeliveryInfo withAddress(String newAddress) { + return new DeliveryInfo(this.receiverName, newAddress, this.deliveryRequest, this.desiredDeliveryDate); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Order.java index c0ef2405d..65cc76666 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Order.java @@ -17,52 +17,35 @@ public class Order extends AggregateRoot { private final UserId userId; private final List items; private final OrderSnapshot snapshot; - private final ReceiverName receiverName; - private final Address address; - private final String deliveryRequest; - private final PaymentMethod paymentMethod; - private final Money totalAmount; - private final Money discountAmount; - private final Money paymentAmount; + private final DeliveryInfo deliveryInfo; + private final OrderAmount orderAmount; private final OrderStatus status; - private final LocalDate desiredDeliveryDate; private final LocalDateTime createdAt; private final LocalDateTime updatedAt; private Order(Long id, UserId userId, List items, OrderSnapshot snapshot, - ReceiverName receiverName, Address address, String deliveryRequest, - PaymentMethod paymentMethod, Money totalAmount, Money discountAmount, - Money paymentAmount, OrderStatus status, LocalDate desiredDeliveryDate, - LocalDateTime createdAt, LocalDateTime updatedAt) { + DeliveryInfo deliveryInfo, OrderAmount orderAmount, + OrderStatus status, LocalDateTime createdAt, LocalDateTime updatedAt) { this.id = id; this.userId = userId; this.items = items; this.snapshot = snapshot; - this.receiverName = receiverName; - this.address = address; - this.deliveryRequest = deliveryRequest; - this.paymentMethod = paymentMethod; - this.totalAmount = totalAmount; - this.discountAmount = discountAmount; - this.paymentAmount = paymentAmount; + this.deliveryInfo = deliveryInfo; + this.orderAmount = orderAmount; this.status = status; - this.desiredDeliveryDate = desiredDeliveryDate; this.createdAt = createdAt; this.updatedAt = updatedAt; } - public static Order create(UserId userId, List orderLines, ReceiverName receiverName, - Address address, String deliveryRequest, PaymentMethod paymentMethod, - Money discountAmount, LocalDate desiredDeliveryDate) { + public static Order create(UserId userId, List orderLines, + DeliveryInfo deliveryInfo, PaymentMethod paymentMethod, + Money discountAmount) { if (userId == null) { throw new IllegalArgumentException("사용자 ID는 필수입니다."); } if (orderLines == null || orderLines.isEmpty()) { throw new IllegalArgumentException("주문 항목은 1개 이상이어야 합니다."); } - if (paymentMethod == null) { - throw new IllegalArgumentException("결제 수단은 필수입니다."); - } List items = orderLines.stream() .map(line -> OrderItem.create(line.productId(), line.quantity(), line.unitPrice())) @@ -74,22 +57,17 @@ public static Order create(UserId userId, List orderLines, ReceiverNa OrderSnapshot snapshot = OrderSnapshot.create(snapshotData + ","); Money totalAmount = calculateTotalAmount(items); - Money paymentAmount = totalAmount.subtract(discountAmount); + OrderAmount orderAmount = OrderAmount.of(paymentMethod, totalAmount, discountAmount); LocalDateTime now = LocalDateTime.now(); - return new Order(null, userId, items, snapshot, receiverName, address, deliveryRequest, - paymentMethod, totalAmount, discountAmount, paymentAmount, - OrderStatus.PAYMENT_COMPLETED, desiredDeliveryDate, now, now); + return new Order(null, userId, items, snapshot, deliveryInfo, orderAmount, + OrderStatus.PAYMENT_COMPLETED, now, now); } - public static Order reconstitute(Long id, UserId userId, List items, OrderSnapshot snapshot, - ReceiverName receiverName, Address address, String deliveryRequest, - PaymentMethod paymentMethod, Money totalAmount, Money discountAmount, - Money paymentAmount, OrderStatus status, LocalDate desiredDeliveryDate, - LocalDateTime createdAt, LocalDateTime updatedAt) { - return new Order(id, userId, items, snapshot, receiverName, address, deliveryRequest, - paymentMethod, totalAmount, discountAmount, paymentAmount, status, - desiredDeliveryDate, createdAt, updatedAt); + public static Order reconstitute(OrderData data) { + return new Order(data.id(), data.userId(), data.items(), data.snapshot(), + data.deliveryInfo(), data.orderAmount(), data.status(), + data.createdAt(), data.updatedAt()); } public Order cancel() { @@ -97,36 +75,48 @@ public Order cancel() { throw new IllegalStateException("현재 상태에서는 주문을 취소할 수 없습니다. 현재 상태: " + status.getDescription()); } - Order cancelled = new Order(this.id, this.userId, this.items, this.snapshot, this.receiverName, - this.address, this.deliveryRequest, this.paymentMethod, this.totalAmount, - this.discountAmount, this.paymentAmount, OrderStatus.CANCELLED, - this.desiredDeliveryDate, this.createdAt, LocalDateTime.now()); + Order cancelled = withStatus(OrderStatus.CANCELLED); List cancelledItems = this.items.stream() - .map(item -> new OrderCancelledEvent.CancelledItem(item.getProductId(), item.getQuantity().getValue())) + .map(item -> new OrderCancelledEvent.CancelledItem(item.getProductId(), item.getQuantity())) .toList(); cancelled.registerEvent(new OrderCancelledEvent(this.id, cancelledItems)); return cancelled; } - public Order updateDeliveryAddress(Address newAddress) { + public Order updateDeliveryAddress(String newAddress) { if (!status.isAddressChangeable()) { throw new IllegalStateException("현재 상태에서는 배송지를 변경할 수 없습니다. 현재 상태: " + status.getDescription()); } - return new Order(this.id, this.userId, this.items, this.snapshot, this.receiverName, - newAddress, this.deliveryRequest, this.paymentMethod, this.totalAmount, - this.discountAmount, this.paymentAmount, this.status, - this.desiredDeliveryDate, this.createdAt, LocalDateTime.now()); + return new Order(this.id, this.userId, this.items, this.snapshot, + this.deliveryInfo.withAddress(newAddress), this.orderAmount, + this.status, this.createdAt, LocalDateTime.now()); } public boolean isCancellable() { return status.isCancellable(); } + private Order withStatus(OrderStatus newStatus) { + return new Order(this.id, this.userId, this.items, this.snapshot, + this.deliveryInfo, this.orderAmount, + newStatus, this.createdAt, LocalDateTime.now()); + } + private static Money calculateTotalAmount(List items) { return items.stream() .map(OrderItem::calculateAmount) .reduce(Money.zero(), Money::add); } + + // Delegate getters + public String getReceiverName() { return deliveryInfo.getReceiverName(); } + public String getAddress() { return deliveryInfo.getAddress(); } + public String getDeliveryRequest() { return deliveryInfo.getDeliveryRequest(); } + public LocalDate getDesiredDeliveryDate() { return deliveryInfo.getDesiredDeliveryDate(); } + public PaymentMethod getPaymentMethod() { return orderAmount.getPaymentMethod(); } + public Money getTotalAmount() { return orderAmount.getTotalAmount(); } + public Money getDiscountAmount() { return orderAmount.getDiscountAmount(); } + public Money getPaymentAmount() { return orderAmount.getPaymentAmount(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderAmount.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderAmount.java new file mode 100644 index 000000000..d964c0d96 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderAmount.java @@ -0,0 +1,41 @@ +package com.loopers.domain.model.order; + +import lombok.Getter; + +@Getter +public class OrderAmount { + + private final PaymentMethod paymentMethod; + private final Money totalAmount; + private final Money discountAmount; + private final Money paymentAmount; + + private OrderAmount(PaymentMethod paymentMethod, Money totalAmount, + Money discountAmount, Money paymentAmount) { + if (paymentMethod == null) { + throw new IllegalArgumentException("결제 수단은 필수입니다."); + } + if (totalAmount == null) { + throw new IllegalArgumentException("총 금액은 필수입니다."); + } + this.paymentMethod = paymentMethod; + this.totalAmount = totalAmount; + this.discountAmount = discountAmount != null ? discountAmount : Money.zero(); + this.paymentAmount = paymentAmount; + } + + public static OrderAmount of(PaymentMethod paymentMethod, Money totalAmount, + Money discountAmount) { + if (totalAmount == null) { + throw new IllegalArgumentException("총 금액은 필수입니다."); + } + Money discount = discountAmount != null ? discountAmount : Money.zero(); + Money payment = totalAmount.subtract(discount); + return new OrderAmount(paymentMethod, totalAmount, discount, payment); + } + + public static OrderAmount reconstitute(PaymentMethod paymentMethod, Money totalAmount, + Money discountAmount, Money paymentAmount) { + return new OrderAmount(paymentMethod, totalAmount, discountAmount, paymentAmount); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderData.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderData.java new file mode 100644 index 000000000..df7f37294 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderData.java @@ -0,0 +1,19 @@ +package com.loopers.domain.model.order; + +import com.loopers.domain.model.user.UserId; + +import java.time.LocalDateTime; +import java.util.List; + +public record OrderData( + Long id, + UserId userId, + List items, + OrderSnapshot snapshot, + DeliveryInfo deliveryInfo, + OrderAmount orderAmount, + OrderStatus status, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderItem.java index fdcca357c..33188c619 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderItem.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderItem.java @@ -10,15 +10,15 @@ public class OrderItem { private final Long id; private final Long productId; - private final Quantity quantity; + private final int quantity; private final Money unitPrice; - public static OrderItem create(Long productId, Quantity quantity, Money unitPrice) { + public static OrderItem create(Long productId, int quantity, Money unitPrice) { if (productId == null) { throw new IllegalArgumentException("상품 ID는 필수입니다."); } - if (quantity == null) { - throw new IllegalArgumentException("주문 수량은 필수입니다."); + if (quantity < 1) { + throw new IllegalArgumentException("주문 수량은 1 이상이어야 합니다."); } if (unitPrice == null) { throw new IllegalArgumentException("단가는 필수입니다."); @@ -26,11 +26,11 @@ public static OrderItem create(Long productId, Quantity quantity, Money unitPric return new OrderItem(null, productId, quantity, unitPrice); } - public static OrderItem reconstitute(Long id, Long productId, Quantity quantity, Money unitPrice) { + public static OrderItem reconstitute(Long id, Long productId, int quantity, Money unitPrice) { return new OrderItem(id, productId, quantity, unitPrice); } public Money calculateAmount() { - return unitPrice.multiply(quantity.getValue()); + return unitPrice.multiply(quantity); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderLine.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderLine.java index 57373b4b0..806715589 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderLine.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/OrderLine.java @@ -4,7 +4,7 @@ public record OrderLine( Long productId, String productName, Money unitPrice, - Quantity quantity + int quantity ) { public OrderLine { if (productId == null) { @@ -16,8 +16,8 @@ public record OrderLine( if (unitPrice == null) { throw new IllegalArgumentException("단가는 필수입니다."); } - if (quantity == null) { - throw new IllegalArgumentException("수량은 필수입니다."); + if (quantity < 1) { + throw new IllegalArgumentException("수량은 1 이상이어야 합니다."); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Quantity.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Quantity.java deleted file mode 100644 index 0f06adfb6..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/Quantity.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.loopers.domain.model.order; - -import lombok.EqualsAndHashCode; -import lombok.Getter; - -@Getter -@EqualsAndHashCode -public class Quantity { - - private final int value; - - private Quantity(int value) { - this.value = value; - } - - public static Quantity of(int value) { - if (value < 1) { - throw new IllegalArgumentException("주문 수량은 1 이상이어야 합니다."); - } - return new Quantity(value); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/ReceiverName.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/order/ReceiverName.java deleted file mode 100644 index 2db5ae56c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/order/ReceiverName.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.loopers.domain.model.order; - -import lombok.EqualsAndHashCode; -import lombok.Getter; - -@Getter -@EqualsAndHashCode -public class ReceiverName { - - private final String value; - - private ReceiverName(String value) { - this.value = value; - } - - public static ReceiverName of(String value) { - if (value == null || value.isBlank()) { - throw new IllegalArgumentException("수령인 이름은 필수 입력값입니다."); - } - return new ReceiverName(value.trim()); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Description.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Description.java deleted file mode 100644 index cd2b4f19f..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Description.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.loopers.domain.model.product; - -import lombok.EqualsAndHashCode; -import lombok.Getter; - -@Getter -@EqualsAndHashCode -public class Description { - - private static final int MAX_LENGTH = 500; - private static final Description EMPTY = new Description(null); - - private final String value; - - private Description(String value) { - this.value = value; - } - - public static Description of(String value) { - if (value == null || value.isBlank()) { - throw new IllegalArgumentException("상품 설명은 필수 입력값입니다."); - } - String trimmed = value.trim(); - if (trimmed.length() > MAX_LENGTH) { - throw new IllegalArgumentException("상품 설명은 " + MAX_LENGTH + "자 이하여야 합니다."); - } - return new Description(trimmed); - } - - public static Description empty() { - return EMPTY; - } - - public static Description ofNullable(String value) { - if (value == null || value.isBlank()) { - return EMPTY; - } - return of(value); - } - - public boolean isEmpty() { - return this.value == null; - } - - public String getValueOrNull() { - return this.value; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/LikeCount.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/LikeCount.java deleted file mode 100644 index 061e81a60..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/LikeCount.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.loopers.domain.model.product; - -import lombok.EqualsAndHashCode; -import lombok.Getter; - -@Getter -@EqualsAndHashCode -public class LikeCount { - - private final int value; - - private LikeCount(int value) { - this.value = value; - } - - public static LikeCount of(int value) { - if (value < 0) { - throw new IllegalArgumentException("좋아요 수는 0 이상이어야 합니다."); - } - return new LikeCount(value); - } - - public static LikeCount zero() { - return new LikeCount(0); - } - - public LikeCount increase() { - return new LikeCount(this.value + 1); - } - - public LikeCount decrease() { - if (this.value <= 0) { - throw new IllegalStateException("좋아요 수는 0 미만이 될 수 없습니다."); - } - return new LikeCount(this.value - 1); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Product.java index 4401c1f80..b07c31cee 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/Product.java @@ -10,66 +10,108 @@ @AllArgsConstructor(access = AccessLevel.PRIVATE) public class Product { + private static final int DESCRIPTION_MAX_LENGTH = 500; + private final Long id; private final Long brandId; private final ProductName name; - private final Price price; + private final ProductPricing pricing; private final Stock stock; - private final LikeCount likeCount; - private final Description description; + private final int likeCount; + private final String description; private final LocalDateTime createdAt; private final LocalDateTime updatedAt; private final LocalDateTime deletedAt; - public static Product create(Long brandId, ProductName name, Price price, Stock stock, String description) { + public static Product create(Long brandId, ProductName name, Price price, Price salePrice, + Stock stock, String description) { LocalDateTime now = LocalDateTime.now(); - return new Product(null, brandId, name, price, stock, LikeCount.zero(), - Description.ofNullable(description), now, now, null); + return new Product(null, brandId, name, ProductPricing.of(price, salePrice), stock, 0, + validateDescription(description), now, now, null); } - public static Product reconstitute(Long id, Long brandId, ProductName name, Price price, Stock stock, - LikeCount likeCount, Description description, - LocalDateTime createdAt, LocalDateTime updatedAt, - LocalDateTime deletedAt) { - return new Product(id, brandId, name, price, stock, likeCount, description, createdAt, updatedAt, deletedAt); + public static Product reconstitute(ProductData data) { + return new Product(data.id(), data.brandId(), data.name(), + ProductPricing.of(data.price(), data.salePrice()), data.stock(), + data.likeCount(), data.description(), + data.createdAt(), data.updatedAt(), data.deletedAt()); } - public Product update(ProductName name, Price price, Stock stock, String description) { - return new Product(this.id, this.brandId, name, price, stock, this.likeCount, - Description.ofNullable(description), this.createdAt, LocalDateTime.now(), this.deletedAt); + public Product update(ProductName name, Price price, Price salePrice, Stock stock, String description) { + return new Product(this.id, this.brandId, name, ProductPricing.of(price, salePrice), stock, this.likeCount, + validateDescription(description), this.createdAt, LocalDateTime.now(), this.deletedAt); } public Product delete() { if (isDeleted()) { throw new IllegalStateException("이미 삭제된 상품입니다."); } - return new Product(this.id, this.brandId, this.name, this.price, this.stock, this.likeCount, - this.description, this.createdAt, this.updatedAt, LocalDateTime.now()); + return withDeletedAt(LocalDateTime.now()); } public Product decreaseStock(int quantity) { - Stock decreased = this.stock.decrease(quantity); - return new Product(this.id, this.brandId, this.name, this.price, decreased, this.likeCount, - this.description, this.createdAt, LocalDateTime.now(), this.deletedAt); + return withStock(this.stock.decrease(quantity)); } public Product increaseStock(int quantity) { - Stock increased = this.stock.increase(quantity); - return new Product(this.id, this.brandId, this.name, this.price, increased, this.likeCount, - this.description, this.createdAt, LocalDateTime.now(), this.deletedAt); + return withStock(this.stock.increase(quantity)); } public Product increaseLikeCount() { - return new Product(this.id, this.brandId, this.name, this.price, this.stock, this.likeCount.increase(), - this.description, this.createdAt, this.updatedAt, this.deletedAt); + return new Product(this.id, this.brandId, this.name, this.pricing, this.stock, + this.likeCount + 1, this.description, this.createdAt, this.updatedAt, this.deletedAt); } public Product decreaseLikeCount() { - return new Product(this.id, this.brandId, this.name, this.price, this.stock, this.likeCount.decrease(), - this.description, this.createdAt, this.updatedAt, this.deletedAt); + if (this.likeCount <= 0) { + throw new IllegalStateException("좋아요 수는 0 미만이 될 수 없습니다."); + } + return new Product(this.id, this.brandId, this.name, this.pricing, this.stock, + this.likeCount - 1, this.description, this.createdAt, this.updatedAt, this.deletedAt); } public boolean isDeleted() { return this.deletedAt != null; } + + public Price getPrice() { + return this.pricing.getPrice(); + } + + public Price getSalePrice() { + return this.pricing.getSalePrice(); + } + + public boolean isOnSale() { + return this.pricing.isOnSale(); + } + + public int getDiscountRate() { + return this.pricing.getDiscountRate(); + } + + public boolean isSoldOut() { + return this.stock.getValue() == 0; + } + + private Product withStock(Stock newStock) { + return new Product(this.id, this.brandId, this.name, this.pricing, newStock, + this.likeCount, this.description, this.createdAt, LocalDateTime.now(), this.deletedAt); + } + + private Product withDeletedAt(LocalDateTime newDeletedAt) { + return new Product(this.id, this.brandId, this.name, this.pricing, this.stock, + this.likeCount, this.description, this.createdAt, this.updatedAt, newDeletedAt); + } + + private static String validateDescription(String description) { + if (description == null || description.isBlank()) { + return null; + } + String trimmed = description.trim(); + if (trimmed.length() > DESCRIPTION_MAX_LENGTH) { + throw new IllegalArgumentException("설명은 " + DESCRIPTION_MAX_LENGTH + "자 이하여야 합니다."); + } + return trimmed; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/ProductData.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/ProductData.java new file mode 100644 index 000000000..e80d75203 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/ProductData.java @@ -0,0 +1,18 @@ +package com.loopers.domain.model.product; + +import java.time.LocalDateTime; + +public record ProductData( + Long id, + Long brandId, + ProductName name, + Price price, + Price salePrice, + Stock stock, + int likeCount, + String description, + LocalDateTime createdAt, + LocalDateTime updatedAt, + LocalDateTime deletedAt +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/product/ProductPricing.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/ProductPricing.java new file mode 100644 index 000000000..a7f713a02 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/product/ProductPricing.java @@ -0,0 +1,36 @@ +package com.loopers.domain.model.product; + +import lombok.Getter; + +@Getter +public class ProductPricing { + + private final Price price; + private final Price salePrice; + + private ProductPricing(Price price, Price salePrice) { + if (price == null) { + throw new IllegalArgumentException("상품 가격은 필수입니다."); + } + this.price = price; + this.salePrice = salePrice; + } + + public static ProductPricing of(Price price, Price salePrice) { + return new ProductPricing(price, salePrice); + } + + public boolean isOnSale() { + return this.salePrice != null; + } + + public int getDiscountRate() { + if (!isOnSale()) return 0; + return calculateDiscountRate(price.getValue(), salePrice.getValue()); + } + + public static int calculateDiscountRate(int price, Integer salePrice) { + if (salePrice == null) return 0; + return (price - salePrice) * 100 / price; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/user/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/Email.java index edf1c0f9a..e8200b365 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/user/Email.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/Email.java @@ -1,10 +1,12 @@ package com.loopers.domain.model.user; -import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; import java.util.regex.Pattern; -@Data +@Getter +@EqualsAndHashCode public class Email { private static final Pattern PATTERN = Pattern.compile( diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/User.java index 190192c6e..e6304d656 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/User.java @@ -1,6 +1,7 @@ package com.loopers.domain.model.user; +import com.loopers.domain.service.PasswordEncoder; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; @@ -16,27 +17,38 @@ public class User { private final String encodedPassword; private final Birthday birth; // YYYYMMDD format with default value private final Email email; - private final WrongPasswordCount wrongPasswordCount; + private final int wrongPasswordCount; private final LocalDateTime createdAt; - public static User register(UserId userId,UserName userName, String encodedPassword, Birthday birth, Email email, WrongPasswordCount wrongPasswordCount, LocalDateTime createdAt) { - return new User(null,userId,userName,encodedPassword,birth,email, wrongPasswordCount,createdAt); + public static User register(UserId userId, UserName userName, String encodedPassword, + Birthday birth, Email email, LocalDateTime createdAt) { + return new User(null, userId, userName, encodedPassword, birth, email, 0, createdAt); } - public static User reconstitute(Long id, UserId userId, UserName userName, String encodedPassword, Birthday birth, Email email, WrongPasswordCount wrongPasswordCount, LocalDateTime createdAt) { - return new User(id, userId, userName, encodedPassword, birth, email, wrongPasswordCount, createdAt); + public static User reconstitute(UserData data) { + return new User(data.id(), data.userId(), data.userName(), data.encodedPassword(), + data.birth(), data.email(), data.wrongPasswordCount(), data.createdAt()); } public boolean matchesPassword(Password password, PasswordMatchChecker checker) { return checker.matches(password, this.encodedPassword); } - public User changePassword(String newEncodedPassword) { + public User changePassword(String currentRawPassword, String newRawPassword, + PasswordEncoder encoder) { + if (!encoder.matches(currentRawPassword, this.encodedPassword)) { + throw new IllegalArgumentException("현재 비밀번호가 일치하지 않습니다."); + } + Password newPassword = Password.of(newRawPassword, this.birth.getValue()); + if (encoder.matches(newPassword.getValue(), this.encodedPassword)) { + throw new IllegalArgumentException("현재 비밀번호는 사용할 수 없습니다."); + } + String encodedNewPassword = encoder.encrypt(newPassword.getValue()); return new User( this.id, this.userId, this.userName, - newEncodedPassword, + encodedNewPassword, this.birth, this.email, this.wrongPasswordCount, diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserData.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserData.java new file mode 100644 index 000000000..509ce187f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserData.java @@ -0,0 +1,15 @@ +package com.loopers.domain.model.user; + +import java.time.LocalDateTime; + +public record UserData( + Long id, + UserId userId, + UserName userName, + String encodedPassword, + Birthday birth, + Email email, + int wrongPasswordCount, + LocalDateTime createdAt +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserId.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserId.java index da5665999..4aba21dcc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserId.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserId.java @@ -1,10 +1,12 @@ package com.loopers.domain.model.user; -import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; import java.util.regex.Pattern; -@Data +@Getter +@EqualsAndHashCode public class UserId { private static final Pattern PATTERN = Pattern.compile("^[a-z0-9]{4,10}$"); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserName.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserName.java index 3ea083fc4..57de291a3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserName.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/UserName.java @@ -1,16 +1,18 @@ package com.loopers.domain.model.user; -import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; import java.util.regex.Pattern; -@Data +@Getter +@EqualsAndHashCode public class UserName { private static final Pattern PATTERN = Pattern.compile("^[a-zA-Z0-9가-힣]{2,20}$"); private final String value; - public UserName(String value) {this.value = value;} + private UserName(String value) {this.value = value;} public static UserName of(String value) { if(value == null || value.isEmpty()) { @@ -22,4 +24,10 @@ public static UserName of(String value) { } return new UserName(trimmed); } + + public String maskedValue() { + if (value == null || value.isEmpty()) return value; + if (value.length() == 1) return "*"; + return value.substring(0, value.length() - 1) + "*"; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/model/user/WrongPasswordCount.java b/apps/commerce-api/src/main/java/com/loopers/domain/model/user/WrongPasswordCount.java deleted file mode 100644 index 81122dff7..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/model/user/WrongPasswordCount.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.loopers.domain.model.user; - -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Value; - -@Value -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class WrongPasswordCount { - - private final int value; - - public static WrongPasswordCount init() { - return new WrongPasswordCount(0); - } - - public static WrongPasswordCount of(int value) { - if (value < 0) { - throw new IllegalArgumentException("비밀번호 오류 횟수는 음수일 수 없습니다."); - } - return new WrongPasswordCount(value); - } - - public int getValue() { - return value; - } - - public WrongPasswordCount increment() { - return new WrongPasswordCount(this.value + 1); - } - - public WrongPasswordCount reset() { - return new WrongPasswordCount(0); - } - - public boolean isLocked() { - return this.value >= 5; - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/repository/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/repository/BrandRepository.java index ad151c62b..af78a4129 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/repository/BrandRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/repository/BrandRepository.java @@ -12,7 +12,11 @@ public interface BrandRepository { Optional findById(Long id); - List findAll(); + Optional findActiveById(Long id); + + List findAllActive(); + + List findAllByIds(List ids); boolean existsByName(BrandName name); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/repository/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/repository/ProductRepository.java index a2c1840ee..a157365c3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/repository/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/repository/ProductRepository.java @@ -1,8 +1,7 @@ package com.loopers.domain.repository; +import com.loopers.domain.model.common.PageResult; import com.loopers.domain.model.product.Product; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import java.util.List; import java.util.Optional; @@ -13,7 +12,11 @@ public interface ProductRepository { Optional findById(Long id); - Page findAllByDeletedAtIsNull(Long brandId, Pageable pageable); + Optional findActiveById(Long id); + + Optional findActiveByIdWithLock(Long id); + + PageResult findAllActive(Long brandId, String sort, int page, int size); List findAllByBrandId(Long brandId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java index a64697e32..623f76575 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -10,4 +10,6 @@ public interface BrandJpaRepository extends JpaRepository boolean existsByName(String name); List findAllByDeletedAtIsNull(); + + List findAllByIdIn(List ids); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java index 08378326c..bf0daff58 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -1,10 +1,9 @@ package com.loopers.infrastructure.brand; import com.loopers.domain.model.brand.Brand; +import com.loopers.domain.model.brand.BrandData; import com.loopers.domain.model.brand.BrandName; import com.loopers.domain.repository.BrandRepository; -import com.loopers.infrastructure.brand.BrandJpaEntity; -import com.loopers.infrastructure.brand.BrandJpaRepository; import org.springframework.stereotype.Repository; import java.util.List; @@ -33,12 +32,24 @@ public Optional findById(Long id) { } @Override - public List findAll() { + public Optional findActiveById(Long id) { + return findById(id).filter(b -> !b.isDeleted()); + } + + @Override + public List findAllActive() { return brandJpaRepository.findAllByDeletedAtIsNull().stream() .map(this::toDomain) .toList(); } + @Override + public List findAllByIds(List ids) { + return brandJpaRepository.findAllByIdIn(ids).stream() + .map(this::toDomain) + .toList(); + } + @Override public boolean existsByName(BrandName name) { return brandJpaRepository.existsByName(name.getValue()); @@ -56,13 +67,13 @@ private BrandJpaEntity toEntity(Brand brand) { } private Brand toDomain(BrandJpaEntity entity) { - return Brand.reconstitute( + return Brand.reconstitute(new BrandData( entity.getId(), BrandName.of(entity.getName()), entity.getDescription(), entity.getCreatedAt(), entity.getUpdatedAt(), entity.getDeletedAt() - ); + )); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java index 77928a0b3..f33202abc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -1,6 +1,8 @@ package com.loopers.infrastructure.like; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -14,4 +16,10 @@ public interface LikeJpaRepository extends JpaRepository { void deleteByUserIdAndProductId(String userId, Long productId); List findAllByUserId(String userId); + + @Query("SELECT l, p, b FROM LikeJpaEntity l " + + "JOIN ProductJpaEntity p ON l.productId = p.id " + + "JOIN BrandJpaEntity b ON p.brandId = b.id " + + "WHERE l.userId = :userId AND p.deletedAt IS NULL") + List findAllWithProductByUserId(@Param("userId") String userId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java index 38b9d5d74..b4c29b8b4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -1,15 +1,18 @@ package com.loopers.infrastructure.like; +import com.loopers.application.like.LikeProductReadPort; import com.loopers.domain.model.like.Like; import com.loopers.domain.model.user.UserId; import com.loopers.domain.repository.LikeRepository; +import com.loopers.infrastructure.brand.BrandJpaEntity; +import com.loopers.infrastructure.product.ProductJpaEntity; import org.springframework.stereotype.Repository; import java.util.List; import java.util.Optional; @Repository -public class LikeRepositoryImpl implements LikeRepository { +public class LikeRepositoryImpl implements LikeRepository, LikeProductReadPort { private final LikeJpaRepository likeJpaRepository; @@ -47,6 +50,28 @@ public List findAllByUserId(UserId userId) { .toList(); } + @Override + public List findLikedProductsByUserId(UserId userId) { + return likeJpaRepository.findAllWithProductByUserId(userId.getValue()).stream() + .map(this::toLikeProductView) + .toList(); + } + + private LikeProductView toLikeProductView(Object[] row) { + LikeJpaEntity like = (LikeJpaEntity) row[0]; + ProductJpaEntity product = (ProductJpaEntity) row[1]; + BrandJpaEntity brand = (BrandJpaEntity) row[2]; + return new LikeProductView( + product.getId(), + product.getName(), + product.getPrice(), + product.getSalePrice(), + product.getStockQuantity(), + brand.getName(), + like.getCreatedAt() + ); + } + private LikeJpaEntity toEntity(Like like) { return new LikeJpaEntity( like.getId(), diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java index 63e7a689a..0707d0cf0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -67,8 +67,8 @@ private OrderJpaEntity toEntity(Order order) { order.getUserId().getValue(), itemEntities, snapshotEntity, - order.getReceiverName().getValue(), - order.getAddress().getValue(), + order.getReceiverName(), + order.getAddress(), order.getDeliveryRequest(), order.getPaymentMethod().name(), order.getTotalAmount().getValue(), @@ -85,7 +85,7 @@ private OrderItemJpaEntity toItemEntity(OrderItem item) { return new OrderItemJpaEntity( item.getId(), item.getProductId(), - item.getQuantity().getValue(), + item.getQuantity(), item.getUnitPrice().getValue() ); } @@ -112,30 +112,38 @@ private Order toDomain(OrderJpaEntity entity) { ); } - return Order.reconstitute( - entity.getId(), - UserId.of(entity.getUserId()), - items, - snapshot, - ReceiverName.of(entity.getReceiverName()), - Address.of(entity.getAddress()), + DeliveryInfo deliveryInfo = DeliveryInfo.of( + entity.getReceiverName(), + entity.getAddress(), entity.getDeliveryRequest(), + entity.getDesiredDeliveryDate() + ); + + OrderAmount orderAmount = OrderAmount.reconstitute( PaymentMethod.valueOf(entity.getPaymentMethod()), Money.of(entity.getTotalAmount()), Money.of(entity.getDiscountAmount()), - Money.of(entity.getPaymentAmount()), + Money.of(entity.getPaymentAmount()) + ); + + return Order.reconstitute(new OrderData( + entity.getId(), + UserId.of(entity.getUserId()), + items, + snapshot, + deliveryInfo, + orderAmount, OrderStatus.valueOf(entity.getStatus()), - entity.getDesiredDeliveryDate(), entity.getCreatedAt(), entity.getUpdatedAt() - ); + )); } private OrderItem toItemDomain(OrderItemJpaEntity entity) { return OrderItem.reconstitute( entity.getId(), entity.getProductId(), - Quantity.of(entity.getQuantity()), + entity.getQuantity(), Money.of(entity.getUnitPrice()) ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaEntity.java index e89a37447..fe3c0aef6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaEntity.java @@ -31,6 +31,8 @@ public class ProductJpaEntity { private String description; + private Integer salePrice; + @Column(nullable = false, updatable = false) private LocalDateTime createdAt; @@ -41,13 +43,14 @@ public class ProductJpaEntity { protected ProductJpaEntity() {} - public ProductJpaEntity(Long id, Long brandId, String name, int price, int stockQuantity, - int likeCount, String description, + public ProductJpaEntity(Long id, Long brandId, String name, int price, Integer salePrice, + int stockQuantity, int likeCount, String description, LocalDateTime createdAt, LocalDateTime updatedAt, LocalDateTime deletedAt) { this.id = id; this.brandId = brandId; this.name = name; this.price = price; + this.salePrice = salePrice; this.stockQuantity = stockQuantity; this.likeCount = likeCount; this.description = description; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index ee5fe15f9..0bd86dbd3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -1,10 +1,15 @@ package com.loopers.infrastructure.product; +import jakarta.persistence.LockModeType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface ProductJpaRepository extends JpaRepository { @@ -13,4 +18,8 @@ public interface ProductJpaRepository extends JpaRepository findAllByDeletedAtIsNull(Pageable pageable); List findAllByBrandIdAndDeletedAtIsNull(Long brandId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM ProductJpaEntity p WHERE p.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index af33d2ac2..d905fb1e9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -1,9 +1,11 @@ package com.loopers.infrastructure.product; +import com.loopers.domain.model.common.PageResult; import com.loopers.domain.model.product.*; import com.loopers.domain.repository.ProductRepository; import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Repository; import java.util.List; @@ -32,14 +34,32 @@ public Optional findById(Long id) { } @Override - public Page findAllByDeletedAtIsNull(Long brandId, Pageable pageable) { - Page page; + public Optional findActiveById(Long id) { + return findById(id).filter(p -> !p.isDeleted()); + } + + @Override + public Optional findActiveByIdWithLock(Long id) { + return productJpaRepository.findByIdForUpdate(id) + .map(this::toDomain) + .filter(p -> !p.isDeleted()); + } + + @Override + public PageResult findAllActive(Long brandId, String sort, int page, int size) { + Sort sorting = resolveSort(sort); + PageRequest pageRequest = PageRequest.of(page, size, sorting); + + Page jpaPage; if (brandId != null) { - page = productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, pageable); + jpaPage = productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, pageRequest); } else { - page = productJpaRepository.findAllByDeletedAtIsNull(pageable); + jpaPage = productJpaRepository.findAllByDeletedAtIsNull(pageRequest); } - return page.map(this::toDomain); + + List content = jpaPage.getContent().stream().map(this::toDomain).toList(); + return new PageResult<>(content, jpaPage.getNumber(), jpaPage.getSize(), + jpaPage.getTotalElements(), jpaPage.getTotalPages()); } @Override @@ -49,15 +69,28 @@ public List findAllByBrandId(Long brandId) { .toList(); } + private Sort resolveSort(String sort) { + if (sort == null) { + return Sort.by(Sort.Direction.DESC, "createdAt"); + } + return switch (sort) { + case "price_asc" -> Sort.by(Sort.Direction.ASC, "price"); + case "price_desc" -> Sort.by(Sort.Direction.DESC, "price"); + case "likes_desc" -> Sort.by(Sort.Direction.DESC, "likeCount"); + default -> Sort.by(Sort.Direction.DESC, "createdAt"); + }; + } + private ProductJpaEntity toEntity(Product product) { return new ProductJpaEntity( product.getId(), product.getBrandId(), product.getName().getValue(), product.getPrice().getValue(), + product.getSalePrice() != null ? product.getSalePrice().getValue() : null, product.getStock().getValue(), - product.getLikeCount().getValue(), - product.getDescription().getValueOrNull(), + product.getLikeCount(), + product.getDescription(), product.getCreatedAt(), product.getUpdatedAt(), product.getDeletedAt() @@ -65,17 +98,18 @@ private ProductJpaEntity toEntity(Product product) { } private Product toDomain(ProductJpaEntity entity) { - return Product.reconstitute( + return Product.reconstitute(new ProductData( entity.getId(), entity.getBrandId(), ProductName.of(entity.getName()), Price.of(entity.getPrice()), + entity.getSalePrice() != null ? Price.of(entity.getSalePrice()) : null, Stock.of(entity.getStockQuantity()), - LikeCount.of(entity.getLikeCount()), - Description.ofNullable(entity.getDescription()), + entity.getLikeCount(), + entity.getDescription(), entity.getCreatedAt(), entity.getUpdatedAt(), entity.getDeletedAt() - ); + )); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaEntity.java index 29b39556b..d6524c009 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaEntity.java @@ -34,19 +34,24 @@ public class UserJpaEntity { @Column(nullable = false) private String email; + @Column(nullable = false) + private int wrongPasswordCount; + @Column(nullable = false, updatable = false) private LocalDateTime createdAt; protected UserJpaEntity() {} - public UserJpaEntity(Long id, UserId userId, String encodedPassword, UserName userName, Birthday birth, Email email, LocalDateTime createdAt) { + public UserJpaEntity(Long id, UserId userId, String encodedPassword, UserName userName, + Birthday birth, Email email, int wrongPasswordCount, LocalDateTime createdAt) { this.id = id; this.userId = userId.getValue(); this.encodedPassword = encodedPassword; this.username = userName.getValue(); this.birthday = birth.getValue(); this.email = email.getValue(); + this.wrongPasswordCount = wrongPasswordCount; this.createdAt = createdAt; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java index 117716935..534efeb6c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -2,8 +2,6 @@ import com.loopers.domain.model.user.*; import com.loopers.domain.repository.UserRepository; -import com.loopers.infrastructure.user.UserJpaEntity; -import com.loopers.infrastructure.user.UserJpaRepository; import org.springframework.stereotype.Repository; import java.util.Optional; @@ -13,7 +11,9 @@ public class UserRepositoryImpl implements UserRepository { private final UserJpaRepository userJpaRepository; - public UserRepositoryImpl( UserJpaRepository userJpaRepository) {this.userJpaRepository = userJpaRepository;} + public UserRepositoryImpl(UserJpaRepository userJpaRepository) { + this.userJpaRepository = userJpaRepository; + } @Override public User save(User user) { @@ -41,21 +41,21 @@ private UserJpaEntity toEntity(User user) { user.getUserName(), user.getBirth(), user.getEmail(), + user.getWrongPasswordCount(), user.getCreatedAt() ); } private User toDomain(UserJpaEntity entity) { - return User.reconstitute( + return User.reconstitute(new UserData( entity.getId(), UserId.of(entity.getUserId()), UserName.of(entity.getUsername()), entity.getEncodedPassword(), Birthday.of(entity.getBirthday()), Email.of(entity.getEmail()), - WrongPasswordCount.init(), + entity.getWrongPasswordCount(), entity.getCreatedAt() - ); + )); } - } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/PageResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/PageResponse.java index 8ef86a2cd..ecb3558e4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/PageResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/PageResponse.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.common; -import org.springframework.data.domain.Page; +import com.loopers.domain.model.common.PageResult; import java.util.List; import java.util.function.Function; @@ -12,11 +12,11 @@ public record PageResponse( long totalElements, int totalPages ) { - public static PageResponse from(Page page, Function mapper) { - List content = page.getContent().stream() + public static PageResponse from(PageResult pageResult, Function mapper) { + List content = pageResult.content().stream() .map(mapper) .toList(); - return new PageResponse<>(content, page.getNumber(), page.getSize(), - page.getTotalElements(), page.getTotalPages()); + return new PageResponse<>(content, pageResult.page(), pageResult.size(), + pageResult.totalElements(), pageResult.totalPages()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/config/WebMvcConfig.java index acff338cb..2893688c4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/config/WebMvcConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/config/WebMvcConfig.java @@ -22,6 +22,7 @@ public WebMvcConfig(AuthenticationInterceptor authenticationInterceptor, public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authenticationInterceptor) .addPathPatterns("/api/v1/users/me", "/api/v1/users/me/**") + .addPathPatterns("/api/v1/users/*/likes") .addPathPatterns("/api/v1/products/*/likes") .addPathPatterns("/api/v1/orders", "/api/v1/orders/**"); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/dto/LikeResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/dto/LikeResponse.java index acd3586b1..45311e2f8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/dto/LikeResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/dto/LikeResponse.java @@ -8,6 +8,11 @@ public record LikeResponse( Long productId, String productName, int price, + Integer salePrice, + boolean onSale, + int discountRate, + String brandName, + boolean soldOut, LocalDateTime likedAt ) { public static LikeResponse from(LikeQueryUseCase.LikeInfo info) { @@ -15,6 +20,11 @@ public static LikeResponse from(LikeQueryUseCase.LikeInfo info) { info.productId(), info.productName(), info.price(), + info.salePrice(), + info.onSale(), + info.discountRate(), + info.brandName(), + info.soldOut(), info.likedAt() ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminController.java index f4e267f45..4642f5806 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminController.java @@ -9,7 +9,7 @@ import com.loopers.interfaces.api.product.dto.ProductDetailResponse; import com.loopers.interfaces.api.product.dto.ProductSummaryResponse; import com.loopers.interfaces.api.product.dto.ProductUpdateRequest; -import org.springframework.data.domain.Page; +import com.loopers.domain.model.common.PageResult; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -37,25 +37,21 @@ public ResponseEntity> getProducts( @RequestParam(required = false) Long brandId, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { - Page products = + PageResult products = productQueryUseCase.getProducts(brandId, null, page, size); return ResponseEntity.ok(PageResponse.from(products, ProductSummaryResponse::from)); } @PostMapping public ResponseEntity createProduct(@RequestBody ProductCreateRequest request) { - createProductUseCase.createProduct( - request.brandId(), request.name(), request.price(), request.stock(), request.description() - ); + createProductUseCase.createProduct(request.toCommand()); return ResponseEntity.ok().build(); } @PutMapping("/{productId}") public ResponseEntity updateProduct(@PathVariable Long productId, @RequestBody ProductUpdateRequest request) { - updateProductUseCase.updateProduct( - productId, request.name(), request.price(), request.stock(), request.description() - ); + updateProductUseCase.updateProduct(request.toCommand(productId)); return ResponseEntity.ok().build(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java index f2c01cab5..28697636d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java @@ -4,7 +4,7 @@ import com.loopers.interfaces.api.common.PageResponse; import com.loopers.interfaces.api.product.dto.ProductDetailResponse; import com.loopers.interfaces.api.product.dto.ProductSummaryResponse; -import org.springframework.data.domain.Page; +import com.loopers.domain.model.common.PageResult; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -24,7 +24,7 @@ public ResponseEntity> getProducts( @RequestParam(required = false) String sort, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size) { - Page products = + PageResult products = productQueryUseCase.getProducts(brandId, sort, page, size); return ResponseEntity.ok(PageResponse.from(products, ProductSummaryResponse::from)); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductCreateRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductCreateRequest.java index 06bb27c27..52ce08927 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductCreateRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductCreateRequest.java @@ -1,9 +1,16 @@ package com.loopers.interfaces.api.product.dto; +import com.loopers.application.product.CreateProductUseCase.ProductCreateCommand; + public record ProductCreateRequest( Long brandId, String name, int price, + Integer salePrice, int stock, String description -) {} +) { + public ProductCreateCommand toCommand() { + return new ProductCreateCommand(brandId, name, price, salePrice, stock, description); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductDetailResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductDetailResponse.java index b20498562..2a9d3e6ac 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductDetailResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductDetailResponse.java @@ -8,6 +8,8 @@ public record ProductDetailResponse( String brandName, String name, int price, + Integer salePrice, + boolean onSale, int stock, int likeCount, String description @@ -19,6 +21,8 @@ public static ProductDetailResponse from(ProductQueryUseCase.ProductDetailInfo i info.brandName(), info.name(), info.price(), + info.salePrice(), + info.onSale(), info.stock(), info.likeCount(), info.description() diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductSummaryResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductSummaryResponse.java index 19b205bc4..7bc99bc8b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductSummaryResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductSummaryResponse.java @@ -8,6 +8,8 @@ public record ProductSummaryResponse( String brandName, String name, int price, + Integer salePrice, + boolean onSale, int likeCount ) { public static ProductSummaryResponse from(ProductQueryUseCase.ProductSummaryInfo info) { @@ -17,6 +19,8 @@ public static ProductSummaryResponse from(ProductQueryUseCase.ProductSummaryInfo info.brandName(), info.name(), info.price(), + info.salePrice(), + info.onSale(), info.likeCount() ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductUpdateRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductUpdateRequest.java index f0ea08187..da063c2e8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductUpdateRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/dto/ProductUpdateRequest.java @@ -1,8 +1,15 @@ package com.loopers.interfaces.api.product.dto; +import com.loopers.application.product.UpdateProductUseCase.ProductUpdateCommand; + public record ProductUpdateRequest( String name, int price, + Integer salePrice, int stock, String description -) {} +) { + public ProductUpdateCommand toCommand(Long productId) { + return new ProductUpdateCommand(productId, name, price, salePrice, stock, description); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java index b035aab67..fdb3ab9a8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserController.java @@ -37,15 +37,9 @@ public UserController( this.likeQueryUseCase = likeQueryUseCase; } - @PostMapping("/register") + @PostMapping public ResponseEntity register(@RequestBody UserRegisterRequest request) { - registerUseCase.register( - request.loginId(), - request.name(), - request.password(), - request.birthday(), - request.email() - ); + registerUseCase.register(request.toCommand()); return ResponseEntity.ok().build(); } @@ -57,10 +51,18 @@ public ResponseEntity getMyInfo(HttpServletRequest request) { return ResponseEntity.ok(UserInfoResponse.from(userInfo)); } - @GetMapping("/me/likes") - public ResponseEntity> getMyLikes(HttpServletRequest request) { - UserId userId = (UserId) request.getAttribute("authenticatedUserId"); - List likes = likeQueryUseCase.getMyLikes(userId).stream() + @GetMapping("/{userId}/likes") + public ResponseEntity> getMyLikes( + @PathVariable String userId, + @RequestParam(defaultValue = "latest") String sort, + @RequestParam(required = false) Boolean saleYn, + @RequestParam(required = false) String status, + HttpServletRequest request) { + UserId authenticatedUserId = (UserId) request.getAttribute("authenticatedUserId"); + if (!authenticatedUserId.getValue().equals(userId)) { + return ResponseEntity.status(403).build(); + } + List likes = likeQueryUseCase.getMyLikes(authenticatedUserId, sort, saleYn, status).stream() .map(LikeResponse::from) .toList(); return ResponseEntity.ok(likes); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/UserRegisterRequest.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/UserRegisterRequest.java index 7c1e4bbe7..f02420f48 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/UserRegisterRequest.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/dto/UserRegisterRequest.java @@ -1,5 +1,7 @@ package com.loopers.interfaces.api.user.dto; +import com.loopers.application.user.RegisterUseCase.RegisterCommand; + import java.time.LocalDate; public record UserRegisterRequest( @@ -8,4 +10,8 @@ public record UserRegisterRequest( String name, LocalDate birthday, String email -) {} +) { + public RegisterCommand toCommand() { + return new RegisterCommand(loginId, name, password, birthday, email); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandQueryServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandQueryServiceTest.java new file mode 100644 index 000000000..7f6a04dff --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandQueryServiceTest.java @@ -0,0 +1,83 @@ +package com.loopers.application.brand; + +import com.loopers.domain.model.brand.Brand; +import com.loopers.domain.model.brand.BrandData; +import com.loopers.domain.model.brand.BrandName; +import com.loopers.domain.repository.BrandRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class BrandQueryServiceTest { + + private BrandRepository brandRepository; + private BrandQueryService service; + + @BeforeEach + void setUp() { + brandRepository = mock(BrandRepository.class); + service = new BrandQueryService(brandRepository); + } + + @Nested + @DisplayName("브랜드 조회") + class QueryBrand { + + @Test + @DisplayName("단건 조회 성공") + void getBrand_success() { + // given + Brand brand = createBrand(1L, "나이키"); + when(brandRepository.findActiveById(1L)).thenReturn(Optional.of(brand)); + + // when + var result = service.getBrand(1L); + + // then + assertThat(result.id()).isEqualTo(1L); + assertThat(result.name()).isEqualTo("나이키"); + } + + @Test + @DisplayName("존재하지 않는 브랜드 조회시 예외") + void getBrand_fail_notFound() { + // given + when(brandRepository.findActiveById(999L)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> service.getBrand(999L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("브랜드를 찾을 수 없습니다"); + } + + @Test + @DisplayName("목록 조회 성공") + void getBrands_success() { + // given + Brand brand1 = createBrand(1L, "나이키"); + Brand brand2 = createBrand(2L, "아디다스"); + + when(brandRepository.findAllActive()).thenReturn(List.of(brand1, brand2)); + + // when + var result = service.getBrands(); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).name()).isEqualTo("나이키"); + } + } + + private Brand createBrand(Long id, String name) { + return Brand.reconstitute(new BrandData(id, BrandName.of(name), "설명", + LocalDateTime.now(), LocalDateTime.now(), null)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java index 49bd8d15d..57b407308 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java @@ -1,8 +1,8 @@ package com.loopers.application.brand; import com.loopers.domain.model.brand.Brand; +import com.loopers.domain.model.brand.BrandData; import com.loopers.domain.model.brand.BrandName; -import com.loopers.domain.model.product.*; import com.loopers.domain.repository.BrandRepository; import com.loopers.domain.model.common.DomainEventPublisher; import org.junit.jupiter.api.BeforeEach; @@ -11,7 +11,6 @@ import org.junit.jupiter.api.Test; import java.time.LocalDateTime; -import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.*; @@ -72,7 +71,7 @@ class UpdateBrand { void updateBrand_success() { // given Brand brand = createBrand(1L, "나이키"); - when(brandRepository.findById(1L)).thenReturn(Optional.of(brand)); + when(brandRepository.findActiveById(1L)).thenReturn(Optional.of(brand)); // when & then assertThatNoException() @@ -85,7 +84,7 @@ void updateBrand_success() { @DisplayName("존재하지 않는 브랜드 수정시 예외") void updateBrand_fail_notFound() { // given - when(brandRepository.findById(999L)).thenReturn(Optional.empty()); + when(brandRepository.findActiveById(999L)).thenReturn(Optional.empty()); // when & then assertThatThrownBy(() -> service.updateBrand(999L, "아디다스", "설명")) @@ -103,7 +102,7 @@ class DeleteBrand { void deleteBrand_success_eventPublished() { // given Brand brand = createBrand(1L, "나이키"); - when(brandRepository.findById(1L)).thenReturn(Optional.of(brand)); + when(brandRepository.findActiveById(1L)).thenReturn(Optional.of(brand)); // when service.deleteBrand(1L); @@ -117,7 +116,7 @@ void deleteBrand_success_eventPublished() { @DisplayName("존재하지 않는 브랜드 삭제시 예외") void deleteBrand_fail_notFound() { // given - when(brandRepository.findById(999L)).thenReturn(Optional.empty()); + when(brandRepository.findActiveById(999L)).thenReturn(Optional.empty()); // when & then assertThatThrownBy(() -> service.deleteBrand(999L)) @@ -126,46 +125,8 @@ void deleteBrand_fail_notFound() { } } - @Nested - @DisplayName("브랜드 조회") - class QueryBrand { - - @Test - @DisplayName("단건 조회 성공") - void getBrand_success() { - // given - Brand brand = createBrand(1L, "나이키"); - when(brandRepository.findById(1L)).thenReturn(Optional.of(brand)); - - // when - var result = service.getBrand(1L); - - // then - assertThat(result.id()).isEqualTo(1L); - assertThat(result.name()).isEqualTo("나이키"); - } - - @Test - @DisplayName("목록 조회 - 삭제된 브랜드 제외") - void getBrands_excludeDeleted() { - // given - Brand active = createBrand(1L, "나이키"); - Brand deleted = Brand.reconstitute(2L, BrandName.of("삭제됨"), "설명", - LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now()); - - when(brandRepository.findAll()).thenReturn(List.of(active, deleted)); - - // when - var result = service.getBrands(); - - // then - assertThat(result).hasSize(1); - assertThat(result.get(0).name()).isEqualTo("나이키"); - } - } - private Brand createBrand(Long id, String name) { - return Brand.reconstitute(id, BrandName.of(name), "설명", - LocalDateTime.now(), LocalDateTime.now(), null); + return Brand.reconstitute(new BrandData(id, BrandName.of(name), "설명", + LocalDateTime.now(), LocalDateTime.now(), null)); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeQueryServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeQueryServiceTest.java new file mode 100644 index 000000000..d30064ec4 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeQueryServiceTest.java @@ -0,0 +1,109 @@ +package com.loopers.application.like; + +import com.loopers.application.like.LikeProductReadPort.LikeProductView; +import com.loopers.domain.model.user.UserId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class LikeQueryServiceTest { + + private LikeProductReadPort likeProductReadPort; + private LikeQueryService service; + + @BeforeEach + void setUp() { + likeProductReadPort = mock(LikeProductReadPort.class); + service = new LikeQueryService(likeProductReadPort); + } + + @Nested + @DisplayName("좋아요 목록 조회") + class GetMyLikes { + + @Test + @DisplayName("좋아요 목록 조회 성공") + void getMyLikes_success() { + // given + UserId userId = UserId.of("test1234"); + LocalDateTime now = LocalDateTime.now(); + List likes = List.of( + new LikeProductView(1L, "상품1", 10000, null, 100, "나이키", now), + new LikeProductView(2L, "상품2", 20000, null, 50, "나이키", now.minusHours(1)) + ); + + when(likeProductReadPort.findLikedProductsByUserId(userId)).thenReturn(likes); + + // when + var result = service.getMyLikes(userId, "latest", null, null); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).brandName()).isEqualTo("나이키"); + } + + @Test + @DisplayName("좋아요 목록이 비어있는 경우") + void getMyLikes_empty() { + // given + UserId userId = UserId.of("test1234"); + when(likeProductReadPort.findLikedProductsByUserId(userId)).thenReturn(List.of()); + + // when + var result = service.getMyLikes(userId, "latest", null, null); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("세일 상품만 필터링") + void getMyLikes_filterBySaleYn() { + // given + UserId userId = UserId.of("test1234"); + LocalDateTime now = LocalDateTime.now(); + List likes = List.of( + new LikeProductView(1L, "일반상품", 10000, null, 100, "나이키", now), + new LikeProductView(2L, "세일상품", 100000, 70000, 50, "나이키", now) + ); + + when(likeProductReadPort.findLikedProductsByUserId(userId)).thenReturn(likes); + + // when + var result = service.getMyLikes(userId, "latest", true, null); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).onSale()).isTrue(); + } + + @Test + @DisplayName("가격순 정렬") + void getMyLikes_sortByPrice() { + // given + UserId userId = UserId.of("test1234"); + LocalDateTime now = LocalDateTime.now(); + List likes = List.of( + new LikeProductView(1L, "비싼상품", 100000, null, 50, "나이키", now), + new LikeProductView(2L, "싼상품", 10000, null, 50, "나이키", now) + ); + + when(likeProductReadPort.findLikedProductsByUserId(userId)).thenReturn(likes); + + // when + var result = service.getMyLikes(userId, "price_asc", null, null); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).price()).isEqualTo(10000); + assertThat(result.get(1).price()).isEqualTo(100000); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java index 608887e70..ac4e91397 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeServiceTest.java @@ -12,7 +12,6 @@ import org.junit.jupiter.api.Test; import java.time.LocalDateTime; -import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.*; @@ -45,7 +44,7 @@ void like_success() { UserId userId = UserId.of("test1234"); Product product = createProduct(1L, 0); - when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + when(productRepository.findActiveById(1L)).thenReturn(Optional.of(product)); when(likeRepository.existsByUserIdAndProductId(userId, 1L)).thenReturn(false); // when @@ -63,7 +62,7 @@ void like_alreadyLiked_ignored() { UserId userId = UserId.of("test1234"); Product product = createProduct(1L, 1); - when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + when(productRepository.findActiveById(1L)).thenReturn(Optional.of(product)); when(likeRepository.existsByUserIdAndProductId(userId, 1L)).thenReturn(true); // when @@ -79,7 +78,7 @@ void like_alreadyLiked_ignored() { void like_fail_productNotFound() { // given UserId userId = UserId.of("test1234"); - when(productRepository.findById(999L)).thenReturn(Optional.empty()); + when(productRepository.findActiveById(999L)).thenReturn(Optional.empty()); // when & then assertThatThrownBy(() -> service.like(userId, 999L)) @@ -100,7 +99,7 @@ void unlike_success() { Product product = createProduct(1L, 1); Like like = Like.reconstitute(1L, userId, 1L, LocalDateTime.now()); - when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + when(productRepository.findActiveById(1L)).thenReturn(Optional.of(product)); when(likeRepository.findByUserIdAndProductId(userId, 1L)).thenReturn(Optional.of(like)); // when @@ -118,7 +117,7 @@ void unlike_notLiked_ignored() { UserId userId = UserId.of("test1234"); Product product = createProduct(1L, 0); - when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + when(productRepository.findActiveById(1L)).thenReturn(Optional.of(product)); when(likeRepository.findByUserIdAndProductId(userId, 1L)).thenReturn(Optional.empty()); // when @@ -130,74 +129,9 @@ void unlike_notLiked_ignored() { } } - @Nested - @DisplayName("좋아요 목록 조회") - class GetMyLikes { - - @Test - @DisplayName("좋아요 목록 조회 성공") - void getMyLikes_success() { - // given - UserId userId = UserId.of("test1234"); - Like like1 = Like.reconstitute(1L, userId, 1L, LocalDateTime.now()); - Like like2 = Like.reconstitute(2L, userId, 2L, LocalDateTime.now()); - Product product1 = createProduct(1L, 5); - Product product2 = createProduct(2L, 3); - - when(likeRepository.findAllByUserId(userId)).thenReturn(List.of(like1, like2)); - when(productRepository.findById(1L)).thenReturn(Optional.of(product1)); - when(productRepository.findById(2L)).thenReturn(Optional.of(product2)); - - // when - var result = service.getMyLikes(userId); - - // then - assertThat(result).hasSize(2); - } - - @Test - @DisplayName("삭제된 상품은 목록에서 제외") - void getMyLikes_excludeDeletedProducts() { - // given - UserId userId = UserId.of("test1234"); - Like like1 = Like.reconstitute(1L, userId, 1L, LocalDateTime.now()); - Like like2 = Like.reconstitute(2L, userId, 2L, LocalDateTime.now()); - - Product activeProduct = createProduct(1L, 5); - Product deletedProduct = Product.reconstitute(2L, 1L, ProductName.of("삭제됨"), - Price.of(10000), Stock.of(0), LikeCount.zero(), Description.ofNullable("설명"), - LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now()); - - when(likeRepository.findAllByUserId(userId)).thenReturn(List.of(like1, like2)); - when(productRepository.findById(1L)).thenReturn(Optional.of(activeProduct)); - when(productRepository.findById(2L)).thenReturn(Optional.of(deletedProduct)); - - // when - var result = service.getMyLikes(userId); - - // then - assertThat(result).hasSize(1); - assertThat(result.get(0).productId()).isEqualTo(1L); - } - - @Test - @DisplayName("좋아요 목록이 비어있는 경우") - void getMyLikes_empty() { - // given - UserId userId = UserId.of("test1234"); - when(likeRepository.findAllByUserId(userId)).thenReturn(List.of()); - - // when - var result = service.getMyLikes(userId); - - // then - assertThat(result).isEmpty(); - } - } - private Product createProduct(Long id, int likeCount) { - return Product.reconstitute(id, 1L, ProductName.of("상품" + id), Price.of(10000), - Stock.of(100), LikeCount.of(likeCount), Description.ofNullable("설명"), - LocalDateTime.now(), LocalDateTime.now(), null); + return Product.reconstitute(new ProductData(id, 1L, ProductName.of("상품" + id), Price.of(10000), + null, Stock.of(100), likeCount, "설명", + LocalDateTime.now(), LocalDateTime.now(), null)); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderQueryServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderQueryServiceTest.java index 5793ee0f1..b5f756b48 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderQueryServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderQueryServiceTest.java @@ -182,13 +182,15 @@ void getOrderDetail_success() { private Order createOrder(Long id, UserId userId, OrderStatus status) { List items = List.of( - OrderItem.reconstitute(1L, 1L, Quantity.of(2), Money.of(50000)) + OrderItem.reconstitute(1L, 1L, 2, Money.of(50000)) ); - return Order.reconstitute(id, userId, items, null, - ReceiverName.of("홍길동"), Address.of("서울시 강남구"), - "배송 요청", PaymentMethod.CARD, - Money.of(100000), Money.zero(), Money.of(100000), - status, LocalDate.now().plusDays(3), - LocalDateTime.now(), LocalDateTime.now()); + DeliveryInfo deliveryInfo = DeliveryInfo.of( + "홍길동", "서울시 강남구", + "배송 요청", LocalDate.now().plusDays(3)); + OrderAmount orderAmount = OrderAmount.reconstitute( + PaymentMethod.CARD, Money.of(100000), Money.zero(), Money.of(100000)); + return Order.reconstitute(new OrderData(id, userId, items, null, + deliveryInfo, orderAmount, status, + LocalDateTime.now(), LocalDateTime.now())); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java index 4a18c3bf6..e3409979a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderServiceTest.java @@ -5,8 +5,7 @@ import com.loopers.domain.model.product.Product; import com.loopers.domain.model.product.ProductName; import com.loopers.domain.model.product.Stock; -import com.loopers.domain.model.product.LikeCount; -import com.loopers.domain.model.product.Description; +import com.loopers.domain.model.product.ProductData; import com.loopers.domain.model.user.UserId; import com.loopers.domain.repository.OrderRepository; import com.loopers.domain.repository.ProductRepository; @@ -50,7 +49,7 @@ void createOrder_success() { // given UserId userId = UserId.of("test1234"); Product product = createProduct(1L, 50000, 100); - when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + when(productRepository.findActiveByIdWithLock(1L)).thenReturn(Optional.of(product)); when(productRepository.save(any(Product.class))).thenReturn(product); var command = new CreateOrderUseCase.OrderCommand( @@ -75,7 +74,7 @@ void createOrder_success() { void createOrder_fail_productNotFound() { // given UserId userId = UserId.of("test1234"); - when(productRepository.findById(999L)).thenReturn(Optional.empty()); + when(productRepository.findActiveByIdWithLock(999L)).thenReturn(Optional.empty()); var command = new CreateOrderUseCase.OrderCommand( List.of(new CreateOrderUseCase.OrderItemCommand(999L, 1)), @@ -94,7 +93,7 @@ void createOrder_fail_insufficientStock() { // given UserId userId = UserId.of("test1234"); Product product = createProduct(1L, 50000, 1); - when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + when(productRepository.findActiveByIdWithLock(1L)).thenReturn(Optional.of(product)); var command = new CreateOrderUseCase.OrderCommand( List.of(new CreateOrderUseCase.OrderItemCommand(1L, 100)), @@ -214,20 +213,22 @@ void updateDeliveryAddress_fail_shipping() { } private Product createProduct(Long id, int price, int stock) { - return Product.reconstitute(id, 1L, ProductName.of("상품" + id), Price.of(price), - Stock.of(stock), LikeCount.zero(), Description.ofNullable("설명"), - LocalDateTime.now(), LocalDateTime.now(), null); + return Product.reconstitute(new ProductData(id, 1L, ProductName.of("상품" + id), Price.of(price), + null, Stock.of(stock), 0, "설명", + LocalDateTime.now(), LocalDateTime.now(), null)); } private Order createOrder(Long id, UserId userId, OrderStatus status) { List items = List.of( - OrderItem.reconstitute(1L, 1L, Quantity.of(2), Money.of(50000)) + OrderItem.reconstitute(1L, 1L, 2, Money.of(50000)) ); - return Order.reconstitute(id, userId, items, null, - ReceiverName.of("홍길동"), Address.of("서울시 강남구"), - "문 앞에 놓아주세요", PaymentMethod.CARD, - Money.of(100000), Money.zero(), Money.of(100000), - status, LocalDate.now().plusDays(3), - LocalDateTime.now(), LocalDateTime.now()); + DeliveryInfo deliveryInfo = DeliveryInfo.of( + "홍길동", "서울시 강남구", + "문 앞에 놓아주세요", LocalDate.now().plusDays(3)); + OrderAmount orderAmount = OrderAmount.reconstitute( + PaymentMethod.CARD, Money.of(100000), Money.zero(), Money.of(100000)); + return Order.reconstitute(new OrderData(id, userId, items, null, + deliveryInfo, orderAmount, status, + LocalDateTime.now(), LocalDateTime.now())); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductQueryServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductQueryServiceTest.java index 3a36c6282..ab0fa30cb 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductQueryServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductQueryServiceTest.java @@ -1,7 +1,9 @@ package com.loopers.application.product; import com.loopers.domain.model.brand.Brand; +import com.loopers.domain.model.brand.BrandData; import com.loopers.domain.model.brand.BrandName; +import com.loopers.domain.model.common.PageResult; import com.loopers.domain.model.product.*; import com.loopers.domain.repository.BrandRepository; import com.loopers.domain.repository.ProductRepository; @@ -9,9 +11,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.PageRequest; import java.time.LocalDateTime; import java.util.List; @@ -46,7 +45,7 @@ void getProduct_success() { Product product = createProduct(1L, 1L, "운동화", 50000); Brand brand = createBrand(1L, "나이키"); - when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + when(productRepository.findActiveById(1L)).thenReturn(Optional.of(product)); when(brandRepository.findById(1L)).thenReturn(Optional.of(brand)); // when @@ -63,10 +62,7 @@ void getProduct_success() { @DisplayName("삭제된 상품 조회시 예외") void getProduct_fail_deleted() { // given - Product deleted = Product.reconstitute(1L, 1L, ProductName.of("삭제됨"), Price.of(10000), - Stock.of(0), LikeCount.zero(), Description.ofNullable("설명"), - LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now()); - when(productRepository.findById(1L)).thenReturn(Optional.of(deleted)); + when(productRepository.findActiveById(1L)).thenReturn(Optional.empty()); // when & then assertThatThrownBy(() -> service.getProduct(1L)) @@ -78,7 +74,7 @@ void getProduct_fail_deleted() { @DisplayName("존재하지 않는 상품 조회시 예외") void getProduct_fail_notFound() { // given - when(productRepository.findById(999L)).thenReturn(Optional.empty()); + when(productRepository.findActiveById(999L)).thenReturn(Optional.empty()); // when & then assertThatThrownBy(() -> service.getProduct(999L)) @@ -99,16 +95,16 @@ void getProducts_success() { Product product2 = createProduct(2L, 1L, "슬리퍼", 30000); Brand brand = createBrand(1L, "나이키"); - Page page = new PageImpl<>(List.of(product1, product2), PageRequest.of(0, 20), 2); - when(productRepository.findAllByDeletedAtIsNull(eq(null), any())).thenReturn(page); - when(brandRepository.findById(1L)).thenReturn(Optional.of(brand)); + PageResult pageResult = new PageResult<>(List.of(product1, product2), 0, 20, 2, 1); + when(productRepository.findAllActive(eq(null), eq(null), eq(0), eq(20))).thenReturn(pageResult); + when(brandRepository.findAllByIds(List.of(1L))).thenReturn(List.of(brand)); // when var result = service.getProducts(null, null, 0, 20); // then - assertThat(result.getContent()).hasSize(2); - assertThat(result.getContent().get(0).brandName()).isEqualTo("나이키"); + assertThat(result.content()).hasSize(2); + assertThat(result.content().get(0).brandName()).isEqualTo("나이키"); } @Test @@ -118,40 +114,41 @@ void getProducts_withBrandFilter() { Product product = createProduct(1L, 1L, "운동화", 50000); Brand brand = createBrand(1L, "나이키"); - Page page = new PageImpl<>(List.of(product), PageRequest.of(0, 20), 1); - when(productRepository.findAllByDeletedAtIsNull(eq(1L), any())).thenReturn(page); - when(brandRepository.findById(1L)).thenReturn(Optional.of(brand)); + PageResult pageResult = new PageResult<>(List.of(product), 0, 20, 1, 1); + when(productRepository.findAllActive(eq(1L), eq(null), eq(0), eq(20))).thenReturn(pageResult); + when(brandRepository.findAllByIds(List.of(1L))).thenReturn(List.of(brand)); // when var result = service.getProducts(1L, null, 0, 20); // then - assertThat(result.getContent()).hasSize(1); + assertThat(result.content()).hasSize(1); } @Test @DisplayName("가격 오름차순 정렬") void getProducts_sortByPriceAsc() { // given - Page page = new PageImpl<>(List.of(), PageRequest.of(0, 20), 0); - when(productRepository.findAllByDeletedAtIsNull(eq(null), any())).thenReturn(page); + PageResult pageResult = new PageResult<>(List.of(), 0, 20, 0, 0); + when(productRepository.findAllActive(eq(null), eq("price_asc"), eq(0), eq(20))).thenReturn(pageResult); + when(brandRepository.findAllByIds(List.of())).thenReturn(List.of()); // when service.getProducts(null, "price_asc", 0, 20); // then - verify(productRepository).findAllByDeletedAtIsNull(eq(null), any()); + verify(productRepository).findAllActive(eq(null), eq("price_asc"), eq(0), eq(20)); } } private Product createProduct(Long id, Long brandId, String name, int price) { - return Product.reconstitute(id, brandId, ProductName.of(name), Price.of(price), - Stock.of(100), LikeCount.of(5), Description.ofNullable("설명"), - LocalDateTime.now(), LocalDateTime.now(), null); + return Product.reconstitute(new ProductData(id, brandId, ProductName.of(name), Price.of(price), + null, Stock.of(100), 5, "설명", + LocalDateTime.now(), LocalDateTime.now(), null)); } private Brand createBrand(Long id, String name) { - return Brand.reconstitute(id, BrandName.of(name), "설명", - LocalDateTime.now(), LocalDateTime.now(), null); + return Brand.reconstitute(new BrandData(id, BrandName.of(name), "설명", + LocalDateTime.now(), LocalDateTime.now(), null)); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java index c6174b6a5..e6d02e4fb 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java @@ -1,6 +1,9 @@ package com.loopers.application.product; +import com.loopers.application.product.CreateProductUseCase.ProductCreateCommand; +import com.loopers.application.product.UpdateProductUseCase.ProductUpdateCommand; import com.loopers.domain.model.brand.Brand; +import com.loopers.domain.model.brand.BrandData; import com.loopers.domain.model.brand.BrandName; import com.loopers.domain.model.product.*; import com.loopers.domain.repository.BrandRepository; @@ -39,11 +42,12 @@ class CreateProduct { void createProduct_success() { // given Brand brand = createBrand(1L); - when(brandRepository.findById(1L)).thenReturn(Optional.of(brand)); + when(brandRepository.findActiveById(1L)).thenReturn(Optional.of(brand)); // when & then + var command = new ProductCreateCommand(1L, "운동화", 50000, null, 100, "좋은 운동화"); assertThatNoException() - .isThrownBy(() -> service.createProduct(1L, "운동화", 50000, 100, "좋은 운동화")); + .isThrownBy(() -> service.createProduct(command)); verify(productRepository).save(any(Product.class)); } @@ -52,10 +56,11 @@ void createProduct_success() { @DisplayName("존재하지 않는 브랜드로 생성시 예외") void createProduct_fail_brandNotFound() { // given - when(brandRepository.findById(999L)).thenReturn(Optional.empty()); + when(brandRepository.findActiveById(999L)).thenReturn(Optional.empty()); // when & then - assertThatThrownBy(() -> service.createProduct(999L, "운동화", 50000, 100, "좋은 운동화")) + var command = new ProductCreateCommand(999L, "운동화", 50000, null, 100, "좋은 운동화"); + assertThatThrownBy(() -> service.createProduct(command)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("존재하지 않는 브랜드"); @@ -66,12 +71,11 @@ void createProduct_fail_brandNotFound() { @DisplayName("삭제된 브랜드로 생성시 예외") void createProduct_fail_deletedBrand() { // given - Brand deleted = Brand.reconstitute(1L, BrandName.of("삭제됨"), "설명", - LocalDateTime.now(), LocalDateTime.now(), LocalDateTime.now()); - when(brandRepository.findById(1L)).thenReturn(Optional.of(deleted)); + when(brandRepository.findActiveById(1L)).thenReturn(Optional.empty()); // when & then - assertThatThrownBy(() -> service.createProduct(1L, "운동화", 50000, 100, "좋은 운동화")) + var command = new ProductCreateCommand(1L, "운동화", 50000, null, 100, "좋은 운동화"); + assertThatThrownBy(() -> service.createProduct(command)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("존재하지 않는 브랜드"); } @@ -86,11 +90,12 @@ class UpdateProduct { void updateProduct_success() { // given Product product = createProduct(1L, 1L); - when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + when(productRepository.findActiveById(1L)).thenReturn(Optional.of(product)); // when & then + var command = new ProductUpdateCommand(1L, "새 이름", 60000, null, 200, "변경된 설명"); assertThatNoException() - .isThrownBy(() -> service.updateProduct(1L, "새 이름", 60000, 200, "변경된 설명")); + .isThrownBy(() -> service.updateProduct(command)); verify(productRepository).save(any(Product.class)); } @@ -99,10 +104,11 @@ void updateProduct_success() { @DisplayName("존재하지 않는 상품 수정시 예외") void updateProduct_fail_notFound() { // given - when(productRepository.findById(999L)).thenReturn(Optional.empty()); + when(productRepository.findActiveById(999L)).thenReturn(Optional.empty()); // when & then - assertThatThrownBy(() -> service.updateProduct(999L, "새 이름", 60000, 200, "설명")) + var command = new ProductUpdateCommand(999L, "새 이름", 60000, null, 200, "설명"); + assertThatThrownBy(() -> service.updateProduct(command)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("상품을 찾을 수 없습니다"); } @@ -117,7 +123,7 @@ class DeleteProduct { void deleteProduct_success() { // given Product product = createProduct(1L, 1L); - when(productRepository.findById(1L)).thenReturn(Optional.of(product)); + when(productRepository.findActiveById(1L)).thenReturn(Optional.of(product)); // when service.deleteProduct(1L); @@ -130,7 +136,7 @@ void deleteProduct_success() { @DisplayName("존재하지 않는 상품 삭제시 예외") void deleteProduct_fail_notFound() { // given - when(productRepository.findById(999L)).thenReturn(Optional.empty()); + when(productRepository.findActiveById(999L)).thenReturn(Optional.empty()); // when & then assertThatThrownBy(() -> service.deleteProduct(999L)) @@ -140,13 +146,13 @@ void deleteProduct_fail_notFound() { } private Brand createBrand(Long id) { - return Brand.reconstitute(id, BrandName.of("나이키"), "스포츠 브랜드", - LocalDateTime.now(), LocalDateTime.now(), null); + return Brand.reconstitute(new BrandData(id, BrandName.of("나이키"), "스포츠 브랜드", + LocalDateTime.now(), LocalDateTime.now(), null)); } private Product createProduct(Long id, Long brandId) { - return Product.reconstitute(id, brandId, ProductName.of("운동화"), Price.of(50000), - Stock.of(100), LikeCount.zero(), Description.ofNullable("좋은 운동화"), - LocalDateTime.now(), LocalDateTime.now(), null); + return Product.reconstitute(new ProductData(id, brandId, ProductName.of("운동화"), Price.of(50000), + null, Stock.of(100), 0, "좋은 운동화", + LocalDateTime.now(), LocalDateTime.now(), null)); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/user/AuthenticationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/AuthenticationServiceTest.java index b62c53715..ff66f043f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/user/AuthenticationServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/AuthenticationServiceTest.java @@ -83,15 +83,15 @@ void authenticate_fail_passwordMismatch() { } private User createUser(UserId userId, String encodedPassword) { - return User.reconstitute( + return User.reconstitute(new UserData( 1L, userId, UserName.of("홍길동"), encodedPassword, Birthday.of(BIRTHDAY), Email.of("test@example.com"), - WrongPasswordCount.init(), + 0, LocalDateTime.now() - ); + )); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceTest.java index e3131705c..bac328884 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/user/UserServiceTest.java @@ -1,5 +1,6 @@ package com.loopers.application.user; +import com.loopers.application.user.RegisterUseCase.RegisterCommand; import com.loopers.domain.model.user.*; import com.loopers.domain.repository.UserRepository; import com.loopers.domain.service.PasswordEncoder; @@ -49,8 +50,9 @@ void register_success() { when(passwordEncoder.encrypt(anyString())).thenReturn("encoded_password"); // when & then + var command = new RegisterCommand(loginId, name, rawPassword, BIRTHDAY, email); assertThatNoException() - .isThrownBy(() -> service.register(loginId, name, rawPassword, BIRTHDAY, email)); + .isThrownBy(() -> service.register(command)); verify(passwordEncoder).encrypt(rawPassword); verify(userRepository).save(any(User.class)); @@ -70,7 +72,8 @@ void register_fail_duplicated_id() { .when(userRepository).save(any(User.class)); // when & then - assertThatThrownBy(() -> service.register(duplicatedId, name, rawPassword, BIRTHDAY, email)) + var command = new RegisterCommand(duplicatedId, name, rawPassword, BIRTHDAY, email); + assertThatThrownBy(() -> service.register(command)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("이미 사용중인 ID"); } @@ -165,16 +168,16 @@ class UserQuery { void getUserInfo_success() { // given UserId userId = UserId.of("test1234"); - User user = User.reconstitute( + User user = User.reconstitute(new UserData( 1L, userId, UserName.of("홍길동"), "encoded_password", Birthday.of(BIRTHDAY), Email.of("test@example.com"), - WrongPasswordCount.init(), + 0, LocalDateTime.now() - ); + )); when(userRepository.findById(userId)).thenReturn(Optional.of(user)); @@ -193,16 +196,16 @@ void getUserInfo_success() { void getUserInfo_maskedName_2chars() { // given UserId userId = UserId.of("test1234"); - User user = User.reconstitute( + User user = User.reconstitute(new UserData( 1L, userId, UserName.of("홍길"), "encoded_password", Birthday.of(BIRTHDAY), Email.of("test@example.com"), - WrongPasswordCount.init(), + 0, LocalDateTime.now() - ); + )); when(userRepository.findById(userId)).thenReturn(Optional.of(user)); @@ -237,15 +240,15 @@ void userName_fail_lessThan2chars() { } private User createUser(UserId userId, String encodedPassword) { - return User.reconstitute( + return User.reconstitute(new UserData( 1L, userId, UserName.of("홍길동"), encodedPassword, Birthday.of(BIRTHDAY), Email.of("test@example.com"), - WrongPasswordCount.init(), + 0, LocalDateTime.now() - ); + )); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/brand/BrandTest.java index ded6ebb13..02d38dc43 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/brand/BrandTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/brand/BrandTest.java @@ -58,7 +58,7 @@ void delete_already_deleted() { @DisplayName("reconstitute로 DB에서 복원") void reconstitute_success() { LocalDateTime now = LocalDateTime.now(); - Brand brand = Brand.reconstitute(1L, BrandName.of("Nike"), "스포츠 브랜드", now, now, null); + Brand brand = Brand.reconstitute(new BrandData(1L, BrandName.of("Nike"), "스포츠 브랜드", now, now, null)); assertThat(brand.getId()).isEqualTo(1L); assertThat(brand.getName().getValue()).isEqualTo("Nike"); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/order/DeliveryInfoTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/DeliveryInfoTest.java new file mode 100644 index 000000000..921f9fbec --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/DeliveryInfoTest.java @@ -0,0 +1,79 @@ +package com.loopers.domain.model.order; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DeliveryInfoTest { + + @Test + @DisplayName("DeliveryInfo 생성 성공") + void of_success() { + DeliveryInfo info = DeliveryInfo.of( + "홍길동", + "서울시 강남구", + "문 앞에 놓아주세요", + LocalDate.of(2025, 6, 15) + ); + + assertThat(info.getReceiverName()).isEqualTo("홍길동"); + assertThat(info.getAddress()).isEqualTo("서울시 강남구"); + assertThat(info.getDeliveryRequest()).isEqualTo("문 앞에 놓아주세요"); + assertThat(info.getDesiredDeliveryDate()).isEqualTo(LocalDate.of(2025, 6, 15)); + } + + @Test + @DisplayName("배송 요청사항과 희망 배송일은 nullable") + void of_nullable_fields() { + DeliveryInfo info = DeliveryInfo.of( + "홍길동", + "서울시", + null, + null + ); + + assertThat(info.getDeliveryRequest()).isNull(); + assertThat(info.getDesiredDeliveryDate()).isNull(); + } + + @Test + @DisplayName("수령인 이름이 null이면 예외") + void of_fail_null_receiverName() { + assertThatThrownBy(() -> DeliveryInfo.of(null, "서울시", null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("수령인 이름은 필수입니다"); + } + + @Test + @DisplayName("배송 주소가 null이면 예외") + void of_fail_null_address() { + assertThatThrownBy(() -> DeliveryInfo.of("홍길동", null, null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("배송 주소는 필수입니다"); + } + + @Test + @DisplayName("withAddress로 새 배송지 반환 - 불변 객체") + void withAddress_returns_new_instance() { + DeliveryInfo original = DeliveryInfo.of( + "홍길동", + "서울시", + "요청사항", + LocalDate.of(2025, 6, 15) + ); + + DeliveryInfo updated = original.withAddress("부산시"); + + assertThat(updated.getAddress()).isEqualTo("부산시"); + assertThat(updated.getReceiverName()).isEqualTo("홍길동"); + assertThat(updated.getDeliveryRequest()).isEqualTo("요청사항"); + assertThat(updated.getDesiredDeliveryDate()).isEqualTo(LocalDate.of(2025, 6, 15)); + + // 원본 불변 확인 + assertThat(original.getAddress()).isEqualTo("서울시"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderAmountTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderAmountTest.java new file mode 100644 index 000000000..d20e6322f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderAmountTest.java @@ -0,0 +1,58 @@ +package com.loopers.domain.model.order; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OrderAmountTest { + + @Test + @DisplayName("of() - paymentAmount 자동 계산 (totalAmount - discountAmount)") + void of_auto_calculate_paymentAmount() { + OrderAmount amount = OrderAmount.of(PaymentMethod.CARD, Money.of(50000), Money.of(5000)); + + assertThat(amount.getPaymentMethod()).isEqualTo(PaymentMethod.CARD); + assertThat(amount.getTotalAmount().getValue()).isEqualTo(50000); + assertThat(amount.getDiscountAmount().getValue()).isEqualTo(5000); + assertThat(amount.getPaymentAmount().getValue()).isEqualTo(45000); + } + + @Test + @DisplayName("of() - discountAmount가 null이면 0원 처리") + void of_null_discount_defaults_to_zero() { + OrderAmount amount = OrderAmount.of(PaymentMethod.BANK_TRANSFER, Money.of(30000), null); + + assertThat(amount.getDiscountAmount().getValue()).isEqualTo(0); + assertThat(amount.getPaymentAmount().getValue()).isEqualTo(30000); + } + + @Test + @DisplayName("of() - paymentMethod null이면 예외") + void of_fail_null_paymentMethod() { + assertThatThrownBy(() -> OrderAmount.of(null, Money.of(10000), Money.zero())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("결제 수단은 필수입니다"); + } + + @Test + @DisplayName("of() - totalAmount null이면 예외") + void of_fail_null_totalAmount() { + assertThatThrownBy(() -> OrderAmount.of(PaymentMethod.CARD, null, Money.zero())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("총 금액은 필수입니다"); + } + + @Test + @DisplayName("reconstitute() - 저장된 값 그대로 복원") + void reconstitute_preserves_stored_values() { + OrderAmount amount = OrderAmount.reconstitute( + PaymentMethod.CARD, Money.of(50000), Money.of(5000), Money.of(45000)); + + assertThat(amount.getPaymentMethod()).isEqualTo(PaymentMethod.CARD); + assertThat(amount.getTotalAmount().getValue()).isEqualTo(50000); + assertThat(amount.getDiscountAmount().getValue()).isEqualTo(5000); + assertThat(amount.getPaymentAmount().getValue()).isEqualTo(45000); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderItemTest.java index 30c3fd753..3f7e971fe 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderItemTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderItemTest.java @@ -11,18 +11,18 @@ class OrderItemTest { @Test @DisplayName("주문 항목 생성 성공") void create_success() { - OrderItem item = OrderItem.create(1L, Quantity.of(2), Money.of(10000)); + OrderItem item = OrderItem.create(1L, 2, Money.of(10000)); assertThat(item.getId()).isNull(); assertThat(item.getProductId()).isEqualTo(1L); - assertThat(item.getQuantity().getValue()).isEqualTo(2); + assertThat(item.getQuantity()).isEqualTo(2); assertThat(item.getUnitPrice().getValue()).isEqualTo(10000); } @Test @DisplayName("productId null이면 예외") void create_fail_null_productId() { - assertThatThrownBy(() -> OrderItem.create(null, Quantity.of(2), Money.of(10000))) + assertThatThrownBy(() -> OrderItem.create(null, 2, Money.of(10000))) .isInstanceOf(IllegalArgumentException.class) .hasMessage("상품 ID는 필수입니다."); } @@ -30,7 +30,7 @@ void create_fail_null_productId() { @Test @DisplayName("수량 0 이하면 예외") void create_fail_zero_quantity() { - assertThatThrownBy(() -> Quantity.of(0)) + assertThatThrownBy(() -> OrderItem.create(1L, 0, Money.of(10000))) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("1 이상"); } @@ -38,14 +38,14 @@ void create_fail_zero_quantity() { @Test @DisplayName("단가 null이면 예외") void create_fail_null_unitPrice() { - assertThatThrownBy(() -> OrderItem.create(1L, Quantity.of(2), null)) + assertThatThrownBy(() -> OrderItem.create(1L, 2, null)) .isInstanceOf(IllegalArgumentException.class); } @Test @DisplayName("금액 계산 (단가 * 수량)") void calculateAmount() { - OrderItem item = OrderItem.create(1L, Quantity.of(3), Money.of(10000)); + OrderItem item = OrderItem.create(1L, 3, Money.of(10000)); assertThat(item.calculateAmount().getValue()).isEqualTo(30000); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderTest.java index 32b3386fd..3ff6d9185 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/order/OrderTest.java @@ -15,19 +15,23 @@ class OrderTest { private Order createOrder() { List orderLines = List.of( - new OrderLine(1L, "상품A", Money.of(10000), Quantity.of(2)), - new OrderLine(2L, "상품B", Money.of(20000), Quantity.of(1)) + new OrderLine(1L, "상품A", Money.of(10000), 2), + new OrderLine(2L, "상품B", Money.of(20000), 1) + ); + + DeliveryInfo deliveryInfo = DeliveryInfo.of( + "홍길동", + "서울시 강남구", + "부재시 문 앞에 놓아주세요", + LocalDate.now().plusDays(3) ); return Order.create( UserId.of("testuser1"), orderLines, - ReceiverName.of("홍길동"), - Address.of("서울시 강남구"), - "부재시 문 앞에 놓아주세요", + deliveryInfo, PaymentMethod.CARD, - Money.zero(), - LocalDate.now().plusDays(3) + Money.zero() ); } @@ -48,14 +52,20 @@ void create_success() { @DisplayName("주문 생성 - 할인 적용") void create_with_discount() { List orderLines = List.of( - new OrderLine(1L, "상품A", Money.of(50000), Quantity.of(1)) + new OrderLine(1L, "상품A", Money.of(50000), 1) + ); + + DeliveryInfo deliveryInfo = DeliveryInfo.of( + "홍길동", + "서울시", + null, + null ); Order order = Order.create( UserId.of("testuser1"), orderLines, - ReceiverName.of("홍길동"), Address.of("서울시"), - null, PaymentMethod.CARD, - Money.of(5000), null + deliveryInfo, PaymentMethod.CARD, + Money.of(5000) ); assertThat(order.getTotalAmount().getValue()).isEqualTo(50000); @@ -67,12 +77,18 @@ void create_with_discount() { @DisplayName("userId null이면 예외") void create_fail_null_userId() { List orderLines = List.of( - new OrderLine(1L, "상품A", Money.of(10000), Quantity.of(1)) + new OrderLine(1L, "상품A", Money.of(10000), 1) + ); + + DeliveryInfo deliveryInfo = DeliveryInfo.of( + "홍길동", + "서울시", + null, + null ); assertThatThrownBy(() -> Order.create(null, orderLines, - ReceiverName.of("홍길동"), Address.of("서울시"), - null, PaymentMethod.CARD, Money.zero(), null)) + deliveryInfo, PaymentMethod.CARD, Money.zero())) .isInstanceOf(IllegalArgumentException.class) .hasMessage("사용자 ID는 필수입니다."); } @@ -80,9 +96,15 @@ void create_fail_null_userId() { @Test @DisplayName("주문 항목 비어있으면 예외") void create_fail_empty_items() { + DeliveryInfo deliveryInfo = DeliveryInfo.of( + "홍길동", + "서울시", + null, + null + ); + assertThatThrownBy(() -> Order.create(UserId.of("testuser1"), List.of(), - ReceiverName.of("홍길동"), Address.of("서울시"), - null, PaymentMethod.CARD, Money.zero(), null)) + deliveryInfo, PaymentMethod.CARD, Money.zero())) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("1개 이상"); } @@ -101,13 +123,17 @@ void cancel_success() { @Test @DisplayName("SHIPPING 상태에서 취소 불가") void cancel_fail_shipping() { - Order order = Order.reconstitute( + DeliveryInfo deliveryInfo = DeliveryInfo.of( + "홍길동", "서울시", null, null); + OrderAmount orderAmount = OrderAmount.reconstitute( + PaymentMethod.CARD, Money.of(10000), Money.zero(), Money.of(10000)); + + Order order = Order.reconstitute(new OrderData( 1L, UserId.of("testuser1"), - List.of(OrderItem.create(1L, Quantity.of(1), Money.of(10000))), - null, ReceiverName.of("홍길동"), Address.of("서울시"), null, - PaymentMethod.CARD, Money.of(10000), Money.zero(), Money.of(10000), - OrderStatus.SHIPPING, null, LocalDateTime.now(), LocalDateTime.now() - ); + List.of(OrderItem.create(1L, 1, Money.of(10000))), + null, deliveryInfo, orderAmount, + OrderStatus.SHIPPING, LocalDateTime.now(), LocalDateTime.now() + )); assertThat(order.isCancellable()).isFalse(); assertThatThrownBy(order::cancel) @@ -118,13 +144,17 @@ void cancel_fail_shipping() { @Test @DisplayName("DELIVERED 상태에서 취소 불가") void cancel_fail_delivered() { - Order order = Order.reconstitute( + DeliveryInfo deliveryInfo = DeliveryInfo.of( + "홍길동", "서울시", null, null); + OrderAmount orderAmount = OrderAmount.reconstitute( + PaymentMethod.CARD, Money.of(10000), Money.zero(), Money.of(10000)); + + Order order = Order.reconstitute(new OrderData( 1L, UserId.of("testuser1"), - List.of(OrderItem.create(1L, Quantity.of(1), Money.of(10000))), - null, ReceiverName.of("홍길동"), Address.of("서울시"), null, - PaymentMethod.CARD, Money.of(10000), Money.zero(), Money.of(10000), - OrderStatus.DELIVERED, null, LocalDateTime.now(), LocalDateTime.now() - ); + List.of(OrderItem.create(1L, 1, Money.of(10000))), + null, deliveryInfo, orderAmount, + OrderStatus.DELIVERED, LocalDateTime.now(), LocalDateTime.now() + )); assertThatThrownBy(order::cancel) .isInstanceOf(IllegalStateException.class); @@ -134,23 +164,27 @@ void cancel_fail_delivered() { @DisplayName("배송지 변경 성공 (PAYMENT_COMPLETED)") void updateDeliveryAddress_success() { Order order = createOrder(); - Order updated = order.updateDeliveryAddress(Address.of("부산시 해운대구")); + Order updated = order.updateDeliveryAddress("부산시 해운대구"); - assertThat(updated.getAddress().getValue()).isEqualTo("부산시 해운대구"); + assertThat(updated.getAddress()).isEqualTo("부산시 해운대구"); } @Test @DisplayName("SHIPPING 상태에서 배송지 변경 불가") void updateDeliveryAddress_fail_shipping() { - Order order = Order.reconstitute( + DeliveryInfo deliveryInfo = DeliveryInfo.of( + "홍길동", "서울시", null, null); + OrderAmount orderAmount = OrderAmount.reconstitute( + PaymentMethod.CARD, Money.of(10000), Money.zero(), Money.of(10000)); + + Order order = Order.reconstitute(new OrderData( 1L, UserId.of("testuser1"), - List.of(OrderItem.create(1L, Quantity.of(1), Money.of(10000))), - null, ReceiverName.of("홍길동"), Address.of("서울시"), null, - PaymentMethod.CARD, Money.of(10000), Money.zero(), Money.of(10000), - OrderStatus.SHIPPING, null, LocalDateTime.now(), LocalDateTime.now() - ); + List.of(OrderItem.create(1L, 1, Money.of(10000))), + null, deliveryInfo, orderAmount, + OrderStatus.SHIPPING, LocalDateTime.now(), LocalDateTime.now() + )); - assertThatThrownBy(() -> order.updateDeliveryAddress(Address.of("부산시"))) + assertThatThrownBy(() -> order.updateDeliveryAddress("부산시")) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("배송지를 변경할 수 없습니다"); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductPricingTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductPricingTest.java new file mode 100644 index 000000000..a16879818 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductPricingTest.java @@ -0,0 +1,36 @@ +package com.loopers.domain.model.product; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ProductPricingTest { + + @Test + @DisplayName("정상가만 있는 경우 세일 아님") + void notOnSale() { + ProductPricing pricing = ProductPricing.of(Price.of(10000), null); + + assertThat(pricing.isOnSale()).isFalse(); + assertThat(pricing.getDiscountRate()).isEqualTo(0); + } + + @Test + @DisplayName("세일가 있는 경우 할인율 계산") + void onSale_withDiscountRate() { + ProductPricing pricing = ProductPricing.of(Price.of(139000), Price.of(99000)); + + assertThat(pricing.isOnSale()).isTrue(); + assertThat(pricing.getDiscountRate()).isEqualTo(28); + } + + @Test + @DisplayName("가격 필수 검증") + void price_required() { + assertThatThrownBy(() -> ProductPricing.of(null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("상품 가격은 필수입니다"); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductTest.java index 6b0870436..6ec26de2f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/product/ProductTest.java @@ -13,6 +13,7 @@ private Product createProduct() { 1L, ProductName.of("에어맥스 90"), Price.of(139000), + null, Stock.of(50), "나이키 에어맥스 90" ); @@ -28,8 +29,27 @@ void create_success() { assertThat(product.getName().getValue()).isEqualTo("에어맥스 90"); assertThat(product.getPrice().getValue()).isEqualTo(139000); assertThat(product.getStock().getValue()).isEqualTo(50); - assertThat(product.getLikeCount().getValue()).isEqualTo(0); + assertThat(product.getLikeCount()).isEqualTo(0); assertThat(product.isDeleted()).isFalse(); + assertThat(product.isOnSale()).isFalse(); + assertThat(product.getDiscountRate()).isEqualTo(0); + } + + @Test + @DisplayName("세일 상품 생성") + void create_withSalePrice() { + Product product = Product.create( + 1L, + ProductName.of("에어맥스 90"), + Price.of(139000), + Price.of(99000), + Stock.of(50), + "나이키 에어맥스 90" + ); + + assertThat(product.isOnSale()).isTrue(); + assertThat(product.getSalePrice().getValue()).isEqualTo(99000); + assertThat(product.getDiscountRate()).isEqualTo(28); // (139000-99000)*100/139000 = 28 } @Test @@ -39,6 +59,7 @@ void update_without_brandId() { Product updated = product.update( ProductName.of("에어맥스 95"), Price.of(159000), + null, Stock.of(30), "나이키 에어맥스 95" ); @@ -93,7 +114,7 @@ void increaseLikeCount() { Product product = createProduct(); Product liked = product.increaseLikeCount(); - assertThat(liked.getLikeCount().getValue()).isEqualTo(1); + assertThat(liked.getLikeCount()).isEqualTo(1); } @Test @@ -102,7 +123,7 @@ void decreaseLikeCount() { Product product = createProduct().increaseLikeCount(); Product unliked = product.decreaseLikeCount(); - assertThat(unliked.getLikeCount().getValue()).isEqualTo(0); + assertThat(unliked.getLikeCount()).isEqualTo(0); } @Test @@ -114,4 +135,15 @@ void decreaseLikeCount_fail_zero() { .isInstanceOf(IllegalStateException.class) .hasMessageContaining("0 미만"); } + + @Test + @DisplayName("품절 여부 확인") + void isSoldOut() { + Product soldOut = Product.create(1L, ProductName.of("품절상품"), Price.of(10000), null, + Stock.of(0), "설명"); + Product inStock = createProduct(); + + assertThat(soldOut.isSoldOut()).isTrue(); + assertThat(inStock.isSoldOut()).isFalse(); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/user/UserNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/UserNameTest.java index 0a1b33eb2..87f18d0cd 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/user/UserNameTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/UserNameTest.java @@ -82,4 +82,24 @@ void create_fail_special_char() { .hasMessageContaining("한글 또는 영문"); } + @Test + @DisplayName("2자 이름 마스킹") + void maskedValue_2chars() { + UserName name = UserName.of("홍길"); + assertThat(name.maskedValue()).isEqualTo("홍*"); + } + + @Test + @DisplayName("3자 이름 마스킹") + void maskedValue_3chars() { + UserName name = UserName.of("홍길동"); + assertThat(name.maskedValue()).isEqualTo("홍길*"); + } + + @Test + @DisplayName("영문 이름 마스킹") + void maskedValue_english() { + UserName name = UserName.of("John"); + assertThat(name.maskedValue()).isEqualTo("Joh*"); + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/user/UserTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/UserTest.java new file mode 100644 index 000000000..eeb78c984 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/UserTest.java @@ -0,0 +1,73 @@ +package com.loopers.domain.model.user; + +import com.loopers.domain.service.PasswordEncoder; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class UserTest { + + private static final LocalDate BIRTHDAY = LocalDate.of(1990, 5, 15); + + @Test + @DisplayName("비밀번호 변경 성공") + void changePassword_success() { + User user = createUser("encoded_current"); + PasswordEncoder encoder = mock(PasswordEncoder.class); + + when(encoder.matches("Current1!", "encoded_current")).thenReturn(true); + when(encoder.matches("NewPass1!", "encoded_current")).thenReturn(false); + when(encoder.encrypt("NewPass1!")).thenReturn("encoded_new"); + + User updated = user.changePassword("Current1!", "NewPass1!", encoder); + + assertThat(updated.getEncodedPassword()).isEqualTo("encoded_new"); + } + + @Test + @DisplayName("현재 비밀번호 불일치시 예외") + void changePassword_fail_wrongCurrent() { + User user = createUser("encoded_current"); + PasswordEncoder encoder = mock(PasswordEncoder.class); + + when(encoder.matches("WrongPw1!", "encoded_current")).thenReturn(false); + + assertThatThrownBy(() -> user.changePassword("WrongPw1!", "NewPass1!", encoder)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("현재 비밀번호가 일치하지 않습니다"); + } + + @Test + @DisplayName("새 비밀번호가 현재와 동일하면 예외") + void changePassword_fail_samePassword() { + User user = createUser("encoded_current"); + PasswordEncoder encoder = mock(PasswordEncoder.class); + + when(encoder.matches("Current1!", "encoded_current")).thenReturn(true); + when(encoder.matches("Current1!", "encoded_current")).thenReturn(true); + + assertThatThrownBy(() -> user.changePassword("Current1!", "Current1!", encoder)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("현재 비밀번호는 사용할 수 없습니다"); + } + + private User createUser(String encodedPassword) { + return User.reconstitute(new UserData( + 1L, + UserId.of("test1234"), + UserName.of("홍길동"), + encodedPassword, + Birthday.of(BIRTHDAY), + Email.of("test@example.com"), + 0, + LocalDateTime.now() + )); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/model/user/WrongPasswordCountTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/model/user/WrongPasswordCountTest.java deleted file mode 100644 index 83fbb5251..000000000 --- a/apps/commerce-api/src/test/java/com/loopers/domain/model/user/WrongPasswordCountTest.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.loopers.domain.model.user; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class WrongPasswordCountTest { - - @Test - @DisplayName("초기값 0으로 생성") - void init_success() { - // given and when - WrongPasswordCount count = WrongPasswordCount.init(); - - // then - assertThat(count.getValue()).isEqualTo(0); - } - - @Test - @DisplayName("유효한 값으로 생성") - void of_success() { - // given - int value = 3; - - // when - WrongPasswordCount count = WrongPasswordCount.of(value); - - // then - assertThat(count.getValue()).isEqualTo(3); - } - - @Test - @DisplayName("음수값이면 예외") - void of_fail_negative() { - // given - int value = -1; - - // when and then - assertThatThrownBy(() -> WrongPasswordCount.of(value)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("음수"); - } - - @Test - @DisplayName("카운트 증가") - void increment_success() { - // given - WrongPasswordCount count = WrongPasswordCount.init(); - - // when - WrongPasswordCount incremented = count.increment(); - - // then - assertThat(incremented.getValue()).isEqualTo(1); - } - - @Test - @DisplayName("카운트 리셋") - void reset_success() { - // given - WrongPasswordCount count = WrongPasswordCount.of(3); - - // when - WrongPasswordCount reset = count.reset(); - - // then - assertThat(reset.getValue()).isEqualTo(0); - } - - @Test - @DisplayName("5회 이상 실패시 잠금") - void isLocked_true() { - // given - WrongPasswordCount count = WrongPasswordCount.of(5); - - // when - boolean locked = count.isLocked(); - - // then - assertThat(locked).isTrue(); - } - - @Test - @DisplayName("5회 미만 실패시 잠금 안됨") - void isLocked_false() { - // given - WrongPasswordCount count = WrongPasswordCount.of(4); - - // when - boolean locked = count.isLocked(); - - // then - assertThat(locked).isFalse(); - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java index 5e7deeebf..55a614978 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiE2ETest.java @@ -67,7 +67,7 @@ void fullLikeFlow() { // Step 3: 좋아요 목록 조회 ResponseEntity likesResponse = restTemplate.exchange( - "/api/v1/users/me/likes", + "/api/v1/users/" + LOGIN_ID + "/likes", HttpMethod.GET, new HttpEntity<>(authHeaders), String.class @@ -128,7 +128,7 @@ private HttpHeaders createAdminHeaders() { private void registerUser(String loginId, String password, String name) { var request = new UserRegisterRequest(loginId, password, name, LocalDate.of(1990, 5, 15), "test@example.com"); - restTemplate.postForEntity("/api/v1/users/register", request, Void.class); + restTemplate.postForEntity("/api/v1/users", request, Void.class); } private void createBrand(String name, String description) { @@ -138,7 +138,7 @@ private void createBrand(String name, String description) { } private void createProduct(Long brandId, String name, int price, int stock) { - var request = new ProductCreateRequest(brandId, name, price, stock, "설명"); + var request = new ProductCreateRequest(brandId, name, price, null, stock, "설명"); restTemplate.exchange("/api-admin/v1/products", HttpMethod.POST, new HttpEntity<>(request, createAdminHeaders()), Void.class); } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiIntegrationTest.java index 44b13290b..54bec699a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeApiIntegrationTest.java @@ -37,11 +37,11 @@ class LikeApiIntegrationTest { private DatabaseCleanUp databaseCleanUp; private static final String LIKE_URL = "/api/v1/products"; - private static final String MY_LIKES_URL = "/api/v1/users/me/likes"; private static final String ADMIN_HEADER = "X-Loopers-Ldap"; private static final String ADMIN_VALUE = "loopers.admin"; private static final String LOGIN_ID = "testuser1"; private static final String PASSWORD = "Password1!"; + private static final String MY_LIKES_URL = "/api/v1/users/" + LOGIN_ID + "/likes"; @BeforeEach void setUp() throws Exception { @@ -148,7 +148,7 @@ void getMyLikes_empty() throws Exception { private void registerUser(String loginId, String password, String name) throws Exception { var request = new UserRegisterRequest(loginId, password, name, LocalDate.of(1990, 5, 15), "test@example.com"); - mockMvc.perform(post("/api/v1/users/register") + mockMvc.perform(post("/api/v1/users") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()); @@ -164,7 +164,7 @@ private void createBrand(String name, String description) throws Exception { } private void createProduct(Long brandId, String name, int price, int stock) throws Exception { - var request = new ProductCreateRequest(brandId, name, price, stock, "설명"); + var request = new ProductCreateRequest(brandId, name, price, null, stock, "설명"); mockMvc.perform(post("/api-admin/v1/products") .header(ADMIN_HEADER, ADMIN_VALUE) .contentType(MediaType.APPLICATION_JSON) diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java index 92c78826f..b98fcefbb 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiE2ETest.java @@ -209,7 +209,7 @@ private HttpHeaders createAdminHeaders() { private void registerUser(String loginId, String password, String name) { var request = new UserRegisterRequest(loginId, password, name, LocalDate.of(1990, 5, 15), "test@example.com"); - restTemplate.postForEntity("/api/v1/users/register", request, Void.class); + restTemplate.postForEntity("/api/v1/users", request, Void.class); } private void createBrand(String name, String description) { @@ -219,7 +219,7 @@ private void createBrand(String name, String description) { } private void createProduct(Long brandId, String name, int price, int stock) { - var request = new ProductCreateRequest(brandId, name, price, stock, "설명"); + var request = new ProductCreateRequest(brandId, name, price, null, stock, "설명"); restTemplate.exchange("/api-admin/v1/products", HttpMethod.POST, new HttpEntity<>(request, createAdminHeaders()), Void.class); } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiIntegrationTest.java index 9b9c2f400..3aa07c19f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderApiIntegrationTest.java @@ -226,7 +226,7 @@ void getAllOrders_fail_unauthorized() throws Exception { private void registerUser(String loginId, String password, String name) throws Exception { var request = new UserRegisterRequest(loginId, password, name, LocalDate.of(1990, 5, 15), "test@example.com"); - mockMvc.perform(post("/api/v1/users/register") + mockMvc.perform(post("/api/v1/users") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()); @@ -242,7 +242,7 @@ private void createBrand(String name, String description) throws Exception { } private void createProduct(Long brandId, String name, int price, int stock) throws Exception { - var request = new ProductCreateRequest(brandId, name, price, stock, "설명"); + var request = new ProductCreateRequest(brandId, name, price, null, stock, "설명"); mockMvc.perform(post("/api-admin/v1/products") .header(ADMIN_HEADER, ADMIN_VALUE) .contentType(MediaType.APPLICATION_JSON) diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java index f4747936a..28ce8d3be 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java @@ -47,7 +47,7 @@ class ProductCrudE2E { void create_then_getDetail() { // given createBrand("나이키", "스포츠"); - var request = new ProductCreateRequest(1L, "운동화", 50000, 100, "좋은 운동화"); + var request = new ProductCreateRequest(1L, "운동화", 50000, null, 100, "좋은 운동화"); // when - 생성 ResponseEntity createResponse = restTemplate.exchange( @@ -168,7 +168,7 @@ private void createBrand(String name, String description) { } private void createProduct(Long brandId, String name, int price, int stock) { - var request = new ProductCreateRequest(brandId, name, price, stock, "설명"); + var request = new ProductCreateRequest(brandId, name, price, null, stock, "설명"); ResponseEntity response = restTemplate.exchange( ADMIN_URL, HttpMethod.POST, diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiIntegrationTest.java index b960625b6..486478c69 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiIntegrationTest.java @@ -54,7 +54,7 @@ class CreateProductApi { void createProduct_success() throws Exception { createBrand("나이키", "스포츠"); - var request = new ProductCreateRequest(1L, "운동화", 50000, 100, "좋은 운동화"); + var request = new ProductCreateRequest(1L, "운동화", 50000, null, 100, "좋은 운동화"); mockMvc.perform(post(ADMIN_URL) .header(ADMIN_HEADER, ADMIN_VALUE) @@ -66,7 +66,7 @@ void createProduct_success() throws Exception { @Test @DisplayName("존재하지 않는 브랜드로 상품 생성시 실패") void createProduct_fail_brandNotFound() throws Exception { - var request = new ProductCreateRequest(999L, "운동화", 50000, 100, "좋은 운동화"); + var request = new ProductCreateRequest(999L, "운동화", 50000, null, 100, "좋은 운동화"); mockMvc.perform(post(ADMIN_URL) .header(ADMIN_HEADER, ADMIN_VALUE) @@ -86,7 +86,7 @@ void updateProduct_success() throws Exception { createBrand("나이키", "스포츠"); createProduct(1L, "운동화", 50000, 100); - var updateRequest = new ProductUpdateRequest("슬리퍼", 30000, 200, "변경된 설명"); + var updateRequest = new ProductUpdateRequest("슬리퍼", 30000, null, 200, "변경된 설명"); mockMvc.perform(put(ADMIN_URL + "/1") .header(ADMIN_HEADER, ADMIN_VALUE) @@ -195,7 +195,7 @@ private void createBrand(String name, String description) throws Exception { } private void createProduct(Long brandId, String name, int price, int stock) throws Exception { - var request = new ProductCreateRequest(brandId, name, price, stock, "설명"); + var request = new ProductCreateRequest(brandId, name, price, null, stock, "설명"); mockMvc.perform(post(ADMIN_URL) .header(ADMIN_HEADER, ADMIN_VALUE) .contentType(MediaType.APPLICATION_JSON) diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java index 0485f9284..42accb243 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiE2ETest.java @@ -51,7 +51,7 @@ void register_then_getMyInfo() { // when - 회원가입 ResponseEntity registerResponse = restTemplate.postForEntity( - BASE_URL + "/register", + BASE_URL, registerRequest, Void.class ); @@ -84,11 +84,11 @@ void register_duplicateId_fail() { var request = createRegisterRequest(loginId, "Password1!", "홍길동"); // 첫 번째 가입 - restTemplate.postForEntity(BASE_URL + "/register", request, Void.class); + restTemplate.postForEntity(BASE_URL, request, Void.class); // when - 동일 ID로 재가입 ResponseEntity response = restTemplate.postForEntity( - BASE_URL + "/register", + BASE_URL, request, Void.class ); @@ -230,7 +230,7 @@ void fullUserFlow() { var registerRequest = createRegisterRequest(loginId, password, "김철수"); ResponseEntity registerResponse = restTemplate.postForEntity( - BASE_URL + "/register", + BASE_URL, registerRequest, Void.class ); @@ -292,6 +292,6 @@ private HttpHeaders createAuthHeaders(String loginId, String password) { private void registerUser(String loginId, String password, String name) { var request = createRegisterRequest(loginId, password, name); - ResponseEntity response = restTemplate.postForEntity(BASE_URL + "/register", request, Void.class); + ResponseEntity response = restTemplate.postForEntity(BASE_URL, request, Void.class); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiIntegrationTest.java index fa1d2eb35..fdc122bb3 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/user/UserApiIntegrationTest.java @@ -58,7 +58,7 @@ void register_success() throws Exception { "test@example.com" ); - mockMvc.perform(post(BASE_URL + "/register") + mockMvc.perform(post(BASE_URL) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()); @@ -76,13 +76,13 @@ void register_fail_duplicateId() throws Exception { ); // 첫 번째 가입 - mockMvc.perform(post(BASE_URL + "/register") + mockMvc.perform(post(BASE_URL) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()); // 동일 ID로 재가입 시도 - mockMvc.perform(post(BASE_URL + "/register") + mockMvc.perform(post(BASE_URL) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()); @@ -99,7 +99,7 @@ void register_fail_missingFields() throws Exception { "test@example.com" ); - mockMvc.perform(post(BASE_URL + "/register") + mockMvc.perform(post(BASE_URL) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()); @@ -116,7 +116,7 @@ void register_fail_invalidEmail() throws Exception { "invalid-email" ); - mockMvc.perform(post(BASE_URL + "/register") + mockMvc.perform(post(BASE_URL) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()); @@ -267,7 +267,7 @@ private void registerUser(String loginId, String password, String name) throws E "test@example.com" ); - mockMvc.perform(post(BASE_URL + "/register") + mockMvc.perform(post(BASE_URL) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk());