From 3f98a0aa9c3b8396998363ed80712357b8b260a4 Mon Sep 17 00:00:00 2001 From: yoon-yoo-tak Date: Mon, 16 Feb 2026 00:38:55 +0900 Subject: [PATCH 01/11] =?UTF-8?q?User=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VO 도입 --- .../java/com/loopers/domain/user/User.java | 83 ++++++------------- .../user/UserJpaRepository.java | 11 ++- .../interfaces/api/ApiControllerAdvice.java | 10 +++ .../interfaces/api/user/UserV1Controller.java | 5 +- .../interfaces/api/user/UserV1Dto.java | 31 ++++++- build.gradle.kts | 2 +- 6 files changed, 74 insertions(+), 68 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java index 165fdc0aa..f4a071bc3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/User.java @@ -1,91 +1,59 @@ package com.loopers.domain.user; import com.loopers.domain.BaseEntity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; import jakarta.persistence.Column; +import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + import java.time.LocalDate; -import java.util.regex.Pattern; @Entity @Table(name = "users") public class User extends BaseEntity { - private static final Pattern LOGIN_ID_PATTERN = Pattern.compile("^[a-zA-Z0-9]+$"); - private static final Pattern EMAIL_PATTERN = Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); - @Column(name = "login_id", nullable = false, unique = true) - private String loginId; + @Embedded + private LoginId loginId; - @Column(name = "password", nullable = false) - private String password; + @Embedded + private Password password; - @Column(name = "name", nullable = false) - private String name; + @Embedded + private UserName name; @Column(name = "birth_date", nullable = false) private LocalDate birthDate; - @Column(name = "email", nullable = false) - private String email; + @Embedded + private Email email; protected User() {} - public User(String loginId, String password, String name, LocalDate birthDate, String email) { - validateLoginId(loginId); - validateName(name); - validateBirthDate(birthDate); - validateEmail(email); - - this.loginId = loginId; - this.password = password; - this.name = name; - this.birthDate = birthDate; - this.email = email; - } - - private void validateLoginId(String loginId) { - if (loginId == null || loginId.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 비어있을 수 없습니다."); - } - if (!LOGIN_ID_PATTERN.matcher(loginId).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 영문과 숫자만 허용됩니다."); - } - } - - private void validateName(String name) { - if (name == null || name.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); - } - } - - private void validateBirthDate(LocalDate birthDate) { + public User(String loginId, String encryptedPassword, String name, LocalDate birthDate, String email) { if (birthDate == null) { throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다."); } - } - private void validateEmail(String email) { - if (email == null || email.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다."); - } - if (!EMAIL_PATTERN.matcher(email).matches()) { - throw new CoreException(ErrorType.BAD_REQUEST, "이메일 형식이 올바르지 않습니다."); - } + this.loginId = new LoginId(loginId); + this.password = new Password(encryptedPassword); + this.name = new UserName(name); + this.birthDate = birthDate; + this.email = new Email(email); } public String getLoginId() { - return loginId; + return loginId.getValue(); } public String getPassword() { - return password; + return password.getEncryptedValue(); } public String getName() { - return name; + return name.getValue(); } public LocalDate getBirthDate() { @@ -93,17 +61,14 @@ public LocalDate getBirthDate() { } public String getEmail() { - return email; + return email.getValue(); } public void changePassword(String newEncryptedPassword) { - this.password = newEncryptedPassword; + this.password = new Password(newEncryptedPassword); } public String getMaskedName() { - if (name.length() <= 1) { - return "*"; - } - return name.substring(0, name.length() - 1) + "*"; + return name.mask(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java index 5f9b5b949..07d067376 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -2,9 +2,16 @@ import com.loopers.domain.user.User; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; public interface UserJpaRepository extends JpaRepository { - boolean existsByLoginId(String loginId); - java.util.Optional findByLoginId(String loginId); + @Query("SELECT COUNT(u) > 0 FROM User u WHERE u.loginId.value = :loginId") + boolean existsByLoginId(@Param("loginId") String loginId); + + @Query("SELECT u FROM User u WHERE u.loginId.value = :loginId") + Optional findByLoginId(@Param("loginId") String loginId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 041716398..0bb32de0d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -9,6 +9,7 @@ import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -39,6 +40,15 @@ public ResponseEntity> handleBadRequest(MethodArgumentTypeMismatc return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(fieldError -> fieldError.getDefaultMessage()) + .findFirst() + .orElse(ErrorType.BAD_REQUEST.getMessage()); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(MissingServletRequestParameterException e) { String name = e.getParameterName(); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index 23ece8a31..eed87e830 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -5,6 +5,7 @@ import com.loopers.domain.user.User; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.auth.AuthUser; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -22,7 +23,7 @@ public class UserV1Controller implements UserV1ApiSpec { @PostMapping @Override - public ApiResponse signup(@RequestBody UserV1Dto.SignupRequest request) { + public ApiResponse signup(@Valid @RequestBody UserV1Dto.SignupRequest request) { UserInfo info = userFacade.signup( request.loginId(), request.password(), @@ -44,7 +45,7 @@ public ApiResponse getMe(@AuthUser User user) { @PutMapping("/password") @Override - public ApiResponse changePassword(@AuthUser User user, @RequestBody UserV1Dto.ChangePasswordRequest request) { + public ApiResponse changePassword(@AuthUser User user, @Valid @RequestBody UserV1Dto.ChangePasswordRequest request) { userFacade.changePassword(user, request.currentPassword(), request.newPassword()); return ApiResponse.success(null); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java index de2a1969c..711bbaae3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -1,13 +1,31 @@ package com.loopers.interfaces.api.user; import com.loopers.application.user.UserInfo; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import java.time.LocalDate; public class UserV1Dto { - public record SignupRequest(String loginId, String password, String name, LocalDate birthDate, String email) { - } + public record SignupRequest( + @NotBlank(message = "로그인 ID는 비어있을 수 없습니다.") + String loginId, + + @NotBlank(message = "비밀번호는 비어있을 수 없습니다.") + String password, + + @NotBlank(message = "이름은 비어있을 수 없습니다.") + String name, + + @NotNull(message = "생년월일은 비어있을 수 없습니다.") + LocalDate birthDate, + + @NotBlank(message = "이메일은 비어있을 수 없습니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + String email + ) {} public record SignupResponse(Long id, String loginId, String name, LocalDate birthDate, String email) { public static SignupResponse from(UserInfo info) { @@ -21,8 +39,13 @@ public static SignupResponse from(UserInfo info) { } } - public record ChangePasswordRequest(String currentPassword, String newPassword) { - } + public record ChangePasswordRequest( + @NotBlank(message = "현재 비밀번호는 비어있을 수 없습니다.") + String currentPassword, + + @NotBlank(message = "새 비밀번호는 비어있을 수 없습니다.") + String newPassword + ) {} public record MeResponse(String loginId, String name, LocalDate birthDate, String email) { public static MeResponse from(UserInfo info) { diff --git a/build.gradle.kts b/build.gradle.kts index 39180cd26..b44c57d89 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -48,7 +48,7 @@ subprojects { dependencies { // Web - runtimeOnly("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-validation") // Spring implementation("org.springframework.boot:spring-boot-starter") // Serialize From ee851fe4fec863ea991bf16d222b246eaa39d8e5 Mon Sep 17 00:00:00 2001 From: yoon-yoo-tak Date: Mon, 16 Feb 2026 00:39:19 +0900 Subject: [PATCH 02/11] =?UTF-8?q?refactor=20:=20User=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 - VO 도입 --- .../java/com/loopers/domain/user/Email.java | 48 ++++++++ .../java/com/loopers/domain/user/LoginId.java | 48 ++++++++ .../com/loopers/domain/user/Password.java | 40 +++++++ .../com/loopers/domain/user/UserName.java | 45 +++++++ .../com/loopers/domain/user/EmailTest.java | 86 ++++++++++++++ .../com/loopers/domain/user/LoginIdTest.java | 86 ++++++++++++++ .../com/loopers/domain/user/PasswordTest.java | 74 ++++++++++++ .../com/loopers/domain/user/UserNameTest.java | 111 ++++++++++++++++++ 8 files changed, 538 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserName.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java new file mode 100644 index 000000000..38666d965 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java @@ -0,0 +1,48 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.Getter; + +import java.util.Objects; +import java.util.regex.Pattern; + +@Embeddable +@Getter +public class Email { + + private static final Pattern PATTERN = Pattern.compile("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); + + @Column(name = "email", nullable = false) + private String value; + + protected Email() {} + + public Email(String value) { + validate(value); + this.value = value; + } + + private void validate(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다."); + } + if (!PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일 형식이 올바르지 않습니다."); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Email email)) return false; + return Objects.equals(value, email.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java new file mode 100644 index 000000000..0e9a40076 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/LoginId.java @@ -0,0 +1,48 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.Getter; + +import java.util.Objects; +import java.util.regex.Pattern; + +@Embeddable +@Getter +public class LoginId { + + private static final Pattern PATTERN = Pattern.compile("^[a-zA-Z0-9]+$"); + + @Column(name = "login_id", nullable = false, unique = true) + private String value; + + protected LoginId() {} + + public LoginId(String value) { + validate(value); + this.value = value; + } + + private void validate(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 비어있을 수 없습니다."); + } + if (!PATTERN.matcher(value).matches()) { + throw new CoreException(ErrorType.BAD_REQUEST, "로그인 ID는 영문과 숫자만 허용됩니다."); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof LoginId loginId)) return false; + return Objects.equals(value, loginId.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java new file mode 100644 index 000000000..c42456f20 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Password.java @@ -0,0 +1,40 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +import java.util.Objects; + +@Embeddable +public class Password { + + @Column(name = "password", nullable = false) + private String encryptedValue; + + protected Password() {} + + public Password(String encryptedValue) { + if (encryptedValue == null || encryptedValue.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "암호화된 비밀번호는 비어있을 수 없습니다."); + } + this.encryptedValue = encryptedValue; + } + + public String getEncryptedValue() { + return encryptedValue; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Password password)) return false; + return Objects.equals(encryptedValue, password.encryptedValue); + } + + @Override + public int hashCode() { + return Objects.hashCode(encryptedValue); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserName.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserName.java new file mode 100644 index 000000000..f9d243c7f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserName.java @@ -0,0 +1,45 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.Getter; + +import java.util.Objects; + +@Embeddable +@Getter +public class UserName { + + @Column(name = "name", nullable = false) + private String value; + + protected UserName() {} + + public UserName(String value) { + if (value == null || value.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "이름은 비어있을 수 없습니다."); + } + this.value = value; + } + + public String mask() { + if (value.length() <= 1) { + return "*"; + } + return value.substring(0, value.length() - 1) + "*"; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof UserName userName)) return false; + return Objects.equals(value, userName.value); + } + + @Override + public int hashCode() { + return Objects.hashCode(value); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java new file mode 100644 index 000000000..9b365444d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/EmailTest.java @@ -0,0 +1,86 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class EmailTest { + + @DisplayName("Email을 생성할 때, ") + @Nested + class Create { + + @DisplayName("올바른 형식이면, 정상적으로 생성된다.") + @Test + void createsEmail_whenFormatIsValid() { + // act + Email email = new Email("test@example.com"); + + // assert + assertThat(email.getValue()).isEqualTo("test@example.com"); + } + + @DisplayName("null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> new Email(null)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsBlank() { + // act + CoreException result = assertThrows(CoreException.class, () -> new Email(" ")); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이메일 형식이 올바르지 않으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenFormatIsInvalid() { + // act + CoreException result = assertThrows(CoreException.class, () -> new Email("invalid-email")); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("동등성을 비교할 때, ") + @Nested + class Equality { + + @DisplayName("같은 값이면 동일한 객체로 판단한다.") + @Test + void isEqual_whenValuesAreSame() { + // arrange + Email email1 = new Email("test@example.com"); + Email email2 = new Email("test@example.com"); + + // assert + assertThat(email1).isEqualTo(email2); + assertThat(email1.hashCode()).isEqualTo(email2.hashCode()); + } + + @DisplayName("다른 값이면 다른 객체로 판단한다.") + @Test + void isNotEqual_whenValuesAreDifferent() { + // arrange + Email email1 = new Email("test1@example.com"); + Email email2 = new Email("test2@example.com"); + + // assert + assertThat(email1).isNotEqualTo(email2); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java new file mode 100644 index 000000000..44c82fc89 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/LoginIdTest.java @@ -0,0 +1,86 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LoginIdTest { + + @DisplayName("LoginId를 생성할 때, ") + @Nested + class Create { + + @DisplayName("영문과 숫자로 구성된 값이면, 정상적으로 생성된다.") + @Test + void createsLoginId_whenValueIsAlphanumeric() { + // act + LoginId loginId = new LoginId("testUser1"); + + // assert + assertThat(loginId.getValue()).isEqualTo("testUser1"); + } + + @DisplayName("null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> new LoginId(null)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsBlank() { + // act + CoreException result = assertThrows(CoreException.class, () -> new LoginId(" ")); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("특수문자가 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueContainsSpecialChars() { + // act + CoreException result = assertThrows(CoreException.class, () -> new LoginId("user@123")); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("동등성을 비교할 때, ") + @Nested + class Equality { + + @DisplayName("같은 값이면 동일한 객체로 판단한다.") + @Test + void isEqual_whenValuesAreSame() { + // arrange + LoginId loginId1 = new LoginId("testUser1"); + LoginId loginId2 = new LoginId("testUser1"); + + // assert + assertThat(loginId1).isEqualTo(loginId2); + assertThat(loginId1.hashCode()).isEqualTo(loginId2.hashCode()); + } + + @DisplayName("다른 값이면 다른 객체로 판단한다.") + @Test + void isNotEqual_whenValuesAreDifferent() { + // arrange + LoginId loginId1 = new LoginId("testUser1"); + LoginId loginId2 = new LoginId("testUser2"); + + // assert + assertThat(loginId1).isNotEqualTo(loginId2); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java new file mode 100644 index 000000000..98d60ea41 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/PasswordTest.java @@ -0,0 +1,74 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PasswordTest { + + @DisplayName("Password를 생성할 때, ") + @Nested + class Create { + + @DisplayName("암호화된 값이 주어지면, 정상적으로 생성된다.") + @Test + void createsPassword_whenEncryptedValueProvided() { + // act + Password password = new Password("$2a$10$encryptedValue"); + + // assert + assertThat(password.getEncryptedValue()).isEqualTo("$2a$10$encryptedValue"); + } + + @DisplayName("null이면, CoreException이 발생한다.") + @Test + void throwsBadRequest_whenValueIsNull() { + // act & assert + assertThatThrownBy(() -> new Password(null)) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + + @DisplayName("빈 문자열이면, CoreException이 발생한다.") + @Test + void throwsBadRequest_whenValueIsBlank() { + // act & assert + assertThatThrownBy(() -> new Password("")) + .isInstanceOf(CoreException.class) + .satisfies(e -> assertThat(((CoreException) e).getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)); + } + } + + @DisplayName("동등성을 비교할 때, ") + @Nested + class Equality { + + @DisplayName("같은 값이면 동일한 객체로 판단한다.") + @Test + void isEqual_whenValuesAreSame() { + // arrange + Password password1 = new Password("encrypted1"); + Password password2 = new Password("encrypted1"); + + // assert + assertThat(password1).isEqualTo(password2); + assertThat(password1.hashCode()).isEqualTo(password2.hashCode()); + } + + @DisplayName("다른 값이면 다른 객체로 판단한다.") + @Test + void isNotEqual_whenValuesAreDifferent() { + // arrange + Password password1 = new Password("encrypted1"); + Password password2 = new Password("encrypted2"); + + // assert + assertThat(password1).isNotEqualTo(password2); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java new file mode 100644 index 000000000..ec8754b12 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserNameTest.java @@ -0,0 +1,111 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class UserNameTest { + + @DisplayName("UserName을 생성할 때, ") + @Nested + class Create { + + @DisplayName("올바른 이름이면, 정상적으로 생성된다.") + @Test + void createsUserName_whenValueIsValid() { + // act + UserName userName = new UserName("홍길동"); + + // assert + assertThat(userName.getValue()).isEqualTo("홍길동"); + } + + @DisplayName("null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> new UserName(null)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsBlank() { + // act + CoreException result = assertThrows(CoreException.class, () -> new UserName(" ")); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("이름을 마스킹할 때, ") + @Nested + class Mask { + + @DisplayName("마지막 글자가 '*'로 대체된다.") + @Test + void masksLastCharacter() { + // arrange + UserName userName = new UserName("홍길동"); + + // act & assert + assertThat(userName.mask()).isEqualTo("홍길*"); + } + + @DisplayName("이름이 한 글자이면, '*'로 대체된다.") + @Test + void masksSingleCharacterName() { + // arrange + UserName userName = new UserName("홍"); + + // act & assert + assertThat(userName.mask()).isEqualTo("*"); + } + + @DisplayName("이름이 두 글자이면, 마지막 글자만 '*'로 대체된다.") + @Test + void masksTwoCharacterName() { + // arrange + UserName userName = new UserName("홍길"); + + // act & assert + assertThat(userName.mask()).isEqualTo("홍*"); + } + } + + @DisplayName("동등성을 비교할 때, ") + @Nested + class Equality { + + @DisplayName("같은 값이면 동일한 객체로 판단한다.") + @Test + void isEqual_whenValuesAreSame() { + // arrange + UserName name1 = new UserName("홍길동"); + UserName name2 = new UserName("홍길동"); + + // assert + assertThat(name1).isEqualTo(name2); + assertThat(name1.hashCode()).isEqualTo(name2.hashCode()); + } + + @DisplayName("다른 값이면 다른 객체로 판단한다.") + @Test + void isNotEqual_whenValuesAreDifferent() { + // arrange + UserName name1 = new UserName("홍길동"); + UserName name2 = new UserName("김철수"); + + // assert + assertThat(name1).isNotEqualTo(name2); + } + } +} From 0547477118cbb0801a1d34bb03af79cb1b5dc11a Mon Sep 17 00:00:00 2001 From: yoon-yoo-tak Date: Mon, 16 Feb 2026 01:19:52 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat=20:=20Brand=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/brand/BrandFacade.java | 46 ++++ .../loopers/application/brand/BrandInfo.java | 17 ++ .../loopers/config/DomainServiceConfig.java | 14 + .../java/com/loopers/domain/PageResult.java | 12 + .../java/com/loopers/domain/brand/Brand.java | 38 +++ .../loopers/domain/brand/BrandRepository.java | 14 + .../loopers/domain/brand/BrandService.java | 37 +++ .../domain/product/ProductRepository.java | 6 + .../domain/product/ProductService.java | 13 + .../brand/BrandJpaRepository.java | 15 ++ .../brand/BrandRepositoryImpl.java | 40 +++ .../product/ProductRepositoryImpl.java | 13 + .../api/auth/AdminAuthInterceptor.java | 24 ++ .../interfaces/api/auth/WebMvcConfig.java | 8 + .../api/brand/AdminBrandV1ApiSpec.java | 24 ++ .../api/brand/AdminBrandV1Controller.java | 66 +++++ .../interfaces/api/brand/AdminBrandV1Dto.java | 42 +++ .../interfaces/api/brand/BrandV1ApiSpec.java | 12 + .../api/brand/BrandV1Controller.java | 25 ++ .../interfaces/api/brand/BrandV1Dto.java | 12 + .../brand/BrandServiceIntegrationTest.java | 191 +++++++++++++ .../com/loopers/domain/brand/BrandTest.java | 92 +++++++ .../api/AdminBrandV1ApiE2ETest.java | 250 ++++++++++++++++++ .../interfaces/api/BrandV1ApiE2ETest.java | 113 ++++++++ 24 files changed, 1124 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/PageResult.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AdminAuthInterceptor.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminBrandV1ApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java new file mode 100644 index 000000000..2300f57c3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,46 @@ +package com.loopers.application.brand; + +import com.loopers.domain.PageResult; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class BrandFacade { + + private final BrandService brandService; + private final ProductService productService; + + @Transactional + public BrandInfo register(String name) { + Brand brand = brandService.register(name); + return BrandInfo.from(brand); + } + + public BrandInfo getById(Long id) { + Brand brand = brandService.getById(id); + return BrandInfo.from(brand); + } + + public PageResult getAll(int page, int size) { + PageResult result = brandService.getAll(page, size); + return result.map(BrandInfo::from); + } + + @Transactional + public BrandInfo update(Long id, String name) { + Brand brand = brandService.update(id, name); + return BrandInfo.from(brand); + } + + @Transactional + public void delete(Long id) { + brandService.getById(id); + productService.deleteAllByBrandId(id); + brandService.delete(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java new file mode 100644 index 000000000..bdc8ad203 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java @@ -0,0 +1,17 @@ +package com.loopers.application.brand; + +import com.loopers.domain.brand.Brand; + +import java.time.ZonedDateTime; + +public record BrandInfo(Long id, String name, ZonedDateTime createdAt, ZonedDateTime updatedAt) { + + public static BrandInfo from(Brand brand) { + return new BrandInfo( + brand.getId(), + brand.getName(), + brand.getCreatedAt(), + brand.getUpdatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/DomainServiceConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/DomainServiceConfig.java index 91f4b12fc..be0bbdeb2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/config/DomainServiceConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/config/DomainServiceConfig.java @@ -1,5 +1,9 @@ package com.loopers.config; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductService; import com.loopers.domain.user.PasswordEncryptor; import com.loopers.domain.user.UserRepository; import com.loopers.domain.user.UserService; @@ -13,4 +17,14 @@ public class DomainServiceConfig { public UserService userService(UserRepository userRepository, PasswordEncryptor passwordEncryptor) { return new UserService(userRepository, passwordEncryptor); } + + @Bean + public BrandService brandService(BrandRepository brandRepository) { + return new BrandService(brandRepository); + } + + @Bean + public ProductService productService(ProductRepository productRepository) { + return new ProductService(productRepository); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/PageResult.java b/apps/commerce-api/src/main/java/com/loopers/domain/PageResult.java new file mode 100644 index 000000000..1a2acb29e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/PageResult.java @@ -0,0 +1,12 @@ +package com.loopers.domain; + +import java.util.List; +import java.util.function.Function; + +public record PageResult(List items, int page, int size, long totalElements, int totalPages) { + + public PageResult map(Function mapper) { + List mappedItems = items.stream().map(mapper).toList(); + return new PageResult<>(mappedItems, page, size, totalElements, totalPages); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java new file mode 100644 index 000000000..e75da9e60 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -0,0 +1,38 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "brands") +public class Brand extends BaseEntity { + + @Column(name = "name", nullable = false) + private String name; + + protected Brand() {} + + public Brand(String name) { + validateName(name); + this.name = name; + } + + public void update(String name) { + validateName(name); + this.name = name; + } + + public String getName() { + return name; + } + + private void validateName(String name) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 비어있을 수 없습니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java new file mode 100644 index 000000000..677d3ba27 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -0,0 +1,14 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.PageResult; + +import java.util.Optional; + +public interface BrandRepository { + + Brand save(Brand brand); + + Optional findById(Long id); + + PageResult findAll(int page, int size); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java new file mode 100644 index 000000000..d5f062fdc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -0,0 +1,37 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.PageResult; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class BrandService { + + private final BrandRepository brandRepository; + + public Brand register(String name) { + return brandRepository.save(new Brand(name)); + } + + public Brand getById(Long id) { + return brandRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); + } + + public PageResult getAll(int page, int size) { + return brandRepository.findAll(page, size); + } + + public Brand update(Long id, String name) { + Brand brand = getById(id); + brand.update(name); + return brandRepository.save(brand); + } + + public void delete(Long id) { + Brand brand = getById(id); + brand.delete(); + brandRepository.save(brand); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java new file mode 100644 index 000000000..83c4bc392 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,6 @@ +package com.loopers.domain.product; + +public interface ProductRepository { + + void softDeleteAllByBrandId(Long brandId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java new file mode 100644 index 000000000..162aee262 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,13 @@ +package com.loopers.domain.product; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class ProductService { + + private final ProductRepository productRepository; + + public void deleteAllByBrandId(Long brandId) { + productRepository.softDeleteAllByBrandId(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 new file mode 100644 index 000000000..3074669de --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -0,0 +1,15 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.brand.Brand; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface BrandJpaRepository extends JpaRepository { + + Optional findByIdAndDeletedAtIsNull(Long id); + + Page findAllByDeletedAtIsNull(Pageable pageable); +} 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 new file mode 100644 index 000000000..6a7808edc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java @@ -0,0 +1,40 @@ +package com.loopers.infrastructure.brand; + +import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class BrandRepositoryImpl implements BrandRepository { + + private final BrandJpaRepository brandJpaRepository; + + @Override + public Brand save(Brand brand) { + return brandJpaRepository.save(brand); + } + + @Override + public Optional findById(Long id) { + return brandJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public PageResult findAll(int page, int size) { + Page result = brandJpaRepository.findAllByDeletedAtIsNull(PageRequest.of(page, size)); + return new PageResult<>( + result.getContent(), + result.getNumber(), + result.getSize(), + result.getTotalElements(), + result.getTotalPages() + ); + } +} 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 new file mode 100644 index 000000000..254241c7b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,13 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductRepository; +import org.springframework.stereotype.Component; + +@Component +public class ProductRepositoryImpl implements ProductRepository { + + @Override + public void softDeleteAllByBrandId(Long brandId) { + // Product 도메인 구현 시 실제 삭제 로직 추가 + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AdminAuthInterceptor.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AdminAuthInterceptor.java new file mode 100644 index 000000000..aba812c73 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AdminAuthInterceptor.java @@ -0,0 +1,24 @@ +package com.loopers.interfaces.api.auth; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class AdminAuthInterceptor implements HandlerInterceptor { + + private static final String HEADER_LDAP = "X-Loopers-Ldap"; + private static final String ADMIN_LDAP = "loopers.admin"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String ldap = request.getHeader(HEADER_LDAP); + if (!ADMIN_LDAP.equals(ldap)) { + throw new CoreException(ErrorType.UNAUTHORIZED, "어드민 인증에 실패했습니다."); + } + return true; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/WebMvcConfig.java index 1b05ec7b3..4e311ba47 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/WebMvcConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/WebMvcConfig.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.util.List; @@ -12,9 +13,16 @@ public class WebMvcConfig implements WebMvcConfigurer { private final AuthUserArgumentResolver authUserArgumentResolver; + private final AdminAuthInterceptor adminAuthInterceptor; @Override public void addArgumentResolvers(List resolvers) { resolvers.add(authUserArgumentResolver); } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(adminAuthInterceptor) + .addPathPatterns("/api-admin/**"); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1ApiSpec.java new file mode 100644 index 000000000..f7255c2f1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1ApiSpec.java @@ -0,0 +1,24 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Admin Brand V1 API", description = "어드민 브랜드 관리 API 입니다.") +public interface AdminBrandV1ApiSpec { + + @Operation(summary = "브랜드 등록", description = "새로운 브랜드를 등록합니다.") + ApiResponse create(AdminBrandV1Dto.CreateRequest request); + + @Operation(summary = "브랜드 목록 조회", description = "브랜드 목록을 페이지 단위로 조회합니다.") + ApiResponse getAll(int page, int size); + + @Operation(summary = "브랜드 상세 조회", description = "특정 브랜드의 상세 정보를 조회합니다.") + ApiResponse getById(Long brandId); + + @Operation(summary = "브랜드 수정", description = "브랜드 정보를 수정합니다.") + ApiResponse update(Long brandId, AdminBrandV1Dto.UpdateRequest request); + + @Operation(summary = "브랜드 삭제", description = "브랜드를 삭제합니다. 해당 브랜드의 모든 상품도 함께 삭제됩니다.") + ApiResponse delete(Long brandId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Controller.java new file mode 100644 index 000000000..90d0e9027 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Controller.java @@ -0,0 +1,66 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.application.brand.BrandInfo; +import com.loopers.domain.PageResult; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/brands") +public class AdminBrandV1Controller implements AdminBrandV1ApiSpec { + + private final BrandFacade brandFacade; + + @PostMapping + @Override + public ApiResponse create(@Valid @RequestBody AdminBrandV1Dto.CreateRequest request) { + BrandInfo info = brandFacade.register(request.name()); + return ApiResponse.success(AdminBrandV1Dto.BrandResponse.from(info)); + } + + @GetMapping + @Override + public ApiResponse getAll( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + PageResult result = brandFacade.getAll(page, size); + return ApiResponse.success(AdminBrandV1Dto.BrandPageResponse.from(result)); + } + + @GetMapping("/{brandId}") + @Override + public ApiResponse getById(@PathVariable Long brandId) { + BrandInfo info = brandFacade.getById(brandId); + return ApiResponse.success(AdminBrandV1Dto.BrandResponse.from(info)); + } + + @PutMapping("/{brandId}") + @Override + public ApiResponse update( + @PathVariable Long brandId, + @Valid @RequestBody AdminBrandV1Dto.UpdateRequest request + ) { + BrandInfo info = brandFacade.update(brandId, request.name()); + return ApiResponse.success(AdminBrandV1Dto.BrandResponse.from(info)); + } + + @DeleteMapping("/{brandId}") + @Override + public ApiResponse delete(@PathVariable Long brandId) { + brandFacade.delete(brandId); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Dto.java new file mode 100644 index 000000000..fa6265f2a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Dto.java @@ -0,0 +1,42 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandInfo; +import com.loopers.domain.PageResult; +import jakarta.validation.constraints.NotBlank; + +import java.time.ZonedDateTime; +import java.util.List; + +public class AdminBrandV1Dto { + + public record CreateRequest( + @NotBlank(message = "브랜드 이름은 비어있을 수 없습니다.") + String name + ) {} + + public record UpdateRequest( + @NotBlank(message = "브랜드 이름은 비어있을 수 없습니다.") + String name + ) {} + + public record BrandResponse(Long id, String name, ZonedDateTime createdAt, ZonedDateTime updatedAt) { + public static BrandResponse from(BrandInfo info) { + return new BrandResponse(info.id(), info.name(), info.createdAt(), info.updatedAt()); + } + } + + public record BrandPageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages + ) { + public static BrandPageResponse from(PageResult result) { + List content = result.items().stream() + .map(BrandResponse::from) + .toList(); + return new BrandPageResponse(content, result.page(), result.size(), result.totalElements(), result.totalPages()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java new file mode 100644 index 000000000..f977e7311 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java @@ -0,0 +1,12 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Brand V1 API", description = "브랜드 API 입니다.") +public interface BrandV1ApiSpec { + + @Operation(summary = "브랜드 정보 조회", description = "특정 브랜드의 정보를 조회합니다.") + ApiResponse getById(Long brandId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java new file mode 100644 index 000000000..c6dfef181 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java @@ -0,0 +1,25 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.application.brand.BrandInfo; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +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; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/brands") +public class BrandV1Controller implements BrandV1ApiSpec { + + private final BrandFacade brandFacade; + + @GetMapping("/{brandId}") + @Override + public ApiResponse getById(@PathVariable Long brandId) { + BrandInfo info = brandFacade.getById(brandId); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java new file mode 100644 index 000000000..e4f1be688 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java @@ -0,0 +1,12 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandInfo; + +public class BrandV1Dto { + + public record BrandResponse(Long id, String name) { + public static BrandResponse from(BrandInfo info) { + return new BrandResponse(info.id(), info.name()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java new file mode 100644 index 000000000..316b2f909 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java @@ -0,0 +1,191 @@ +package com.loopers.domain.brand; + +import com.loopers.domain.PageResult; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +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 static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class BrandServiceIntegrationTest { + + @Autowired + private BrandService brandService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("브랜드를 등록할 때, ") + @Nested + class Register { + + @DisplayName("올바른 이름이면, 브랜드가 저장되고 반환된다.") + @Test + void savesAndReturnsBrand_whenNameIsValid() { + // act + Brand result = brandService.register("나이키"); + + // assert + assertAll( + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.getName()).isEqualTo("나이키") + ); + } + } + + @DisplayName("브랜드를 조회할 때, ") + @Nested + class GetById { + + @DisplayName("존재하는 브랜드이면, 브랜드를 반환한다.") + @Test + void returnsBrand_whenBrandExists() { + // arrange + Brand brand = brandService.register("나이키"); + + // act + Brand result = brandService.getById(brand.getId()); + + // assert + assertThat(result.getName()).isEqualTo("나이키"); + } + + @DisplayName("존재하지 않는 브랜드이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenBrandDoesNotExist() { + // act + CoreException result = assertThrows(CoreException.class, () -> brandService.getById(999L)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("삭제된 브랜드이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenBrandIsDeleted() { + // arrange + Brand brand = brandService.register("나이키"); + brandService.delete(brand.getId()); + + // act + CoreException result = assertThrows(CoreException.class, () -> brandService.getById(brand.getId())); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드 목록을 조회할 때, ") + @Nested + class GetAll { + + @DisplayName("브랜드가 존재하면, 페이지 결과를 반환한다.") + @Test + void returnsPageResult_whenBrandsExist() { + // arrange + brandService.register("나이키"); + brandService.register("아디다스"); + brandService.register("뉴발란스"); + + // act + PageResult result = brandService.getAll(0, 2); + + // assert + assertAll( + () -> assertThat(result.items()).hasSize(2), + () -> assertThat(result.totalElements()).isEqualTo(3), + () -> assertThat(result.totalPages()).isEqualTo(2), + () -> assertThat(result.page()).isEqualTo(0) + ); + } + + @DisplayName("삭제된 브랜드는 목록에 포함되지 않는다.") + @Test + void excludesDeletedBrands() { + // arrange + Brand brand = brandService.register("나이키"); + brandService.register("아디다스"); + brandService.delete(brand.getId()); + + // act + PageResult result = brandService.getAll(0, 20); + + // assert + assertAll( + () -> assertThat(result.items()).hasSize(1), + () -> assertThat(result.items().get(0).getName()).isEqualTo("아디다스") + ); + } + } + + @DisplayName("브랜드를 수정할 때, ") + @Nested + class Update { + + @DisplayName("올바른 이름이면, 브랜드 이름이 수정된다.") + @Test + void updatesBrandName_whenNameIsValid() { + // arrange + Brand brand = brandService.register("나이키"); + + // act + Brand result = brandService.update(brand.getId(), "아디다스"); + + // assert + assertThat(result.getName()).isEqualTo("아디다스"); + } + + @DisplayName("존재하지 않는 브랜드이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenBrandDoesNotExist() { + // act + CoreException result = assertThrows(CoreException.class, () -> brandService.update(999L, "아디다스")); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드를 삭제할 때, ") + @Nested + class Delete { + + @DisplayName("존재하는 브랜드이면, 논리 삭제된다.") + @Test + void softDeletesBrand_whenBrandExists() { + // arrange + Brand brand = brandService.register("나이키"); + + // act + brandService.delete(brand.getId()); + + // assert + CoreException result = assertThrows(CoreException.class, () -> brandService.getById(brand.getId())); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("존재하지 않는 브랜드이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenBrandDoesNotExist() { + // act + CoreException result = assertThrows(CoreException.class, () -> brandService.delete(999L)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java new file mode 100644 index 000000000..dd6acf1dd --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -0,0 +1,92 @@ +package com.loopers.domain.brand; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class BrandTest { + + @DisplayName("브랜드를 생성할 때, ") + @Nested + class Create { + + @DisplayName("올바른 이름이면, 정상적으로 생성된다.") + @Test + void createsBrand_whenNameIsValid() { + // act + Brand brand = new Brand("나이키"); + + // assert + assertThat(brand.getName()).isEqualTo("나이키"); + } + + @DisplayName("이름이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsNull() { + // act + CoreException result = assertThrows(CoreException.class, () -> new Brand(null)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsBlank() { + // act + CoreException result = assertThrows(CoreException.class, () -> new Brand(" ")); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("브랜드를 수정할 때, ") + @Nested + class Update { + + @DisplayName("올바른 이름이면, 정상적으로 수정된다.") + @Test + void updatesBrand_whenNameIsValid() { + // arrange + Brand brand = new Brand("나이키"); + + // act + brand.update("아디다스"); + + // assert + assertThat(brand.getName()).isEqualTo("아디다스"); + } + + @DisplayName("이름이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsNull() { + // arrange + Brand brand = new Brand("나이키"); + + // act + CoreException result = assertThrows(CoreException.class, () -> brand.update(null)); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 빈 문자열이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsBlank() { + // arrange + Brand brand = new Brand("나이키"); + + // act + CoreException result = assertThrows(CoreException.class, () -> brand.update(" ")); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminBrandV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminBrandV1ApiE2ETest.java new file mode 100644 index 000000000..9fe804522 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminBrandV1ApiE2ETest.java @@ -0,0 +1,250 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.brand.AdminBrandV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +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.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AdminBrandV1ApiE2ETest { + + private static final String ENDPOINT = "/api-admin/v1/brands"; + private static final String HEADER_LDAP = "X-Loopers-Ldap"; + private static final String ADMIN_LDAP = "loopers.admin"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public AdminBrandV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set(HEADER_LDAP, ADMIN_LDAP); + headers.set("Content-Type", "application/json"); + return headers; + } + + private AdminBrandV1Dto.BrandResponse createBrand(String name) { + AdminBrandV1Dto.CreateRequest request = new AdminBrandV1Dto.CreateRequest(name); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data(); + } + + @DisplayName("POST /api-admin/v1/brands") + @Nested + class Create { + + @DisplayName("올바른 요청이면, 200 OK와 함께 브랜드 정보를 반환한다.") + @Test + void returnsBrandInfo_whenValidRequest() { + // arrange + AdminBrandV1Dto.CreateRequest request = new AdminBrandV1Dto.CreateRequest("나이키"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().id()).isNotNull() + ); + } + + @DisplayName("이름이 비어있으면, 400 BAD_REQUEST를 반환한다.") + @Test + void returnsBadRequest_whenNameIsBlank() { + // arrange + AdminBrandV1Dto.CreateRequest request = new AdminBrandV1Dto.CreateRequest(""); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("어드민 인증이 없으면, 401 UNAUTHORIZED를 반환한다.") + @Test + void returnsUnauthorized_whenNoAdminAuth() { + // arrange + AdminBrandV1Dto.CreateRequest request = new AdminBrandV1Dto.CreateRequest("나이키"); + HttpHeaders headers = new HttpHeaders(); + headers.set("Content-Type", "application/json"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, headers), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("GET /api-admin/v1/brands") + @Nested + class GetAll { + + @DisplayName("브랜드가 존재하면, 페이지 결과를 반환한다.") + @Test + void returnsPageResult_whenBrandsExist() { + // arrange + createBrand("나이키"); + createBrand("아디다스"); + createBrand("뉴발란스"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?page=0&size=2", HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().content()).hasSize(2), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(3), + () -> assertThat(response.getBody().data().totalPages()).isEqualTo(2) + ); + } + } + + @DisplayName("GET /api-admin/v1/brands/{brandId}") + @Nested + class GetById { + + @DisplayName("존재하는 브랜드이면, 브랜드 정보를 반환한다.") + @Test + void returnsBrandInfo_whenBrandExists() { + // arrange + AdminBrandV1Dto.BrandResponse created = createBrand("나이키"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + created.id(), HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().createdAt()).isNotNull() + ); + } + + @DisplayName("존재하지 않는 브랜드이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenBrandDoesNotExist() { + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/999", HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("PUT /api-admin/v1/brands/{brandId}") + @Nested + class Update { + + @DisplayName("올바른 요청이면, 수정된 브랜드 정보를 반환한다.") + @Test + void returnsUpdatedBrand_whenValidRequest() { + // arrange + AdminBrandV1Dto.BrandResponse created = createBrand("나이키"); + AdminBrandV1Dto.UpdateRequest request = new AdminBrandV1Dto.UpdateRequest("아디다스"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + created.id(), HttpMethod.PUT, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("아디다스") + ); + } + } + + @DisplayName("DELETE /api-admin/v1/brands/{brandId}") + @Nested + class Delete { + + @DisplayName("존재하는 브랜드이면, 200 OK를 반환하고 조회되지 않는다.") + @Test + void deletesAndReturnsSuccess_whenBrandExists() { + // arrange + AdminBrandV1Dto.BrandResponse created = createBrand("나이키"); + + // act + ResponseEntity> deleteResponse = testRestTemplate.exchange( + ENDPOINT + "/" + created.id(), HttpMethod.DELETE, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert - delete succeeds + assertTrue(deleteResponse.getStatusCode().is2xxSuccessful()); + + // assert - brand is no longer retrievable + ResponseEntity> getResponse = testRestTemplate.exchange( + ENDPOINT + "/" + created.id(), HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("존재하지 않는 브랜드이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenBrandDoesNotExist() { + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/999", HttpMethod.DELETE, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java new file mode 100644 index 000000000..4d01b10ed --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandV1ApiE2ETest.java @@ -0,0 +1,113 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.brand.AdminBrandV1Dto; +import com.loopers.interfaces.api.brand.BrandV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +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.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class BrandV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/brands"; + private static final String ADMIN_ENDPOINT = "/api-admin/v1/brands"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public BrandV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private Long createBrandViaAdmin(String name) { + AdminBrandV1Dto.CreateRequest request = new AdminBrandV1Dto.CreateRequest(name); + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + @DisplayName("GET /api/v1/brands/{brandId}") + @Nested + class GetById { + + @DisplayName("존재하는 브랜드이면, 브랜드 정보를 반환한다.") + @Test + void returnsBrandInfo_whenBrandExists() { + // arrange + Long brandId = createBrandViaAdmin("나이키"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + brandId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().id()).isEqualTo(brandId), + () -> assertThat(response.getBody().data().name()).isEqualTo("나이키") + ); + } + + @DisplayName("존재하지 않는 브랜드이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenBrandDoesNotExist() { + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/999", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("인증 없이도 조회할 수 있다.") + @Test + void returnsBrandInfo_withoutAuth() { + // arrange + Long brandId = createBrandViaAdmin("아디다스"); + + // act + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + brandId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + // assert + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } +} From 3385c41d1e8b3accf8f240587f2d5d1ab3dacce4 Mon Sep 17 00:00:00 2001 From: yoon-yoo-tak Date: Mon, 16 Feb 2026 01:37:57 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat=20:=20Product=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/product/ProductFacade.java | 64 +++++ .../application/product/ProductInfo.java | 32 +++ .../loopers/domain/brand/BrandRepository.java | 4 + .../loopers/domain/brand/BrandService.java | 10 + .../com/loopers/domain/product/Money.java | 29 ++ .../com/loopers/domain/product/Product.java | 83 ++++++ .../domain/product/ProductRepository.java | 14 + .../domain/product/ProductService.java | 36 +++ .../domain/product/ProductSortType.java | 18 ++ .../com/loopers/domain/product/Stock.java | 20 ++ .../brand/BrandJpaRepository.java | 4 + .../brand/BrandRepositoryImpl.java | 7 + .../product/ProductJpaRepository.java | 32 +++ .../product/ProductRepositoryImpl.java | 62 +++- .../api/product/AdminProductV1ApiSpec.java | 24 ++ .../api/product/AdminProductV1Controller.java | 68 +++++ .../api/product/AdminProductV1Dto.java | 77 +++++ .../api/product/ProductV1ApiSpec.java | 15 + .../api/product/ProductV1Controller.java | 40 +++ .../interfaces/api/product/ProductV1Dto.java | 40 +++ .../com/loopers/domain/product/MoneyTest.java | 77 +++++ .../ProductServiceIntegrationTest.java | 218 ++++++++++++++ .../loopers/domain/product/ProductTest.java | 148 ++++++++++ .../com/loopers/domain/product/StockTest.java | 68 +++++ .../api/AdminProductV1ApiE2ETest.java | 271 ++++++++++++++++++ .../interfaces/api/ProductV1ApiE2ETest.java | 174 +++++++++++ 26 files changed, 1634 insertions(+), 1 deletion(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/StockTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminProductV1ApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java new file mode 100644 index 000000000..a30734f6b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,64 @@ +package com.loopers.application.product; + +import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.ProductSortType; +import com.loopers.domain.product.Stock; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class ProductFacade { + + private final ProductService productService; + private final BrandService brandService; + + @Transactional + public ProductInfo register(Long brandId, String name, int price, int stock) { + Brand brand = brandService.getById(brandId); + Product product = productService.register(brandId, name, new Money(price), new Stock(stock)); + return ProductInfo.from(product, brand); + } + + public ProductInfo getById(Long id) { + Product product = productService.getById(id); + Brand brand = brandService.getById(product.getBrandId()); + return ProductInfo.from(product, brand); + } + + public PageResult getAll(Long brandId, ProductSortType sort, int page, int size) { + PageResult result = productService.getAll(brandId, sort, page, size); + + Set brandIds = result.items().stream() + .map(Product::getBrandId) + .collect(Collectors.toSet()); + Map brandMap = brandService.getByIds(brandIds); + + return result.map(product -> { + Brand brand = brandMap.get(product.getBrandId()); + return ProductInfo.from(product, brand); + }); + } + + @Transactional + public ProductInfo update(Long id, String name, int price, int stock) { + Product product = productService.update(id, name, new Money(price), new Stock(stock)); + Brand brand = brandService.getById(product.getBrandId()); + return ProductInfo.from(product, brand); + } + + @Transactional + public void delete(Long id) { + productService.delete(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java new file mode 100644 index 000000000..d589e88ae --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -0,0 +1,32 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; + +import java.time.ZonedDateTime; + +public record ProductInfo( + Long id, + Long brandId, + String brandName, + String name, + int price, + int stock, + int likeCount, + ZonedDateTime createdAt, + ZonedDateTime updatedAt +) { + public static ProductInfo from(Product product, Brand brand) { + return new ProductInfo( + product.getId(), + product.getBrandId(), + brand.getName(), + product.getName(), + product.getPrice().amount(), + product.getStock().quantity(), + product.getLikeCount(), + product.getCreatedAt(), + product.getUpdatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java index 677d3ba27..3db171a40 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -2,7 +2,9 @@ import com.loopers.domain.PageResult; +import java.util.List; import java.util.Optional; +import java.util.Set; public interface BrandRepository { @@ -10,5 +12,7 @@ public interface BrandRepository { Optional findById(Long id); + List findAllByIds(Set ids); + PageResult findAll(int page, int size); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java index d5f062fdc..de0268fc1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java @@ -5,6 +5,11 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + @RequiredArgsConstructor public class BrandService { @@ -19,6 +24,11 @@ public Brand getById(Long id) { .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다.")); } + public Map getByIds(Set ids) { + List brands = brandRepository.findAllByIds(ids); + return brands.stream().collect(Collectors.toMap(Brand::getId, brand -> brand)); + } + public PageResult getAll(int page, int size) { return brandRepository.findAll(page, size); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java new file mode 100644 index 000000000..4969c505f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java @@ -0,0 +1,29 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record Money(int amount) { + + public Money { + if (amount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "금액은 0 이상이어야 합니다."); + } + } + + public Money plus(Money other) { + return new Money(this.amount + other.amount); + } + + public Money minus(Money other) { + return new Money(this.amount - other.amount); + } + + public Money multiply(int quantity) { + return new Money(this.amount * quantity); + } + + public boolean isGreaterThanOrEqual(Money other) { + return this.amount >= other.amount; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java new file mode 100644 index 000000000..9a2263134 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -0,0 +1,83 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "products") +public class Product extends BaseEntity { + + @Column(name = "brand_id", nullable = false) + private Long brandId; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "price", nullable = false) + private int price; + + @Column(name = "stock", nullable = false) + private int stock; + + @Column(name = "like_count", nullable = false) + private int likeCount; + + protected Product() {} + + public Product(Long brandId, String name, Money price, Stock stock) { + validate(brandId, name, price, stock); + this.brandId = brandId; + this.name = name; + this.price = price.amount(); + this.stock = stock.quantity(); + this.likeCount = 0; + } + + public void update(String name, Money price, Stock stock) { + validate(this.brandId, name, price, stock); + this.name = name; + this.price = price.amount(); + this.stock = stock.quantity(); + } + + public void deductStock(int quantity) { + Stock currentStock = getStock(); + Stock deducted = currentStock.deduct(quantity); + this.stock = deducted.quantity(); + } + + public void addLikeCount() { + this.likeCount++; + } + + public void subtractLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } + + public Long getBrandId() { return brandId; } + public String getName() { return name; } + public Money getPrice() { return new Money(price); } + public Stock getStock() { return new Stock(stock); } + public int getLikeCount() { return likeCount; } + + private void validate(Long brandId, String name, Money price, Stock stock) { + if (brandId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 ID는 필수입니다."); + } + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 이름은 비어있을 수 없습니다."); + } + if (price == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 가격은 필수입니다."); + } + if (stock == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 재고는 필수입니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 83c4bc392..13bfaeb6c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -1,6 +1,20 @@ package com.loopers.domain.product; +import com.loopers.domain.PageResult; + +import java.util.Optional; + public interface ProductRepository { + Product save(Product product); + + Optional findById(Long id); + + PageResult findAll(Long brandId, ProductSortType sort, int page, int size); + void softDeleteAllByBrandId(Long brandId); + + void incrementLikeCount(Long productId); + + void decrementLikeCount(Long productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 162aee262..189f31544 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -1,5 +1,8 @@ package com.loopers.domain.product; +import com.loopers.domain.PageResult; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @RequiredArgsConstructor @@ -7,7 +10,40 @@ public class ProductService { private final ProductRepository productRepository; + public Product register(Long brandId, String name, Money price, Stock stock) { + return productRepository.save(new Product(brandId, name, price, stock)); + } + + public Product getById(Long id) { + return productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + } + + public PageResult getAll(Long brandId, ProductSortType sort, int page, int size) { + return productRepository.findAll(brandId, sort, page, size); + } + + public Product update(Long id, String name, Money price, Stock stock) { + Product product = getById(id); + product.update(name, price, stock); + return productRepository.save(product); + } + + public void delete(Long id) { + Product product = getById(id); + product.delete(); + productRepository.save(product); + } + public void deleteAllByBrandId(Long brandId) { productRepository.softDeleteAllByBrandId(brandId); } + + public void incrementLikeCount(Long productId) { + productRepository.incrementLikeCount(productId); + } + + public void decrementLikeCount(Long productId) { + productRepository.decrementLikeCount(productId); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java new file mode 100644 index 000000000..93712a6aa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java @@ -0,0 +1,18 @@ +package com.loopers.domain.product; + +public enum ProductSortType { + LATEST, + PRICE_ASC, + LIKES_DESC; + + public static ProductSortType from(String value) { + if (value == null || value.isBlank()) { + return LATEST; + } + try { + return valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + return LATEST; + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java new file mode 100644 index 000000000..81f22fb9d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java @@ -0,0 +1,20 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record Stock(int quantity) { + + public Stock { + if (quantity < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다."); + } + } + + public Stock deduct(int amount) { + if (this.quantity < amount) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + return new Stock(this.quantity - amount); + } +} 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 3074669de..c7c0bf360 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 @@ -5,11 +5,15 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Collection; +import java.util.List; import java.util.Optional; public interface BrandJpaRepository extends JpaRepository { Optional findByIdAndDeletedAtIsNull(Long id); + List findAllByIdInAndDeletedAtIsNull(Collection ids); + Page findAllByDeletedAtIsNull(Pageable pageable); } 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 6a7808edc..ff5f43432 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 @@ -8,7 +8,9 @@ import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Component; +import java.util.List; import java.util.Optional; +import java.util.Set; @RequiredArgsConstructor @Component @@ -26,6 +28,11 @@ public Optional findById(Long id) { return brandJpaRepository.findByIdAndDeletedAtIsNull(id); } + @Override + public List findAllByIds(Set ids) { + return brandJpaRepository.findAllByIdInAndDeletedAtIsNull(ids); + } + @Override public PageResult findAll(int page, int size) { Page result = brandJpaRepository.findAllByDeletedAtIsNull(PageRequest.of(page, size)); 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 new file mode 100644 index 000000000..ac5309433 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,32 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +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.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface ProductJpaRepository extends JpaRepository { + + Optional findByIdAndDeletedAtIsNull(Long id); + + Page findAllByDeletedAtIsNull(Pageable pageable); + + Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); + + @Modifying + @Query("UPDATE Product p SET p.deletedAt = CURRENT_TIMESTAMP WHERE p.brandId = :brandId AND p.deletedAt IS NULL") + void softDeleteAllByBrandId(@Param("brandId") Long brandId); + + @Modifying + @Query("UPDATE Product p SET p.likeCount = p.likeCount + 1 WHERE p.id = :productId") + void incrementLikeCount(@Param("productId") Long productId); + + @Modifying + @Query("UPDATE Product p SET p.likeCount = p.likeCount - 1 WHERE p.id = :productId AND p.likeCount > 0") + void decrementLikeCount(@Param("productId") Long productId); +} 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 254241c7b..9f11ae522 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,13 +1,73 @@ package com.loopers.infrastructure.product; +import com.loopers.domain.PageResult; +import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.ProductSortType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; +import java.util.Optional; + +@RequiredArgsConstructor @Component public class ProductRepositoryImpl implements ProductRepository { + private final ProductJpaRepository productJpaRepository; + + @Override + public Product save(Product product) { + return productJpaRepository.save(product); + } + + @Override + public Optional findById(Long id) { + return productJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public PageResult findAll(Long brandId, ProductSortType sort, int page, int size) { + PageRequest pageRequest = PageRequest.of(page, size, toSort(sort)); + + Page result; + if (brandId != null) { + result = productJpaRepository.findAllByBrandIdAndDeletedAtIsNull(brandId, pageRequest); + } else { + result = productJpaRepository.findAllByDeletedAtIsNull(pageRequest); + } + + return new PageResult<>( + result.getContent(), + result.getNumber(), + result.getSize(), + result.getTotalElements(), + result.getTotalPages() + ); + } + @Override public void softDeleteAllByBrandId(Long brandId) { - // Product 도메인 구현 시 실제 삭제 로직 추가 + productJpaRepository.softDeleteAllByBrandId(brandId); + } + + @Override + public void incrementLikeCount(Long productId) { + productJpaRepository.incrementLikeCount(productId); + } + + @Override + public void decrementLikeCount(Long productId) { + productJpaRepository.decrementLikeCount(productId); + } + + private Sort toSort(ProductSortType sortType) { + return switch (sortType) { + case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "price"); + case LIKES_DESC -> Sort.by(Sort.Direction.DESC, "likeCount"); + case LATEST -> Sort.by(Sort.Direction.DESC, "createdAt"); + }; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1ApiSpec.java new file mode 100644 index 000000000..654998880 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1ApiSpec.java @@ -0,0 +1,24 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Admin Product V1 API", description = "어드민 상품 관리 API 입니다.") +public interface AdminProductV1ApiSpec { + + @Operation(summary = "상품 등록", description = "새로운 상품을 등록합니다. 상품의 브랜드는 이미 등록된 브랜드여야 합니다.") + ApiResponse create(AdminProductV1Dto.CreateRequest request); + + @Operation(summary = "상품 목록 조회", description = "상품 목록을 페이지 단위로 조회합니다. 브랜드별 필터링이 가능합니다.") + ApiResponse getAll(Long brandId, int page, int size); + + @Operation(summary = "상품 상세 조회", description = "특정 상품의 상세 정보를 조회합니다.") + ApiResponse getById(Long productId); + + @Operation(summary = "상품 수정", description = "상품 정보를 수정합니다. 상품의 브랜드는 수정할 수 없습니다.") + ApiResponse update(Long productId, AdminProductV1Dto.UpdateRequest request); + + @Operation(summary = "상품 삭제", description = "상품을 삭제합니다.") + ApiResponse delete(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Controller.java new file mode 100644 index 000000000..9868c9e06 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Controller.java @@ -0,0 +1,68 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.domain.PageResult; +import com.loopers.domain.product.ProductSortType; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/products") +public class AdminProductV1Controller implements AdminProductV1ApiSpec { + + private final ProductFacade productFacade; + + @PostMapping + @Override + public ApiResponse create(@Valid @RequestBody AdminProductV1Dto.CreateRequest request) { + ProductInfo info = productFacade.register(request.brandId(), request.name(), request.price(), request.stock()); + return ApiResponse.success(AdminProductV1Dto.ProductResponse.from(info)); + } + + @GetMapping + @Override + public ApiResponse getAll( + @RequestParam(required = false) Long brandId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + PageResult result = productFacade.getAll(brandId, ProductSortType.LATEST, page, size); + return ApiResponse.success(AdminProductV1Dto.ProductPageResponse.from(result)); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse getById(@PathVariable Long productId) { + ProductInfo info = productFacade.getById(productId); + return ApiResponse.success(AdminProductV1Dto.ProductResponse.from(info)); + } + + @PutMapping("/{productId}") + @Override + public ApiResponse update( + @PathVariable Long productId, + @Valid @RequestBody AdminProductV1Dto.UpdateRequest request + ) { + ProductInfo info = productFacade.update(productId, request.name(), request.price(), request.stock()); + return ApiResponse.success(AdminProductV1Dto.ProductResponse.from(info)); + } + + @DeleteMapping("/{productId}") + @Override + public ApiResponse delete(@PathVariable Long productId) { + productFacade.delete(productId); + return ApiResponse.success(null); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Dto.java new file mode 100644 index 000000000..9c4416551 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Dto.java @@ -0,0 +1,77 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductInfo; +import com.loopers.domain.PageResult; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.ZonedDateTime; +import java.util.List; + +public class AdminProductV1Dto { + + public record CreateRequest( + @NotNull(message = "브랜드 ID는 필수입니다.") + Long brandId, + + @NotBlank(message = "상품 이름은 비어있을 수 없습니다.") + String name, + + @NotNull(message = "상품 가격은 필수입니다.") + @Min(value = 0, message = "상품 가격은 0 이상이어야 합니다.") + Integer price, + + @NotNull(message = "상품 재고는 필수입니다.") + @Min(value = 0, message = "상품 재고는 0 이상이어야 합니다.") + Integer stock + ) {} + + public record UpdateRequest( + @NotBlank(message = "상품 이름은 비어있을 수 없습니다.") + String name, + + @NotNull(message = "상품 가격은 필수입니다.") + @Min(value = 0, message = "상품 가격은 0 이상이어야 합니다.") + Integer price, + + @NotNull(message = "상품 재고는 필수입니다.") + @Min(value = 0, message = "상품 재고는 0 이상이어야 합니다.") + Integer stock + ) {} + + public record ProductResponse( + Long id, + Long brandId, + String brandName, + String name, + int price, + int stock, + int likeCount, + ZonedDateTime createdAt, + ZonedDateTime updatedAt + ) { + public static ProductResponse from(ProductInfo info) { + return new ProductResponse( + info.id(), info.brandId(), info.brandName(), info.name(), + info.price(), info.stock(), info.likeCount(), + info.createdAt(), info.updatedAt() + ); + } + } + + public record ProductPageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages + ) { + public static ProductPageResponse from(PageResult result) { + List content = result.items().stream() + .map(ProductResponse::from) + .toList(); + return new ProductPageResponse(content, result.page(), result.size(), result.totalElements(), result.totalPages()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java new file mode 100644 index 000000000..98304be0e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -0,0 +1,15 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Product V1 API", description = "상품 API 입니다.") +public interface ProductV1ApiSpec { + + @Operation(summary = "상품 목록 조회", description = "상품 목록을 조회합니다. 브랜드별 필터링과 정렬이 가능합니다.") + ApiResponse getAll(Long brandId, String sort, int page, int size); + + @Operation(summary = "상품 정보 조회", description = "특정 상품의 정보를 조회합니다.") + ApiResponse getById(Long productId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java new file mode 100644 index 000000000..56823ec21 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,40 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.domain.PageResult; +import com.loopers.domain.product.ProductSortType; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller implements ProductV1ApiSpec { + + private final ProductFacade productFacade; + + @GetMapping + @Override + public ApiResponse getAll( + @RequestParam(required = false) Long brandId, + @RequestParam(defaultValue = "latest") String sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + PageResult result = productFacade.getAll(brandId, ProductSortType.from(sort), page, size); + return ApiResponse.success(ProductV1Dto.ProductPageResponse.from(result)); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse getById(@PathVariable Long productId) { + ProductInfo info = productFacade.getById(productId); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java new file mode 100644 index 000000000..0008cb59b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,40 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductInfo; +import com.loopers.domain.PageResult; + +import java.util.List; + +public class ProductV1Dto { + + public record ProductResponse( + Long id, + Long brandId, + String brandName, + String name, + int price, + int likeCount + ) { + public static ProductResponse from(ProductInfo info) { + return new ProductResponse( + info.id(), info.brandId(), info.brandName(), info.name(), + info.price(), info.likeCount() + ); + } + } + + public record ProductPageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages + ) { + public static ProductPageResponse from(PageResult result) { + List content = result.items().stream() + .map(ProductResponse::from) + .toList(); + return new ProductPageResponse(content, result.page(), result.size(), result.totalElements(), result.totalPages()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java new file mode 100644 index 000000000..6c43cc3d1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java @@ -0,0 +1,77 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class MoneyTest { + + @DisplayName("Money를 생성할 때, ") + @Nested + class Create { + + @DisplayName("0 이상의 금액이면, 정상 생성된다.") + @Test + void createsMoney_whenAmountIsZeroOrPositive() { + Money money = new Money(1000); + assertThat(money.amount()).isEqualTo(1000); + } + + @DisplayName("0원이면, 정상 생성된다.") + @Test + void createsMoney_whenAmountIsZero() { + Money money = new Money(0); + assertThat(money.amount()).isEqualTo(0); + } + + @DisplayName("음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenAmountIsNegative() { + CoreException result = assertThrows(CoreException.class, () -> new Money(-1)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("Money 연산할 때, ") + @Nested + class Operations { + + @DisplayName("더하면, 합산된 금액을 반환한다.") + @Test + void returnsSum_whenAdding() { + Money a = new Money(1000); + Money b = new Money(2000); + assertThat(a.plus(b)).isEqualTo(new Money(3000)); + } + + @DisplayName("빼면, 차감된 금액을 반환한다.") + @Test + void returnsDifference_whenSubtracting() { + Money a = new Money(3000); + Money b = new Money(1000); + assertThat(a.minus(b)).isEqualTo(new Money(2000)); + } + + @DisplayName("곱하면, 곱셈된 금액을 반환한다.") + @Test + void returnsProduct_whenMultiplying() { + Money money = new Money(1000); + assertThat(money.multiply(3)).isEqualTo(new Money(3000)); + } + + @DisplayName("크거나 같은지 비교할 수 있다.") + @Test + void comparesGreaterThanOrEqual() { + Money a = new Money(3000); + Money b = new Money(2000); + assertThat(a.isGreaterThanOrEqual(b)).isTrue(); + assertThat(b.isGreaterThanOrEqual(a)).isFalse(); + assertThat(a.isGreaterThanOrEqual(new Money(3000))).isTrue(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java new file mode 100644 index 000000000..5a74238db --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -0,0 +1,218 @@ +package com.loopers.domain.product; + +import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +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.transaction.annotation.Transactional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class ProductServiceIntegrationTest { + + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private Long brandId; + + @BeforeEach + void setUp() { + Brand brand = brandService.register("나이키"); + brandId = brand.getId(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("상품을 등록할 때, ") + @Nested + class Register { + + @DisplayName("올바른 정보이면, 상품이 저장되고 반환된다.") + @Test + void savesAndReturnsProduct_whenValidInfo() { + Product result = productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); + + assertAll( + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.getBrandId()).isEqualTo(brandId), + () -> assertThat(result.getName()).isEqualTo("에어맥스"), + () -> assertThat(result.getPrice()).isEqualTo(new Money(129000)), + () -> assertThat(result.getStock()).isEqualTo(new Stock(100)), + () -> assertThat(result.getLikeCount()).isEqualTo(0) + ); + } + } + + @DisplayName("상품을 조회할 때, ") + @Nested + class GetById { + + @DisplayName("존재하는 상품이면, 상품을 반환한다.") + @Test + void returnsProduct_whenProductExists() { + Product product = productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); + + Product result = productService.getById(product.getId()); + + assertThat(result.getName()).isEqualTo("에어맥스"); + } + + @DisplayName("존재하지 않는 상품이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenProductDoesNotExist() { + CoreException result = assertThrows(CoreException.class, () -> productService.getById(999L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("삭제된 상품이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenProductIsDeleted() { + Product product = productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); + productService.delete(product.getId()); + + CoreException result = assertThrows(CoreException.class, () -> productService.getById(product.getId())); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품 목록을 조회할 때, ") + @Nested + class GetAll { + + @DisplayName("상품이 존재하면, 페이지 결과를 반환한다.") + @Test + void returnsPageResult_whenProductsExist() { + productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); + productService.register(brandId, "울트라부스트", new Money(159000), new Stock(50)); + productService.register(brandId, "뉴발란스 990", new Money(199000), new Stock(30)); + + PageResult result = productService.getAll(null, ProductSortType.LATEST, 0, 2); + + assertAll( + () -> assertThat(result.items()).hasSize(2), + () -> assertThat(result.totalElements()).isEqualTo(3), + () -> assertThat(result.totalPages()).isEqualTo(2) + ); + } + + @DisplayName("브랜드별 필터링이 동작한다.") + @Test + void filtersByBrandId() { + Brand brand2 = brandService.register("아디다스"); + productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); + productService.register(brand2.getId(), "울트라부스트", new Money(159000), new Stock(50)); + + PageResult result = productService.getAll(brandId, ProductSortType.LATEST, 0, 20); + + assertAll( + () -> assertThat(result.items()).hasSize(1), + () -> assertThat(result.items().get(0).getName()).isEqualTo("에어맥스") + ); + } + + @DisplayName("삭제된 상품은 목록에 포함되지 않는다.") + @Test + void excludesDeletedProducts() { + Product product = productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); + productService.register(brandId, "울트라부스트", new Money(159000), new Stock(50)); + productService.delete(product.getId()); + + PageResult result = productService.getAll(null, ProductSortType.LATEST, 0, 20); + + assertAll( + () -> assertThat(result.items()).hasSize(1), + () -> assertThat(result.items().get(0).getName()).isEqualTo("울트라부스트") + ); + } + } + + @DisplayName("상품을 수정할 때, ") + @Nested + class Update { + + @DisplayName("올바른 정보이면, 상품이 수정된다.") + @Test + void updatesProduct_whenValidInfo() { + Product product = productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); + + Product result = productService.update(product.getId(), "에어포스1", new Money(109000), new Stock(200)); + + assertAll( + () -> assertThat(result.getName()).isEqualTo("에어포스1"), + () -> assertThat(result.getPrice()).isEqualTo(new Money(109000)), + () -> assertThat(result.getStock()).isEqualTo(new Stock(200)) + ); + } + + @DisplayName("존재하지 않는 상품이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenProductDoesNotExist() { + CoreException result = assertThrows(CoreException.class, + () -> productService.update(999L, "에어맥스", new Money(129000), new Stock(100))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("상품을 삭제할 때, ") + @Nested + class Delete { + + @DisplayName("존재하는 상품이면, 논리 삭제된다.") + @Test + void softDeletesProduct_whenProductExists() { + Product product = productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); + + productService.delete(product.getId()); + + CoreException result = assertThrows(CoreException.class, () -> productService.getById(product.getId())); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("존재하지 않는 상품이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenProductDoesNotExist() { + CoreException result = assertThrows(CoreException.class, () -> productService.delete(999L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("브랜드 삭제 시, ") + @Nested + class DeleteAllByBrand { + + @DisplayName("해당 브랜드의 모든 상품이 삭제된다.") + @Test + @Transactional + void deletesAllProductsOfBrand() { + productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); + productService.register(brandId, "에어포스1", new Money(109000), new Stock(200)); + + productService.deleteAllByBrandId(brandId); + + PageResult result = productService.getAll(brandId, ProductSortType.LATEST, 0, 20); + assertThat(result.items()).isEmpty(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java new file mode 100644 index 000000000..5529b68a2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -0,0 +1,148 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ProductTest { + + @DisplayName("상품을 생성할 때, ") + @Nested + class Create { + + @DisplayName("올바른 정보이면, 상품이 생성된다.") + @Test + void createsProduct_whenValidInfo() { + Product product = new Product(1L, "나이키 에어맥스", new Money(129000), new Stock(100)); + + assertAll( + () -> assertThat(product.getBrandId()).isEqualTo(1L), + () -> assertThat(product.getName()).isEqualTo("나이키 에어맥스"), + () -> assertThat(product.getPrice()).isEqualTo(new Money(129000)), + () -> assertThat(product.getStock()).isEqualTo(new Stock(100)), + () -> assertThat(product.getLikeCount()).isEqualTo(0) + ); + } + + @DisplayName("brandId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenBrandIdIsNull() { + CoreException result = assertThrows(CoreException.class, + () -> new Product(null, "나이키 에어맥스", new Money(129000), new Stock(100))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이름이 비어있으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenNameIsBlank() { + CoreException result = assertThrows(CoreException.class, + () -> new Product(1L, "", new Money(129000), new Stock(100))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("가격이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenPriceIsNull() { + CoreException result = assertThrows(CoreException.class, + () -> new Product(1L, "나이키 에어맥스", null, new Stock(100))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("재고가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenStockIsNull() { + CoreException result = assertThrows(CoreException.class, + () -> new Product(1L, "나이키 에어맥스", new Money(129000), null)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("상품을 수정할 때, ") + @Nested + class Update { + + @DisplayName("올바른 정보이면, 이름/가격/재고가 수정된다.") + @Test + void updatesProduct_whenValidInfo() { + Product product = new Product(1L, "나이키 에어맥스", new Money(129000), new Stock(100)); + product.update("아디다스 울트라부스트", new Money(159000), new Stock(50)); + + assertAll( + () -> assertThat(product.getName()).isEqualTo("아디다스 울트라부스트"), + () -> assertThat(product.getPrice()).isEqualTo(new Money(159000)), + () -> assertThat(product.getStock()).isEqualTo(new Stock(50)) + ); + } + + @DisplayName("brandId는 변경되지 않는다.") + @Test + void doesNotChangeBrandId() { + Product product = new Product(1L, "나이키 에어맥스", new Money(129000), new Stock(100)); + product.update("아디다스 울트라부스트", new Money(159000), new Stock(50)); + + assertThat(product.getBrandId()).isEqualTo(1L); + } + } + + @DisplayName("재고를 차감할 때, ") + @Nested + class DeductStock { + + @DisplayName("충분한 재고가 있으면, 재고가 차감된다.") + @Test + void deductsStock_whenSufficient() { + Product product = new Product(1L, "나이키 에어맥스", new Money(129000), new Stock(10)); + product.deductStock(3); + + assertThat(product.getStock()).isEqualTo(new Stock(7)); + } + + @DisplayName("재고가 부족하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenInsufficient() { + Product product = new Product(1L, "나이키 에어맥스", new Money(129000), new Stock(2)); + + CoreException result = assertThrows(CoreException.class, () -> product.deductStock(3)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("좋아요 수를 변경할 때, ") + @Nested + class LikeCount { + + @DisplayName("좋아요를 추가하면, 1 증가한다.") + @Test + void incrementsLikeCount() { + Product product = new Product(1L, "나이키 에어맥스", new Money(129000), new Stock(10)); + product.addLikeCount(); + + assertThat(product.getLikeCount()).isEqualTo(1); + } + + @DisplayName("좋아요를 취소하면, 1 감소한다.") + @Test + void decrementsLikeCount() { + Product product = new Product(1L, "나이키 에어맥스", new Money(129000), new Stock(10)); + product.addLikeCount(); + product.subtractLikeCount(); + + assertThat(product.getLikeCount()).isEqualTo(0); + } + + @DisplayName("좋아요가 0일 때 취소하면, 0 이하로 내려가지 않는다.") + @Test + void doesNotGoBelowZero() { + Product product = new Product(1L, "나이키 에어맥스", new Money(129000), new Stock(10)); + product.subtractLikeCount(); + + assertThat(product.getLikeCount()).isEqualTo(0); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/StockTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/StockTest.java new file mode 100644 index 000000000..4d7cf6fd6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/StockTest.java @@ -0,0 +1,68 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class StockTest { + + @DisplayName("Stock을 생성할 때, ") + @Nested + class Create { + + @DisplayName("0 이상이면, 정상 생성된다.") + @Test + void createsStock_whenQuantityIsZeroOrPositive() { + Stock stock = new Stock(10); + assertThat(stock.quantity()).isEqualTo(10); + } + + @DisplayName("0이면, 정상 생성된다.") + @Test + void createsStock_whenQuantityIsZero() { + Stock stock = new Stock(0); + assertThat(stock.quantity()).isEqualTo(0); + } + + @DisplayName("음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenQuantityIsNegative() { + CoreException result = assertThrows(CoreException.class, () -> new Stock(-1)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("재고를 차감할 때, ") + @Nested + class Deduct { + + @DisplayName("충분한 재고가 있으면, 차감된 Stock을 반환한다.") + @Test + void returnsDeductedStock_whenSufficient() { + Stock stock = new Stock(10); + Stock result = stock.deduct(3); + assertThat(result.quantity()).isEqualTo(7); + } + + @DisplayName("재고가 부족하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenInsufficient() { + Stock stock = new Stock(2); + CoreException result = assertThrows(CoreException.class, () -> stock.deduct(3)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("재고와 동일한 수량이면, 0이 된다.") + @Test + void returnsZero_whenExactAmount() { + Stock stock = new Stock(5); + Stock result = stock.deduct(5); + assertThat(result.quantity()).isEqualTo(0); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminProductV1ApiE2ETest.java new file mode 100644 index 000000000..bc6bfc624 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminProductV1ApiE2ETest.java @@ -0,0 +1,271 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.brand.AdminBrandV1Dto; +import com.loopers.interfaces.api.product.AdminProductV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +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.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AdminProductV1ApiE2ETest { + + private static final String ENDPOINT = "/api-admin/v1/products"; + private static final String BRAND_ENDPOINT = "/api-admin/v1/brands"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public AdminProductV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + private Long brandId; + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.set("Content-Type", "application/json"); + return headers; + } + + @BeforeEach + void setUp() { + AdminBrandV1Dto.CreateRequest brandRequest = new AdminBrandV1Dto.CreateRequest("나이키"); + ResponseEntity> brandResponse = testRestTemplate.exchange( + BRAND_ENDPOINT, HttpMethod.POST, new HttpEntity<>(brandRequest, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + brandId = brandResponse.getBody().data().id(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private AdminProductV1Dto.ProductResponse createProduct(String name, int price, int stock) { + AdminProductV1Dto.CreateRequest request = new AdminProductV1Dto.CreateRequest(brandId, name, price, stock); + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data(); + } + + @DisplayName("POST /api-admin/v1/products") + @Nested + class Create { + + @DisplayName("올바른 요청이면, 200 OK와 함께 상품 정보를 반환한다.") + @Test + void returnsProductInfo_whenValidRequest() { + AdminProductV1Dto.CreateRequest request = new AdminProductV1Dto.CreateRequest(brandId, "에어맥스", 129000, 100); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스"), + () -> assertThat(response.getBody().data().brandName()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().price()).isEqualTo(129000), + () -> assertThat(response.getBody().data().stock()).isEqualTo(100), + () -> assertThat(response.getBody().data().likeCount()).isEqualTo(0) + ); + } + + @DisplayName("존재하지 않는 브랜드이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenBrandDoesNotExist() { + AdminProductV1Dto.CreateRequest request = new AdminProductV1Dto.CreateRequest(999L, "에어맥스", 129000, 100); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("이름이 비어있으면, 400 BAD_REQUEST를 반환한다.") + @Test + void returnsBadRequest_whenNameIsBlank() { + AdminProductV1Dto.CreateRequest request = new AdminProductV1Dto.CreateRequest(brandId, "", 129000, 100); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api-admin/v1/products") + @Nested + class GetAll { + + @DisplayName("상품이 존재하면, 페이지 결과를 반환한다.") + @Test + void returnsPageResult_whenProductsExist() { + createProduct("에어맥스", 129000, 100); + createProduct("에어포스1", 109000, 200); + createProduct("에어조던", 179000, 50); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?page=0&size=2", HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().content()).hasSize(2), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(3), + () -> assertThat(response.getBody().data().totalPages()).isEqualTo(2) + ); + } + + @DisplayName("브랜드별 필터링이 동작한다.") + @Test + void filtersByBrandId() { + createProduct("에어맥스", 129000, 100); + + AdminBrandV1Dto.CreateRequest brand2Request = new AdminBrandV1Dto.CreateRequest("아디다스"); + ResponseEntity> brand2Response = testRestTemplate.exchange( + BRAND_ENDPOINT, HttpMethod.POST, new HttpEntity<>(brand2Request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + Long brand2Id = brand2Response.getBody().data().id(); + + AdminProductV1Dto.CreateRequest product2 = new AdminProductV1Dto.CreateRequest(brand2Id, "울트라부스트", 159000, 50); + testRestTemplate.exchange( + ENDPOINT, HttpMethod.POST, new HttpEntity<>(product2, adminHeaders()), + new ParameterizedTypeReference>() {} + ); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?brandId=" + brandId, HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getBody().data().content()).hasSize(1), + () -> assertThat(response.getBody().data().content().get(0).name()).isEqualTo("에어맥스") + ); + } + } + + @DisplayName("GET /api-admin/v1/products/{productId}") + @Nested + class GetById { + + @DisplayName("존재하는 상품이면, 상품 정보를 반환한다.") + @Test + void returnsProductInfo_whenProductExists() { + AdminProductV1Dto.ProductResponse created = createProduct("에어맥스", 129000, 100); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + created.id(), HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스"), + () -> assertThat(response.getBody().data().stock()).isEqualTo(100), + () -> assertThat(response.getBody().data().createdAt()).isNotNull() + ); + } + + @DisplayName("존재하지 않는 상품이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenProductDoesNotExist() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/999", HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("PUT /api-admin/v1/products/{productId}") + @Nested + class Update { + + @DisplayName("올바른 요청이면, 수정된 상품 정보를 반환한다.") + @Test + void returnsUpdatedProduct_whenValidRequest() { + AdminProductV1Dto.ProductResponse created = createProduct("에어맥스", 129000, 100); + AdminProductV1Dto.UpdateRequest request = new AdminProductV1Dto.UpdateRequest("에어포스1", 109000, 200); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + created.id(), HttpMethod.PUT, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어포스1"), + () -> assertThat(response.getBody().data().price()).isEqualTo(109000), + () -> assertThat(response.getBody().data().stock()).isEqualTo(200), + () -> assertThat(response.getBody().data().brandId()).isEqualTo(brandId) + ); + } + } + + @DisplayName("DELETE /api-admin/v1/products/{productId}") + @Nested + class Delete { + + @DisplayName("존재하는 상품이면, 200 OK를 반환하고 조회되지 않는다.") + @Test + void deletesAndReturnsSuccess_whenProductExists() { + AdminProductV1Dto.ProductResponse created = createProduct("에어맥스", 129000, 100); + + ResponseEntity> deleteResponse = testRestTemplate.exchange( + ENDPOINT + "/" + created.id(), HttpMethod.DELETE, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertTrue(deleteResponse.getStatusCode().is2xxSuccessful()); + + ResponseEntity> getResponse = testRestTemplate.exchange( + ENDPOINT + "/" + created.id(), HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("존재하지 않는 상품이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenProductDoesNotExist() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/999", HttpMethod.DELETE, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java new file mode 100644 index 000000000..36df1b127 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java @@ -0,0 +1,174 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.brand.AdminBrandV1Dto; +import com.loopers.interfaces.api.product.AdminProductV1Dto; +import com.loopers.interfaces.api.product.ProductV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +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.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class ProductV1ApiE2ETest { + + private static final String ENDPOINT = "/api/v1/products"; + private static final String ADMIN_BRAND_ENDPOINT = "/api-admin/v1/brands"; + private static final String ADMIN_PRODUCT_ENDPOINT = "/api-admin/v1/products"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public ProductV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + private Long brandId; + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.set("Content-Type", "application/json"); + return headers; + } + + @BeforeEach + void setUp() { + AdminBrandV1Dto.CreateRequest brandRequest = new AdminBrandV1Dto.CreateRequest("나이키"); + ResponseEntity> brandResponse = testRestTemplate.exchange( + ADMIN_BRAND_ENDPOINT, HttpMethod.POST, new HttpEntity<>(brandRequest, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + brandId = brandResponse.getBody().data().id(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private Long createProductViaAdmin(String name, int price, int stock) { + AdminProductV1Dto.CreateRequest request = new AdminProductV1Dto.CreateRequest(brandId, name, price, stock); + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_PRODUCT_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data().id(); + } + + @DisplayName("GET /api/v1/products") + @Nested + class GetAll { + + @DisplayName("상품이 존재하면, 페이지 결과를 반환한다.") + @Test + void returnsPageResult_whenProductsExist() { + createProductViaAdmin("에어맥스", 129000, 100); + createProductViaAdmin("에어포스1", 109000, 200); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?page=0&size=10", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().content()).hasSize(2), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(2) + ); + } + + @DisplayName("인증 없이도 조회할 수 있다.") + @Test + void returnsProducts_withoutAuth() { + createProductViaAdmin("에어맥스", 129000, 100); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("브랜드별 필터링이 동작한다.") + @Test + void filtersByBrandId() { + createProductViaAdmin("에어맥스", 129000, 100); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "?brandId=" + brandId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(response.getBody().data().content()).hasSize(1), + () -> assertThat(response.getBody().data().content().get(0).brandId()).isEqualTo(brandId) + ); + } + } + + @DisplayName("GET /api/v1/products/{productId}") + @Nested + class GetById { + + @DisplayName("존재하는 상품이면, 상품 정보를 반환한다 (stock 미노출).") + @Test + void returnsProductInfo_whenProductExists() { + Long productId = createProductViaAdmin("에어맥스", 129000, 100); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + productId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().name()).isEqualTo("에어맥스"), + () -> assertThat(response.getBody().data().brandName()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().price()).isEqualTo(129000), + () -> assertThat(response.getBody().data().likeCount()).isEqualTo(0) + ); + } + + @DisplayName("존재하지 않는 상품이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenProductDoesNotExist() { + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/999", HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("인증 없이도 조회할 수 있다.") + @Test + void returnsProductInfo_withoutAuth() { + Long productId = createProductViaAdmin("에어맥스", 129000, 100); + + ResponseEntity> response = testRestTemplate.exchange( + ENDPOINT + "/" + productId, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + } +} From bd617a988ef7759778418cffb45fa955bf1fc83f Mon Sep 17 00:00:00 2001 From: yoon-yoo-tak Date: Mon, 16 Feb 2026 01:52:22 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat=20:=20Like=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/like/LikeFacade.java | 63 +++++ .../loopers/application/like/LikeInfo.java | 29 +++ .../loopers/config/DomainServiceConfig.java | 7 + .../java/com/loopers/domain/like/Like.java | 54 ++++ .../loopers/domain/like/LikeRepository.java | 17 ++ .../com/loopers/domain/like/LikeService.java | 30 +++ .../like/LikeJpaRepository.java | 16 ++ .../like/LikeRepositoryImpl.java | 41 +++ .../interfaces/api/like/LikeV1ApiSpec.java | 19 ++ .../interfaces/api/like/LikeV1Controller.java | 43 ++++ .../interfaces/api/like/LikeV1Dto.java | 35 +++ .../like/LikeServiceIntegrationTest.java | 129 ++++++++++ .../com/loopers/domain/like/LikeTest.java | 44 ++++ .../interfaces/api/LikeV1ApiE2ETest.java | 237 ++++++++++++++++++ 14 files changed, 764 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java new file mode 100644 index 000000000..d7769c9dc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,63 @@ +package com.loopers.application.like; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class LikeFacade { + + private final LikeService likeService; + private final ProductService productService; + private final BrandService brandService; + + @Transactional + public void like(Long userId, Long productId) { + productService.getById(productId); + likeService.like(userId, productId); + productService.incrementLikeCount(productId); + } + + @Transactional + public void unlike(Long userId, Long productId) { + productService.getById(productId); + likeService.unlike(userId, productId); + productService.decrementLikeCount(productId); + } + + public List getMyLikes(Long userId) { + List likes = likeService.getMyLikes(userId); + + Set productIds = likes.stream() + .map(Like::getProductId) + .collect(Collectors.toSet()); + + Map productMap = productIds.stream() + .collect(Collectors.toMap(id -> id, productService::getById)); + + Set brandIds = productMap.values().stream() + .map(Product::getBrandId) + .collect(Collectors.toSet()); + Map brandMap = brandService.getByIds(brandIds); + + return likes.stream() + .map(like -> { + Product product = productMap.get(like.getProductId()); + Brand brand = brandMap.get(product.getBrandId()); + return LikeInfo.from(like, product, brand); + }) + .toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java new file mode 100644 index 000000000..e3d02474a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java @@ -0,0 +1,29 @@ +package com.loopers.application.like; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.like.Like; +import com.loopers.domain.product.Product; + +import java.time.ZonedDateTime; + +public record LikeInfo( + Long likeId, + Long productId, + String productName, + String brandName, + int price, + int likeCount, + ZonedDateTime likedAt +) { + public static LikeInfo from(Like like, Product product, Brand brand) { + return new LikeInfo( + like.getId(), + product.getId(), + product.getName(), + brand.getName(), + product.getPrice().amount(), + product.getLikeCount(), + like.getCreatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/DomainServiceConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/DomainServiceConfig.java index be0bbdeb2..73bff9499 100644 --- a/apps/commerce-api/src/main/java/com/loopers/config/DomainServiceConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/config/DomainServiceConfig.java @@ -2,6 +2,8 @@ import com.loopers.domain.brand.BrandRepository; import com.loopers.domain.brand.BrandService; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.like.LikeService; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.ProductService; import com.loopers.domain.user.PasswordEncryptor; @@ -27,4 +29,9 @@ public BrandService brandService(BrandRepository brandRepository) { public ProductService productService(ProductRepository productRepository) { return new ProductService(productRepository); } + + @Bean + public LikeService likeService(LikeRepository likeRepository) { + return new LikeService(likeRepository); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java new file mode 100644 index 000000000..24af90ec3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java @@ -0,0 +1,54 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; + +import java.time.ZonedDateTime; + +@Getter +@Entity +@Table(name = "likes", uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "product_id"}) +}) +public class Like { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + protected Like() {} + + public Like(Long userId, Long productId) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "유저 ID는 필수입니다."); + } + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다."); + } + this.userId = userId; + this.productId = productId; + } + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java new file mode 100644 index 000000000..cce1c8860 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,17 @@ +package com.loopers.domain.like; + +import java.util.List; +import java.util.Optional; + +public interface LikeRepository { + + Like save(Like like); + + void delete(Like like); + + boolean existsByUserIdAndProductId(Long userId, Long productId); + + Optional findByUserIdAndProductId(Long userId, Long productId); + + List findAllByUserId(Long userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java new file mode 100644 index 000000000..a5924c20b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -0,0 +1,30 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +@RequiredArgsConstructor +public class LikeService { + + private final LikeRepository likeRepository; + + public void like(Long userId, Long productId) { + if (likeRepository.existsByUserIdAndProductId(userId, productId)) { + throw new CoreException(ErrorType.CONFLICT, "이미 좋아요한 상품입니다."); + } + likeRepository.save(new Like(userId, productId)); + } + + public void unlike(Long userId, Long productId) { + Like like = likeRepository.findByUserIdAndProductId(userId, productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "좋아요를 찾을 수 없습니다.")); + likeRepository.delete(like); + } + + public List getMyLikes(Long userId) { + return likeRepository.findAllByUserId(userId); + } +} 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 new file mode 100644 index 000000000..d7c4c4892 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface LikeJpaRepository extends JpaRepository { + + boolean existsByUserIdAndProductId(Long userId, Long productId); + + Optional findByUserIdAndProductId(Long userId, Long productId); + + List findAllByUserId(Long 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 new file mode 100644 index 000000000..f6a7c109f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,41 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + @Override + public Like save(Like like) { + return likeJpaRepository.save(like); + } + + @Override + public void delete(Like like) { + likeJpaRepository.delete(like); + } + + @Override + public boolean existsByUserIdAndProductId(Long userId, Long productId) { + return likeJpaRepository.existsByUserIdAndProductId(userId, productId); + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return likeJpaRepository.findByUserIdAndProductId(userId, productId); + } + + @Override + public List findAllByUserId(Long userId) { + return likeJpaRepository.findAllByUserId(userId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java new file mode 100644 index 000000000..abf022c65 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java @@ -0,0 +1,19 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.domain.user.User; +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Like V1 API", description = "좋아요 API 입니다.") +public interface LikeV1ApiSpec { + + @Operation(summary = "좋아요 등록", description = "상품에 좋아요를 등록합니다.") + ApiResponse like(User user, Long productId); + + @Operation(summary = "좋아요 취소", description = "상품의 좋아요를 취소합니다.") + ApiResponse unlike(User user, Long productId); + + @Operation(summary = "내 좋아요 목록 조회", description = "내가 좋아요한 상품 목록을 조회합니다.") + ApiResponse getMyLikes(User user); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java new file mode 100644 index 000000000..71453e5ac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,43 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeFacade; +import com.loopers.application.like.LikeInfo; +import com.loopers.domain.user.User; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthUser; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +public class LikeV1Controller implements LikeV1ApiSpec { + + private final LikeFacade likeFacade; + + @PostMapping("/api/v1/products/{productId}/likes") + @Override + public ApiResponse like(@AuthUser User user, @PathVariable Long productId) { + likeFacade.like(user.getId(), productId); + return ApiResponse.success(); + } + + @DeleteMapping("/api/v1/products/{productId}/likes") + @Override + public ApiResponse unlike(@AuthUser User user, @PathVariable Long productId) { + likeFacade.unlike(user.getId(), productId); + return ApiResponse.success(); + } + + @GetMapping("/api/v1/likes") + @Override + public ApiResponse getMyLikes(@AuthUser User user) { + List infos = likeFacade.getMyLikes(user.getId()); + return ApiResponse.success(LikeV1Dto.LikeListResponse.from(infos)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java new file mode 100644 index 000000000..60820e83b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,35 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeInfo; + +import java.time.ZonedDateTime; +import java.util.List; + +public class LikeV1Dto { + + public record LikeResponse( + Long likeId, + Long productId, + String productName, + String brandName, + int price, + int likeCount, + ZonedDateTime likedAt + ) { + public static LikeResponse from(LikeInfo info) { + return new LikeResponse( + info.likeId(), info.productId(), info.productName(), + info.brandName(), info.price(), info.likeCount(), info.likedAt() + ); + } + } + + public record LikeListResponse(List likes) { + public static LikeListResponse from(List infos) { + List likes = infos.stream() + .map(LikeResponse::from) + .toList(); + return new LikeListResponse(likes); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java new file mode 100644 index 000000000..ae39e0385 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java @@ -0,0 +1,129 @@ +package com.loopers.domain.like; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.Stock; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +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 java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class LikeServiceIntegrationTest { + + @Autowired + private LikeService likeService; + + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private Long brandId; + private Long productId; + + @BeforeEach + void setUp() { + brandId = brandService.register("나이키").getId(); + Product product = productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); + productId = product.getId(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("좋아요를 등록할 때, ") + @Nested + class LikeProduct { + + @DisplayName("처음 좋아요하면, 좋아요가 저장된다.") + @Test + void savesLike_whenFirstTime() { + likeService.like(1L, productId); + + List likes = likeService.getMyLikes(1L); + assertAll( + () -> assertThat(likes).hasSize(1), + () -> assertThat(likes.get(0).getUserId()).isEqualTo(1L), + () -> assertThat(likes.get(0).getProductId()).isEqualTo(productId) + ); + } + + @DisplayName("이미 좋아요한 상품이면, CONFLICT 예외가 발생한다.") + @Test + void throwsConflict_whenAlreadyLiked() { + likeService.like(1L, productId); + + CoreException result = assertThrows(CoreException.class, () -> likeService.like(1L, productId)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("좋아요를 취소할 때, ") + @Nested + class UnlikeProduct { + + @DisplayName("좋아요가 존재하면, 삭제된다.") + @Test + void deletesLike_whenLikeExists() { + likeService.like(1L, productId); + + likeService.unlike(1L, productId); + + List likes = likeService.getMyLikes(1L); + assertThat(likes).isEmpty(); + } + + @DisplayName("좋아요가 존재하지 않으면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenLikeDoesNotExist() { + CoreException result = assertThrows(CoreException.class, () -> likeService.unlike(1L, productId)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("내 좋아요 목록을 조회할 때, ") + @Nested + class GetMyLikes { + + @DisplayName("좋아요한 상품이 있으면, 목록을 반환한다.") + @Test + void returnsLikes_whenLikesExist() { + Product product2 = productService.register(brandId, "에어포스1", new Money(109000), new Stock(200)); + likeService.like(1L, productId); + likeService.like(1L, product2.getId()); + + List result = likeService.getMyLikes(1L); + + assertThat(result).hasSize(2); + } + + @DisplayName("좋아요한 상품이 없으면, 빈 목록을 반환한다.") + @Test + void returnsEmptyList_whenNoLikes() { + List result = likeService.getMyLikes(1L); + + assertThat(result).isEmpty(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java new file mode 100644 index 000000000..d6ceb9b98 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeTest.java @@ -0,0 +1,44 @@ +package com.loopers.domain.like; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class LikeTest { + + @DisplayName("좋아요를 생성할 때, ") + @Nested + class Create { + + @DisplayName("올바른 정보이면, 좋아요가 생성된다.") + @Test + void createsLike_whenValidInfo() { + Like like = new Like(1L, 100L); + + assertAll( + () -> assertThat(like.getUserId()).isEqualTo(1L), + () -> assertThat(like.getProductId()).isEqualTo(100L) + ); + } + + @DisplayName("userId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenUserIdIsNull() { + CoreException result = assertThrows(CoreException.class, () -> new Like(null, 100L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenProductIdIsNull() { + CoreException result = assertThrows(CoreException.class, () -> new Like(1L, null)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java new file mode 100644 index 000000000..34d4d2085 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/LikeV1ApiE2ETest.java @@ -0,0 +1,237 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.brand.AdminBrandV1Dto; +import com.loopers.interfaces.api.like.LikeV1Dto; +import com.loopers.interfaces.api.product.AdminProductV1Dto; +import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +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.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class LikeV1ApiE2ETest { + + private static final String LIKE_ENDPOINT = "/api/v1/products/{productId}/likes"; + private static final String MY_LIKES_ENDPOINT = "/api/v1/likes"; + private static final String ADMIN_BRAND_ENDPOINT = "/api-admin/v1/brands"; + private static final String ADMIN_PRODUCT_ENDPOINT = "/api-admin/v1/products"; + private static final String SIGNUP_ENDPOINT = "/api/v1/users"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public LikeV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + private Long productId; + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private HttpHeaders authHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testUser1"); + headers.set("X-Loopers-LoginPw", "Abcd1234!"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private void signupUser() { + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + "testUser1", "Abcd1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com" + ); + testRestTemplate.exchange(SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference>() {}); + } + + @BeforeEach + void setUp() { + signupUser(); + + AdminBrandV1Dto.CreateRequest brandRequest = new AdminBrandV1Dto.CreateRequest("나이키"); + ResponseEntity> brandResponse = testRestTemplate.exchange( + ADMIN_BRAND_ENDPOINT, HttpMethod.POST, new HttpEntity<>(brandRequest, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + Long brandId = brandResponse.getBody().data().id(); + + AdminProductV1Dto.CreateRequest productRequest = new AdminProductV1Dto.CreateRequest(brandId, "에어맥스", 129000, 100); + ResponseEntity> productResponse = testRestTemplate.exchange( + ADMIN_PRODUCT_ENDPOINT, HttpMethod.POST, new HttpEntity<>(productRequest, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + productId = productResponse.getBody().data().id(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private String likeUrl(Long productId) { + return "/api/v1/products/" + productId + "/likes"; + } + + @DisplayName("POST /api/v1/products/{productId}/likes") + @Nested + class LikeProduct { + + @DisplayName("인증된 사용자가 좋아요하면, 200 OK를 반환한다.") + @Test + void returnsSuccess_whenAuthenticated() { + ResponseEntity> response = testRestTemplate.exchange( + likeUrl(productId), HttpMethod.POST, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("이미 좋아요한 상품이면, 409 CONFLICT를 반환한다.") + @Test + void returnsConflict_whenAlreadyLiked() { + testRestTemplate.exchange( + likeUrl(productId), HttpMethod.POST, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference>() {} + ); + + ResponseEntity> response = testRestTemplate.exchange( + likeUrl(productId), HttpMethod.POST, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @DisplayName("존재하지 않는 상품이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenProductDoesNotExist() { + ResponseEntity> response = testRestTemplate.exchange( + likeUrl(999L), HttpMethod.POST, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("인증되지 않은 사용자이면, 401 UNAUTHORIZED를 반환한다.") + @Test + void returnsUnauthorized_whenNotAuthenticated() { + ResponseEntity> response = testRestTemplate.exchange( + likeUrl(productId), HttpMethod.POST, null, + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("DELETE /api/v1/products/{productId}/likes") + @Nested + class UnlikeProduct { + + @DisplayName("좋아요가 존재하면, 200 OK를 반환한다.") + @Test + void returnsSuccess_whenLikeExists() { + testRestTemplate.exchange( + likeUrl(productId), HttpMethod.POST, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference>() {} + ); + + ResponseEntity> response = testRestTemplate.exchange( + likeUrl(productId), HttpMethod.DELETE, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("좋아요가 존재하지 않으면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenLikeDoesNotExist() { + ResponseEntity> response = testRestTemplate.exchange( + likeUrl(productId), HttpMethod.DELETE, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("GET /api/v1/likes") + @Nested + class GetMyLikes { + + @DisplayName("좋아요한 상품이 있으면, 목록을 반환한다.") + @Test + void returnsLikes_whenLikesExist() { + testRestTemplate.exchange( + likeUrl(productId), HttpMethod.POST, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference>() {} + ); + + ResponseEntity> response = testRestTemplate.exchange( + MY_LIKES_ENDPOINT, HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().likes()).hasSize(1), + () -> assertThat(response.getBody().data().likes().get(0).productName()).isEqualTo("에어맥스"), + () -> assertThat(response.getBody().data().likes().get(0).brandName()).isEqualTo("나이키"), + () -> assertThat(response.getBody().data().likes().get(0).price()).isEqualTo(129000) + ); + } + + @DisplayName("좋아요한 상품이 없으면, 빈 목록을 반환한다.") + @Test + void returnsEmptyList_whenNoLikes() { + ResponseEntity> response = testRestTemplate.exchange( + MY_LIKES_ENDPOINT, HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().likes()).isEmpty() + ); + } + + @DisplayName("인증되지 않은 사용자이면, 401 UNAUTHORIZED를 반환한다.") + @Test + void returnsUnauthorized_whenNotAuthenticated() { + ResponseEntity> response = testRestTemplate.exchange( + MY_LIKES_ENDPOINT, HttpMethod.GET, null, + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } +} From a5de85cd86146d328a2d98d7a874d9f42bfacb77 Mon Sep 17 00:00:00 2001 From: yoon-yoo-tak Date: Mon, 16 Feb 2026 02:07:39 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat=20:=20cart=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/cart/CartFacade.java | 66 ++++ .../loopers/application/cart/CartInfo.java | 25 ++ .../loopers/application/like/LikeFacade.java | 4 +- .../loopers/config/DomainServiceConfig.java | 7 + .../com/loopers/domain/cart/CartItem.java | 70 ++++ .../loopers/domain/cart/CartRepository.java | 19 ++ .../com/loopers/domain/cart/CartService.java | 56 ++++ .../com/loopers/domain/cart/Quantity.java | 17 + .../domain/product/ProductRepository.java | 4 + .../domain/product/ProductService.java | 10 + .../cart/CartJpaRepository.java | 16 + .../cart/CartRepositoryImpl.java | 46 +++ .../product/ProductJpaRepository.java | 4 + .../product/ProductRepositoryImpl.java | 7 + .../interfaces/api/cart/CartV1ApiSpec.java | 22 ++ .../interfaces/api/cart/CartV1Controller.java | 59 ++++ .../interfaces/api/cart/CartV1Dto.java | 44 +++ .../com/loopers/domain/cart/CartItemTest.java | 91 ++++++ .../cart/CartServiceIntegrationTest.java | 185 +++++++++++ .../com/loopers/domain/cart/QuantityTest.java | 52 +++ .../interfaces/api/CartV1ApiE2ETest.java | 304 ++++++++++++++++++ 21 files changed, 1106 insertions(+), 2 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/cart/CartFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/cart/CartInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItem.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/cart/CartRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/cart/CartService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/cart/Quantity.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/cart/CartItemTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/cart/CartServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/cart/QuantityTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/CartV1ApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartFacade.java new file mode 100644 index 000000000..f4ef73dd3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartFacade.java @@ -0,0 +1,66 @@ +package com.loopers.application.cart; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.cart.CartItem; +import com.loopers.domain.cart.CartService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class CartFacade { + + private final CartService cartService; + private final ProductService productService; + private final BrandService brandService; + + @Transactional + public void addToCart(Long userId, Long productId, int quantity) { + productService.getById(productId); + cartService.addToCart(userId, productId, quantity); + } + + @Transactional(readOnly = true) + public List getMyCart(Long userId) { + List cartItems = cartService.getCartItems(userId); + + Set productIds = cartItems.stream() + .map(CartItem::getProductId) + .collect(Collectors.toSet()); + + Map productMap = productService.getByIds(productIds); + + Set brandIds = productMap.values().stream() + .map(Product::getBrandId) + .collect(Collectors.toSet()); + Map brandMap = brandService.getByIds(brandIds); + + return cartItems.stream() + .filter(cartItem -> productMap.containsKey(cartItem.getProductId())) + .map(cartItem -> { + Product product = productMap.get(cartItem.getProductId()); + Brand brand = brandMap.get(product.getBrandId()); + return CartInfo.from(cartItem, product, brand); + }) + .toList(); + } + + @Transactional + public void updateQuantity(Long cartItemId, Long userId, int quantity) { + cartService.updateQuantity(cartItemId, userId, quantity); + } + + @Transactional + public void removeItem(Long cartItemId, Long userId) { + cartService.removeItem(cartItemId, userId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartInfo.java new file mode 100644 index 000000000..19a6ab0f8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartInfo.java @@ -0,0 +1,25 @@ +package com.loopers.application.cart; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.cart.CartItem; +import com.loopers.domain.product.Product; + +public record CartInfo( + Long cartItemId, + Long productId, + String productName, + String brandName, + int price, + int quantity +) { + public static CartInfo from(CartItem cartItem, Product product, Brand brand) { + return new CartInfo( + cartItem.getId(), + product.getId(), + product.getName(), + brand.getName(), + product.getPrice().amount(), + cartItem.getQuantity().value() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index d7769c9dc..b168b6ada 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -44,8 +44,7 @@ public List getMyLikes(Long userId) { .map(Like::getProductId) .collect(Collectors.toSet()); - Map productMap = productIds.stream() - .collect(Collectors.toMap(id -> id, productService::getById)); + Map productMap = productService.getByIds(productIds); Set brandIds = productMap.values().stream() .map(Product::getBrandId) @@ -53,6 +52,7 @@ public List getMyLikes(Long userId) { Map brandMap = brandService.getByIds(brandIds); return likes.stream() + .filter(like -> productMap.containsKey(like.getProductId())) .map(like -> { Product product = productMap.get(like.getProductId()); Brand brand = brandMap.get(product.getBrandId()); diff --git a/apps/commerce-api/src/main/java/com/loopers/config/DomainServiceConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/DomainServiceConfig.java index 73bff9499..47609f5cd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/config/DomainServiceConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/config/DomainServiceConfig.java @@ -2,6 +2,8 @@ import com.loopers.domain.brand.BrandRepository; import com.loopers.domain.brand.BrandService; +import com.loopers.domain.cart.CartRepository; +import com.loopers.domain.cart.CartService; import com.loopers.domain.like.LikeRepository; import com.loopers.domain.like.LikeService; import com.loopers.domain.product.ProductRepository; @@ -34,4 +36,9 @@ public ProductService productService(ProductRepository productRepository) { public LikeService likeService(LikeRepository likeRepository) { return new LikeService(likeRepository); } + + @Bean + public CartService cartService(CartRepository cartRepository) { + return new CartService(cartRepository); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItem.java new file mode 100644 index 000000000..231715cb7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItem.java @@ -0,0 +1,70 @@ +package com.loopers.domain.cart; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; + +import java.time.ZonedDateTime; + +@Getter +@Entity +@Table(name = "cart_items", uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "product_id"}) +}) +public class CartItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "quantity", nullable = false) + private int quantity; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + protected CartItem() {} + + public CartItem(Long userId, Long productId, int quantity) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "유저 ID는 필수입니다."); + } + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다."); + } + this.userId = userId; + this.productId = productId; + this.quantity = new Quantity(quantity).value(); + } + + public Quantity getQuantity() { + return new Quantity(quantity); + } + + public void addQuantity(int amount) { + this.quantity = getQuantity().add(amount).value(); + } + + public void updateQuantity(int quantity) { + this.quantity = new Quantity(quantity).value(); + } + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartRepository.java new file mode 100644 index 000000000..75d432c4c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartRepository.java @@ -0,0 +1,19 @@ +package com.loopers.domain.cart; + +import java.util.List; +import java.util.Optional; + +public interface CartRepository { + + CartItem save(CartItem cartItem); + + void delete(CartItem cartItem); + + void deleteAllByUserId(Long userId); + + Optional findById(Long id); + + Optional findByUserIdAndProductId(Long userId, Long productId); + + List findAllByUserId(Long userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartService.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartService.java new file mode 100644 index 000000000..0856eec65 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartService.java @@ -0,0 +1,56 @@ +package com.loopers.domain.cart; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +public class CartService { + + private final CartRepository cartRepository; + + public CartItem addToCart(Long userId, Long productId, int quantity) { + Optional existing = cartRepository.findByUserIdAndProductId(userId, productId); + + if (existing.isPresent()) { + CartItem cartItem = existing.get(); + cartItem.addQuantity(quantity); + return cartRepository.save(cartItem); + } + + return cartRepository.save(new CartItem(userId, productId, quantity)); + } + + public CartItem updateQuantity(Long cartItemId, Long userId, int quantity) { + CartItem cartItem = getByIdAndUserId(cartItemId, userId); + cartItem.updateQuantity(quantity); + return cartRepository.save(cartItem); + } + + public void removeItem(Long cartItemId, Long userId) { + CartItem cartItem = getByIdAndUserId(cartItemId, userId); + cartRepository.delete(cartItem); + } + + public List getCartItems(Long userId) { + return cartRepository.findAllByUserId(userId); + } + + public void clearCart(Long userId) { + cartRepository.deleteAllByUserId(userId); + } + + private CartItem getByIdAndUserId(Long cartItemId, Long userId) { + CartItem cartItem = cartRepository.findById(cartItemId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "장바구니 항목을 찾을 수 없습니다.")); + + if (!cartItem.getUserId().equals(userId)) { + throw new CoreException(ErrorType.NOT_FOUND, "장바구니 항목을 찾을 수 없습니다."); + } + + return cartItem; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/Quantity.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/Quantity.java new file mode 100644 index 000000000..74575d585 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/Quantity.java @@ -0,0 +1,17 @@ +package com.loopers.domain.cart; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +public record Quantity(int value) { + + public Quantity { + if (value < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 합니다."); + } + } + + public Quantity add(int amount) { + return new Quantity(this.value + amount); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 13bfaeb6c..f62f30f8c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -2,6 +2,8 @@ import com.loopers.domain.PageResult; +import java.util.Collection; +import java.util.List; import java.util.Optional; public interface ProductRepository { @@ -10,6 +12,8 @@ public interface ProductRepository { Optional findById(Long id); + List findAllByIds(Collection ids); + PageResult findAll(Long brandId, ProductSortType sort, int page, int size); void softDeleteAllByBrandId(Long brandId); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index 189f31544..fd60b1e59 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -5,6 +5,11 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + @RequiredArgsConstructor public class ProductService { @@ -19,6 +24,11 @@ public Product getById(Long id) { .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); } + public Map getByIds(Set ids) { + List products = productRepository.findAllByIds(ids); + return products.stream().collect(Collectors.toMap(Product::getId, product -> product)); + } + public PageResult getAll(Long brandId, ProductSortType sort, int page, int size) { return productRepository.findAll(brandId, sort, page, size); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartJpaRepository.java new file mode 100644 index 000000000..7103696c0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartJpaRepository.java @@ -0,0 +1,16 @@ +package com.loopers.infrastructure.cart; + +import com.loopers.domain.cart.CartItem; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface CartJpaRepository extends JpaRepository { + + Optional findByUserIdAndProductId(Long userId, Long productId); + + List findAllByUserId(Long userId); + + void deleteAllByUserId(Long userId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartRepositoryImpl.java new file mode 100644 index 000000000..bc6ee66a1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartRepositoryImpl.java @@ -0,0 +1,46 @@ +package com.loopers.infrastructure.cart; + +import com.loopers.domain.cart.CartItem; +import com.loopers.domain.cart.CartRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class CartRepositoryImpl implements CartRepository { + + private final CartJpaRepository cartJpaRepository; + + @Override + public CartItem save(CartItem cartItem) { + return cartJpaRepository.save(cartItem); + } + + @Override + public void delete(CartItem cartItem) { + cartJpaRepository.delete(cartItem); + } + + @Override + public void deleteAllByUserId(Long userId) { + cartJpaRepository.deleteAllByUserId(userId); + } + + @Override + public Optional findById(Long id) { + return cartJpaRepository.findById(id); + } + + @Override + public Optional findByUserIdAndProductId(Long userId, Long productId) { + return cartJpaRepository.findByUserIdAndProductId(userId, productId); + } + + @Override + public List findAllByUserId(Long userId) { + return cartJpaRepository.findAllByUserId(userId); + } +} 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 ac5309433..211bfa69e 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 @@ -8,12 +8,16 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.util.Collection; +import java.util.List; import java.util.Optional; public interface ProductJpaRepository extends JpaRepository { Optional findByIdAndDeletedAtIsNull(Long id); + List findAllByIdInAndDeletedAtIsNull(Collection ids); + Page findAllByDeletedAtIsNull(Pageable pageable); Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); 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 9f11ae522..6dff4a188 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 @@ -10,6 +10,8 @@ import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; +import java.util.Collection; +import java.util.List; import java.util.Optional; @RequiredArgsConstructor @@ -28,6 +30,11 @@ public Optional findById(Long id) { return productJpaRepository.findByIdAndDeletedAtIsNull(id); } + @Override + public List findAllByIds(Collection ids) { + return productJpaRepository.findAllByIdInAndDeletedAtIsNull(ids); + } + @Override public PageResult findAll(Long brandId, ProductSortType sort, int page, int size) { PageRequest pageRequest = PageRequest.of(page, size, toSort(sort)); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1ApiSpec.java new file mode 100644 index 000000000..e739e0cf8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1ApiSpec.java @@ -0,0 +1,22 @@ +package com.loopers.interfaces.api.cart; + +import com.loopers.domain.user.User; +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Cart V1 API", description = "장바구니 API 입니다.") +public interface CartV1ApiSpec { + + @Operation(summary = "장바구니 담기", description = "상품을 장바구니에 담습니다. 이미 담긴 상품이면 수량이 합산됩니다.") + ApiResponse addToCart(User user, CartV1Dto.AddRequest request); + + @Operation(summary = "장바구니 조회", description = "내 장바구니를 조회합니다.") + ApiResponse getMyCart(User user); + + @Operation(summary = "수량 변경", description = "장바구니 항목의 수량을 변경합니다.") + ApiResponse updateQuantity(User user, Long cartItemId, CartV1Dto.UpdateQuantityRequest request); + + @Operation(summary = "항목 삭제", description = "장바구니에서 항목을 삭제합니다.") + ApiResponse removeItem(User user, Long cartItemId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Controller.java new file mode 100644 index 000000000..afa4c813c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Controller.java @@ -0,0 +1,59 @@ +package com.loopers.interfaces.api.cart; + +import com.loopers.application.cart.CartFacade; +import com.loopers.application.cart.CartInfo; +import com.loopers.domain.user.User; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthUser; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/cart") +public class CartV1Controller implements CartV1ApiSpec { + + private final CartFacade cartFacade; + + @PostMapping("/items") + @Override + public ApiResponse addToCart(@AuthUser User user, @Valid @RequestBody CartV1Dto.AddRequest request) { + cartFacade.addToCart(user.getId(), request.productId(), request.quantity()); + return ApiResponse.success(); + } + + @GetMapping + @Override + public ApiResponse getMyCart(@AuthUser User user) { + List infos = cartFacade.getMyCart(user.getId()); + return ApiResponse.success(CartV1Dto.CartResponse.from(infos)); + } + + @PutMapping("/items/{cartItemId}") + @Override + public ApiResponse updateQuantity( + @AuthUser User user, + @PathVariable Long cartItemId, + @Valid @RequestBody CartV1Dto.UpdateQuantityRequest request + ) { + cartFacade.updateQuantity(cartItemId, user.getId(), request.quantity()); + return ApiResponse.success(); + } + + @DeleteMapping("/items/{cartItemId}") + @Override + public ApiResponse removeItem(@AuthUser User user, @PathVariable Long cartItemId) { + cartFacade.removeItem(cartItemId, user.getId()); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Dto.java new file mode 100644 index 000000000..1fc3129de --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Dto.java @@ -0,0 +1,44 @@ +package com.loopers.interfaces.api.cart; + +import com.loopers.application.cart.CartInfo; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public class CartV1Dto { + + public record AddRequest( + @NotNull Long productId, + @Min(1) int quantity + ) {} + + public record UpdateQuantityRequest( + @Min(1) int quantity + ) {} + + public record CartItemResponse( + Long cartItemId, + Long productId, + String productName, + String brandName, + int price, + int quantity + ) { + public static CartItemResponse from(CartInfo info) { + return new CartItemResponse( + info.cartItemId(), info.productId(), info.productName(), + info.brandName(), info.price(), info.quantity() + ); + } + } + + public record CartResponse(List items) { + public static CartResponse from(List infos) { + List items = infos.stream() + .map(CartItemResponse::from) + .toList(); + return new CartResponse(items); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartItemTest.java new file mode 100644 index 000000000..41a2c93bc --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartItemTest.java @@ -0,0 +1,91 @@ +package com.loopers.domain.cart; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class CartItemTest { + + @DisplayName("CartItem을 생성할 때, ") + @Nested + class Create { + + @DisplayName("올바른 정보이면, CartItem이 생성된다.") + @Test + void createsCartItem_whenValidInfo() { + CartItem cartItem = new CartItem(1L, 100L, 2); + + assertAll( + () -> assertThat(cartItem.getUserId()).isEqualTo(1L), + () -> assertThat(cartItem.getProductId()).isEqualTo(100L), + () -> assertThat(cartItem.getQuantity()).isEqualTo(new Quantity(2)) + ); + } + + @DisplayName("userId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenUserIdIsNull() { + CoreException result = assertThrows(CoreException.class, () -> new CartItem(null, 100L, 2)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenProductIdIsNull() { + CoreException result = assertThrows(CoreException.class, () -> new CartItem(1L, null, 2)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("수량이 0이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenQuantityIsZero() { + CoreException result = assertThrows(CoreException.class, () -> new CartItem(1L, 100L, 0)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("수량을 합산할 때, ") + @Nested + class AddQuantity { + + @DisplayName("양수를 합산하면, 수량이 증가한다.") + @Test + void addsQuantity_whenAmountIsPositive() { + CartItem cartItem = new CartItem(1L, 100L, 2); + + cartItem.addQuantity(3); + + assertThat(cartItem.getQuantity()).isEqualTo(new Quantity(5)); + } + } + + @DisplayName("수량을 변경할 때, ") + @Nested + class UpdateQuantity { + + @DisplayName("1 이상이면, 수량이 변경된다.") + @Test + void updatesQuantity_whenValueIsPositive() { + CartItem cartItem = new CartItem(1L, 100L, 2); + + cartItem.updateQuantity(5); + + assertThat(cartItem.getQuantity()).isEqualTo(new Quantity(5)); + } + + @DisplayName("0이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsZero() { + CartItem cartItem = new CartItem(1L, 100L, 2); + + CoreException result = assertThrows(CoreException.class, () -> cartItem.updateQuantity(0)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartServiceIntegrationTest.java new file mode 100644 index 000000000..6cd71e935 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartServiceIntegrationTest.java @@ -0,0 +1,185 @@ +package com.loopers.domain.cart; + +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.Stock; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +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.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class CartServiceIntegrationTest { + + @Autowired + private CartService cartService; + + @Autowired + private ProductService productService; + + @Autowired + private BrandService brandService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + private Long brandId; + private Long productId; + + @BeforeEach + void setUp() { + brandId = brandService.register("나이키").getId(); + Product product = productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); + productId = product.getId(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("장바구니에 상품을 담을 때, ") + @Nested + class AddToCart { + + @DisplayName("새로운 상품이면, 장바구니 항목이 생성된다.") + @Test + void createsCartItem_whenNewProduct() { + CartItem result = cartService.addToCart(1L, productId, 2); + + assertAll( + () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.getUserId()).isEqualTo(1L), + () -> assertThat(result.getProductId()).isEqualTo(productId), + () -> assertThat(result.getQuantity()).isEqualTo(new Quantity(2)) + ); + } + + @DisplayName("이미 담긴 상품이면, 수량이 합산된다.") + @Test + void addsQuantity_whenProductAlreadyInCart() { + cartService.addToCart(1L, productId, 2); + + CartItem result = cartService.addToCart(1L, productId, 3); + + assertThat(result.getQuantity()).isEqualTo(new Quantity(5)); + } + } + + @DisplayName("장바구니 수량을 변경할 때, ") + @Nested + class UpdateQuantity { + + @DisplayName("올바른 수량이면, 수량이 변경된다.") + @Test + void updatesQuantity_whenValid() { + CartItem cartItem = cartService.addToCart(1L, productId, 2); + + CartItem result = cartService.updateQuantity(cartItem.getId(), 1L, 5); + + assertThat(result.getQuantity()).isEqualTo(new Quantity(5)); + } + + @DisplayName("존재하지 않는 항목이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenItemDoesNotExist() { + CoreException result = assertThrows(CoreException.class, + () -> cartService.updateQuantity(999L, 1L, 5)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + + @DisplayName("다른 유저의 항목이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenOtherUsersItem() { + CartItem cartItem = cartService.addToCart(1L, productId, 2); + + CoreException result = assertThrows(CoreException.class, + () -> cartService.updateQuantity(cartItem.getId(), 999L, 5)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("장바구니 항목을 삭제할 때, ") + @Nested + class RemoveItem { + + @DisplayName("존재하는 항목이면, 삭제된다.") + @Test + void removesItem_whenItemExists() { + CartItem cartItem = cartService.addToCart(1L, productId, 2); + + cartService.removeItem(cartItem.getId(), 1L); + + List items = cartService.getCartItems(1L); + assertThat(items).isEmpty(); + } + + @DisplayName("존재하지 않는 항목이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenItemDoesNotExist() { + CoreException result = assertThrows(CoreException.class, + () -> cartService.removeItem(999L, 1L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("장바구니를 조회할 때, ") + @Nested + class GetCartItems { + + @DisplayName("항목이 있으면, 목록을 반환한다.") + @Test + void returnsItems_whenItemsExist() { + Product product2 = productService.register(brandId, "에어포스1", new Money(109000), new Stock(200)); + cartService.addToCart(1L, productId, 2); + cartService.addToCart(1L, product2.getId(), 1); + + List result = cartService.getCartItems(1L); + + assertThat(result).hasSize(2); + } + + @DisplayName("항목이 없으면, 빈 목록을 반환한다.") + @Test + void returnsEmptyList_whenNoItems() { + List result = cartService.getCartItems(1L); + + assertThat(result).isEmpty(); + } + } + + @DisplayName("장바구니를 비울 때, ") + @Nested + class ClearCart { + + @DisplayName("모든 항목이 삭제된다.") + @Test + @Transactional + void clearsAllItems() { + Product product2 = productService.register(brandId, "에어포스1", new Money(109000), new Stock(200)); + cartService.addToCart(1L, productId, 2); + cartService.addToCart(1L, product2.getId(), 1); + + cartService.clearCart(1L); + + List result = cartService.getCartItems(1L); + assertThat(result).isEmpty(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/cart/QuantityTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/cart/QuantityTest.java new file mode 100644 index 000000000..63315f847 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/cart/QuantityTest.java @@ -0,0 +1,52 @@ +package com.loopers.domain.cart; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class QuantityTest { + + @DisplayName("Quantity를 생성할 때, ") + @Nested + class Create { + + @DisplayName("1 이상이면, 정상 생성된다.") + @Test + void createsQuantity_whenValueIsPositive() { + Quantity quantity = new Quantity(5); + assertThat(quantity.value()).isEqualTo(5); + } + + @DisplayName("0이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsZero() { + CoreException result = assertThrows(CoreException.class, () -> new Quantity(0)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("음수이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenValueIsNegative() { + CoreException result = assertThrows(CoreException.class, () -> new Quantity(-1)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("수량을 합산할 때, ") + @Nested + class Add { + + @DisplayName("양수를 합산하면, 값이 증가한다.") + @Test + void addsQuantity_whenAmountIsPositive() { + Quantity quantity = new Quantity(3); + Quantity result = quantity.add(2); + assertThat(result.value()).isEqualTo(5); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/CartV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/CartV1ApiE2ETest.java new file mode 100644 index 000000000..f4ab74879 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/CartV1ApiE2ETest.java @@ -0,0 +1,304 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.brand.AdminBrandV1Dto; +import com.loopers.interfaces.api.cart.CartV1Dto; +import com.loopers.interfaces.api.product.AdminProductV1Dto; +import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +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.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class CartV1ApiE2ETest { + + private static final String CART_ENDPOINT = "/api/v1/cart"; + private static final String CART_ITEMS_ENDPOINT = "/api/v1/cart/items"; + private static final String ADMIN_BRAND_ENDPOINT = "/api-admin/v1/brands"; + private static final String ADMIN_PRODUCT_ENDPOINT = "/api-admin/v1/products"; + private static final String SIGNUP_ENDPOINT = "/api/v1/users"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public CartV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + private Long productId; + private Long productId2; + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private HttpHeaders authHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testUser1"); + headers.set("X-Loopers-LoginPw", "Abcd1234!"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private void signupUser() { + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + "testUser1", "Abcd1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com" + ); + testRestTemplate.exchange(SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference>() {}); + } + + @BeforeEach + void setUp() { + signupUser(); + + AdminBrandV1Dto.CreateRequest brandRequest = new AdminBrandV1Dto.CreateRequest("나이키"); + ResponseEntity> brandResponse = testRestTemplate.exchange( + ADMIN_BRAND_ENDPOINT, HttpMethod.POST, new HttpEntity<>(brandRequest, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + Long brandId = brandResponse.getBody().data().id(); + + AdminProductV1Dto.CreateRequest productRequest1 = new AdminProductV1Dto.CreateRequest(brandId, "에어맥스", 129000, 100); + ResponseEntity> productResponse1 = testRestTemplate.exchange( + ADMIN_PRODUCT_ENDPOINT, HttpMethod.POST, new HttpEntity<>(productRequest1, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + productId = productResponse1.getBody().data().id(); + + AdminProductV1Dto.CreateRequest productRequest2 = new AdminProductV1Dto.CreateRequest(brandId, "에어포스1", 109000, 200); + ResponseEntity> productResponse2 = testRestTemplate.exchange( + ADMIN_PRODUCT_ENDPOINT, HttpMethod.POST, new HttpEntity<>(productRequest2, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + productId2 = productResponse2.getBody().data().id(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private Long addToCart(Long productId, int quantity) { + CartV1Dto.AddRequest request = new CartV1Dto.AddRequest(productId, quantity); + testRestTemplate.exchange( + CART_ITEMS_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference>() {} + ); + + ResponseEntity> cartResponse = testRestTemplate.exchange( + CART_ENDPOINT, HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + return cartResponse.getBody().data().items().stream() + .filter(item -> item.productId().equals(productId)) + .findFirst() + .map(CartV1Dto.CartItemResponse::cartItemId) + .orElse(null); + } + + @DisplayName("POST /api/v1/cart/items") + @Nested + class AddToCart { + + @DisplayName("새 상품을 담으면, 200 OK를 반환한다.") + @Test + void returnsSuccess_whenAddingNewProduct() { + CartV1Dto.AddRequest request = new CartV1Dto.AddRequest(productId, 2); + + ResponseEntity> response = testRestTemplate.exchange( + CART_ITEMS_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + } + + @DisplayName("이미 담긴 상품을 다시 담으면, 수량이 합산된다.") + @Test + void addsQuantity_whenProductAlreadyInCart() { + CartV1Dto.AddRequest request1 = new CartV1Dto.AddRequest(productId, 2); + testRestTemplate.exchange( + CART_ITEMS_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request1, authHeaders()), + new ParameterizedTypeReference>() {} + ); + + CartV1Dto.AddRequest request2 = new CartV1Dto.AddRequest(productId, 3); + testRestTemplate.exchange( + CART_ITEMS_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request2, authHeaders()), + new ParameterizedTypeReference>() {} + ); + + ResponseEntity> cartResponse = testRestTemplate.exchange( + CART_ENDPOINT, HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertThat(cartResponse.getBody().data().items()).hasSize(1), + () -> assertThat(cartResponse.getBody().data().items().get(0).quantity()).isEqualTo(5) + ); + } + + @DisplayName("존재하지 않는 상품이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenProductDoesNotExist() { + CartV1Dto.AddRequest request = new CartV1Dto.AddRequest(999L, 2); + + ResponseEntity> response = testRestTemplate.exchange( + CART_ITEMS_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("인증되지 않은 사용자이면, 401 UNAUTHORIZED를 반환한다.") + @Test + void returnsUnauthorized_whenNotAuthenticated() { + CartV1Dto.AddRequest request = new CartV1Dto.AddRequest(productId, 2); + + ResponseEntity> response = testRestTemplate.exchange( + CART_ITEMS_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("GET /api/v1/cart") + @Nested + class GetMyCart { + + @DisplayName("장바구니에 항목이 있으면, 상품/브랜드 정보와 함께 목록을 반환한다.") + @Test + void returnsCartWithProductInfo_whenItemsExist() { + addToCart(productId, 2); + addToCart(productId2, 1); + + ResponseEntity> response = testRestTemplate.exchange( + CART_ENDPOINT, HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().items()).hasSize(2), + () -> assertThat(response.getBody().data().items().get(0).brandName()).isEqualTo("나이키") + ); + } + + @DisplayName("장바구니가 비어있으면, 빈 목록을 반환한다.") + @Test + void returnsEmptyList_whenCartIsEmpty() { + ResponseEntity> response = testRestTemplate.exchange( + CART_ENDPOINT, HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().items()).isEmpty() + ); + } + } + + @DisplayName("PUT /api/v1/cart/items/{cartItemId}") + @Nested + class UpdateQuantity { + + @DisplayName("올바른 수량이면, 200 OK를 반환한다.") + @Test + void returnsSuccess_whenValidQuantity() { + Long cartItemId = addToCart(productId, 2); + + CartV1Dto.UpdateQuantityRequest request = new CartV1Dto.UpdateQuantityRequest(5); + ResponseEntity> response = testRestTemplate.exchange( + CART_ITEMS_ENDPOINT + "/" + cartItemId, HttpMethod.PUT, + new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + + ResponseEntity> cartResponse = testRestTemplate.exchange( + CART_ENDPOINT, HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + assertThat(cartResponse.getBody().data().items().get(0).quantity()).isEqualTo(5); + } + + @DisplayName("존재하지 않는 항목이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenItemDoesNotExist() { + CartV1Dto.UpdateQuantityRequest request = new CartV1Dto.UpdateQuantityRequest(5); + ResponseEntity> response = testRestTemplate.exchange( + CART_ITEMS_ENDPOINT + "/999", HttpMethod.PUT, + new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } + + @DisplayName("DELETE /api/v1/cart/items/{cartItemId}") + @Nested + class RemoveItem { + + @DisplayName("존재하는 항목이면, 200 OK를 반환하고 삭제된다.") + @Test + void returnsSuccess_whenItemExists() { + Long cartItemId = addToCart(productId, 2); + + ResponseEntity> response = testRestTemplate.exchange( + CART_ITEMS_ENDPOINT + "/" + cartItemId, HttpMethod.DELETE, + new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertTrue(response.getStatusCode().is2xxSuccessful()); + + ResponseEntity> cartResponse = testRestTemplate.exchange( + CART_ENDPOINT, HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + assertThat(cartResponse.getBody().data().items()).isEmpty(); + } + + @DisplayName("존재하지 않는 항목이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenItemDoesNotExist() { + ResponseEntity> response = testRestTemplate.exchange( + CART_ITEMS_ENDPOINT + "/999", HttpMethod.DELETE, + new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} From c4e93bab7aa35c6700491509cc74ceab5e5cfa33 Mon Sep 17 00:00:00 2001 From: yoon-yoo-tak Date: Mon, 16 Feb 2026 02:38:44 +0900 Subject: [PATCH 07/11] =?UTF-8?q?feat=20:=20order=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderDetailInfo.java | 48 +++ .../application/order/OrderFacade.java | 147 ++++++++ .../loopers/application/order/OrderInfo.java | 23 ++ .../loopers/config/DomainServiceConfig.java | 8 + .../loopers/domain/{cart => }/Quantity.java | 2 +- .../com/loopers/domain/cart/CartItem.java | 1 + .../java/com/loopers/domain/order/Order.java | 45 +++ .../com/loopers/domain/order/OrderItem.java | 70 ++++ .../domain/order/OrderItemCommand.java | 11 + .../domain/order/OrderItemRepository.java | 13 + .../loopers/domain/order/OrderRepository.java | 17 + .../loopers/domain/order/OrderService.java | 57 +++ .../com/loopers/domain/order/OrderStatus.java | 5 + .../domain/product/ProductRepository.java | 2 + .../domain/product/ProductService.java | 5 + .../order/OrderItemJpaRepository.java | 14 + .../order/OrderItemRepositoryImpl.java | 31 ++ .../order/OrderJpaRepository.java | 20 + .../order/OrderRepositoryImpl.java | 50 +++ .../product/ProductJpaRepository.java | 7 + .../product/ProductRepositoryImpl.java | 5 + .../api/order/AdminOrderV1ApiSpec.java | 15 + .../api/order/AdminOrderV1Controller.java | 38 ++ .../interfaces/api/order/AdminOrderV1Dto.java | 71 ++++ .../interfaces/api/order/OrderV1ApiSpec.java | 24 ++ .../api/order/OrderV1Controller.java | 74 ++++ .../interfaces/api/order/OrderV1Dto.java | 85 +++++ .../domain/{cart => }/QuantityTest.java | 2 +- .../com/loopers/domain/cart/CartItemTest.java | 1 + .../cart/CartServiceIntegrationTest.java | 1 + .../loopers/domain/order/OrderItemTest.java | 84 +++++ .../order/OrderServiceIntegrationTest.java | 178 +++++++++ .../com/loopers/domain/order/OrderTest.java | 48 +++ .../api/AdminOrderV1ApiE2ETest.java | 181 +++++++++ .../interfaces/api/OrderV1ApiE2ETest.java | 354 ++++++++++++++++++ 35 files changed, 1735 insertions(+), 2 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderDetailInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java rename apps/commerce-api/src/main/java/com/loopers/domain/{cart => }/Quantity.java (92%) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java rename apps/commerce-api/src/test/java/com/loopers/domain/{cart => }/QuantityTest.java (98%) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminOrderV1ApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderDetailInfo.java new file mode 100644 index 000000000..ad3916711 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderDetailInfo.java @@ -0,0 +1,48 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; + +import java.time.ZonedDateTime; +import java.util.List; + +public record OrderDetailInfo( + Long orderId, + Long userId, + int totalPrice, + String status, + ZonedDateTime createdAt, + List items +) { + public static OrderDetailInfo from(Order order, List items) { + List itemInfos = items.stream() + .map(OrderItemInfo::from) + .toList(); + return new OrderDetailInfo( + order.getId(), + order.getUserId(), + order.getTotalPrice().amount(), + order.getStatus().name(), + order.getCreatedAt(), + itemInfos + ); + } + + public record OrderItemInfo( + Long productId, + String productName, + int productPrice, + String brandName, + int quantity + ) { + public static OrderItemInfo from(OrderItem item) { + return new OrderItemInfo( + item.getProductId(), + item.getProductName(), + item.getProductPrice().amount(), + item.getBrandName(), + item.getQuantity().value() + ); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java new file mode 100644 index 000000000..a6bf943f0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,147 @@ +package com.loopers.application.order; + +import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandService; +import com.loopers.domain.cart.CartItem; +import com.loopers.domain.cart.CartService; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderItemCommand; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class OrderFacade { + + private final OrderService orderService; + private final ProductService productService; + private final BrandService brandService; + private final CartService cartService; + + @Transactional + public OrderDetailInfo createOrder(Long userId, List items) { + if (items == null || items.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 하나 이상이어야 합니다."); + } + return processOrder(userId, items); + } + + @Transactional + public OrderDetailInfo createOrderFromCart(Long userId) { + List cartItems = cartService.getCartItems(userId); + if (cartItems.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "장바구니가 비어있습니다."); + } + + List items = cartItems.stream() + .map(ci -> new OrderItemRequest(ci.getProductId(), ci.getQuantity().value())) + .toList(); + + OrderDetailInfo result = processOrder(userId, items); + + cartService.clearCart(userId); + + return result; + } + + @Transactional(readOnly = true) + public PageResult getMyOrders(Long userId, LocalDate startAt, LocalDate endAt, int page, int size) { + ZonedDateTime start = startAt.atStartOfDay(ZoneId.systemDefault()); + ZonedDateTime end = endAt.plusDays(1).atStartOfDay(ZoneId.systemDefault()); + PageResult orders = orderService.getMyOrders(userId, start, end, page, size); + return orders.map(OrderInfo::from); + } + + @Transactional(readOnly = true) + public OrderDetailInfo getMyOrderDetail(Long userId, Long orderId) { + Order order = orderService.getByIdAndUserId(orderId, userId); + List orderItems = orderService.getOrderItems(orderId); + return OrderDetailInfo.from(order, orderItems); + } + + @Transactional(readOnly = true) + public PageResult getAllOrders(int page, int size) { + PageResult orders = orderService.getAllOrders(page, size); + return orders.map(OrderInfo::from); + } + + @Transactional(readOnly = true) + public OrderDetailInfo getOrderDetail(Long orderId) { + Order order = orderService.getById(orderId); + List orderItems = orderService.getOrderItems(orderId); + return OrderDetailInfo.from(order, orderItems); + } + + private OrderDetailInfo processOrder(Long userId, List items) { + // Validate for duplicate product IDs + Set uniqueProductIds = new HashSet<>(); + for (OrderItemRequest item : items) { + if (!uniqueProductIds.add(item.productId())) { + throw new CoreException(ErrorType.BAD_REQUEST, "중복된 상품이 포함되어 있습니다."); + } + } + + // Sort by productId to prevent deadlocks + List sortedItems = items.stream() + .sorted(Comparator.comparing(OrderItemRequest::productId)) + .toList(); + + // Get products with pessimistic lock and deduct stock + List products = new ArrayList<>(); + for (OrderItemRequest item : sortedItems) { + Product product = productService.getByIdWithLock(item.productId()); + product.deductStock(item.quantity()); + products.add(product); + } + + // Get brands for snapshots + Set brandIds = products.stream() + .map(Product::getBrandId) + .collect(Collectors.toSet()); + Map brandMap = brandService.getByIds(brandIds); + + // Build order item commands and calculate total + Money totalPrice = new Money(0); + List itemCommands = new ArrayList<>(); + for (int i = 0; i < sortedItems.size(); i++) { + OrderItemRequest item = sortedItems.get(i); + Product product = products.get(i); + Brand brand = brandMap.get(product.getBrandId()); + + Money itemTotal = product.getPrice().multiply(item.quantity()); + totalPrice = totalPrice.plus(itemTotal); + + itemCommands.add(new OrderItemCommand( + product.getId(), product.getName(), product.getPrice(), + brand.getName(), item.quantity() + )); + } + + // Create order + Order order = orderService.createOrder(userId, totalPrice, itemCommands); + List orderItems = orderService.getOrderItems(order.getId()); + return OrderDetailInfo.from(order, orderItems); + } + + public record OrderItemRequest(Long productId, int quantity) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java new file mode 100644 index 000000000..5003859cf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,23 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.Order; + +import java.time.ZonedDateTime; + +public record OrderInfo( + Long orderId, + Long userId, + int totalPrice, + String status, + ZonedDateTime createdAt +) { + public static OrderInfo from(Order order) { + return new OrderInfo( + order.getId(), + order.getUserId(), + order.getTotalPrice().amount(), + order.getStatus().name(), + order.getCreatedAt() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/DomainServiceConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/DomainServiceConfig.java index 47609f5cd..457b83c24 100644 --- a/apps/commerce-api/src/main/java/com/loopers/config/DomainServiceConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/config/DomainServiceConfig.java @@ -6,6 +6,9 @@ import com.loopers.domain.cart.CartService; import com.loopers.domain.like.LikeRepository; import com.loopers.domain.like.LikeService; +import com.loopers.domain.order.OrderItemRepository; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.order.OrderService; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.ProductService; import com.loopers.domain.user.PasswordEncryptor; @@ -41,4 +44,9 @@ public LikeService likeService(LikeRepository likeRepository) { public CartService cartService(CartRepository cartRepository) { return new CartService(cartRepository); } + + @Bean + public OrderService orderService(OrderRepository orderRepository, OrderItemRepository orderItemRepository) { + return new OrderService(orderRepository, orderItemRepository); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/Quantity.java b/apps/commerce-api/src/main/java/com/loopers/domain/Quantity.java similarity index 92% rename from apps/commerce-api/src/main/java/com/loopers/domain/cart/Quantity.java rename to apps/commerce-api/src/main/java/com/loopers/domain/Quantity.java index 74575d585..ca77d27f0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/cart/Quantity.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/Quantity.java @@ -1,4 +1,4 @@ -package com.loopers.domain.cart; +package com.loopers.domain; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItem.java index 231715cb7..79b814334 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItem.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItem.java @@ -1,5 +1,6 @@ package com.loopers.domain.cart; +import com.loopers.domain.Quantity; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.persistence.Column; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java new file mode 100644 index 000000000..5b402e4d9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -0,0 +1,45 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "orders") +public class Order extends BaseEntity { + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "total_price", nullable = false) + private int totalPrice; + + @Column(name = "status", nullable = false) + private String status; + + protected Order() {} + + public Order(Long userId, Money totalPrice) { + validate(userId, totalPrice); + this.userId = userId; + this.totalPrice = totalPrice.amount(); + this.status = OrderStatus.ORDERED.name(); + } + + public Long getUserId() { return userId; } + public Money getTotalPrice() { return new Money(totalPrice); } + public OrderStatus getStatus() { return OrderStatus.valueOf(status); } + + private void validate(Long userId, Money totalPrice) { + if (userId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "유저 ID는 필수입니다."); + } + if (totalPrice == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "총 금액은 필수입니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java new file mode 100644 index 000000000..8da82ad63 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -0,0 +1,70 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.Quantity; +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "order_items") +public class OrderItem extends BaseEntity { + + @Column(name = "order_id", nullable = false) + private Long orderId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "product_name", nullable = false) + private String productName; + + @Column(name = "product_price", nullable = false) + private int productPrice; + + @Column(name = "brand_name", nullable = false) + private String brandName; + + @Column(name = "quantity", nullable = false) + private int quantity; + + protected OrderItem() {} + + public OrderItem(Long orderId, Long productId, String productName, Money productPrice, String brandName, int quantity) { + validate(orderId, productId, productName, productPrice, brandName); + this.orderId = orderId; + this.productId = productId; + this.productName = productName; + this.productPrice = productPrice.amount(); + this.brandName = brandName; + this.quantity = new Quantity(quantity).value(); + } + + public Long getOrderId() { return orderId; } + public Long getProductId() { return productId; } + public String getProductName() { return productName; } + public Money getProductPrice() { return new Money(productPrice); } + public String getBrandName() { return brandName; } + public Quantity getQuantity() { return new Quantity(quantity); } + + private void validate(Long orderId, Long productId, String productName, Money productPrice, String brandName) { + if (orderId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 ID는 필수입니다."); + } + if (productId == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다."); + } + if (productName == null || productName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 이름은 필수입니다."); + } + if (productPrice == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 가격은 필수입니다."); + } + if (brandName == null || brandName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 필수입니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemCommand.java new file mode 100644 index 000000000..a1dec2aec --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemCommand.java @@ -0,0 +1,11 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.Money; + +public record OrderItemCommand( + Long productId, + String productName, + Money productPrice, + String brandName, + int quantity +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java new file mode 100644 index 000000000..9ada41361 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java @@ -0,0 +1,13 @@ +package com.loopers.domain.order; + +import java.util.Collection; +import java.util.List; + +public interface OrderItemRepository { + + OrderItem save(OrderItem orderItem); + + List findAllByOrderId(Long orderId); + + List findAllByOrderIds(Collection orderIds); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java new file mode 100644 index 000000000..04165e621 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,17 @@ +package com.loopers.domain.order; + +import com.loopers.domain.PageResult; + +import java.time.ZonedDateTime; +import java.util.Optional; + +public interface OrderRepository { + + Order save(Order order); + + Optional findById(Long id); + + PageResult findByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startAt, ZonedDateTime endAt, int page, int size); + + PageResult findAll(int page, int size); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java new file mode 100644 index 000000000..76e10f4ce --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,57 @@ +package com.loopers.domain.order; + +import com.loopers.domain.PageResult; +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; + +import java.time.ZonedDateTime; +import java.util.List; + +@RequiredArgsConstructor +public class OrderService { + + private final OrderRepository orderRepository; + private final OrderItemRepository orderItemRepository; + + public Order createOrder(Long userId, Money totalPrice, List itemCommands) { + Order order = new Order(userId, totalPrice); + Order savedOrder = orderRepository.save(order); + + List items = itemCommands.stream() + .map(cmd -> new OrderItem( + savedOrder.getId(), cmd.productId(), cmd.productName(), + cmd.productPrice(), cmd.brandName(), cmd.quantity() + )) + .toList(); + items.forEach(orderItemRepository::save); + + return savedOrder; + } + + public Order getById(Long id) { + return orderRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + } + + public Order getByIdAndUserId(Long id, Long userId) { + Order order = getById(id); + if (!order.getUserId().equals(userId)) { + throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."); + } + return order; + } + + public List getOrderItems(Long orderId) { + return orderItemRepository.findAllByOrderId(orderId); + } + + public PageResult getMyOrders(Long userId, ZonedDateTime startAt, ZonedDateTime endAt, int page, int size) { + return orderRepository.findByUserIdAndCreatedAtBetween(userId, startAt, endAt, page, size); + } + + public PageResult getAllOrders(int page, int size) { + return orderRepository.findAll(page, size); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java new file mode 100644 index 000000000..6dab40e1f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -0,0 +1,5 @@ +package com.loopers.domain.order; + +public enum OrderStatus { + ORDERED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index f62f30f8c..63a9f71a2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -12,6 +12,8 @@ public interface ProductRepository { Optional findById(Long id); + Optional findByIdWithLock(Long id); + List findAllByIds(Collection ids); PageResult findAll(Long brandId, ProductSortType sort, int page, int size); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index fd60b1e59..08198359d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -24,6 +24,11 @@ public Product getById(Long id) { .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); } + public Product getByIdWithLock(Long id) { + return productRepository.findByIdWithLock(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); + } + public Map getByIds(Set ids) { List products = productRepository.findAllByIds(ids); return products.stream().collect(Collectors.toMap(Product::getId, product -> product)); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java new file mode 100644 index 000000000..e927f8b57 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItem; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; + +public interface OrderItemJpaRepository extends JpaRepository { + + List findAllByOrderIdAndDeletedAtIsNull(Long orderId); + + List findAllByOrderIdInAndDeletedAtIsNull(Collection orderIds); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java new file mode 100644 index 000000000..9383809d0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java @@ -0,0 +1,31 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderItem; +import com.loopers.domain.order.OrderItemRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.List; + +@RequiredArgsConstructor +@Component +public class OrderItemRepositoryImpl implements OrderItemRepository { + + private final OrderItemJpaRepository orderItemJpaRepository; + + @Override + public OrderItem save(OrderItem orderItem) { + return orderItemJpaRepository.save(orderItem); + } + + @Override + public List findAllByOrderId(Long orderId) { + return orderItemJpaRepository.findAllByOrderIdAndDeletedAtIsNull(orderId); + } + + @Override + public List findAllByOrderIds(Collection orderIds) { + return orderItemJpaRepository.findAllByOrderIdInAndDeletedAtIsNull(orderIds); + } +} 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 new file mode 100644 index 000000000..7e508e0fc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,20 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.Order; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.ZonedDateTime; +import java.util.Optional; + +public interface OrderJpaRepository extends JpaRepository { + + Optional findByIdAndDeletedAtIsNull(Long id); + + Page findByUserIdAndCreatedAtBetweenAndDeletedAtIsNull( + Long userId, ZonedDateTime startAt, ZonedDateTime endAt, Pageable pageable + ); + + Page findAllByDeletedAtIsNull(Pageable pageable); +} 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 new file mode 100644 index 000000000..7608a9259 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,50 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.PageResult; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Component; + +import java.time.ZonedDateTime; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public Order save(Order order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findByIdAndDeletedAtIsNull(id); + } + + @Override + public PageResult findByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startAt, ZonedDateTime endAt, int page, int size) { + PageRequest pageRequest = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + Page result = orderJpaRepository.findByUserIdAndCreatedAtBetweenAndDeletedAtIsNull(userId, startAt, endAt, pageRequest); + return new PageResult<>( + result.getContent(), result.getNumber(), result.getSize(), + result.getTotalElements(), result.getTotalPages() + ); + } + + @Override + public PageResult findAll(int page, int size) { + PageRequest pageRequest = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + Page result = orderJpaRepository.findAllByDeletedAtIsNull(pageRequest); + return new PageResult<>( + result.getContent(), result.getNumber(), result.getSize(), + result.getTotalElements(), result.getTotalPages() + ); + } +} 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 211bfa69e..0cb6ca48a 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 @@ -4,10 +4,13 @@ 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.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import jakarta.persistence.LockModeType; + import java.util.Collection; import java.util.List; import java.util.Optional; @@ -16,6 +19,10 @@ public interface ProductJpaRepository extends JpaRepository { Optional findByIdAndDeletedAtIsNull(Long id); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM Product p WHERE p.id = :id AND p.deletedAt IS NULL") + Optional findByIdWithLock(@Param("id") Long id); + List findAllByIdInAndDeletedAtIsNull(Collection ids); Page findAllByDeletedAtIsNull(Pageable pageable); 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 6dff4a188..46df23631 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 @@ -30,6 +30,11 @@ public Optional findById(Long id) { return productJpaRepository.findByIdAndDeletedAtIsNull(id); } + @Override + public Optional findByIdWithLock(Long id) { + return productJpaRepository.findByIdWithLock(id); + } + @Override public List findAllByIds(Collection ids) { return productJpaRepository.findAllByIdInAndDeletedAtIsNull(ids); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1ApiSpec.java new file mode 100644 index 000000000..453fa2f5b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1ApiSpec.java @@ -0,0 +1,15 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Admin Order V1 API", description = "어드민 주문 API 입니다.") +public interface AdminOrderV1ApiSpec { + + @Operation(summary = "주문 목록 조회", description = "전체 주문 목록을 페이지 단위로 조회합니다.") + ApiResponse getAllOrders(int page, int size); + + @Operation(summary = "주문 상세 조회", description = "주문 상세 내역을 조회합니다.") + ApiResponse getOrderDetail(Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Controller.java new file mode 100644 index 000000000..990845897 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Controller.java @@ -0,0 +1,38 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderDetailInfo; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.domain.PageResult; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api-admin/v1/orders") +public class AdminOrderV1Controller implements AdminOrderV1ApiSpec { + + private final OrderFacade orderFacade; + + @GetMapping + @Override + public ApiResponse getAllOrders( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + PageResult result = orderFacade.getAllOrders(page, size); + return ApiResponse.success(AdminOrderV1Dto.OrderPageResponse.from(result)); + } + + @GetMapping("/{orderId}") + @Override + public ApiResponse getOrderDetail(@PathVariable Long orderId) { + OrderDetailInfo info = orderFacade.getOrderDetail(orderId); + return ApiResponse.success(AdminOrderV1Dto.OrderDetailResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Dto.java new file mode 100644 index 000000000..06a098364 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Dto.java @@ -0,0 +1,71 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderDetailInfo; +import com.loopers.application.order.OrderInfo; +import com.loopers.domain.PageResult; + +import java.time.ZonedDateTime; +import java.util.List; + +public class AdminOrderV1Dto { + + public record OrderResponse( + Long orderId, + Long userId, + int totalPrice, + String status, + ZonedDateTime createdAt + ) { + public static OrderResponse from(OrderInfo info) { + return new OrderResponse(info.orderId(), info.userId(), info.totalPrice(), info.status(), info.createdAt()); + } + } + + public record OrderDetailResponse( + Long orderId, + Long userId, + int totalPrice, + String status, + ZonedDateTime createdAt, + List items + ) { + public static OrderDetailResponse from(OrderDetailInfo info) { + List items = info.items().stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderDetailResponse( + info.orderId(), info.userId(), info.totalPrice(), info.status(), info.createdAt(), items + ); + } + } + + public record OrderItemResponse( + Long productId, + String productName, + int productPrice, + String brandName, + int quantity + ) { + public static OrderItemResponse from(OrderDetailInfo.OrderItemInfo info) { + return new OrderItemResponse( + info.productId(), info.productName(), info.productPrice(), + info.brandName(), info.quantity() + ); + } + } + + public record OrderPageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages + ) { + public static OrderPageResponse from(PageResult result) { + List content = result.items().stream() + .map(OrderResponse::from) + .toList(); + return new OrderPageResponse(content, result.page(), result.size(), result.totalElements(), result.totalPages()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java new file mode 100644 index 000000000..38d973c8f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -0,0 +1,24 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.domain.user.User; +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.time.LocalDate; + +@Tag(name = "Order V1 API", description = "주문 API 입니다.") +public interface OrderV1ApiSpec { + + @Operation(summary = "주문 요청", description = "상품을 직접 지정하여 주문합니다.") + ApiResponse createOrder(User user, OrderV1Dto.CreateOrderRequest request); + + @Operation(summary = "장바구니 주문", description = "장바구니의 모든 항목으로 주문합니다.") + ApiResponse createOrderFromCart(User user); + + @Operation(summary = "내 주문 목록 조회", description = "기간별 주문 목록을 조회합니다.") + ApiResponse getMyOrders(User user, LocalDate startAt, LocalDate endAt, int page, int size); + + @Operation(summary = "주문 상세 조회", description = "주문 상세 내역을 조회합니다.") + ApiResponse getMyOrderDetail(User user, Long orderId); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java new file mode 100644 index 000000000..0c1ada150 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,74 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderDetailInfo; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.domain.PageResult; +import com.loopers.domain.user.User; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthUser; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/orders") +public class OrderV1Controller implements OrderV1ApiSpec { + + private final OrderFacade orderFacade; + + @PostMapping + @Override + public ApiResponse createOrder( + @AuthUser User user, + @Valid @RequestBody OrderV1Dto.CreateOrderRequest request + ) { + List items = request.items().stream() + .map(i -> new OrderFacade.OrderItemRequest(i.productId(), i.quantity())) + .collect(Collectors.toList()); + OrderDetailInfo info = orderFacade.createOrder(user.getId(), items); + return ApiResponse.success(OrderV1Dto.OrderDetailResponse.from(info)); + } + + @PostMapping("/cart") + @Override + public ApiResponse createOrderFromCart(@AuthUser User user) { + OrderDetailInfo info = orderFacade.createOrderFromCart(user.getId()); + return ApiResponse.success(OrderV1Dto.OrderDetailResponse.from(info)); + } + + @GetMapping + @Override + public ApiResponse getMyOrders( + @AuthUser User user, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startAt, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endAt, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + PageResult result = orderFacade.getMyOrders(user.getId(), startAt, endAt, page, size); + return ApiResponse.success(OrderV1Dto.OrderPageResponse.from(result)); + } + + @GetMapping("/{orderId}") + @Override + public ApiResponse getMyOrderDetail( + @AuthUser User user, + @PathVariable Long orderId + ) { + OrderDetailInfo info = orderFacade.getMyOrderDetail(user.getId(), orderId); + return ApiResponse.success(OrderV1Dto.OrderDetailResponse.from(info)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java new file mode 100644 index 000000000..6ac1080ee --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,85 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderDetailInfo; +import com.loopers.application.order.OrderInfo; +import com.loopers.domain.PageResult; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +import java.time.ZonedDateTime; +import java.util.List; + +public class OrderV1Dto { + + public record CreateOrderRequest( + @NotEmpty(message = "주문 항목은 하나 이상이어야 합니다.") + @Valid + List items + ) {} + + public record OrderItemRequest( + @NotNull(message = "상품 ID는 필수입니다.") + Long productId, + + @Min(value = 1, message = "수량은 1 이상이어야 합니다.") + int quantity + ) {} + + public record OrderResponse( + Long orderId, + int totalPrice, + String status, + ZonedDateTime createdAt + ) { + public static OrderResponse from(OrderInfo info) { + return new OrderResponse(info.orderId(), info.totalPrice(), info.status(), info.createdAt()); + } + } + + public record OrderDetailResponse( + Long orderId, + int totalPrice, + String status, + ZonedDateTime createdAt, + List items + ) { + public static OrderDetailResponse from(OrderDetailInfo info) { + List items = info.items().stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderDetailResponse(info.orderId(), info.totalPrice(), info.status(), info.createdAt(), items); + } + } + + public record OrderItemResponse( + Long productId, + String productName, + int productPrice, + String brandName, + int quantity + ) { + public static OrderItemResponse from(OrderDetailInfo.OrderItemInfo info) { + return new OrderItemResponse( + info.productId(), info.productName(), info.productPrice(), + info.brandName(), info.quantity() + ); + } + } + + public record OrderPageResponse( + List content, + int page, + int size, + long totalElements, + int totalPages + ) { + public static OrderPageResponse from(PageResult result) { + List content = result.items().stream() + .map(OrderResponse::from) + .toList(); + return new OrderPageResponse(content, result.page(), result.size(), result.totalElements(), result.totalPages()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/cart/QuantityTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/QuantityTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/cart/QuantityTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/QuantityTest.java index 63315f847..ebf324a30 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/cart/QuantityTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/QuantityTest.java @@ -1,4 +1,4 @@ -package com.loopers.domain.cart; +package com.loopers.domain; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartItemTest.java index 41a2c93bc..2a0c03b29 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartItemTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartItemTest.java @@ -1,5 +1,6 @@ package com.loopers.domain.cart; +import com.loopers.domain.Quantity; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.DisplayName; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartServiceIntegrationTest.java index 6cd71e935..6aceafb8a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartServiceIntegrationTest.java @@ -1,5 +1,6 @@ package com.loopers.domain.cart; +import com.loopers.domain.Quantity; import com.loopers.domain.brand.BrandService; import com.loopers.domain.product.Money; import com.loopers.domain.product.Product; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java new file mode 100644 index 000000000..1bba869e3 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java @@ -0,0 +1,84 @@ +package com.loopers.domain.order; + +import com.loopers.domain.Quantity; +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OrderItemTest { + + @DisplayName("OrderItem을 생성할 때, ") + @Nested + class Create { + + @DisplayName("올바른 정보이면, OrderItem이 생성된다.") + @Test + void createsOrderItem_whenValidInfo() { + OrderItem orderItem = new OrderItem(1L, 100L, "에어맥스", new Money(129000), "나이키", 2); + + assertAll( + () -> assertThat(orderItem.getOrderId()).isEqualTo(1L), + () -> assertThat(orderItem.getProductId()).isEqualTo(100L), + () -> assertThat(orderItem.getProductName()).isEqualTo("에어맥스"), + () -> assertThat(orderItem.getProductPrice()).isEqualTo(new Money(129000)), + () -> assertThat(orderItem.getBrandName()).isEqualTo("나이키"), + () -> assertThat(orderItem.getQuantity()).isEqualTo(new Quantity(2)) + ); + } + + @DisplayName("orderId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenOrderIdIsNull() { + CoreException result = assertThrows(CoreException.class, + () -> new OrderItem(null, 100L, "에어맥스", new Money(129000), "나이키", 2)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("productId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenProductIdIsNull() { + CoreException result = assertThrows(CoreException.class, + () -> new OrderItem(1L, null, "에어맥스", new Money(129000), "나이키", 2)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("상품 이름이 비어있으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenProductNameIsBlank() { + CoreException result = assertThrows(CoreException.class, + () -> new OrderItem(1L, 100L, "", new Money(129000), "나이키", 2)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("상품 가격이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenProductPriceIsNull() { + CoreException result = assertThrows(CoreException.class, + () -> new OrderItem(1L, 100L, "에어맥스", null, "나이키", 2)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("브랜드 이름이 비어있으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenBrandNameIsBlank() { + CoreException result = assertThrows(CoreException.class, + () -> new OrderItem(1L, 100L, "에어맥스", new Money(129000), "", 2)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("수량이 0이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenQuantityIsZero() { + CoreException result = assertThrows(CoreException.class, + () -> new OrderItem(1L, 100L, "에어맥스", new Money(129000), "나이키", 0)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java new file mode 100644 index 000000000..726f3a7cb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java @@ -0,0 +1,178 @@ +package com.loopers.domain.order; + +import com.loopers.domain.PageResult; +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +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 java.time.ZonedDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest +class OrderServiceIntegrationTest { + + @Autowired + private OrderService orderService; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private Order createTestOrder(Long userId, int totalPrice) { + List items = List.of( + new OrderItemCommand(1L, "에어맥스", new Money(129000), "나이키", 2) + ); + return orderService.createOrder(userId, new Money(totalPrice), items); + } + + @DisplayName("주문을 생성할 때, ") + @Nested + class CreateOrder { + + @DisplayName("올바른 정보이면, 주문과 주문 항목이 생성된다.") + @Test + void createsOrderAndItems_whenValidInfo() { + List items = List.of( + new OrderItemCommand(1L, "에어맥스", new Money(129000), "나이키", 2), + new OrderItemCommand(2L, "에어포스1", new Money(109000), "나이키", 1) + ); + + Order order = orderService.createOrder(1L, new Money(367000), items); + + assertAll( + () -> assertThat(order.getId()).isNotNull(), + () -> assertThat(order.getUserId()).isEqualTo(1L), + () -> assertThat(order.getTotalPrice()).isEqualTo(new Money(367000)), + () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.ORDERED) + ); + + List orderItems = orderService.getOrderItems(order.getId()); + assertAll( + () -> assertThat(orderItems).hasSize(2), + () -> assertThat(orderItems.get(0).getProductName()).isEqualTo("에어맥스"), + () -> assertThat(orderItems.get(0).getBrandName()).isEqualTo("나이키"), + () -> assertThat(orderItems.get(1).getProductName()).isEqualTo("에어포스1") + ); + } + } + + @DisplayName("주문을 ID로 조회할 때, ") + @Nested + class GetById { + + @DisplayName("존재하는 주문이면, 주문을 반환한다.") + @Test + void returnsOrder_whenOrderExists() { + Order created = createTestOrder(1L, 258000); + + Order result = orderService.getById(created.getId()); + + assertThat(result.getId()).isEqualTo(created.getId()); + } + + @DisplayName("존재하지 않는 주문이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenOrderDoesNotExist() { + CoreException result = assertThrows(CoreException.class, + () -> orderService.getById(999L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("유저의 주문을 조회할 때, ") + @Nested + class GetByIdAndUserId { + + @DisplayName("본인의 주문이면, 주문을 반환한다.") + @Test + void returnsOrder_whenOwner() { + Order created = createTestOrder(1L, 258000); + + Order result = orderService.getByIdAndUserId(created.getId(), 1L); + + assertThat(result.getId()).isEqualTo(created.getId()); + } + + @DisplayName("다른 유저의 주문이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenNotOwner() { + Order created = createTestOrder(1L, 258000); + + CoreException result = assertThrows(CoreException.class, + () -> orderService.getByIdAndUserId(created.getId(), 999L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("내 주문 목록을 조회할 때, ") + @Nested + class GetMyOrders { + + @DisplayName("기간 내 주문이 있으면, 목록을 반환한다.") + @Test + void returnsOrders_whenOrdersExistInRange() { + createTestOrder(1L, 258000); + createTestOrder(1L, 109000); + createTestOrder(2L, 50000); + + ZonedDateTime start = ZonedDateTime.now().minusDays(1); + ZonedDateTime end = ZonedDateTime.now().plusDays(1); + + PageResult result = orderService.getMyOrders(1L, start, end, 0, 20); + + assertAll( + () -> assertThat(result.items()).hasSize(2), + () -> assertThat(result.totalElements()).isEqualTo(2) + ); + } + + @DisplayName("기간 내 주문이 없으면, 빈 목록을 반환한다.") + @Test + void returnsEmpty_whenNoOrdersInRange() { + createTestOrder(1L, 258000); + + ZonedDateTime start = ZonedDateTime.now().plusDays(1); + ZonedDateTime end = ZonedDateTime.now().plusDays(2); + + PageResult result = orderService.getMyOrders(1L, start, end, 0, 20); + + assertThat(result.items()).isEmpty(); + } + } + + @DisplayName("전체 주문 목록을 조회할 때, ") + @Nested + class GetAllOrders { + + @DisplayName("주문이 존재하면, 페이지 결과를 반환한다.") + @Test + void returnsPageResult_whenOrdersExist() { + createTestOrder(1L, 258000); + createTestOrder(2L, 109000); + createTestOrder(3L, 50000); + + PageResult result = orderService.getAllOrders(0, 2); + + assertAll( + () -> assertThat(result.items()).hasSize(2), + () -> assertThat(result.totalElements()).isEqualTo(3), + () -> assertThat(result.totalPages()).isEqualTo(2) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java new file mode 100644 index 000000000..8b4ec96a1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -0,0 +1,48 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OrderTest { + + @DisplayName("Order를 생성할 때, ") + @Nested + class Create { + + @DisplayName("올바른 정보이면, Order가 생성된다.") + @Test + void createsOrder_whenValidInfo() { + Order order = new Order(1L, new Money(50000)); + + assertAll( + () -> assertThat(order.getUserId()).isEqualTo(1L), + () -> assertThat(order.getTotalPrice()).isEqualTo(new Money(50000)), + () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.ORDERED) + ); + } + + @DisplayName("userId가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenUserIdIsNull() { + CoreException result = assertThrows(CoreException.class, + () -> new Order(null, new Money(50000))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("totalPrice가 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenTotalPriceIsNull() { + CoreException result = assertThrows(CoreException.class, + () -> new Order(1L, null)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminOrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminOrderV1ApiE2ETest.java new file mode 100644 index 000000000..db619f91f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/AdminOrderV1ApiE2ETest.java @@ -0,0 +1,181 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.brand.AdminBrandV1Dto; +import com.loopers.interfaces.api.order.AdminOrderV1Dto; +import com.loopers.interfaces.api.order.OrderV1Dto; +import com.loopers.interfaces.api.product.AdminProductV1Dto; +import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +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.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class AdminOrderV1ApiE2ETest { + + private static final String ADMIN_ORDER_ENDPOINT = "/api-admin/v1/orders"; + private static final String ORDER_ENDPOINT = "/api/v1/orders"; + private static final String ADMIN_BRAND_ENDPOINT = "/api-admin/v1/brands"; + private static final String ADMIN_PRODUCT_ENDPOINT = "/api-admin/v1/products"; + private static final String SIGNUP_ENDPOINT = "/api/v1/users"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public AdminOrderV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + private Long productId; + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private HttpHeaders authHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testUser1"); + headers.set("X-Loopers-LoginPw", "Abcd1234!"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private void signupUser() { + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + "testUser1", "Abcd1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com" + ); + testRestTemplate.exchange(SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference>() {}); + } + + @BeforeEach + void setUp() { + signupUser(); + + AdminBrandV1Dto.CreateRequest brandRequest = new AdminBrandV1Dto.CreateRequest("나이키"); + ResponseEntity> brandResponse = testRestTemplate.exchange( + ADMIN_BRAND_ENDPOINT, HttpMethod.POST, new HttpEntity<>(brandRequest, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + Long brandId = brandResponse.getBody().data().id(); + + AdminProductV1Dto.CreateRequest productRequest = new AdminProductV1Dto.CreateRequest(brandId, "에어맥스", 129000, 100); + ResponseEntity> productResponse = testRestTemplate.exchange( + ADMIN_PRODUCT_ENDPOINT, HttpMethod.POST, new HttpEntity<>(productRequest, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + productId = productResponse.getBody().data().id(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private OrderV1Dto.OrderDetailResponse createOrder(int quantity) { + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + List.of(new OrderV1Dto.OrderItemRequest(productId, quantity)) + ); + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data(); + } + + @DisplayName("GET /api-admin/v1/orders") + @Nested + class GetAllOrders { + + @DisplayName("주문이 존재하면, 페이지 결과를 반환한다.") + @Test + void returnsPageResult_whenOrdersExist() { + createOrder(2); + createOrder(1); + createOrder(3); + + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ORDER_ENDPOINT + "?page=0&size=2", HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().content()).hasSize(2), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(3), + () -> assertThat(response.getBody().data().totalPages()).isEqualTo(2), + () -> assertThat(response.getBody().data().content().get(0).userId()).isNotNull() + ); + } + + @DisplayName("어드민 인증이 없으면, 401 UNAUTHORIZED를 반환한다.") + @Test + void returnsUnauthorized_whenNotAdmin() { + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ORDER_ENDPOINT, HttpMethod.GET, new HttpEntity<>(new HttpHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("GET /api-admin/v1/orders/{orderId}") + @Nested + class GetOrderDetail { + + @DisplayName("존재하는 주문이면, 주문 상세를 반환한다.") + @Test + void returnsOrderDetail_whenOrderExists() { + OrderV1Dto.OrderDetailResponse created = createOrder(2); + + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ORDER_ENDPOINT + "/" + created.orderId(), HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().orderId()).isEqualTo(created.orderId()), + () -> assertThat(response.getBody().data().userId()).isNotNull(), + () -> assertThat(response.getBody().data().items()).hasSize(1), + () -> assertThat(response.getBody().data().items().get(0).productName()).isEqualTo("에어맥스"), + () -> assertThat(response.getBody().data().items().get(0).brandName()).isEqualTo("나이키") + ); + } + + @DisplayName("존재하지 않는 주문이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenOrderDoesNotExist() { + ResponseEntity> response = testRestTemplate.exchange( + ADMIN_ORDER_ENDPOINT + "/999", HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java new file mode 100644 index 000000000..58af71b91 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/OrderV1ApiE2ETest.java @@ -0,0 +1,354 @@ +package com.loopers.interfaces.api; + +import com.loopers.interfaces.api.brand.AdminBrandV1Dto; +import com.loopers.interfaces.api.cart.CartV1Dto; +import com.loopers.interfaces.api.order.OrderV1Dto; +import com.loopers.interfaces.api.product.AdminProductV1Dto; +import com.loopers.interfaces.api.user.UserV1Dto; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +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.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class OrderV1ApiE2ETest { + + private static final String ORDER_ENDPOINT = "/api/v1/orders"; + private static final String CART_ITEMS_ENDPOINT = "/api/v1/cart/items"; + private static final String ADMIN_BRAND_ENDPOINT = "/api-admin/v1/brands"; + private static final String ADMIN_PRODUCT_ENDPOINT = "/api-admin/v1/products"; + private static final String SIGNUP_ENDPOINT = "/api/v1/users"; + + private final TestRestTemplate testRestTemplate; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public OrderV1ApiE2ETest(TestRestTemplate testRestTemplate, DatabaseCleanUp databaseCleanUp) { + this.testRestTemplate = testRestTemplate; + this.databaseCleanUp = databaseCleanUp; + } + + private Long productId; + private Long productId2; + + private HttpHeaders adminHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-Ldap", "loopers.admin"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private HttpHeaders authHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Loopers-LoginId", "testUser1"); + headers.set("X-Loopers-LoginPw", "Abcd1234!"); + headers.set("Content-Type", "application/json"); + return headers; + } + + private void signupUser() { + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + "testUser1", "Abcd1234!", "홍길동", LocalDate.of(1995, 3, 15), "test@example.com" + ); + testRestTemplate.exchange(SIGNUP_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference>() {}); + } + + @BeforeEach + void setUp() { + signupUser(); + + AdminBrandV1Dto.CreateRequest brandRequest = new AdminBrandV1Dto.CreateRequest("나이키"); + ResponseEntity> brandResponse = testRestTemplate.exchange( + ADMIN_BRAND_ENDPOINT, HttpMethod.POST, new HttpEntity<>(brandRequest, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + Long brandId = brandResponse.getBody().data().id(); + + AdminProductV1Dto.CreateRequest productRequest1 = new AdminProductV1Dto.CreateRequest(brandId, "에어맥스", 129000, 100); + ResponseEntity> productResponse1 = testRestTemplate.exchange( + ADMIN_PRODUCT_ENDPOINT, HttpMethod.POST, new HttpEntity<>(productRequest1, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + productId = productResponse1.getBody().data().id(); + + AdminProductV1Dto.CreateRequest productRequest2 = new AdminProductV1Dto.CreateRequest(brandId, "에어포스1", 109000, 200); + ResponseEntity> productResponse2 = testRestTemplate.exchange( + ADMIN_PRODUCT_ENDPOINT, HttpMethod.POST, new HttpEntity<>(productRequest2, adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + productId2 = productResponse2.getBody().data().id(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private OrderV1Dto.OrderDetailResponse createOrder(Long pId, int quantity) { + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + List.of(new OrderV1Dto.OrderItemRequest(pId, quantity)) + ); + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference<>() {} + ); + return response.getBody().data(); + } + + @DisplayName("POST /api/v1/orders") + @Nested + class CreateOrder { + + @DisplayName("올바른 주문 요청이면, 주문 상세 정보를 반환한다.") + @Test + void returnsOrderDetail_whenValidRequest() { + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + List.of( + new OrderV1Dto.OrderItemRequest(productId, 2), + new OrderV1Dto.OrderItemRequest(productId2, 1) + ) + ); + + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().orderId()).isNotNull(), + () -> assertThat(response.getBody().data().totalPrice()).isEqualTo(129000 * 2 + 109000), + () -> assertThat(response.getBody().data().status()).isEqualTo("ORDERED"), + () -> assertThat(response.getBody().data().items()).hasSize(2) + ); + } + + @DisplayName("주문 후 상품 재고가 차감된다.") + @Test + void deductsStock_whenOrderSucceeds() { + createOrder(productId, 3); + + ResponseEntity> productResponse = testRestTemplate.exchange( + ADMIN_PRODUCT_ENDPOINT + "/" + productId, HttpMethod.GET, new HttpEntity<>(adminHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(productResponse.getBody().data().stock()).isEqualTo(97); + } + + @DisplayName("중복된 상품이 포함되면, 400 BAD_REQUEST를 반환한다.") + @Test + void returnsBadRequest_whenDuplicateProducts() { + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + List.of( + new OrderV1Dto.OrderItemRequest(productId, 2), + new OrderV1Dto.OrderItemRequest(productId, 3) + ) + ); + + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("재고가 부족하면, 400 BAD_REQUEST를 반환한다.") + @Test + void returnsBadRequest_whenStockInsufficient() { + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + List.of(new OrderV1Dto.OrderItemRequest(productId, 999)) + ); + + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + + @DisplayName("존재하지 않는 상품이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenProductDoesNotExist() { + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + List.of(new OrderV1Dto.OrderItemRequest(999L, 1)) + ); + + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @DisplayName("인증되지 않은 사용자이면, 401 UNAUTHORIZED를 반환한다.") + @Test + void returnsUnauthorized_whenNotAuthenticated() { + OrderV1Dto.CreateOrderRequest request = new OrderV1Dto.CreateOrderRequest( + List.of(new OrderV1Dto.OrderItemRequest(productId, 1)) + ); + + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + } + + @DisplayName("POST /api/v1/orders/cart") + @Nested + class CreateOrderFromCart { + + @DisplayName("장바구니에 항목이 있으면, 주문이 생성되고 장바구니가 비워진다.") + @Test + void createsOrderAndClearsCart_whenCartHasItems() { + // Add items to cart + CartV1Dto.AddRequest cartRequest1 = new CartV1Dto.AddRequest(productId, 2); + testRestTemplate.exchange( + CART_ITEMS_ENDPOINT, HttpMethod.POST, new HttpEntity<>(cartRequest1, authHeaders()), + new ParameterizedTypeReference>() {} + ); + CartV1Dto.AddRequest cartRequest2 = new CartV1Dto.AddRequest(productId2, 1); + testRestTemplate.exchange( + CART_ITEMS_ENDPOINT, HttpMethod.POST, new HttpEntity<>(cartRequest2, authHeaders()), + new ParameterizedTypeReference>() {} + ); + + // Create order from cart + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT + "/cart", HttpMethod.POST, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().totalPrice()).isEqualTo(129000 * 2 + 109000), + () -> assertThat(response.getBody().data().items()).hasSize(2) + ); + + // Verify cart is empty + ResponseEntity> cartResponse = testRestTemplate.exchange( + "/api/v1/cart", HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + assertThat(cartResponse.getBody().data().items()).isEmpty(); + } + + @DisplayName("장바구니가 비어있으면, 400 BAD_REQUEST를 반환한다.") + @Test + void returnsBadRequest_whenCartIsEmpty() { + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT + "/cart", HttpMethod.POST, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + } + } + + @DisplayName("GET /api/v1/orders") + @Nested + class GetMyOrders { + + @DisplayName("기간 내 주문이 있으면, 주문 목록을 반환한다.") + @Test + void returnsOrders_whenOrdersExistInRange() { + createOrder(productId, 2); + createOrder(productId2, 1); + + String today = LocalDate.now().toString(); + String tomorrow = LocalDate.now().plusDays(1).toString(); + + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT + "?startAt=" + today + "&endAt=" + tomorrow, + HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().content()).hasSize(2), + () -> assertThat(response.getBody().data().totalElements()).isEqualTo(2) + ); + } + + @DisplayName("기간 외 주문이면, 빈 목록을 반환한다.") + @Test + void returnsEmpty_whenNoOrdersInRange() { + createOrder(productId, 2); + + String futureStart = LocalDate.now().plusDays(10).toString(); + String futureEnd = LocalDate.now().plusDays(20).toString(); + + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT + "?startAt=" + futureStart + "&endAt=" + futureEnd, + HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().content()).isEmpty() + ); + } + } + + @DisplayName("GET /api/v1/orders/{orderId}") + @Nested + class GetMyOrderDetail { + + @DisplayName("본인의 주문이면, 주문 상세를 반환한다.") + @Test + void returnsOrderDetail_whenOwner() { + OrderV1Dto.OrderDetailResponse created = createOrder(productId, 2); + + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT + "/" + created.orderId(), HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody().data().orderId()).isEqualTo(created.orderId()), + () -> assertThat(response.getBody().data().items()).hasSize(1), + () -> assertThat(response.getBody().data().items().get(0).productName()).isEqualTo("에어맥스"), + () -> assertThat(response.getBody().data().items().get(0).brandName()).isEqualTo("나이키") + ); + } + + @DisplayName("존재하지 않는 주문이면, 404 NOT_FOUND를 반환한다.") + @Test + void returnsNotFound_whenOrderDoesNotExist() { + ResponseEntity> response = testRestTemplate.exchange( + ORDER_ENDPOINT + "/999", HttpMethod.GET, new HttpEntity<>(authHeaders()), + new ParameterizedTypeReference<>() {} + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + } +} From c851bce41fe19d3dc09129a76dc1729d0bee6138 Mon Sep 17 00:00:00 2001 From: yoon-yoo-tak Date: Thu, 19 Feb 2026 00:07:42 +0900 Subject: [PATCH 08/11] =?UTF-8?q?refactor=20:=20DDD=20/=20Layered=EB=A5=BC?= =?UTF-8?q?=20=EC=A7=80=ED=82=A4=EB=8F=84=EB=A1=9D=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .docs/02-sequence-diagrams.md | 264 ++++++++++-------- .docs/03-class-diagram.md | 5 +- .docs/api-spec.md | 128 +++++++++ .../brand/BrandApplicationService.java | 58 ++++ .../application/brand/BrandFacade.java | 46 --- .../loopers/application/brand/BrandInfo.java | 17 -- .../cart/CartApplicationService.java | 39 +++ .../loopers/application/cart/CartFacade.java | 66 ----- .../loopers/application/cart/CartInfo.java | 25 -- .../like/LikeApplicationService.java | 49 ++++ .../loopers/application/like/LikeFacade.java | 63 ----- .../loopers/application/like/LikeInfo.java | 29 -- .../order/OrderApplicationService.java | 132 +++++++++ .../application/order/OrderDetailInfo.java | 48 ---- .../application/order/OrderFacade.java | 147 ---------- .../loopers/application/order/OrderInfo.java | 23 -- .../product/ProductApplicationService.java | 54 ++++ .../application/product/ProductFacade.java | 64 ----- .../application/product/ProductInfo.java | 32 --- ...acade.java => UserApplicationService.java} | 16 +- .../loopers/application/user/UserInfo.java | 27 -- .../loopers/config/DomainServiceConfig.java | 37 ++- .../java/com/loopers/domain/brand/Brand.java | 2 +- ...ndService.java => BrandDomainService.java} | 4 +- ...artService.java => CartDomainService.java} | 2 +- ...ikeService.java => LikeDomainService.java} | 2 +- .../java/com/loopers/domain/order/Order.java | 28 +- .../domain/order/OrderDomainService.java | 88 ++++++ .../com/loopers/domain/order/OrderItem.java | 44 ++- .../domain/order/OrderItemRepository.java | 13 - .../loopers/domain/order/OrderLineItem.java | 3 + .../loopers/domain/order/OrderRepository.java | 2 + .../loopers/domain/order/OrderService.java | 57 ---- .../com/loopers/domain/product/Product.java | 11 +- ...Service.java => ProductDomainService.java} | 18 +- .../domain/product/ProductRepository.java | 4 - ...serService.java => UserDomainService.java} | 2 +- .../order/OrderItemJpaRepository.java | 14 - .../order/OrderItemRepositoryImpl.java | 31 -- .../order/OrderJpaRepository.java | 5 + .../order/OrderRepositoryImpl.java | 5 + .../product/ProductJpaRepository.java | 11 +- .../product/ProductRepositoryImpl.java | 10 - .../user/UserJpaRepository.java | 4 +- .../interfaces/api/ApiControllerAdvice.java | 8 +- .../loopers/interfaces/api/ApiResponse.java | 2 +- .../api/auth/AuthUserArgumentResolver.java | 6 +- .../api/brand/AdminBrandV1Controller.java | 24 +- .../interfaces/api/brand/AdminBrandV1Dto.java | 8 +- .../api/brand/BrandV1Controller.java | 10 +- .../interfaces/api/brand/BrandV1Dto.java | 6 +- .../interfaces/api/cart/CartV1ApiSpec.java | 6 +- .../interfaces/api/cart/CartV1Controller.java | 54 +++- .../interfaces/api/cart/CartV1Dto.java | 21 +- .../interfaces/api/like/LikeV1ApiSpec.java | 4 +- .../interfaces/api/like/LikeV1Controller.java | 50 +++- .../interfaces/api/like/LikeV1Dto.java | 15 +- .../api/order/AdminOrderV1Controller.java | 13 +- .../interfaces/api/order/AdminOrderV1Dto.java | 22 +- .../api/order/OrderV1Controller.java | 29 +- .../interfaces/api/order/OrderV1Dto.java | 22 +- .../api/product/AdminProductV1Controller.java | 40 ++- .../api/product/AdminProductV1Dto.java | 17 +- .../api/product/ProductV1Controller.java | 26 +- .../interfaces/api/product/ProductV1Dto.java | 15 +- .../interfaces/api/user/UserV1Controller.java | 18 +- .../interfaces/api/user/UserV1Dto.java | 24 +- .../com/loopers/support/error/ErrorType.java | 13 +- .../java/com/loopers/ArchitectureTest.java | 107 +++++++ ...=> BrandDomainServiceIntegrationTest.java} | 4 +- .../com/loopers/domain/brand/BrandTest.java | 10 +- ... => CartDomainServiceIntegrationTest.java} | 12 +- ... => LikeDomainServiceIntegrationTest.java} | 14 +- ...=> OrderDomainServiceIntegrationTest.java} | 92 ++++-- .../loopers/domain/order/OrderItemTest.java | 27 +- ... ProductDomainServiceIntegrationTest.java} | 54 +++- .../loopers/domain/product/ProductTest.java | 54 ++-- ... => UserDomainServiceIntegrationTest.java} | 4 +- build.gradle.kts | 1 + gradle.properties | 1 + 80 files changed, 1411 insertions(+), 1151 deletions(-) create mode 100644 .docs/api-spec.md create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApplicationService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/cart/CartApplicationService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/cart/CartFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/cart/CartInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderDetailInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductApplicationService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java rename apps/commerce-api/src/main/java/com/loopers/application/user/{UserFacade.java => UserApplicationService.java} (51%) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java rename apps/commerce-api/src/main/java/com/loopers/domain/brand/{BrandService.java => BrandDomainService.java} (95%) rename apps/commerce-api/src/main/java/com/loopers/domain/cart/{CartService.java => CartDomainService.java} (98%) rename apps/commerce-api/src/main/java/com/loopers/domain/like/{LikeService.java => LikeDomainService.java} (96%) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderLineItem.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java rename apps/commerce-api/src/main/java/com/loopers/domain/product/{ProductService.java => ProductDomainService.java} (77%) rename apps/commerce-api/src/main/java/com/loopers/domain/user/{UserService.java => UserDomainService.java} (98%) delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/ArchitectureTest.java rename apps/commerce-api/src/test/java/com/loopers/domain/brand/{BrandServiceIntegrationTest.java => BrandDomainServiceIntegrationTest.java} (98%) rename apps/commerce-api/src/test/java/com/loopers/domain/cart/{CartServiceIntegrationTest.java => CartDomainServiceIntegrationTest.java} (95%) rename apps/commerce-api/src/test/java/com/loopers/domain/like/{LikeServiceIntegrationTest.java => LikeDomainServiceIntegrationTest.java} (91%) rename apps/commerce-api/src/test/java/com/loopers/domain/order/{OrderServiceIntegrationTest.java => OrderDomainServiceIntegrationTest.java} (62%) rename apps/commerce-api/src/test/java/com/loopers/domain/product/{ProductServiceIntegrationTest.java => ProductDomainServiceIntegrationTest.java} (81%) rename apps/commerce-api/src/test/java/com/loopers/domain/user/{UserServiceIntegrationTest.java => UserDomainServiceIntegrationTest.java} (98%) diff --git a/.docs/02-sequence-diagrams.md b/.docs/02-sequence-diagrams.md index fe40def5a..0b4b63a59 100644 --- a/.docs/02-sequence-diagrams.md +++ b/.docs/02-sequence-diagrams.md @@ -9,40 +9,47 @@ > 시나리오 2.2 — 고객이 마음에 드는 상품에 좋아요를 누른다. -> 취소도 같은 흐름이라고 생각하면 된다. +> 취소도 같은 흐름이라고 생각하면 된다. (incrementLikeCount → decrementLikeCount) ```mermaid sequenceDiagram actor 고객 participant LikeV1Controller - participant LikeFacade - participant ProductService - participant LikeService + participant LikeApplicationService + participant ProductDomainService + participant LikeDomainService participant Like participant LikeRepository + participant ProductRepository Note right of 고객: 인증된 고객 - 고객->>LikeV1Controller: 좋아요 등록 요청 - LikeV1Controller->>LikeFacade: 좋아요 등록 + 고객->>+LikeV1Controller: 좋아요 등록 요청 + LikeV1Controller->>+LikeApplicationService: 좋아요 등록 - LikeFacade->>ProductService: 상품 조회 + LikeApplicationService->>+ProductDomainService: 상품 조회 alt 상품이 존재하지 않거나 삭제됨 - ProductService-->>고객: 실패 + ProductDomainService-->>고객: 실패 end + ProductDomainService-->>-LikeApplicationService: 상품 - LikeFacade->>LikeService: 좋아요 등록 - LikeService->>LikeRepository: 중복 좋아요 확인 + LikeApplicationService->>+LikeDomainService: 좋아요 등록 + LikeDomainService->>+LikeRepository: 중복 좋아요 확인 + LikeRepository-->>-LikeDomainService: 결과 alt 이미 좋아요한 상품 - LikeService-->>고객: 실패 + LikeDomainService-->>고객: 실패 end - LikeService->>Like: 좋아요 생성 - LikeService->>LikeRepository: 좋아요 저장 + LikeDomainService->>+Like: 좋아요 생성 + Like-->>-LikeDomainService: 좋아요 + LikeDomainService->>+LikeRepository: 좋아요 저장 + LikeRepository-->>-LikeDomainService: 완료 + LikeDomainService->>+ProductRepository: 좋아요 수 증가 (원자적 UPDATE) + ProductRepository-->>-LikeDomainService: 완료 - LikeService-->>LikeFacade: 결과 반환 - LikeFacade-->>LikeV1Controller: 결과 반환 - LikeV1Controller-->>고객: 성공 + LikeDomainService-->>-LikeApplicationService: 결과 반환 + LikeApplicationService-->>-LikeV1Controller: 결과 반환 + LikeV1Controller-->>-고객: 성공 ``` --- @@ -59,169 +66,204 @@ sequenceDiagram sequenceDiagram actor 고객 participant CartV1Controller - participant CartFacade - participant ProductService - participant CartService + participant CartApplicationService + participant ProductDomainService + participant CartDomainService participant CartItem participant CartRepository Note right of 고객: 인증된 고객 - 고객->>CartV1Controller: 장바구니 담기 요청 - CartV1Controller->>CartFacade: 장바구니 담기 + 고객->>+CartV1Controller: 장바구니 담기 요청 + CartV1Controller->>+CartApplicationService: 장바구니 담기 - CartFacade->>ProductService: 상품 조회 + CartApplicationService->>+ProductDomainService: 상품 조회 alt 상품이 존재하지 않거나 삭제됨 - ProductService-->>고객: 실패 + ProductDomainService-->>고객: 실패 end + ProductDomainService-->>-CartApplicationService: 상품 - CartFacade->>CartService: 장바구니에 상품 담기 - CartService->>CartRepository: 기존 장바구니 항목 조회 + CartApplicationService->>+CartDomainService: 장바구니에 상품 담기 + CartDomainService->>+CartRepository: 기존 장바구니 항목 조회 + CartRepository-->>-CartDomainService: 항목 alt 이미 담긴 상품 - CartService->>CartItem: 수량 합산 + CartDomainService->>+CartItem: 수량 합산 + CartItem-->>-CartDomainService: 완료 else 새로운 상품 - CartService->>CartItem: 항목 생성 + CartDomainService->>+CartItem: 항목 생성 + CartItem-->>-CartDomainService: 항목 end - CartService->>CartRepository: 저장 + CartDomainService->>+CartRepository: 저장 + CartRepository-->>-CartDomainService: 완료 - CartService-->>CartFacade: 결과 반환 - CartFacade-->>CartV1Controller: 결과 반환 - CartV1Controller-->>고객: 성공 + CartDomainService-->>-CartApplicationService: 결과 반환 + CartApplicationService-->>-CartV1Controller: 결과 반환 + CartV1Controller-->>-고객: 성공 ``` --- -## 장바구니에서 주문하기 +## 주문하기 -> 시나리오 2.3 / 2.4 — 고객이 장바구니의 모든 항목을 한 번에 주문한다. +> 시나리오 2.4 - 고객은 여러 상품을 한 번에 주문한다. 주문 후 자신의 주문 내역을 조회할 수 있다. **다이어그램이 필요한 이유** -- 도메인 간 협력: Cart → Product → Order 세 도메인이 협력 -- 조건 분기: 장바구니 비어있음, 상품 유효성, 재고 부족 -- 주문 성공 후 장바구니 비우기까지 하나의 트랜잭션 +- 조건 분기: 상품 유효성 검증, 재고 부족 검증, 중복 상품 검증 +- 도메인 간 협력: 주문이 상품과 브랜드의 상태를 확인해야 한다 +- 도메인 책임: 재고 차감은 Product, 중복 검증과 금액 계산은 OrderDomainService의 책임 ```mermaid sequenceDiagram actor 고객 participant OrderV1Controller - participant OrderFacade - participant CartService - participant ProductService + participant OrderApplicationService + participant ProductDomainService participant Product - participant OrderService + participant BrandDomainService + participant OrderDomainService participant Order Note right of 고객: 인증된 고객 - 고객->>OrderV1Controller: 장바구니 주문 요청 - OrderV1Controller->>OrderFacade: 장바구니 주문 - - OrderFacade->>CartService: 장바구니 조회 - alt 장바구니가 비어있음 - CartService-->>고객: 실패 + 고객->>+OrderV1Controller: 주문 요청 + OrderV1Controller->>+OrderApplicationService: 주문 요청 + + loop 각 주문 항목 (productId 순으로 정렬) + OrderApplicationService->>+ProductDomainService: 상품 조회 (비관적 락) + alt 상품이 존재하지 않거나 삭제됨 + ProductDomainService-->>고객: 실패 + end + ProductDomainService-->>-OrderApplicationService: 상품 + OrderApplicationService->>+Product: 재고 차감 + alt 재고 부족 + Product-->>고객: 실패 + end + Product-->>-OrderApplicationService: 완료 end - OrderFacade->>ProductService: 상품 유효성 확인 - alt 판매 불가 상품 존재 - ProductService-->>고객: 실패 - end + OrderApplicationService->>+BrandDomainService: 브랜드 정보 조회 (스냅샷용) + BrandDomainService-->>-OrderApplicationService: 브랜드 목록 - OrderFacade->>ProductService: 재고 확인 및 차감 - ProductService->>Product: 재고 차감 - alt 재고 부족 - ProductService-->>고객: 실패 + OrderApplicationService->>+OrderDomainService: 주문 생성 (스냅샷 데이터 전달) + OrderDomainService->>OrderDomainService: 중복 상품 검증 + alt 중복 상품 존재 + OrderDomainService-->>고객: 실패 end + OrderDomainService->>OrderDomainService: 총 금액 계산 + OrderDomainService->>+Order: 주문 및 주문 항목 생성 + Order-->>-OrderDomainService: 주문 - OrderFacade->>OrderService: 주문 생성 (스냅샷 포함) - OrderService->>Order: 주문 생성 - - OrderFacade->>CartService: 장바구니 비우기 - - OrderFacade-->>OrderV1Controller: 결과 반환 - OrderV1Controller-->>고객: 성공 + OrderDomainService-->>-OrderApplicationService: 결과 반환 + OrderApplicationService-->>-OrderV1Controller: 결과 반환 + OrderV1Controller-->>-고객: 성공 ``` --- -## 브랜드 삭제 (연쇄 삭제) +## 장바구니에서 주문하기 -> 시나리오 2.5 — 어드민이 브랜드를 삭제한다. 이때 해당 브랜드의 모든 상품도 함께 삭제된다. +> 시나리오 2.3 / 2.4 — 고객이 장바구니의 모든 항목을 한 번에 주문한다. **다이어그램이 필요한 이유** -- 도메인 간 협력: Brand 삭제가 Product 연쇄 삭제를 트리거한다 -- 삭제 순서: 상품을 먼저 삭제한 뒤 브랜드를 삭제해야 정합성이 유지된다 +- 도메인 간 협력: Cart → Product → Brand → Order 네 도메인이 협력 +- 조건 분기: 장바구니 비어있음, 상품 유효성, 재고 부족 +- 주문 성공 후 장바구니 비우기까지 하나의 트랜잭션 ```mermaid sequenceDiagram - actor 어드민 - participant AdminBrandV1Controller - participant BrandFacade - participant BrandService - participant ProductService - participant Brand + actor 고객 + participant OrderV1Controller + participant OrderApplicationService + participant CartDomainService + participant ProductDomainService participant Product + participant BrandDomainService + participant OrderDomainService + participant Order - Note right of 어드민: 인증된 어드민 + Note right of 고객: 인증된 고객 - 어드민->>AdminBrandV1Controller: 브랜드 삭제 요청 - AdminBrandV1Controller->>BrandFacade: 브랜드 삭제 + 고객->>+OrderV1Controller: 장바구니 주문 요청 + OrderV1Controller->>+OrderApplicationService: 장바구니 주문 - BrandFacade->>BrandService: 브랜드 조회 - alt 브랜드가 존재하지 않거나 삭제됨 - BrandService-->>어드민: 실패 + OrderApplicationService->>+CartDomainService: 장바구니 조회 + CartDomainService-->>-OrderApplicationService: 장바구니 항목 + alt 장바구니가 비어있음 + OrderApplicationService-->>고객: 실패 + end + + loop 각 장바구니 항목 (productId 순으로 정렬) + OrderApplicationService->>+ProductDomainService: 상품 조회 (비관적 락) + alt 상품이 존재하지 않거나 삭제됨 + ProductDomainService-->>고객: 실패 + end + ProductDomainService-->>-OrderApplicationService: 상품 + OrderApplicationService->>+Product: 재고 차감 + alt 재고 부족 + Product-->>고객: 실패 + end + Product-->>-OrderApplicationService: 완료 end - BrandFacade->>ProductService: 해당 브랜드의 상품 전체 삭제 - ProductService->>Product: 논리 삭제 (soft delete) + OrderApplicationService->>+BrandDomainService: 브랜드 정보 조회 (스냅샷용) + BrandDomainService-->>-OrderApplicationService: 브랜드 목록 - BrandFacade->>BrandService: 브랜드 삭제 - BrandService->>Brand: 논리 삭제 (soft delete) + OrderApplicationService->>+OrderDomainService: 주문 생성 (스냅샷 데이터 전달) + OrderDomainService->>OrderDomainService: 중복 상품 검증 + OrderDomainService->>OrderDomainService: 총 금액 계산 + OrderDomainService->>+Order: 주문 및 주문 항목 생성 + Order-->>-OrderDomainService: 주문 - BrandFacade-->>AdminBrandV1Controller: 결과 반환 - AdminBrandV1Controller-->>어드민: 성공 + OrderDomainService-->>-OrderApplicationService: 결과 반환 + + OrderApplicationService->>+CartDomainService: 장바구니 비우기 + CartDomainService-->>-OrderApplicationService: 완료 + + OrderApplicationService-->>-OrderV1Controller: 결과 반환 + OrderV1Controller-->>-고객: 성공 ``` --- -### 주문하기 +## 브랜드 삭제 (연쇄 삭제) -> 시나리오 2.4 - 고객은 여러 상품을 한 번에 주문한다. 주문 후 자신의 주문 내역을 조회할 수 있다. +> 시나리오 2.5 — 어드민이 브랜드를 삭제한다. 이때 해당 브랜드의 모든 상품도 함께 삭제된다. **다이어그램이 필요한 이유** -- 조건 분기: 상품 유효성 검증, 재고 부족 검증 -- 도메인 간 협력: 주문이 상품의 상태/재고를 확인해야 한다 -- 도메인 책임: 가격 정보 제공은 Product, 금액 계산은 Order의 책임 +- 도메인 간 협력: Brand 삭제가 Product 연쇄 삭제를 트리거한다 +- 삭제 순서: 상품을 먼저 삭제한 뒤 브랜드를 삭제해야 정합성이 유지된다 ```mermaid sequenceDiagram - actor 고객 - participant OrderV1Controller - participant OrderFacade - participant ProductService + actor 어드민 + participant AdminBrandV1Controller + participant BrandApplicationService + participant BrandDomainService + participant ProductDomainService + participant Brand participant Product - participant OrderService - participant Order - Note right of 고객: 인증된 고객 + Note right of 어드민: 인증된 어드민 - 고객->>OrderV1Controller: 주문 요청 - OrderV1Controller->>OrderFacade: 주문 요청 + 어드민->>+AdminBrandV1Controller: 브랜드 삭제 요청 + AdminBrandV1Controller->>+BrandApplicationService: 브랜드 삭제 - OrderFacade->>ProductService: 상품 유효성 확인 - alt 판매 불가 상품 존재 - ProductService-->>고객: 실패 + BrandApplicationService->>+BrandDomainService: 브랜드 조회 + alt 브랜드가 존재하지 않거나 삭제됨 + BrandDomainService-->>어드민: 실패 end + BrandDomainService-->>-BrandApplicationService: 브랜드 - OrderFacade->>ProductService: 재고 확인 및 차감 - ProductService->>Product: 재고 차감 - alt 재고 부족 - ProductService-->>고객: 실패 - end + BrandApplicationService->>+ProductDomainService: 해당 브랜드의 상품 전체 삭제 + ProductDomainService->>+Product: 논리 삭제 (soft delete) + Product-->>-ProductDomainService: 완료 + ProductDomainService-->>-BrandApplicationService: 완료 - OrderFacade->>OrderService: 주문 생성 - OrderService->>Order: 주문 생성 (스냅샷 포함) + BrandApplicationService->>+BrandDomainService: 브랜드 삭제 + BrandDomainService->>+Brand: 논리 삭제 (soft delete) + Brand-->>-BrandDomainService: 완료 + BrandDomainService-->>-BrandApplicationService: 완료 - OrderService-->>OrderFacade: 결과 반환 - OrderFacade-->>OrderV1Controller: 결과 반환 - OrderV1Controller-->>고객: 성공 -``` \ No newline at end of file + BrandApplicationService-->>-AdminBrandV1Controller: 결과 반환 + AdminBrandV1Controller-->>-어드민: 성공 +``` diff --git a/.docs/03-class-diagram.md b/.docs/03-class-diagram.md index a5d774d5d..5215ec7a1 100644 --- a/.docs/03-class-diagram.md +++ b/.docs/03-class-diagram.md @@ -30,8 +30,6 @@ classDiagram int likeCount +update(String, Money, Stock) void +deductStock(int) void - +addLikeCount() void - +subtractLikeCount() void } class Like { @@ -103,7 +101,6 @@ classDiagram |---|---|---| | User | changePassword(Password) | 새 Password VO로 교체 | | Product | deductStock(int) | 재고 부족 시 CoreException(BAD_REQUEST) | -| Product | addLikeCount() / subtractLikeCount() | 좋아요 등록/취소 시 카운터 증감 | | CartItem | addQuantity(int) | 이미 담긴 상품 → 수량 합산 | | CartItem | updateQuantity(int) | 수량 변경, 0 이하 불가 | @@ -128,7 +125,7 @@ classDiagram - **Rich Domain Model**: 비즈니스 로직은 엔티티 메서드에 포함한다. Facade는 오케스트레이션만 담당한다. - **FK 미사용**: 모든 관계는 ID 참조만. FK 제약조건 없음. 참조 무결성은 애플리케이션 레벨에서 검증한다. - **Cart 엔티티 없음**: CartItem만 사용. User가 곧 Cart 소유자이다. -- **좋아요 수 비정규화**: Product에 likeCount 필드로 저장. 좋아요 등록/취소 시 카운터를 증감한다. +- **좋아요 수 비정규화**: Product에 likeCount 필드로 저장. LikeService에서 좋아요 등록/취소 시 원자적 UPDATE(`ProductRepository.incrementLikeCount/decrementLikeCount`)로 카운터를 증감한다. - **N:M 관계**: Like, CartItem 교차 테이블로 해소한다. - **likes, cart_items 물리 삭제**: 이력이 필요 없는 토글/임시 데이터이므로 Soft Delete 대신 물리 삭제 처리. UNIQUE 제약조건과의 충돌을 방지한다. - **order_items의 deleted_at 유지**: 주문 항목은 삭제 시나리오가 없으나, BaseEntity 상속 일관성을 위해 deleted_at을 유지한다. diff --git a/.docs/api-spec.md b/.docs/api-spec.md new file mode 100644 index 000000000..39b55014f --- /dev/null +++ b/.docs/api-spec.md @@ -0,0 +1,128 @@ +## ✅ API 제안사항 + +- 대고객 기능은 `/api/v1` prefix 를 통해 제공합니다. + + ```markdown + 유저 로그인이 필요한 기능은 아래 헤더를 통해 유저를 식별해 제공합니다. + 인증/인가는 주요 스코프가 아니므로 구현하지 않습니다. + 유저는 타 유저의 정보에 직접 접근할 수 없습니다. + + * **X-Loopers-LoginId** : 로그인 ID + * **X-Loopers-LoginPw** : 비밀번호 + ``` + +- 어드민 기능은 `/api-admin/v1` prefix 를 통해 제공합니다. + + ```markdown + 어드민 기능은 아래 헤더를 통해 어드민을 식별해 제공합니다. + + * **X-Loopers-Ldap** : loopers.admin + + LDAP : Lightweight Directory Access Protocol + 중앙 집중형 사용자 인증, 정보 검색, 액세스 제어. + -> 회사 사내 어드민 + ``` + + +## ✅ 요구사항 + +## 👤 유저 (Users) + +| **METHOD** | **URI** | **user_required** | **설명** | +| --- | --- | --- | --- | +| POST | `/api/v1/users` | X | 회원가입 | +| GET | `/api/v1/users/me` | O | 내 정보 조회 | +| PUT | `/api/v1/users/password` | O | 비밀번호 변경 | + +--- + +## 🏷 브랜드 & 상품 (Brands / Products) + +| **METHOD** | **URI** | **user_required** | **설명** | +| --- | --- | --- | --- | +| GET | `/api/v1/brands/{brandId}` | X | 브랜드 정보 조회 | +| GET | `/api/v1/products` | X | 상품 목록 조회 | +| GET | `/api/v1/products/{productId}` | X | 상품 정보 조회 | + +### ✅ 상품 목록 조회 쿼리 파라미터 + +| **파라미터** | **예시** | **설명** | +| --- | --- | --- | +| `brandId` | `1` | 특정 브랜드의 상품만 필터링 | +| `sort` | `latest` / `price_asc` / `likes_desc` | 정렬 기준 | +| `page` | `0` | 페이지 번호 (기본값 0) | +| `size` | `20` | 페이지당 상품 수 (기본값 20) | + +> 💡 정렬 기준은 선택 구현입니다. +> +> +> 필수는 `latest`, 그 외는 `price_asc`, `likes_desc` 정도로 제한해도 충분합니다. +> + +--- + +## 🏷 브랜드 & 상품 ADMIN + +| **METHOD** | **URI** | **ldap_required** | **설명** | +| --- | --- | --- | --- | +| GET | `/api-admin/v1/brands?page=0&size=20` | O | **등록된 브랜드 목록 조회** | +| GET | `/api-admin/v1/brands/{brandId}` | O | **브랜드 상세 조회** | +| POST | `/api-admin/v1/brands` | O | **브랜드 등록** | +| PUT | `/api-admin/v1/brands/{brandId}` | O | **브랜드 정보 수정** | +| DELETE | `/api-admin/v1/brands/{brandId}` | O | **브랜드 삭제** +* 브랜드 제거 시, 해당 브랜드의 상품들도 삭제되어야 함 | +| GET | `/api-admin/v1/products?page=0&size=20&brandId={brandId}` | O | **등록된 상품 목록 조회** | +| GET | `/api-admin/v1/products/{productId}` | O | **상품 상세 조회** | +| POST | `/api-admin/v1/products` | O | **상품 등록** +* 상품의 브랜드는 이미 등록된 브랜드여야 함 | +| PUT | `/api-admin/v1/products/{productId}` | O | **상품 정보 수정** +* 상품의 브랜드는 수정할 수 없음 | +| DELETE | `/api-admin/v1/products/{productId}` | O | **상품 삭제** | + +> 상품, 브랜드 정보 중 고객과 어드민에게 제공되어야 할 정보에 대해 고민해보세요. +> + +--- + +## ❤️ 좋아요 (Likes) + +| **METHOD** | **URI** | **user_required** | **설명** | +| --- | --- | --- | --- | +| POST | `/api/v1/products/{productId}/likes` | O | 상품 좋아요 등록 | +| DELETE | `/api/v1/products/{productId}/likes` | O | 상품 좋아요 취소 | +| GET | `/api/v1/likes` | O | 내가 좋아요 한 상품 목록 조회 | + +--- + +## 🧾 주문 (Orders) + +| **METHOD** | **URI** | **user_required** | **설명** | +| --- | --- | --- | --- | +| POST | `/api/v1/orders` | O | 주문 요청 | +| GET | `/api/v1/orders?startAt=2026-01-31&endAt=2026-02-10` | O | 유저의 주문 목록 조회 | +| GET | `/api/v1/orders/{orderId}` | O | 단일 주문 상세 조회 | + +**요청 예시:** + +```json +{ + "items": [ + { "productId": 1, "quantity": 2 }, + { "productId": 3, "quantity": 1 } + ] +} +``` + +> **결제**는 과정 진행 중, **추가로 개발**하게 됩니다! +**주문 정보**에는 당시의 상품 정보가 스냅샷으로 저장되어야 합니다. +**주문 시에 다음 동작이 보장되어야 합니다 :** 상품 재고 확인 및 차감 +> + +--- + +## 🧾 주문 ADMIN + +| **METHOD** | **URI** | **ldap_required** | **설명** | +| --- | --- | --- | --- | +| GET | `/api-admin/v1/orders?page=0&size=20` | O | 주문 목록 조회 | +| GET | `/api-admin/v1/orders/{orderId}` | O | 단일 주문 상세 조회 | \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApplicationService.java new file mode 100644 index 000000000..74faf0a32 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApplicationService.java @@ -0,0 +1,58 @@ +package com.loopers.application.brand; + +import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandDomainService; +import com.loopers.domain.product.ProductDomainService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; +import java.util.Set; + +@RequiredArgsConstructor +@Component +public class BrandApplicationService { + + private final BrandDomainService brandService; + private final ProductDomainService productService; + + @Transactional + public Brand register(String name) { + return brandService.register(name); + } + + @Transactional(readOnly = true) + public Brand getById(Long id) { + return brandService.getById(id); + } + + @Transactional(readOnly = true) + public Map getByIds(Set ids) { + return brandService.getByIds(ids); + } + + @Transactional(readOnly = true) + public PageResult getAll(int page, int size) { + return brandService.getAll(page, size); + } + + @Transactional + public Brand update(Long id, String name) { + return brandService.update(id, name); + } + + /** + * 단일 트랜잭션에서 Product aggregate와 Brand aggregate를 함께 수정한다. + * "하나의 트랜잭션 = 하나의 Aggregate" 원칙의 의도적 예외: + * 브랜드 삭제 시 소속 상품이 남아있으면 orphan 데이터가 발생하므로 + * 참조 무결성을 위해 원자적으로 처리한다. + * 추후 Event로 처리 + */ + @Transactional + public void delete(Long id) { + productService.deleteAllByBrandId(id); + brandService.delete(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java deleted file mode 100644 index 2300f57c3..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.loopers.application.brand; - -import com.loopers.domain.PageResult; -import com.loopers.domain.brand.BrandService; -import com.loopers.domain.brand.Brand; -import com.loopers.domain.product.ProductService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@RequiredArgsConstructor -@Component -public class BrandFacade { - - private final BrandService brandService; - private final ProductService productService; - - @Transactional - public BrandInfo register(String name) { - Brand brand = brandService.register(name); - return BrandInfo.from(brand); - } - - public BrandInfo getById(Long id) { - Brand brand = brandService.getById(id); - return BrandInfo.from(brand); - } - - public PageResult getAll(int page, int size) { - PageResult result = brandService.getAll(page, size); - return result.map(BrandInfo::from); - } - - @Transactional - public BrandInfo update(Long id, String name) { - Brand brand = brandService.update(id, name); - return BrandInfo.from(brand); - } - - @Transactional - public void delete(Long id) { - brandService.getById(id); - productService.deleteAllByBrandId(id); - brandService.delete(id); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java deleted file mode 100644 index bdc8ad203..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.loopers.application.brand; - -import com.loopers.domain.brand.Brand; - -import java.time.ZonedDateTime; - -public record BrandInfo(Long id, String name, ZonedDateTime createdAt, ZonedDateTime updatedAt) { - - public static BrandInfo from(Brand brand) { - return new BrandInfo( - brand.getId(), - brand.getName(), - brand.getCreatedAt(), - brand.getUpdatedAt() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartApplicationService.java new file mode 100644 index 000000000..1bcb39058 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartApplicationService.java @@ -0,0 +1,39 @@ +package com.loopers.application.cart; + +import com.loopers.domain.cart.CartItem; +import com.loopers.domain.cart.CartDomainService; +import com.loopers.domain.product.ProductDomainService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class CartApplicationService { + + private final CartDomainService cartService; + private final ProductDomainService productService; + + @Transactional + public void addToCart(Long userId, Long productId, int quantity) { + productService.getById(productId); + cartService.addToCart(userId, productId, quantity); + } + + @Transactional(readOnly = true) + public List getMyCart(Long userId) { + return cartService.getCartItems(userId); + } + + @Transactional + public void updateQuantity(Long cartItemId, Long userId, int quantity) { + cartService.updateQuantity(cartItemId, userId, quantity); + } + + @Transactional + public void removeItem(Long cartItemId, Long userId) { + cartService.removeItem(cartItemId, userId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartFacade.java deleted file mode 100644 index f4ef73dd3..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartFacade.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.loopers.application.cart; - -import com.loopers.domain.brand.Brand; -import com.loopers.domain.brand.BrandService; -import com.loopers.domain.cart.CartItem; -import com.loopers.domain.cart.CartService; -import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -@RequiredArgsConstructor -@Component -public class CartFacade { - - private final CartService cartService; - private final ProductService productService; - private final BrandService brandService; - - @Transactional - public void addToCart(Long userId, Long productId, int quantity) { - productService.getById(productId); - cartService.addToCart(userId, productId, quantity); - } - - @Transactional(readOnly = true) - public List getMyCart(Long userId) { - List cartItems = cartService.getCartItems(userId); - - Set productIds = cartItems.stream() - .map(CartItem::getProductId) - .collect(Collectors.toSet()); - - Map productMap = productService.getByIds(productIds); - - Set brandIds = productMap.values().stream() - .map(Product::getBrandId) - .collect(Collectors.toSet()); - Map brandMap = brandService.getByIds(brandIds); - - return cartItems.stream() - .filter(cartItem -> productMap.containsKey(cartItem.getProductId())) - .map(cartItem -> { - Product product = productMap.get(cartItem.getProductId()); - Brand brand = brandMap.get(product.getBrandId()); - return CartInfo.from(cartItem, product, brand); - }) - .toList(); - } - - @Transactional - public void updateQuantity(Long cartItemId, Long userId, int quantity) { - cartService.updateQuantity(cartItemId, userId, quantity); - } - - @Transactional - public void removeItem(Long cartItemId, Long userId) { - cartService.removeItem(cartItemId, userId); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartInfo.java deleted file mode 100644 index 19a6ab0f8..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartInfo.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.loopers.application.cart; - -import com.loopers.domain.brand.Brand; -import com.loopers.domain.cart.CartItem; -import com.loopers.domain.product.Product; - -public record CartInfo( - Long cartItemId, - Long productId, - String productName, - String brandName, - int price, - int quantity -) { - public static CartInfo from(CartItem cartItem, Product product, Brand brand) { - return new CartInfo( - cartItem.getId(), - product.getId(), - product.getName(), - brand.getName(), - product.getPrice().amount(), - cartItem.getQuantity().value() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java new file mode 100644 index 000000000..f8b5ca675 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java @@ -0,0 +1,49 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeDomainService; +import com.loopers.domain.product.ProductDomainService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class LikeApplicationService { + + private final LikeDomainService likeService; + private final ProductDomainService productService; + + /** + * 단일 트랜잭션에서 Like aggregate와 Product aggregate를 함께 수정한다. + * "하나의 트랜잭션 = 하나의 Aggregate" 원칙의 의도적 예외: + * likeCount는 비정규화 카운터이며, Like 엔티티가 source of truth이다. + * 일관성과 단순성을 위해 동일 트랜잭션에서 원자적으로 처리한다. + */ + @Transactional + public void like(Long userId, Long productId) { + productService.getById(productId); + likeService.like(userId, productId); + productService.incrementLikeCount(productId); + } + + /** + * 단일 트랜잭션에서 Like aggregate와 Product aggregate를 함께 수정한다. + * "하나의 트랜잭션 = 하나의 Aggregate" 원칙의 의도적 예외: + * likeCount는 비정규화 카운터이며, Like 엔티티가 source of truth이다. + * 일관성과 단순성을 위해 동일 트랜잭션에서 원자적으로 처리한다. + */ + @Transactional + public void unlike(Long userId, Long productId) { + productService.getById(productId); + likeService.unlike(userId, productId); + productService.decrementLikeCount(productId); + } + + @Transactional(readOnly = true) + public List getMyLikes(Long userId) { + return likeService.getMyLikes(userId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java deleted file mode 100644 index b168b6ada..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.loopers.application.like; - -import com.loopers.domain.brand.Brand; -import com.loopers.domain.brand.BrandService; -import com.loopers.domain.like.Like; -import com.loopers.domain.like.LikeService; -import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -@RequiredArgsConstructor -@Component -public class LikeFacade { - - private final LikeService likeService; - private final ProductService productService; - private final BrandService brandService; - - @Transactional - public void like(Long userId, Long productId) { - productService.getById(productId); - likeService.like(userId, productId); - productService.incrementLikeCount(productId); - } - - @Transactional - public void unlike(Long userId, Long productId) { - productService.getById(productId); - likeService.unlike(userId, productId); - productService.decrementLikeCount(productId); - } - - public List getMyLikes(Long userId) { - List likes = likeService.getMyLikes(userId); - - Set productIds = likes.stream() - .map(Like::getProductId) - .collect(Collectors.toSet()); - - Map productMap = productService.getByIds(productIds); - - Set brandIds = productMap.values().stream() - .map(Product::getBrandId) - .collect(Collectors.toSet()); - Map brandMap = brandService.getByIds(brandIds); - - return likes.stream() - .filter(like -> productMap.containsKey(like.getProductId())) - .map(like -> { - Product product = productMap.get(like.getProductId()); - Brand brand = brandMap.get(product.getBrandId()); - return LikeInfo.from(like, product, brand); - }) - .toList(); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java deleted file mode 100644 index e3d02474a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.loopers.application.like; - -import com.loopers.domain.brand.Brand; -import com.loopers.domain.like.Like; -import com.loopers.domain.product.Product; - -import java.time.ZonedDateTime; - -public record LikeInfo( - Long likeId, - Long productId, - String productName, - String brandName, - int price, - int likeCount, - ZonedDateTime likedAt -) { - public static LikeInfo from(Like like, Product product, Brand brand) { - return new LikeInfo( - like.getId(), - product.getId(), - product.getName(), - brand.getName(), - product.getPrice().amount(), - product.getLikeCount(), - like.getCreatedAt() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java new file mode 100644 index 000000000..710655a36 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java @@ -0,0 +1,132 @@ +package com.loopers.application.order; + +import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandDomainService; +import com.loopers.domain.cart.CartItem; +import com.loopers.domain.cart.CartDomainService; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItemCommand; +import com.loopers.domain.order.OrderLineItem; +import com.loopers.domain.order.OrderDomainService; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductDomainService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class OrderApplicationService { + + private final OrderDomainService orderService; + private final ProductDomainService productService; + private final BrandDomainService brandService; + private final CartDomainService cartService; + + @Transactional + public Order createOrder(Long userId, List items) { + return processOrder(userId, items); + } + + /** + * 단일 트랜잭션에서 Cart aggregate(비우기), Product aggregate(재고 차감), + * Order aggregate(생성)를 함께 수정한다. + * "하나의 트랜잭션 = 하나의 Aggregate" 원칙의 의도적 예외: + * 장바구니 기반 주문 시 재고 차감, 주문 생성, 장바구니 비우기를 + * 원자적으로 처리하여 일관성을 보장한다. + */ + @Transactional + public Order createOrderFromCart(Long userId) { + List cartItems = cartService.getCartItems(userId); + if (cartItems.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "장바구니가 비어있습니다."); + } + + List items = cartItems.stream() + .map(ci -> new OrderLineItem(ci.getProductId(), ci.getQuantity().value())) + .toList(); + + Order order = processOrder(userId, items); + + cartService.clearCart(userId); + + return order; + } + + @Transactional(readOnly = true) + public PageResult getMyOrders(Long userId, LocalDate startAt, LocalDate endAt, int page, int size) { + return orderService.getMyOrders(userId, startAt, endAt, page, size); + } + + @Transactional(readOnly = true) + public Order getMyOrder(Long userId, Long orderId) { + return orderService.getByIdAndUserIdWithItems(orderId, userId); + } + + @Transactional(readOnly = true) + public PageResult getAllOrders(int page, int size) { + return orderService.getAllOrders(page, size); + } + + @Transactional(readOnly = true) + public Order getOrder(Long orderId) { + return orderService.getByIdWithItems(orderId); + } + + /** + * 단일 트랜잭션에서 Product aggregate(재고 차감)와 Order aggregate(생성)를 함께 수정한다. + * "하나의 트랜잭션 = 하나의 Aggregate" 원칙의 의도적 예외: + * 재고 차감과 주문 생성은 원자적으로 처리되어야 하며, + * 분리 시 재고 불일치 또는 유령 주문이 발생할 수 있다. + */ + private Order processOrder(Long userId, List items) { + // Sort by productId to prevent deadlocks + List sortedItems = items.stream() + .sorted(Comparator.comparing(OrderLineItem::productId)) + .toList(); + + // Get products with pessimistic lock and deduct stock + List products = new ArrayList<>(); + for (OrderLineItem item : sortedItems) { + Product product = productService.deductStockWithLock(item.productId(), item.quantity()); + products.add(product); + } + + // Get brands for snapshots + Set brandIds = products.stream() + .map(Product::getBrandId) + .collect(Collectors.toSet()); + Map brandMap = brandService.getByIds(brandIds); + + // Build order item commands + List itemCommands = new ArrayList<>(); + for (int i = 0; i < sortedItems.size(); i++) { + OrderLineItem item = sortedItems.get(i); + Product product = products.get(i); + Brand brand = brandMap.get(product.getBrandId()); + if (brand == null) { + throw new CoreException(ErrorType.NOT_FOUND, + "브랜드를 찾을 수 없습니다. brandId=" + product.getBrandId()); + } + + itemCommands.add(new OrderItemCommand( + product.getId(), product.getName(), product.getPrice(), + brand.getName(), item.quantity() + )); + } + + // Create order (validation and price calculation handled by OrderDomainService) + return orderService.createOrder(userId, itemCommands); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderDetailInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderDetailInfo.java deleted file mode 100644 index ad3916711..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderDetailInfo.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.loopers.application.order; - -import com.loopers.domain.order.Order; -import com.loopers.domain.order.OrderItem; - -import java.time.ZonedDateTime; -import java.util.List; - -public record OrderDetailInfo( - Long orderId, - Long userId, - int totalPrice, - String status, - ZonedDateTime createdAt, - List items -) { - public static OrderDetailInfo from(Order order, List items) { - List itemInfos = items.stream() - .map(OrderItemInfo::from) - .toList(); - return new OrderDetailInfo( - order.getId(), - order.getUserId(), - order.getTotalPrice().amount(), - order.getStatus().name(), - order.getCreatedAt(), - itemInfos - ); - } - - public record OrderItemInfo( - Long productId, - String productName, - int productPrice, - String brandName, - int quantity - ) { - public static OrderItemInfo from(OrderItem item) { - return new OrderItemInfo( - item.getProductId(), - item.getProductName(), - item.getProductPrice().amount(), - item.getBrandName(), - item.getQuantity().value() - ); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java deleted file mode 100644 index a6bf943f0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ /dev/null @@ -1,147 +0,0 @@ -package com.loopers.application.order; - -import com.loopers.domain.PageResult; -import com.loopers.domain.brand.Brand; -import com.loopers.domain.brand.BrandService; -import com.loopers.domain.cart.CartItem; -import com.loopers.domain.cart.CartService; -import com.loopers.domain.order.Order; -import com.loopers.domain.order.OrderItem; -import com.loopers.domain.order.OrderItemCommand; -import com.loopers.domain.order.OrderService; -import com.loopers.domain.product.Money; -import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductService; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDate; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -@RequiredArgsConstructor -@Component -public class OrderFacade { - - private final OrderService orderService; - private final ProductService productService; - private final BrandService brandService; - private final CartService cartService; - - @Transactional - public OrderDetailInfo createOrder(Long userId, List items) { - if (items == null || items.isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 하나 이상이어야 합니다."); - } - return processOrder(userId, items); - } - - @Transactional - public OrderDetailInfo createOrderFromCart(Long userId) { - List cartItems = cartService.getCartItems(userId); - if (cartItems.isEmpty()) { - throw new CoreException(ErrorType.BAD_REQUEST, "장바구니가 비어있습니다."); - } - - List items = cartItems.stream() - .map(ci -> new OrderItemRequest(ci.getProductId(), ci.getQuantity().value())) - .toList(); - - OrderDetailInfo result = processOrder(userId, items); - - cartService.clearCart(userId); - - return result; - } - - @Transactional(readOnly = true) - public PageResult getMyOrders(Long userId, LocalDate startAt, LocalDate endAt, int page, int size) { - ZonedDateTime start = startAt.atStartOfDay(ZoneId.systemDefault()); - ZonedDateTime end = endAt.plusDays(1).atStartOfDay(ZoneId.systemDefault()); - PageResult orders = orderService.getMyOrders(userId, start, end, page, size); - return orders.map(OrderInfo::from); - } - - @Transactional(readOnly = true) - public OrderDetailInfo getMyOrderDetail(Long userId, Long orderId) { - Order order = orderService.getByIdAndUserId(orderId, userId); - List orderItems = orderService.getOrderItems(orderId); - return OrderDetailInfo.from(order, orderItems); - } - - @Transactional(readOnly = true) - public PageResult getAllOrders(int page, int size) { - PageResult orders = orderService.getAllOrders(page, size); - return orders.map(OrderInfo::from); - } - - @Transactional(readOnly = true) - public OrderDetailInfo getOrderDetail(Long orderId) { - Order order = orderService.getById(orderId); - List orderItems = orderService.getOrderItems(orderId); - return OrderDetailInfo.from(order, orderItems); - } - - private OrderDetailInfo processOrder(Long userId, List items) { - // Validate for duplicate product IDs - Set uniqueProductIds = new HashSet<>(); - for (OrderItemRequest item : items) { - if (!uniqueProductIds.add(item.productId())) { - throw new CoreException(ErrorType.BAD_REQUEST, "중복된 상품이 포함되어 있습니다."); - } - } - - // Sort by productId to prevent deadlocks - List sortedItems = items.stream() - .sorted(Comparator.comparing(OrderItemRequest::productId)) - .toList(); - - // Get products with pessimistic lock and deduct stock - List products = new ArrayList<>(); - for (OrderItemRequest item : sortedItems) { - Product product = productService.getByIdWithLock(item.productId()); - product.deductStock(item.quantity()); - products.add(product); - } - - // Get brands for snapshots - Set brandIds = products.stream() - .map(Product::getBrandId) - .collect(Collectors.toSet()); - Map brandMap = brandService.getByIds(brandIds); - - // Build order item commands and calculate total - Money totalPrice = new Money(0); - List itemCommands = new ArrayList<>(); - for (int i = 0; i < sortedItems.size(); i++) { - OrderItemRequest item = sortedItems.get(i); - Product product = products.get(i); - Brand brand = brandMap.get(product.getBrandId()); - - Money itemTotal = product.getPrice().multiply(item.quantity()); - totalPrice = totalPrice.plus(itemTotal); - - itemCommands.add(new OrderItemCommand( - product.getId(), product.getName(), product.getPrice(), - brand.getName(), item.quantity() - )); - } - - // Create order - Order order = orderService.createOrder(userId, totalPrice, itemCommands); - List orderItems = orderService.getOrderItems(order.getId()); - return OrderDetailInfo.from(order, orderItems); - } - - public record OrderItemRequest(Long productId, int quantity) {} -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java deleted file mode 100644 index 5003859cf..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.loopers.application.order; - -import com.loopers.domain.order.Order; - -import java.time.ZonedDateTime; - -public record OrderInfo( - Long orderId, - Long userId, - int totalPrice, - String status, - ZonedDateTime createdAt -) { - public static OrderInfo from(Order order) { - return new OrderInfo( - order.getId(), - order.getUserId(), - order.getTotalPrice().amount(), - order.getStatus().name(), - order.getCreatedAt() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApplicationService.java new file mode 100644 index 000000000..f42c58515 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApplicationService.java @@ -0,0 +1,54 @@ +package com.loopers.application.product; + +import com.loopers.domain.PageResult; +import com.loopers.domain.brand.BrandDomainService; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductDomainService; +import com.loopers.domain.product.ProductSortType; +import com.loopers.domain.product.Stock; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; +import java.util.Set; + +@RequiredArgsConstructor +@Component +public class ProductApplicationService { + + private final ProductDomainService productService; + private final BrandDomainService brandService; + + @Transactional + public Product register(Long brandId, String name, int price, int stock) { + brandService.getById(brandId); + return productService.register(brandId, name, new Money(price), new Stock(stock)); + } + + @Transactional(readOnly = true) + public Product getById(Long id) { + return productService.getById(id); + } + + @Transactional(readOnly = true) + public Map getByIds(Set ids) { + return productService.getByIds(ids); + } + + @Transactional(readOnly = true) + public PageResult getAll(Long brandId, ProductSortType sort, int page, int size) { + return productService.getAll(brandId, sort, page, size); + } + + @Transactional + public Product update(Long id, String name, int price, int stock) { + return productService.update(id, name, new Money(price), new Stock(stock)); + } + + @Transactional + public void delete(Long id) { + productService.delete(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java deleted file mode 100644 index a30734f6b..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.loopers.application.product; - -import com.loopers.domain.PageResult; -import com.loopers.domain.brand.Brand; -import com.loopers.domain.brand.BrandService; -import com.loopers.domain.product.Money; -import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductService; -import com.loopers.domain.product.ProductSortType; -import com.loopers.domain.product.Stock; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - -@RequiredArgsConstructor -@Component -public class ProductFacade { - - private final ProductService productService; - private final BrandService brandService; - - @Transactional - public ProductInfo register(Long brandId, String name, int price, int stock) { - Brand brand = brandService.getById(brandId); - Product product = productService.register(brandId, name, new Money(price), new Stock(stock)); - return ProductInfo.from(product, brand); - } - - public ProductInfo getById(Long id) { - Product product = productService.getById(id); - Brand brand = brandService.getById(product.getBrandId()); - return ProductInfo.from(product, brand); - } - - public PageResult getAll(Long brandId, ProductSortType sort, int page, int size) { - PageResult result = productService.getAll(brandId, sort, page, size); - - Set brandIds = result.items().stream() - .map(Product::getBrandId) - .collect(Collectors.toSet()); - Map brandMap = brandService.getByIds(brandIds); - - return result.map(product -> { - Brand brand = brandMap.get(product.getBrandId()); - return ProductInfo.from(product, brand); - }); - } - - @Transactional - public ProductInfo update(Long id, String name, int price, int stock) { - Product product = productService.update(id, name, new Money(price), new Stock(stock)); - Brand brand = brandService.getById(product.getBrandId()); - return ProductInfo.from(product, brand); - } - - @Transactional - public void delete(Long id) { - productService.delete(id); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java deleted file mode 100644 index d589e88ae..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.loopers.application.product; - -import com.loopers.domain.brand.Brand; -import com.loopers.domain.product.Product; - -import java.time.ZonedDateTime; - -public record ProductInfo( - Long id, - Long brandId, - String brandName, - String name, - int price, - int stock, - int likeCount, - ZonedDateTime createdAt, - ZonedDateTime updatedAt -) { - public static ProductInfo from(Product product, Brand brand) { - return new ProductInfo( - product.getId(), - product.getBrandId(), - brand.getName(), - product.getName(), - product.getPrice().amount(), - product.getStock().quantity(), - product.getLikeCount(), - product.getCreatedAt(), - product.getUpdatedAt() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserApplicationService.java similarity index 51% rename from apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java rename to apps/commerce-api/src/main/java/com/loopers/application/user/UserApplicationService.java index f7de628d2..eed8efa77 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserApplicationService.java @@ -1,7 +1,7 @@ package com.loopers.application.user; import com.loopers.domain.user.User; -import com.loopers.domain.user.UserService; +import com.loopers.domain.user.UserDomainService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -10,18 +10,18 @@ @RequiredArgsConstructor @Component -public class UserFacade { +public class UserApplicationService { - private final UserService userService; + private final UserDomainService userService; @Transactional - public UserInfo signup(String loginId, String rawPassword, String name, LocalDate birthDate, String email) { - User user = userService.signup(loginId, rawPassword, name, birthDate, email); - return UserInfo.from(user); + public User signup(String loginId, String rawPassword, String name, LocalDate birthDate, String email) { + return userService.signup(loginId, rawPassword, name, birthDate, email); } - public UserInfo getMyInfo(User user) { - return UserInfo.fromWithMaskedName(user); + @Transactional(readOnly = true) + public User authenticate(String loginId, String rawPassword) { + return userService.authenticate(loginId, rawPassword); } @Transactional diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java deleted file mode 100644 index dfaa81f00..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.loopers.application.user; - -import com.loopers.domain.user.User; - -import java.time.LocalDate; - -public record UserInfo(Long id, String loginId, String name, LocalDate birthDate, String email) { - public static UserInfo from(User user) { - return new UserInfo( - user.getId(), - user.getLoginId(), - user.getName(), - user.getBirthDate(), - user.getEmail() - ); - } - - public static UserInfo fromWithMaskedName(User user) { - return new UserInfo( - user.getId(), - user.getLoginId(), - user.getMaskedName(), - user.getBirthDate(), - user.getEmail() - ); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/DomainServiceConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/DomainServiceConfig.java index 457b83c24..ca70bf04c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/config/DomainServiceConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/config/DomainServiceConfig.java @@ -1,19 +1,18 @@ package com.loopers.config; +import com.loopers.domain.brand.BrandDomainService; import com.loopers.domain.brand.BrandRepository; -import com.loopers.domain.brand.BrandService; +import com.loopers.domain.cart.CartDomainService; import com.loopers.domain.cart.CartRepository; -import com.loopers.domain.cart.CartService; +import com.loopers.domain.like.LikeDomainService; import com.loopers.domain.like.LikeRepository; -import com.loopers.domain.like.LikeService; -import com.loopers.domain.order.OrderItemRepository; +import com.loopers.domain.order.OrderDomainService; import com.loopers.domain.order.OrderRepository; -import com.loopers.domain.order.OrderService; +import com.loopers.domain.product.ProductDomainService; import com.loopers.domain.product.ProductRepository; -import com.loopers.domain.product.ProductService; import com.loopers.domain.user.PasswordEncryptor; +import com.loopers.domain.user.UserDomainService; import com.loopers.domain.user.UserRepository; -import com.loopers.domain.user.UserService; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -21,32 +20,32 @@ public class DomainServiceConfig { @Bean - public UserService userService(UserRepository userRepository, PasswordEncryptor passwordEncryptor) { - return new UserService(userRepository, passwordEncryptor); + public UserDomainService userDomainService(UserRepository userRepository, PasswordEncryptor passwordEncryptor) { + return new UserDomainService(userRepository, passwordEncryptor); } @Bean - public BrandService brandService(BrandRepository brandRepository) { - return new BrandService(brandRepository); + public BrandDomainService brandDomainService(BrandRepository brandRepository) { + return new BrandDomainService(brandRepository); } @Bean - public ProductService productService(ProductRepository productRepository) { - return new ProductService(productRepository); + public ProductDomainService productDomainService(ProductRepository productRepository) { + return new ProductDomainService(productRepository); } @Bean - public LikeService likeService(LikeRepository likeRepository) { - return new LikeService(likeRepository); + public LikeDomainService likeDomainService(LikeRepository likeRepository) { + return new LikeDomainService(likeRepository); } @Bean - public CartService cartService(CartRepository cartRepository) { - return new CartService(cartRepository); + public CartDomainService cartDomainService(CartRepository cartRepository) { + return new CartDomainService(cartRepository); } @Bean - public OrderService orderService(OrderRepository orderRepository, OrderItemRepository orderItemRepository) { - return new OrderService(orderRepository, orderItemRepository); + public OrderDomainService orderDomainService(OrderRepository orderRepository) { + return new OrderDomainService(orderRepository); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java index e75da9e60..653ec42d5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/Brand.java @@ -21,7 +21,7 @@ public Brand(String name) { this.name = name; } - public void update(String name) { + public void rename(String name) { validateName(name); this.name = name; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandDomainService.java similarity index 95% rename from apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java rename to apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandDomainService.java index de0268fc1..e160c47a6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandDomainService.java @@ -11,7 +11,7 @@ import java.util.stream.Collectors; @RequiredArgsConstructor -public class BrandService { +public class BrandDomainService { private final BrandRepository brandRepository; @@ -35,7 +35,7 @@ public PageResult getAll(int page, int size) { public Brand update(Long id, String name) { Brand brand = getById(id); - brand.update(name); + brand.rename(name); return brandRepository.save(brand); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartService.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartDomainService.java similarity index 98% rename from apps/commerce-api/src/main/java/com/loopers/domain/cart/CartService.java rename to apps/commerce-api/src/main/java/com/loopers/domain/cart/CartDomainService.java index 0856eec65..22aac061e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartDomainService.java @@ -8,7 +8,7 @@ import java.util.Optional; @RequiredArgsConstructor -public class CartService { +public class CartDomainService { private final CartRepository cartRepository; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeDomainService.java similarity index 96% rename from apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java rename to apps/commerce-api/src/main/java/com/loopers/domain/like/LikeDomainService.java index a5924c20b..af20f09f5 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeDomainService.java @@ -7,7 +7,7 @@ import java.util.List; @RequiredArgsConstructor -public class LikeService { +public class LikeDomainService { private final LikeRepository likeRepository; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index 5b402e4d9..66548b5f9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -4,10 +4,18 @@ import com.loopers.domain.product.Money; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + @Entity @Table(name = "orders") public class Order extends BaseEntity { @@ -18,8 +26,12 @@ public class Order extends BaseEntity { @Column(name = "total_price", nullable = false) private int totalPrice; + @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false) - private String status; + private OrderStatus status; + + @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) + private List items = new ArrayList<>(); protected Order() {} @@ -27,12 +39,22 @@ public Order(Long userId, Money totalPrice) { validate(userId, totalPrice); this.userId = userId; this.totalPrice = totalPrice.amount(); - this.status = OrderStatus.ORDERED.name(); + this.status = OrderStatus.ORDERED; + } + + public void addItems(List commands) { + for (OrderItemCommand cmd : commands) { + this.items.add(new OrderItem( + this, cmd.productId(), cmd.productName(), + cmd.productPrice(), cmd.brandName(), cmd.quantity() + )); + } } public Long getUserId() { return userId; } public Money getTotalPrice() { return new Money(totalPrice); } - public OrderStatus getStatus() { return OrderStatus.valueOf(status); } + public OrderStatus getStatus() { return status; } + public List getItems() { return Collections.unmodifiableList(items); } private void validate(Long userId, Money totalPrice) { if (userId == null) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java new file mode 100644 index 000000000..5beba0193 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java @@ -0,0 +1,88 @@ +package com.loopers.domain.order; + +import com.loopers.domain.PageResult; +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@RequiredArgsConstructor +public class OrderDomainService { + + private final OrderRepository orderRepository; + + public Order createOrder(Long userId, List itemCommands) { + if (itemCommands == null || itemCommands.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 하나 이상이어야 합니다."); + } + + validateNoDuplicateProducts(itemCommands); + + Money totalPrice = calculateTotalPrice(itemCommands); + + Order order = new Order(userId, totalPrice); + order.addItems(itemCommands); + + return orderRepository.save(order); + } + + private void validateNoDuplicateProducts(List itemCommands) { + Set uniqueProductIds = new HashSet<>(); + for (OrderItemCommand cmd : itemCommands) { + if (!uniqueProductIds.add(cmd.productId())) { + throw new CoreException(ErrorType.BAD_REQUEST, "중복된 상품이 포함되어 있습니다."); + } + } + } + + private Money calculateTotalPrice(List itemCommands) { + Money total = new Money(0); + for (OrderItemCommand cmd : itemCommands) { + total = total.plus(cmd.productPrice().multiply(cmd.quantity())); + } + return total; + } + + public Order getById(Long id) { + return orderRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + } + + public Order getByIdWithItems(Long id) { + return orderRepository.findByIdWithItems(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); + } + + public Order getByIdAndUserId(Long id, Long userId) { + Order order = getById(id); + if (!order.getUserId().equals(userId)) { + throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."); + } + return order; + } + + public Order getByIdAndUserIdWithItems(Long id, Long userId) { + Order order = getByIdWithItems(id); + if (!order.getUserId().equals(userId)) { + throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."); + } + return order; + } + + public PageResult getMyOrders(Long userId, LocalDate startAt, LocalDate endAt, int page, int size) { + ZonedDateTime start = startAt.atStartOfDay(ZoneId.systemDefault()); + ZonedDateTime end = endAt.plusDays(1).atStartOfDay(ZoneId.systemDefault()); + return orderRepository.findByUserIdAndCreatedAtBetween(userId, start, end, page, size); + } + + public PageResult getAllOrders(int page, int size) { + return orderRepository.findAll(page, size); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java index 8da82ad63..af9d276dc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -1,20 +1,33 @@ package com.loopers.domain.order; -import com.loopers.domain.BaseEntity; import com.loopers.domain.Quantity; import com.loopers.domain.product.Money; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; import jakarta.persistence.Table; +import java.time.ZonedDateTime; + @Entity @Table(name = "order_items") -public class OrderItem extends BaseEntity { +public class OrderItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(name = "order_id", nullable = false) - private Long orderId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id", nullable = false) + private Order order; @Column(name = "product_id", nullable = false) private Long productId; @@ -31,11 +44,14 @@ public class OrderItem extends BaseEntity { @Column(name = "quantity", nullable = false) private int quantity; + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + protected OrderItem() {} - public OrderItem(Long orderId, Long productId, String productName, Money productPrice, String brandName, int quantity) { - validate(orderId, productId, productName, productPrice, brandName); - this.orderId = orderId; + OrderItem(Order order, Long productId, String productName, Money productPrice, String brandName, int quantity) { + validate(order, productId, productName, productPrice, brandName); + this.order = order; this.productId = productId; this.productName = productName; this.productPrice = productPrice.amount(); @@ -43,16 +59,22 @@ public OrderItem(Long orderId, Long productId, String productName, Money product this.quantity = new Quantity(quantity).value(); } - public Long getOrderId() { return orderId; } + public Long getId() { return id; } public Long getProductId() { return productId; } public String getProductName() { return productName; } public Money getProductPrice() { return new Money(productPrice); } public String getBrandName() { return brandName; } public Quantity getQuantity() { return new Quantity(quantity); } + public ZonedDateTime getCreatedAt() { return createdAt; } + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } - private void validate(Long orderId, Long productId, String productName, Money productPrice, String brandName) { - if (orderId == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "주문 ID는 필수입니다."); + private void validate(Order order, Long productId, String productName, Money productPrice, String brandName) { + if (order == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문은 필수입니다."); } if (productId == null) { throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다."); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java deleted file mode 100644 index 9ada41361..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemRepository.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.loopers.domain.order; - -import java.util.Collection; -import java.util.List; - -public interface OrderItemRepository { - - OrderItem save(OrderItem orderItem); - - List findAllByOrderId(Long orderId); - - List findAllByOrderIds(Collection orderIds); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderLineItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderLineItem.java new file mode 100644 index 000000000..91557301d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderLineItem.java @@ -0,0 +1,3 @@ +package com.loopers.domain.order; + +public record OrderLineItem(Long productId, int quantity) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java index 04165e621..2209e39af 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -11,6 +11,8 @@ public interface OrderRepository { Optional findById(Long id); + Optional findByIdWithItems(Long id); + PageResult findByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startAt, ZonedDateTime endAt, int page, int size); PageResult findAll(int page, int size); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java deleted file mode 100644 index 76e10f4ce..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.loopers.domain.order; - -import com.loopers.domain.PageResult; -import com.loopers.domain.product.Money; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; - -import java.time.ZonedDateTime; -import java.util.List; - -@RequiredArgsConstructor -public class OrderService { - - private final OrderRepository orderRepository; - private final OrderItemRepository orderItemRepository; - - public Order createOrder(Long userId, Money totalPrice, List itemCommands) { - Order order = new Order(userId, totalPrice); - Order savedOrder = orderRepository.save(order); - - List items = itemCommands.stream() - .map(cmd -> new OrderItem( - savedOrder.getId(), cmd.productId(), cmd.productName(), - cmd.productPrice(), cmd.brandName(), cmd.quantity() - )) - .toList(); - items.forEach(orderItemRepository::save); - - return savedOrder; - } - - public Order getById(Long id) { - return orderRepository.findById(id) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); - } - - public Order getByIdAndUserId(Long id, Long userId) { - Order order = getById(id); - if (!order.getUserId().equals(userId)) { - throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."); - } - return order; - } - - public List getOrderItems(Long orderId) { - return orderItemRepository.findAllByOrderId(orderId); - } - - public PageResult getMyOrders(Long userId, ZonedDateTime startAt, ZonedDateTime endAt, int page, int size) { - return orderRepository.findByUserIdAndCreatedAtBetween(userId, startAt, endAt, page, size); - } - - public PageResult getAllOrders(int page, int size) { - return orderRepository.findAll(page, size); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 9a2263134..23b236e81 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -37,7 +37,7 @@ public Product(Long brandId, String name, Money price, Stock stock) { this.likeCount = 0; } - public void update(String name, Money price, Stock stock) { + public void changeDetails(String name, Money price, Stock stock) { validate(this.brandId, name, price, stock); this.name = name; this.price = price.amount(); @@ -50,14 +50,15 @@ public void deductStock(int quantity) { this.stock = deducted.quantity(); } - public void addLikeCount() { + public void incrementLikeCount() { this.likeCount++; } - public void subtractLikeCount() { - if (this.likeCount > 0) { - this.likeCount--; + public void decrementLikeCount() { + if (this.likeCount <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "좋아요 수는 0 미만이 될 수 없습니다."); } + this.likeCount--; } public Long getBrandId() { return brandId; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java similarity index 77% rename from apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java rename to apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java index 08198359d..71c851f0b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java @@ -11,7 +11,7 @@ import java.util.stream.Collectors; @RequiredArgsConstructor -public class ProductService { +public class ProductDomainService { private final ProductRepository productRepository; @@ -40,7 +40,7 @@ public PageResult getAll(Long brandId, ProductSortType sort, int page, public Product update(Long id, String name, Money price, Stock stock) { Product product = getById(id); - product.update(name, price, stock); + product.changeDetails(name, price, stock); return productRepository.save(product); } @@ -54,11 +54,21 @@ public void deleteAllByBrandId(Long brandId) { productRepository.softDeleteAllByBrandId(brandId); } + public Product deductStockWithLock(Long productId, int quantity) { + Product product = getByIdWithLock(productId); + product.deductStock(quantity); + return product; + } + public void incrementLikeCount(Long productId) { - productRepository.incrementLikeCount(productId); + Product product = getByIdWithLock(productId); + product.incrementLikeCount(); + productRepository.save(product); } public void decrementLikeCount(Long productId) { - productRepository.decrementLikeCount(productId); + Product product = getByIdWithLock(productId); + product.decrementLikeCount(); + productRepository.save(product); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 63a9f71a2..e68f005ac 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -19,8 +19,4 @@ public interface ProductRepository { PageResult findAll(Long brandId, ProductSortType sort, int page, int size); void softDeleteAllByBrandId(Long brandId); - - void incrementLikeCount(Long productId); - - void decrementLikeCount(Long productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserDomainService.java similarity index 98% rename from apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java rename to apps/commerce-api/src/main/java/com/loopers/domain/user/UserDomainService.java index 30d3ee5c7..bab66edd7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserDomainService.java @@ -7,7 +7,7 @@ import java.time.LocalDate; @RequiredArgsConstructor -public class UserService { +public class UserDomainService { private final UserRepository userRepository; private final PasswordEncryptor passwordEncryptor; diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java deleted file mode 100644 index e927f8b57..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemJpaRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.loopers.infrastructure.order; - -import com.loopers.domain.order.OrderItem; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Collection; -import java.util.List; - -public interface OrderItemJpaRepository extends JpaRepository { - - List findAllByOrderIdAndDeletedAtIsNull(Long orderId); - - List findAllByOrderIdInAndDeletedAtIsNull(Collection orderIds); -} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java deleted file mode 100644 index 9383809d0..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderItemRepositoryImpl.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.loopers.infrastructure.order; - -import com.loopers.domain.order.OrderItem; -import com.loopers.domain.order.OrderItemRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Collection; -import java.util.List; - -@RequiredArgsConstructor -@Component -public class OrderItemRepositoryImpl implements OrderItemRepository { - - private final OrderItemJpaRepository orderItemJpaRepository; - - @Override - public OrderItem save(OrderItem orderItem) { - return orderItemJpaRepository.save(orderItem); - } - - @Override - public List findAllByOrderId(Long orderId) { - return orderItemJpaRepository.findAllByOrderIdAndDeletedAtIsNull(orderId); - } - - @Override - public List findAllByOrderIds(Collection orderIds) { - return orderItemJpaRepository.findAllByOrderIdInAndDeletedAtIsNull(orderIds); - } -} 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 7e508e0fc..91ca724c9 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 @@ -4,6 +4,8 @@ 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.Query; +import org.springframework.data.repository.query.Param; import java.time.ZonedDateTime; import java.util.Optional; @@ -12,6 +14,9 @@ public interface OrderJpaRepository extends JpaRepository { Optional findByIdAndDeletedAtIsNull(Long id); + @Query("SELECT o FROM Order o LEFT JOIN FETCH o.items WHERE o.id = :id AND o.deletedAt IS NULL") + Optional findByIdWithItems(@Param("id") Long id); + Page findByUserIdAndCreatedAtBetweenAndDeletedAtIsNull( Long userId, ZonedDateTime startAt, ZonedDateTime endAt, Pageable pageable ); 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 7608a9259..590faa7ab 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 @@ -28,6 +28,11 @@ public Optional findById(Long id) { return orderJpaRepository.findByIdAndDeletedAtIsNull(id); } + @Override + public Optional findByIdWithItems(Long id) { + return orderJpaRepository.findByIdWithItems(id); + } + @Override public PageResult findByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startAt, ZonedDateTime endAt, int page, int size) { PageRequest pageRequest = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); 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 0cb6ca48a..0d7043282 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 @@ -29,15 +29,8 @@ public interface ProductJpaRepository extends JpaRepository { Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); - @Modifying - @Query("UPDATE Product p SET p.deletedAt = CURRENT_TIMESTAMP WHERE p.brandId = :brandId AND p.deletedAt IS NULL") + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("UPDATE Product p SET p.deletedAt = CURRENT_TIMESTAMP, p.updatedAt = CURRENT_TIMESTAMP WHERE p.brandId = :brandId AND p.deletedAt IS NULL") void softDeleteAllByBrandId(@Param("brandId") Long brandId); - @Modifying - @Query("UPDATE Product p SET p.likeCount = p.likeCount + 1 WHERE p.id = :productId") - void incrementLikeCount(@Param("productId") Long productId); - - @Modifying - @Query("UPDATE Product p SET p.likeCount = p.likeCount - 1 WHERE p.id = :productId AND p.likeCount > 0") - void decrementLikeCount(@Param("productId") Long productId); } 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 46df23631..4572c37c4 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 @@ -65,16 +65,6 @@ public void softDeleteAllByBrandId(Long brandId) { productJpaRepository.softDeleteAllByBrandId(brandId); } - @Override - public void incrementLikeCount(Long productId) { - productJpaRepository.incrementLikeCount(productId); - } - - @Override - public void decrementLikeCount(Long productId) { - productJpaRepository.decrementLikeCount(productId); - } - private Sort toSort(ProductSortType sortType) { return switch (sortType) { case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "price"); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java index 07d067376..ae862d052 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -9,9 +9,9 @@ public interface UserJpaRepository extends JpaRepository { - @Query("SELECT COUNT(u) > 0 FROM User u WHERE u.loginId.value = :loginId") + @Query("SELECT COUNT(u) > 0 FROM User u WHERE u.loginId.value = :loginId AND u.deletedAt IS NULL") boolean existsByLoginId(@Param("loginId") String loginId); - @Query("SELECT u FROM User u WHERE u.loginId.value = :loginId") + @Query("SELECT u FROM User u WHERE u.loginId.value = :loginId AND u.deletedAt IS NULL") Optional findByLoginId(@Param("loginId") String loginId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java index 0bb32de0d..f62fe83b9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.java @@ -7,6 +7,7 @@ import com.loopers.support.error.ErrorType; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -57,6 +58,11 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(IllegalArgumentException e) { + return failureResponse(ErrorType.BAD_REQUEST, e.getMessage()); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) { String errorMessage; @@ -137,7 +143,7 @@ private String extractMissingParameter(String message) { } private ResponseEntity> failureResponse(ErrorType errorType, String errorMessage) { - return ResponseEntity.status(errorType.getStatus()) + return ResponseEntity.status(HttpStatus.valueOf(errorType.getStatusCode())) .body(ApiResponse.fail(errorType.getCode(), errorMessage != null ? errorMessage : errorType.getMessage())); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java index 33b77b529..811da17c3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java @@ -15,7 +15,7 @@ public static Metadata fail(String errorCode, String errorMessage) { } } - public static ApiResponse success() { + public static ApiResponse success() { return new ApiResponse<>(Metadata.success(), null); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthUserArgumentResolver.java index fc462ba91..5c57db145 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthUserArgumentResolver.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthUserArgumentResolver.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.auth; +import com.loopers.application.user.UserApplicationService; import com.loopers.domain.user.User; -import com.loopers.domain.user.UserService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -19,7 +19,7 @@ public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver { private static final String HEADER_LOGIN_ID = "X-Loopers-LoginId"; private static final String HEADER_LOGIN_PW = "X-Loopers-LoginPw"; - private final UserService userService; + private final UserApplicationService userApplicationService; @Override public boolean supportsParameter(MethodParameter parameter) { @@ -37,6 +37,6 @@ public User resolveArgument(MethodParameter parameter, ModelAndViewContainer mav throw new CoreException(ErrorType.UNAUTHORIZED, "인증 헤더가 누락되었습니다."); } - return userService.authenticate(loginId, password); + return userApplicationService.authenticate(loginId, password); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Controller.java index 90d0e9027..aa307ac73 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Controller.java @@ -1,8 +1,8 @@ package com.loopers.interfaces.api.brand; -import com.loopers.application.brand.BrandFacade; -import com.loopers.application.brand.BrandInfo; +import com.loopers.application.brand.BrandApplicationService; import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -21,13 +21,13 @@ @RequestMapping("/api-admin/v1/brands") public class AdminBrandV1Controller implements AdminBrandV1ApiSpec { - private final BrandFacade brandFacade; + private final BrandApplicationService brandApplicationService; @PostMapping @Override public ApiResponse create(@Valid @RequestBody AdminBrandV1Dto.CreateRequest request) { - BrandInfo info = brandFacade.register(request.name()); - return ApiResponse.success(AdminBrandV1Dto.BrandResponse.from(info)); + Brand brand = brandApplicationService.register(request.name()); + return ApiResponse.success(AdminBrandV1Dto.BrandResponse.from(brand)); } @GetMapping @@ -36,15 +36,15 @@ public ApiResponse getAll( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { - PageResult result = brandFacade.getAll(page, size); + PageResult result = brandApplicationService.getAll(page, size); return ApiResponse.success(AdminBrandV1Dto.BrandPageResponse.from(result)); } @GetMapping("/{brandId}") @Override public ApiResponse getById(@PathVariable Long brandId) { - BrandInfo info = brandFacade.getById(brandId); - return ApiResponse.success(AdminBrandV1Dto.BrandResponse.from(info)); + Brand brand = brandApplicationService.getById(brandId); + return ApiResponse.success(AdminBrandV1Dto.BrandResponse.from(brand)); } @PutMapping("/{brandId}") @@ -53,14 +53,14 @@ public ApiResponse update( @PathVariable Long brandId, @Valid @RequestBody AdminBrandV1Dto.UpdateRequest request ) { - BrandInfo info = brandFacade.update(brandId, request.name()); - return ApiResponse.success(AdminBrandV1Dto.BrandResponse.from(info)); + Brand brand = brandApplicationService.update(brandId, request.name()); + return ApiResponse.success(AdminBrandV1Dto.BrandResponse.from(brand)); } @DeleteMapping("/{brandId}") @Override public ApiResponse delete(@PathVariable Long brandId) { - brandFacade.delete(brandId); - return ApiResponse.success(null); + brandApplicationService.delete(brandId); + return ApiResponse.success(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Dto.java index fa6265f2a..f35bd29cc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/AdminBrandV1Dto.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.brand; -import com.loopers.application.brand.BrandInfo; import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; import jakarta.validation.constraints.NotBlank; import java.time.ZonedDateTime; @@ -20,8 +20,8 @@ public record UpdateRequest( ) {} public record BrandResponse(Long id, String name, ZonedDateTime createdAt, ZonedDateTime updatedAt) { - public static BrandResponse from(BrandInfo info) { - return new BrandResponse(info.id(), info.name(), info.createdAt(), info.updatedAt()); + public static BrandResponse from(Brand brand) { + return new BrandResponse(brand.getId(), brand.getName(), brand.getCreatedAt(), brand.getUpdatedAt()); } } @@ -32,7 +32,7 @@ public record BrandPageResponse( long totalElements, int totalPages ) { - public static BrandPageResponse from(PageResult result) { + public static BrandPageResponse from(PageResult result) { List content = result.items().stream() .map(BrandResponse::from) .toList(); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java index c6dfef181..09a665058 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.brand; -import com.loopers.application.brand.BrandFacade; -import com.loopers.application.brand.BrandInfo; +import com.loopers.application.brand.BrandApplicationService; +import com.loopers.domain.brand.Brand; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -14,12 +14,12 @@ @RequestMapping("/api/v1/brands") public class BrandV1Controller implements BrandV1ApiSpec { - private final BrandFacade brandFacade; + private final BrandApplicationService brandApplicationService; @GetMapping("/{brandId}") @Override public ApiResponse getById(@PathVariable Long brandId) { - BrandInfo info = brandFacade.getById(brandId); - return ApiResponse.success(BrandV1Dto.BrandResponse.from(info)); + Brand brand = brandApplicationService.getById(brandId); + return ApiResponse.success(BrandV1Dto.BrandResponse.from(brand)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java index e4f1be688..a2379f523 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java @@ -1,12 +1,12 @@ package com.loopers.interfaces.api.brand; -import com.loopers.application.brand.BrandInfo; +import com.loopers.domain.brand.Brand; public class BrandV1Dto { public record BrandResponse(Long id, String name) { - public static BrandResponse from(BrandInfo info) { - return new BrandResponse(info.id(), info.name()); + public static BrandResponse from(Brand brand) { + return new BrandResponse(brand.getId(), brand.getName()); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1ApiSpec.java index e739e0cf8..fa5ac92cc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1ApiSpec.java @@ -9,14 +9,14 @@ public interface CartV1ApiSpec { @Operation(summary = "장바구니 담기", description = "상품을 장바구니에 담습니다. 이미 담긴 상품이면 수량이 합산됩니다.") - ApiResponse addToCart(User user, CartV1Dto.AddRequest request); + ApiResponse addToCart(User user, CartV1Dto.AddRequest request); @Operation(summary = "장바구니 조회", description = "내 장바구니를 조회합니다.") ApiResponse getMyCart(User user); @Operation(summary = "수량 변경", description = "장바구니 항목의 수량을 변경합니다.") - ApiResponse updateQuantity(User user, Long cartItemId, CartV1Dto.UpdateQuantityRequest request); + ApiResponse updateQuantity(User user, Long cartItemId, CartV1Dto.UpdateQuantityRequest request); @Operation(summary = "항목 삭제", description = "장바구니에서 항목을 삭제합니다.") - ApiResponse removeItem(User user, Long cartItemId); + ApiResponse removeItem(User user, Long cartItemId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Controller.java index afa4c813c..5a24b69e8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Controller.java @@ -1,7 +1,11 @@ package com.loopers.interfaces.api.cart; -import com.loopers.application.cart.CartFacade; -import com.loopers.application.cart.CartInfo; +import com.loopers.application.brand.BrandApplicationService; +import com.loopers.application.cart.CartApplicationService; +import com.loopers.application.product.ProductApplicationService; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.cart.CartItem; +import com.loopers.domain.product.Product; import com.loopers.domain.user.User; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.auth.AuthUser; @@ -17,43 +21,71 @@ import org.springframework.web.bind.annotation.RestController; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; @RequiredArgsConstructor @RestController @RequestMapping("/api/v1/cart") public class CartV1Controller implements CartV1ApiSpec { - private final CartFacade cartFacade; + private final CartApplicationService cartApplicationService; + private final ProductApplicationService productApplicationService; + private final BrandApplicationService brandApplicationService; @PostMapping("/items") @Override - public ApiResponse addToCart(@AuthUser User user, @Valid @RequestBody CartV1Dto.AddRequest request) { - cartFacade.addToCart(user.getId(), request.productId(), request.quantity()); + public ApiResponse addToCart(@AuthUser User user, @Valid @RequestBody CartV1Dto.AddRequest request) { + cartApplicationService.addToCart(user.getId(), request.productId(), request.quantity()); return ApiResponse.success(); } @GetMapping @Override public ApiResponse getMyCart(@AuthUser User user) { - List infos = cartFacade.getMyCart(user.getId()); - return ApiResponse.success(CartV1Dto.CartResponse.from(infos)); + List cartItems = cartApplicationService.getMyCart(user.getId()); + + Set productIds = cartItems.stream() + .map(CartItem::getProductId) + .collect(Collectors.toSet()); + Map productMap = productApplicationService.getByIds(productIds); + + Set brandIds = productMap.values().stream() + .map(Product::getBrandId) + .collect(Collectors.toSet()); + Map brandMap = brandApplicationService.getByIds(brandIds); + + List itemResponses = cartItems.stream() + .filter(cartItem -> { + Product product = productMap.get(cartItem.getProductId()); + return product != null && brandMap.containsKey(product.getBrandId()); + }) + .map(cartItem -> { + Product product = productMap.get(cartItem.getProductId()); + Brand brand = brandMap.get(product.getBrandId()); + return CartV1Dto.CartItemResponse.from(cartItem, product, brand); + }) + .toList(); + + return ApiResponse.success(CartV1Dto.CartResponse.from(itemResponses)); } @PutMapping("/items/{cartItemId}") @Override - public ApiResponse updateQuantity( + public ApiResponse updateQuantity( @AuthUser User user, @PathVariable Long cartItemId, @Valid @RequestBody CartV1Dto.UpdateQuantityRequest request ) { - cartFacade.updateQuantity(cartItemId, user.getId(), request.quantity()); + cartApplicationService.updateQuantity(cartItemId, user.getId(), request.quantity()); return ApiResponse.success(); } @DeleteMapping("/items/{cartItemId}") @Override - public ApiResponse removeItem(@AuthUser User user, @PathVariable Long cartItemId) { - cartFacade.removeItem(cartItemId, user.getId()); + public ApiResponse removeItem(@AuthUser User user, @PathVariable Long cartItemId) { + cartApplicationService.removeItem(cartItemId, user.getId()); return ApiResponse.success(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Dto.java index 1fc3129de..70585834c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Dto.java @@ -1,6 +1,8 @@ package com.loopers.interfaces.api.cart; -import com.loopers.application.cart.CartInfo; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.cart.CartItem; +import com.loopers.domain.product.Product; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; @@ -9,12 +11,12 @@ public class CartV1Dto { public record AddRequest( - @NotNull Long productId, - @Min(1) int quantity + @NotNull(message = "상품 ID는 필수입니다.") Long productId, + @Min(value = 1, message = "수량은 1 이상이어야 합니다.") int quantity ) {} public record UpdateQuantityRequest( - @Min(1) int quantity + @Min(value = 1, message = "수량은 1 이상이어야 합니다.") int quantity ) {} public record CartItemResponse( @@ -25,19 +27,16 @@ public record CartItemResponse( int price, int quantity ) { - public static CartItemResponse from(CartInfo info) { + public static CartItemResponse from(CartItem cartItem, Product product, Brand brand) { return new CartItemResponse( - info.cartItemId(), info.productId(), info.productName(), - info.brandName(), info.price(), info.quantity() + cartItem.getId(), product.getId(), product.getName(), + brand.getName(), product.getPrice().amount(), cartItem.getQuantity().value() ); } } public record CartResponse(List items) { - public static CartResponse from(List infos) { - List items = infos.stream() - .map(CartItemResponse::from) - .toList(); + public static CartResponse from(List items) { return new CartResponse(items); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java index abf022c65..dcf9663fa 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java @@ -9,10 +9,10 @@ public interface LikeV1ApiSpec { @Operation(summary = "좋아요 등록", description = "상품에 좋아요를 등록합니다.") - ApiResponse like(User user, Long productId); + ApiResponse like(User user, Long productId); @Operation(summary = "좋아요 취소", description = "상품의 좋아요를 취소합니다.") - ApiResponse unlike(User user, Long productId); + ApiResponse unlike(User user, Long productId); @Operation(summary = "내 좋아요 목록 조회", description = "내가 좋아요한 상품 목록을 조회합니다.") ApiResponse getMyLikes(User user); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java index 71453e5ac..9294c664b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -1,7 +1,11 @@ package com.loopers.interfaces.api.like; -import com.loopers.application.like.LikeFacade; -import com.loopers.application.like.LikeInfo; +import com.loopers.application.brand.BrandApplicationService; +import com.loopers.application.like.LikeApplicationService; +import com.loopers.application.product.ProductApplicationService; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.like.Like; +import com.loopers.domain.product.Product; import com.loopers.domain.user.User; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.auth.AuthUser; @@ -13,31 +17,59 @@ import org.springframework.web.bind.annotation.RestController; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; @RequiredArgsConstructor @RestController public class LikeV1Controller implements LikeV1ApiSpec { - private final LikeFacade likeFacade; + private final LikeApplicationService likeApplicationService; + private final ProductApplicationService productApplicationService; + private final BrandApplicationService brandApplicationService; @PostMapping("/api/v1/products/{productId}/likes") @Override - public ApiResponse like(@AuthUser User user, @PathVariable Long productId) { - likeFacade.like(user.getId(), productId); + public ApiResponse like(@AuthUser User user, @PathVariable Long productId) { + likeApplicationService.like(user.getId(), productId); return ApiResponse.success(); } @DeleteMapping("/api/v1/products/{productId}/likes") @Override - public ApiResponse unlike(@AuthUser User user, @PathVariable Long productId) { - likeFacade.unlike(user.getId(), productId); + public ApiResponse unlike(@AuthUser User user, @PathVariable Long productId) { + likeApplicationService.unlike(user.getId(), productId); return ApiResponse.success(); } @GetMapping("/api/v1/likes") @Override public ApiResponse getMyLikes(@AuthUser User user) { - List infos = likeFacade.getMyLikes(user.getId()); - return ApiResponse.success(LikeV1Dto.LikeListResponse.from(infos)); + List likes = likeApplicationService.getMyLikes(user.getId()); + + Set productIds = likes.stream() + .map(Like::getProductId) + .collect(Collectors.toSet()); + Map productMap = productApplicationService.getByIds(productIds); + + Set brandIds = productMap.values().stream() + .map(Product::getBrandId) + .collect(Collectors.toSet()); + Map brandMap = brandApplicationService.getByIds(brandIds); + + List likeResponses = likes.stream() + .filter(like -> { + Product product = productMap.get(like.getProductId()); + return product != null && brandMap.containsKey(product.getBrandId()); + }) + .map(like -> { + Product product = productMap.get(like.getProductId()); + Brand brand = brandMap.get(product.getBrandId()); + return LikeV1Dto.LikeResponse.from(like, product, brand); + }) + .toList(); + + return ApiResponse.success(LikeV1Dto.LikeListResponse.from(likeResponses)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java index 60820e83b..0291622bf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -1,6 +1,8 @@ package com.loopers.interfaces.api.like; -import com.loopers.application.like.LikeInfo; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.like.Like; +import com.loopers.domain.product.Product; import java.time.ZonedDateTime; import java.util.List; @@ -16,19 +18,16 @@ public record LikeResponse( int likeCount, ZonedDateTime likedAt ) { - public static LikeResponse from(LikeInfo info) { + public static LikeResponse from(Like like, Product product, Brand brand) { return new LikeResponse( - info.likeId(), info.productId(), info.productName(), - info.brandName(), info.price(), info.likeCount(), info.likedAt() + like.getId(), product.getId(), product.getName(), + brand.getName(), product.getPrice().amount(), product.getLikeCount(), like.getCreatedAt() ); } } public record LikeListResponse(List likes) { - public static LikeListResponse from(List infos) { - List likes = infos.stream() - .map(LikeResponse::from) - .toList(); + public static LikeListResponse from(List likes) { return new LikeListResponse(likes); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Controller.java index 990845897..e25a78134 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Controller.java @@ -1,9 +1,8 @@ package com.loopers.interfaces.api.order; -import com.loopers.application.order.OrderDetailInfo; -import com.loopers.application.order.OrderFacade; -import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderApplicationService; import com.loopers.domain.PageResult; +import com.loopers.domain.order.Order; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -17,7 +16,7 @@ @RequestMapping("/api-admin/v1/orders") public class AdminOrderV1Controller implements AdminOrderV1ApiSpec { - private final OrderFacade orderFacade; + private final OrderApplicationService orderApplicationService; @GetMapping @Override @@ -25,14 +24,14 @@ public ApiResponse getAllOrders( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { - PageResult result = orderFacade.getAllOrders(page, size); + PageResult result = orderApplicationService.getAllOrders(page, size); return ApiResponse.success(AdminOrderV1Dto.OrderPageResponse.from(result)); } @GetMapping("/{orderId}") @Override public ApiResponse getOrderDetail(@PathVariable Long orderId) { - OrderDetailInfo info = orderFacade.getOrderDetail(orderId); - return ApiResponse.success(AdminOrderV1Dto.OrderDetailResponse.from(info)); + Order order = orderApplicationService.getOrder(orderId); + return ApiResponse.success(AdminOrderV1Dto.OrderDetailResponse.from(order)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Dto.java index 06a098364..b0d0a6299 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/AdminOrderV1Dto.java @@ -1,8 +1,8 @@ package com.loopers.interfaces.api.order; -import com.loopers.application.order.OrderDetailInfo; -import com.loopers.application.order.OrderInfo; import com.loopers.domain.PageResult; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; import java.time.ZonedDateTime; import java.util.List; @@ -16,8 +16,8 @@ public record OrderResponse( String status, ZonedDateTime createdAt ) { - public static OrderResponse from(OrderInfo info) { - return new OrderResponse(info.orderId(), info.userId(), info.totalPrice(), info.status(), info.createdAt()); + public static OrderResponse from(Order order) { + return new OrderResponse(order.getId(), order.getUserId(), order.getTotalPrice().amount(), order.getStatus().name(), order.getCreatedAt()); } } @@ -29,12 +29,12 @@ public record OrderDetailResponse( ZonedDateTime createdAt, List items ) { - public static OrderDetailResponse from(OrderDetailInfo info) { - List items = info.items().stream() + public static OrderDetailResponse from(Order order) { + List items = order.getItems().stream() .map(OrderItemResponse::from) .toList(); return new OrderDetailResponse( - info.orderId(), info.userId(), info.totalPrice(), info.status(), info.createdAt(), items + order.getId(), order.getUserId(), order.getTotalPrice().amount(), order.getStatus().name(), order.getCreatedAt(), items ); } } @@ -46,10 +46,10 @@ public record OrderItemResponse( String brandName, int quantity ) { - public static OrderItemResponse from(OrderDetailInfo.OrderItemInfo info) { + public static OrderItemResponse from(OrderItem item) { return new OrderItemResponse( - info.productId(), info.productName(), info.productPrice(), - info.brandName(), info.quantity() + item.getProductId(), item.getProductName(), item.getProductPrice().amount(), + item.getBrandName(), item.getQuantity().value() ); } } @@ -61,7 +61,7 @@ public record OrderPageResponse( long totalElements, int totalPages ) { - public static OrderPageResponse from(PageResult result) { + public static OrderPageResponse from(PageResult result) { List content = result.items().stream() .map(OrderResponse::from) .toList(); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java index 0c1ada150..712345f0d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -1,9 +1,9 @@ package com.loopers.interfaces.api.order; -import com.loopers.application.order.OrderDetailInfo; -import com.loopers.application.order.OrderFacade; -import com.loopers.application.order.OrderInfo; +import com.loopers.application.order.OrderApplicationService; import com.loopers.domain.PageResult; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderLineItem; import com.loopers.domain.user.User; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.auth.AuthUser; @@ -20,14 +20,13 @@ import java.time.LocalDate; import java.util.List; -import java.util.stream.Collectors; @RequiredArgsConstructor @RestController @RequestMapping("/api/v1/orders") public class OrderV1Controller implements OrderV1ApiSpec { - private final OrderFacade orderFacade; + private final OrderApplicationService orderApplicationService; @PostMapping @Override @@ -35,18 +34,18 @@ public ApiResponse createOrder( @AuthUser User user, @Valid @RequestBody OrderV1Dto.CreateOrderRequest request ) { - List items = request.items().stream() - .map(i -> new OrderFacade.OrderItemRequest(i.productId(), i.quantity())) - .collect(Collectors.toList()); - OrderDetailInfo info = orderFacade.createOrder(user.getId(), items); - return ApiResponse.success(OrderV1Dto.OrderDetailResponse.from(info)); + List items = request.items().stream() + .map(i -> new OrderLineItem(i.productId(), i.quantity())) + .toList(); + Order order = orderApplicationService.createOrder(user.getId(), items); + return ApiResponse.success(OrderV1Dto.OrderDetailResponse.from(order)); } @PostMapping("/cart") @Override public ApiResponse createOrderFromCart(@AuthUser User user) { - OrderDetailInfo info = orderFacade.createOrderFromCart(user.getId()); - return ApiResponse.success(OrderV1Dto.OrderDetailResponse.from(info)); + Order order = orderApplicationService.createOrderFromCart(user.getId()); + return ApiResponse.success(OrderV1Dto.OrderDetailResponse.from(order)); } @GetMapping @@ -58,7 +57,7 @@ public ApiResponse getMyOrders( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { - PageResult result = orderFacade.getMyOrders(user.getId(), startAt, endAt, page, size); + PageResult result = orderApplicationService.getMyOrders(user.getId(), startAt, endAt, page, size); return ApiResponse.success(OrderV1Dto.OrderPageResponse.from(result)); } @@ -68,7 +67,7 @@ public ApiResponse getMyOrderDetail( @AuthUser User user, @PathVariable Long orderId ) { - OrderDetailInfo info = orderFacade.getMyOrderDetail(user.getId(), orderId); - return ApiResponse.success(OrderV1Dto.OrderDetailResponse.from(info)); + Order order = orderApplicationService.getMyOrder(user.getId(), orderId); + return ApiResponse.success(OrderV1Dto.OrderDetailResponse.from(order)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java index 6ac1080ee..b8a998441 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -1,8 +1,8 @@ package com.loopers.interfaces.api.order; -import com.loopers.application.order.OrderDetailInfo; -import com.loopers.application.order.OrderInfo; import com.loopers.domain.PageResult; +import com.loopers.domain.order.Order; +import com.loopers.domain.order.OrderItem; import jakarta.validation.Valid; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotEmpty; @@ -33,8 +33,8 @@ public record OrderResponse( String status, ZonedDateTime createdAt ) { - public static OrderResponse from(OrderInfo info) { - return new OrderResponse(info.orderId(), info.totalPrice(), info.status(), info.createdAt()); + public static OrderResponse from(Order order) { + return new OrderResponse(order.getId(), order.getTotalPrice().amount(), order.getStatus().name(), order.getCreatedAt()); } } @@ -45,11 +45,11 @@ public record OrderDetailResponse( ZonedDateTime createdAt, List items ) { - public static OrderDetailResponse from(OrderDetailInfo info) { - List items = info.items().stream() + public static OrderDetailResponse from(Order order) { + List items = order.getItems().stream() .map(OrderItemResponse::from) .toList(); - return new OrderDetailResponse(info.orderId(), info.totalPrice(), info.status(), info.createdAt(), items); + return new OrderDetailResponse(order.getId(), order.getTotalPrice().amount(), order.getStatus().name(), order.getCreatedAt(), items); } } @@ -60,10 +60,10 @@ public record OrderItemResponse( String brandName, int quantity ) { - public static OrderItemResponse from(OrderDetailInfo.OrderItemInfo info) { + public static OrderItemResponse from(OrderItem item) { return new OrderItemResponse( - info.productId(), info.productName(), info.productPrice(), - info.brandName(), info.quantity() + item.getProductId(), item.getProductName(), item.getProductPrice().amount(), + item.getBrandName(), item.getQuantity().value() ); } } @@ -75,7 +75,7 @@ public record OrderPageResponse( long totalElements, int totalPages ) { - public static OrderPageResponse from(PageResult result) { + public static OrderPageResponse from(PageResult result) { List content = result.items().stream() .map(OrderResponse::from) .toList(); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Controller.java index 9868c9e06..64c37db72 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Controller.java @@ -1,8 +1,10 @@ package com.loopers.interfaces.api.product; -import com.loopers.application.product.ProductFacade; -import com.loopers.application.product.ProductInfo; +import com.loopers.application.brand.BrandApplicationService; +import com.loopers.application.product.ProductApplicationService; import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductSortType; import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; @@ -17,18 +19,24 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + @RequiredArgsConstructor @RestController @RequestMapping("/api-admin/v1/products") public class AdminProductV1Controller implements AdminProductV1ApiSpec { - private final ProductFacade productFacade; + private final ProductApplicationService productApplicationService; + private final BrandApplicationService brandApplicationService; @PostMapping @Override public ApiResponse create(@Valid @RequestBody AdminProductV1Dto.CreateRequest request) { - ProductInfo info = productFacade.register(request.brandId(), request.name(), request.price(), request.stock()); - return ApiResponse.success(AdminProductV1Dto.ProductResponse.from(info)); + Product product = productApplicationService.register(request.brandId(), request.name(), request.price(), request.stock()); + Brand brand = brandApplicationService.getById(product.getBrandId()); + return ApiResponse.success(AdminProductV1Dto.ProductResponse.from(product, brand)); } @GetMapping @@ -38,15 +46,20 @@ public ApiResponse getAll( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { - PageResult result = productFacade.getAll(brandId, ProductSortType.LATEST, page, size); - return ApiResponse.success(AdminProductV1Dto.ProductPageResponse.from(result)); + PageResult result = productApplicationService.getAll(brandId, ProductSortType.LATEST, page, size); + Set brandIds = result.items().stream() + .map(Product::getBrandId) + .collect(Collectors.toSet()); + Map brandMap = brandApplicationService.getByIds(brandIds); + return ApiResponse.success(AdminProductV1Dto.ProductPageResponse.from(result, brandMap)); } @GetMapping("/{productId}") @Override public ApiResponse getById(@PathVariable Long productId) { - ProductInfo info = productFacade.getById(productId); - return ApiResponse.success(AdminProductV1Dto.ProductResponse.from(info)); + Product product = productApplicationService.getById(productId); + Brand brand = brandApplicationService.getById(product.getBrandId()); + return ApiResponse.success(AdminProductV1Dto.ProductResponse.from(product, brand)); } @PutMapping("/{productId}") @@ -55,14 +68,15 @@ public ApiResponse update( @PathVariable Long productId, @Valid @RequestBody AdminProductV1Dto.UpdateRequest request ) { - ProductInfo info = productFacade.update(productId, request.name(), request.price(), request.stock()); - return ApiResponse.success(AdminProductV1Dto.ProductResponse.from(info)); + Product product = productApplicationService.update(productId, request.name(), request.price(), request.stock()); + Brand brand = brandApplicationService.getById(product.getBrandId()); + return ApiResponse.success(AdminProductV1Dto.ProductResponse.from(product, brand)); } @DeleteMapping("/{productId}") @Override public ApiResponse delete(@PathVariable Long productId) { - productFacade.delete(productId); - return ApiResponse.success(null); + productApplicationService.delete(productId); + return ApiResponse.success(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Dto.java index 9c4416551..a2bd6e64f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Dto.java @@ -1,13 +1,15 @@ package com.loopers.interfaces.api.product; -import com.loopers.application.product.ProductInfo; import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import java.time.ZonedDateTime; import java.util.List; +import java.util.Map; public class AdminProductV1Dto { @@ -51,11 +53,11 @@ public record ProductResponse( ZonedDateTime createdAt, ZonedDateTime updatedAt ) { - public static ProductResponse from(ProductInfo info) { + public static ProductResponse from(Product product, Brand brand) { return new ProductResponse( - info.id(), info.brandId(), info.brandName(), info.name(), - info.price(), info.stock(), info.likeCount(), - info.createdAt(), info.updatedAt() + product.getId(), product.getBrandId(), brand.getName(), product.getName(), + product.getPrice().amount(), product.getStock().quantity(), product.getLikeCount(), + product.getCreatedAt(), product.getUpdatedAt() ); } } @@ -67,9 +69,10 @@ public record ProductPageResponse( long totalElements, int totalPages ) { - public static ProductPageResponse from(PageResult result) { + public static ProductPageResponse from(PageResult result, Map brandMap) { List content = result.items().stream() - .map(ProductResponse::from) + .filter(product -> brandMap.containsKey(product.getBrandId())) + .map(product -> ProductResponse.from(product, brandMap.get(product.getBrandId()))) .toList(); return new ProductPageResponse(content, result.page(), result.size(), result.totalElements(), result.totalPages()); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java index 56823ec21..9da2e7740 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -1,8 +1,10 @@ package com.loopers.interfaces.api.product; -import com.loopers.application.product.ProductFacade; -import com.loopers.application.product.ProductInfo; +import com.loopers.application.brand.BrandApplicationService; +import com.loopers.application.product.ProductApplicationService; import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductSortType; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; @@ -12,12 +14,17 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + @RequiredArgsConstructor @RestController @RequestMapping("/api/v1/products") public class ProductV1Controller implements ProductV1ApiSpec { - private final ProductFacade productFacade; + private final ProductApplicationService productApplicationService; + private final BrandApplicationService brandApplicationService; @GetMapping @Override @@ -27,14 +34,19 @@ public ApiResponse getAll( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { - PageResult result = productFacade.getAll(brandId, ProductSortType.from(sort), page, size); - return ApiResponse.success(ProductV1Dto.ProductPageResponse.from(result)); + PageResult result = productApplicationService.getAll(brandId, ProductSortType.from(sort), page, size); + Set brandIds = result.items().stream() + .map(Product::getBrandId) + .collect(Collectors.toSet()); + Map brandMap = brandApplicationService.getByIds(brandIds); + return ApiResponse.success(ProductV1Dto.ProductPageResponse.from(result, brandMap)); } @GetMapping("/{productId}") @Override public ApiResponse getById(@PathVariable Long productId) { - ProductInfo info = productFacade.getById(productId); - return ApiResponse.success(ProductV1Dto.ProductResponse.from(info)); + Product product = productApplicationService.getById(productId); + Brand brand = brandApplicationService.getById(product.getBrandId()); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(product, brand)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java index 0008cb59b..428772d85 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -1,9 +1,11 @@ package com.loopers.interfaces.api.product; -import com.loopers.application.product.ProductInfo; import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; import java.util.List; +import java.util.Map; public class ProductV1Dto { @@ -15,10 +17,10 @@ public record ProductResponse( int price, int likeCount ) { - public static ProductResponse from(ProductInfo info) { + public static ProductResponse from(Product product, Brand brand) { return new ProductResponse( - info.id(), info.brandId(), info.brandName(), info.name(), - info.price(), info.likeCount() + product.getId(), product.getBrandId(), brand.getName(), product.getName(), + product.getPrice().amount(), product.getLikeCount() ); } } @@ -30,9 +32,10 @@ public record ProductPageResponse( long totalElements, int totalPages ) { - public static ProductPageResponse from(PageResult result) { + public static ProductPageResponse from(PageResult result, Map brandMap) { List content = result.items().stream() - .map(ProductResponse::from) + .filter(product -> brandMap.containsKey(product.getBrandId())) + .map(product -> ProductResponse.from(product, brandMap.get(product.getBrandId()))) .toList(); return new ProductPageResponse(content, result.page(), result.size(), result.totalElements(), result.totalPages()); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index eed87e830..925da94d4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -1,7 +1,6 @@ package com.loopers.interfaces.api.user; -import com.loopers.application.user.UserFacade; -import com.loopers.application.user.UserInfo; +import com.loopers.application.user.UserApplicationService; import com.loopers.domain.user.User; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.auth.AuthUser; @@ -19,34 +18,31 @@ @RequestMapping("/api/v1/users") public class UserV1Controller implements UserV1ApiSpec { - private final UserFacade userFacade; + private final UserApplicationService userApplicationService; @PostMapping @Override public ApiResponse signup(@Valid @RequestBody UserV1Dto.SignupRequest request) { - UserInfo info = userFacade.signup( + User user = userApplicationService.signup( request.loginId(), request.password(), request.name(), request.birthDate(), request.email() ); - UserV1Dto.SignupResponse response = UserV1Dto.SignupResponse.from(info); - return ApiResponse.success(response); + return ApiResponse.success(UserV1Dto.SignupResponse.from(user)); } @GetMapping("/me") @Override public ApiResponse getMe(@AuthUser User user) { - UserInfo info = userFacade.getMyInfo(user); - UserV1Dto.MeResponse response = UserV1Dto.MeResponse.from(info); - return ApiResponse.success(response); + return ApiResponse.success(UserV1Dto.MeResponse.from(user)); } @PutMapping("/password") @Override public ApiResponse changePassword(@AuthUser User user, @Valid @RequestBody UserV1Dto.ChangePasswordRequest request) { - userFacade.changePassword(user, request.currentPassword(), request.newPassword()); - return ApiResponse.success(null); + userApplicationService.changePassword(user, request.currentPassword(), request.newPassword()); + return ApiResponse.success(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java index 711bbaae3..1530cf829 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.user; -import com.loopers.application.user.UserInfo; +import com.loopers.domain.user.User; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -28,13 +28,13 @@ public record SignupRequest( ) {} public record SignupResponse(Long id, String loginId, String name, LocalDate birthDate, String email) { - public static SignupResponse from(UserInfo info) { + public static SignupResponse from(User user) { return new SignupResponse( - info.id(), - info.loginId(), - info.name(), - info.birthDate(), - info.email() + user.getId(), + user.getLoginId(), + user.getName(), + user.getBirthDate(), + user.getEmail() ); } } @@ -48,12 +48,12 @@ public record ChangePasswordRequest( ) {} public record MeResponse(String loginId, String name, LocalDate birthDate, String email) { - public static MeResponse from(UserInfo info) { + public static MeResponse from(User user) { return new MeResponse( - info.loginId(), - info.name(), - info.birthDate(), - info.email() + user.getLoginId(), + user.getMaskedName(), + user.getBirthDate(), + user.getEmail() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 3497b43ce..ec61b7308 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -2,19 +2,18 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; @Getter @RequiredArgsConstructor public enum ErrorType { /** 범용 에러 */ - INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), "일시적인 오류가 발생했습니다."), - BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.getReasonPhrase(), "잘못된 요청입니다."), - NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.getReasonPhrase(), "존재하지 않는 요청입니다."), - CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.getReasonPhrase(), "이미 존재하는 리소스입니다."), - UNAUTHORIZED(HttpStatus.UNAUTHORIZED, HttpStatus.UNAUTHORIZED.getReasonPhrase(), "인증에 실패했습니다."); + INTERNAL_ERROR(500, "Internal Server Error", "일시적인 오류가 발생했습니다."), + BAD_REQUEST(400, "Bad Request", "잘못된 요청입니다."), + NOT_FOUND(404, "Not Found", "존재하지 않는 요청입니다."), + CONFLICT(409, "Conflict", "이미 존재하는 리소스입니다."), + UNAUTHORIZED(401, "Unauthorized", "인증에 실패했습니다."); - private final HttpStatus status; + private final int statusCode; private final String code; private final String message; } diff --git a/apps/commerce-api/src/test/java/com/loopers/ArchitectureTest.java b/apps/commerce-api/src/test/java/com/loopers/ArchitectureTest.java new file mode 100644 index 000000000..3a4a5a4bb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/ArchitectureTest.java @@ -0,0 +1,107 @@ +package com.loopers; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Repository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static com.tngtech.archunit.library.Architectures.layeredArchitecture; + +@AnalyzeClasses(packages = "com.loopers", importOptions = ImportOption.DoNotIncludeTests.class) +class ArchitectureTest { + + // ── 1. 계층형 아키텍처 의존성 검증 ────────────────────────────────────────── + // Interfaces → Application → Domain ← Infrastructure + // Interfaces → Domain 허용: Controller가 User, PageResult, ProductSortType 등 도메인 타입을 직접 참조 + // Config, Support 패키지는 레이어 외부이므로 검사 대상에서 제외 + @ArchTest + static final ArchRule layered_architecture_is_respected = layeredArchitecture() + .consideringOnlyDependenciesInAnyPackage("com.loopers..") + .layer("Interfaces").definedBy("..interfaces..") + .layer("Application").definedBy("..application..") + .layer("Domain").definedBy("..domain..") + .layer("Infrastructure").definedBy("..infrastructure..") + .layer("Config").definedBy("..config..") + + .whereLayer("Interfaces").mayNotBeAccessedByAnyLayer() + .whereLayer("Application").mayOnlyBeAccessedByLayers("Interfaces") + .whereLayer("Domain").mayOnlyBeAccessedByLayers("Application", "Infrastructure", "Interfaces", "Config") + .whereLayer("Infrastructure").mayNotBeAccessedByAnyLayer() + .whereLayer("Config").mayNotBeAccessedByAnyLayer(); + + // ── 2. Domain 계층 독립성 (DIP 핵심) ──────────────────────────────────────── + @ArchTest + static final ArchRule domain_should_not_depend_on_infrastructure = noClasses() + .that().resideInAPackage("..domain..") + .should().dependOnClassesThat().resideInAPackage("..infrastructure.."); + + @ArchTest + static final ArchRule domain_should_not_depend_on_application = noClasses() + .that().resideInAPackage("..domain..") + .should().dependOnClassesThat().resideInAPackage("..application.."); + + @ArchTest + static final ArchRule domain_should_not_depend_on_interfaces = noClasses() + .that().resideInAPackage("..domain..") + .should().dependOnClassesThat().resideInAPackage("..interfaces.."); + + // ── 3. 클래스 배치 규칙 (DIP) ─────────────────────────────────────────────── + // Repository 인터페이스는 Domain 패키지에 위치한다 (JpaRepository 제외) + @ArchTest + static final ArchRule repository_interfaces_should_be_in_domain = classes() + .that().haveSimpleNameEndingWith("Repository") + .and().haveSimpleNameNotContaining("Jpa") + .and().areInterfaces() + .should().resideInAPackage("..domain.."); + + // Repository 구현체는 Infrastructure 패키지에 위치한다 + @ArchTest + static final ArchRule repository_implementations_should_be_in_infrastructure = classes() + .that().haveSimpleNameEndingWith("RepositoryImpl") + .should().resideInAPackage("..infrastructure.."); + + // DomainService는 Domain 패키지에 위치한다 + @ArchTest + static final ArchRule domain_services_should_be_in_domain = classes() + .that().haveSimpleNameEndingWith("DomainService") + .should().resideInAPackage("..domain.."); + + // ApplicationService는 Application 패키지에 위치한다 + @ArchTest + static final ArchRule application_services_should_be_in_application = classes() + .that().haveSimpleNameEndingWith("ApplicationService") + .should().resideInAPackage("..application.."); + + // Controller는 Interfaces 패키지에 위치한다 + @ArchTest + static final ArchRule controllers_should_be_in_interfaces = classes() + .that().haveSimpleNameEndingWith("Controller") + .should().resideInAPackage("..interfaces.."); + + // ── 4. Domain 순수성 ──────────────────────────────────────────────────────── + // Domain은 Spring Web 기술에 의존하지 않는다 + @ArchTest + static final ArchRule domain_should_not_depend_on_spring_web = noClasses() + .that().resideInAPackage("..domain..") + .should().dependOnClassesThat().resideInAPackage("..springframework.web.."); + + // Domain에 @Service, @Component, @Repository를 사용하지 않는다 (DomainServiceConfig에서 @Bean 등록) + @ArchTest + static final ArchRule domain_should_not_use_spring_stereotype_annotations = noClasses() + .that().resideInAPackage("..domain..") + .should().beAnnotatedWith(Service.class) + .orShould().beAnnotatedWith(Component.class) + .orShould().beAnnotatedWith(Repository.class); + + // Domain에서 @Transactional을 사용하지 않는다 (트랜잭션은 ApplicationService 책임) + @ArchTest + static final ArchRule domain_should_not_use_transactional = noClasses() + .that().resideInAPackage("..domain..") + .should().beAnnotatedWith(Transactional.class); +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandDomainServiceIntegrationTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandDomainServiceIntegrationTest.java index 316b2f909..751dc6f90 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandDomainServiceIntegrationTest.java @@ -16,10 +16,10 @@ import static org.junit.jupiter.api.Assertions.assertThrows; @SpringBootTest -class BrandServiceIntegrationTest { +class BrandDomainServiceIntegrationTest { @Autowired - private BrandService brandService; + private BrandDomainService brandService; @Autowired private DatabaseCleanUp databaseCleanUp; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java index dd6acf1dd..f6f9353ab 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/brand/BrandTest.java @@ -46,9 +46,9 @@ void throwsBadRequest_whenNameIsBlank() { } } - @DisplayName("브랜드를 수정할 때, ") + @DisplayName("브랜드 이름을 변경할 때, ") @Nested - class Update { + class Rename { @DisplayName("올바른 이름이면, 정상적으로 수정된다.") @Test @@ -57,7 +57,7 @@ void updatesBrand_whenNameIsValid() { Brand brand = new Brand("나이키"); // act - brand.update("아디다스"); + brand.rename("아디다스"); // assert assertThat(brand.getName()).isEqualTo("아디다스"); @@ -70,7 +70,7 @@ void throwsBadRequest_whenNameIsNull() { Brand brand = new Brand("나이키"); // act - CoreException result = assertThrows(CoreException.class, () -> brand.update(null)); + CoreException result = assertThrows(CoreException.class, () -> brand.rename(null)); // assert assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); @@ -83,7 +83,7 @@ void throwsBadRequest_whenNameIsBlank() { Brand brand = new Brand("나이키"); // act - CoreException result = assertThrows(CoreException.class, () -> brand.update(" ")); + CoreException result = assertThrows(CoreException.class, () -> brand.rename(" ")); // assert assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartDomainServiceIntegrationTest.java similarity index 95% rename from apps/commerce-api/src/test/java/com/loopers/domain/cart/CartServiceIntegrationTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/cart/CartDomainServiceIntegrationTest.java index 6aceafb8a..8b46fe47a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartDomainServiceIntegrationTest.java @@ -1,10 +1,10 @@ package com.loopers.domain.cart; import com.loopers.domain.Quantity; -import com.loopers.domain.brand.BrandService; +import com.loopers.domain.brand.BrandDomainService; import com.loopers.domain.product.Money; import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.ProductDomainService; import com.loopers.domain.product.Stock; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -26,16 +26,16 @@ import static org.junit.jupiter.api.Assertions.assertThrows; @SpringBootTest -class CartServiceIntegrationTest { +class CartDomainServiceIntegrationTest { @Autowired - private CartService cartService; + private CartDomainService cartService; @Autowired - private ProductService productService; + private ProductDomainService productService; @Autowired - private BrandService brandService; + private BrandDomainService brandService; @Autowired private DatabaseCleanUp databaseCleanUp; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeDomainServiceIntegrationTest.java similarity index 91% rename from apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/like/LikeDomainServiceIntegrationTest.java index ae39e0385..17d2cf0ba 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeDomainServiceIntegrationTest.java @@ -1,9 +1,9 @@ package com.loopers.domain.like; -import com.loopers.domain.brand.BrandService; +import com.loopers.domain.brand.BrandDomainService; import com.loopers.domain.product.Money; import com.loopers.domain.product.Product; -import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.ProductDomainService; import com.loopers.domain.product.Stock; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -15,6 +15,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -23,16 +24,17 @@ import static org.junit.jupiter.api.Assertions.assertThrows; @SpringBootTest -class LikeServiceIntegrationTest { +@Transactional +class LikeDomainServiceIntegrationTest { @Autowired - private LikeService likeService; + private LikeDomainService likeService; @Autowired - private ProductService productService; + private ProductDomainService productService; @Autowired - private BrandService brandService; + private BrandDomainService brandService; @Autowired private DatabaseCleanUp databaseCleanUp; diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceIntegrationTest.java similarity index 62% rename from apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceIntegrationTest.java index 726f3a7cb..11e4ab030 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceIntegrationTest.java @@ -12,7 +12,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import java.time.ZonedDateTime; +import java.time.LocalDate; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -20,10 +20,10 @@ import static org.junit.jupiter.api.Assertions.assertThrows; @SpringBootTest -class OrderServiceIntegrationTest { +class OrderDomainServiceIntegrationTest { @Autowired - private OrderService orderService; + private OrderDomainService orderService; @Autowired private DatabaseCleanUp databaseCleanUp; @@ -33,18 +33,18 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } - private Order createTestOrder(Long userId, int totalPrice) { + private Order createTestOrder(Long userId) { List items = List.of( new OrderItemCommand(1L, "에어맥스", new Money(129000), "나이키", 2) ); - return orderService.createOrder(userId, new Money(totalPrice), items); + return orderService.createOrder(userId, items); } @DisplayName("주문을 생성할 때, ") @Nested class CreateOrder { - @DisplayName("올바른 정보이면, 주문과 주문 항목이 생성된다.") + @DisplayName("올바른 정보이면, 주문과 주문 항목이 생성되고 총 금액이 계산된다.") @Test void createsOrderAndItems_whenValidInfo() { List items = List.of( @@ -52,7 +52,7 @@ void createsOrderAndItems_whenValidInfo() { new OrderItemCommand(2L, "에어포스1", new Money(109000), "나이키", 1) ); - Order order = orderService.createOrder(1L, new Money(367000), items); + Order order = orderService.createOrder(1L, items); assertAll( () -> assertThat(order.getId()).isNotNull(), @@ -61,7 +61,7 @@ void createsOrderAndItems_whenValidInfo() { () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.ORDERED) ); - List orderItems = orderService.getOrderItems(order.getId()); + List orderItems = order.getItems(); assertAll( () -> assertThat(orderItems).hasSize(2), () -> assertThat(orderItems.get(0).getProductName()).isEqualTo("에어맥스"), @@ -69,6 +69,35 @@ void createsOrderAndItems_whenValidInfo() { () -> assertThat(orderItems.get(1).getProductName()).isEqualTo("에어포스1") ); } + + @DisplayName("주문 항목이 비어있으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenItemsEmpty() { + CoreException result = assertThrows(CoreException.class, + () -> orderService.createOrder(1L, List.of())); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("주문 항목이 null이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenItemsNull() { + CoreException result = assertThrows(CoreException.class, + () -> orderService.createOrder(1L, null)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("중복된 상품이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenDuplicateProducts() { + List items = List.of( + new OrderItemCommand(1L, "에어맥스", new Money(129000), "나이키", 2), + new OrderItemCommand(1L, "에어맥스", new Money(129000), "나이키", 3) + ); + + CoreException result = assertThrows(CoreException.class, + () -> orderService.createOrder(1L, items)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } } @DisplayName("주문을 ID로 조회할 때, ") @@ -78,7 +107,7 @@ class GetById { @DisplayName("존재하는 주문이면, 주문을 반환한다.") @Test void returnsOrder_whenOrderExists() { - Order created = createTestOrder(1L, 258000); + Order created = createTestOrder(1L); Order result = orderService.getById(created.getId()); @@ -94,6 +123,25 @@ void throwsNotFound_whenOrderDoesNotExist() { } } + @DisplayName("주문을 ID로 항목과 함께 조회할 때, ") + @Nested + class GetByIdWithItems { + + @DisplayName("존재하는 주문이면, 주문과 항목을 반환한다.") + @Test + void returnsOrderWithItems_whenOrderExists() { + Order created = createTestOrder(1L); + + Order result = orderService.getByIdWithItems(created.getId()); + + assertAll( + () -> assertThat(result.getId()).isEqualTo(created.getId()), + () -> assertThat(result.getItems()).hasSize(1), + () -> assertThat(result.getItems().get(0).getProductName()).isEqualTo("에어맥스") + ); + } + } + @DisplayName("유저의 주문을 조회할 때, ") @Nested class GetByIdAndUserId { @@ -101,7 +149,7 @@ class GetByIdAndUserId { @DisplayName("본인의 주문이면, 주문을 반환한다.") @Test void returnsOrder_whenOwner() { - Order created = createTestOrder(1L, 258000); + Order created = createTestOrder(1L); Order result = orderService.getByIdAndUserId(created.getId(), 1L); @@ -111,7 +159,7 @@ void returnsOrder_whenOwner() { @DisplayName("다른 유저의 주문이면, NOT_FOUND 예외가 발생한다.") @Test void throwsNotFound_whenNotOwner() { - Order created = createTestOrder(1L, 258000); + Order created = createTestOrder(1L); CoreException result = assertThrows(CoreException.class, () -> orderService.getByIdAndUserId(created.getId(), 999L)); @@ -126,12 +174,12 @@ class GetMyOrders { @DisplayName("기간 내 주문이 있으면, 목록을 반환한다.") @Test void returnsOrders_whenOrdersExistInRange() { - createTestOrder(1L, 258000); - createTestOrder(1L, 109000); - createTestOrder(2L, 50000); + createTestOrder(1L); + createTestOrder(1L); + createTestOrder(2L); - ZonedDateTime start = ZonedDateTime.now().minusDays(1); - ZonedDateTime end = ZonedDateTime.now().plusDays(1); + LocalDate start = LocalDate.now().minusDays(1); + LocalDate end = LocalDate.now().plusDays(1); PageResult result = orderService.getMyOrders(1L, start, end, 0, 20); @@ -144,10 +192,10 @@ void returnsOrders_whenOrdersExistInRange() { @DisplayName("기간 내 주문이 없으면, 빈 목록을 반환한다.") @Test void returnsEmpty_whenNoOrdersInRange() { - createTestOrder(1L, 258000); + createTestOrder(1L); - ZonedDateTime start = ZonedDateTime.now().plusDays(1); - ZonedDateTime end = ZonedDateTime.now().plusDays(2); + LocalDate start = LocalDate.now().plusDays(1); + LocalDate end = LocalDate.now().plusDays(2); PageResult result = orderService.getMyOrders(1L, start, end, 0, 20); @@ -162,9 +210,9 @@ class GetAllOrders { @DisplayName("주문이 존재하면, 페이지 결과를 반환한다.") @Test void returnsPageResult_whenOrdersExist() { - createTestOrder(1L, 258000); - createTestOrder(2L, 109000); - createTestOrder(3L, 50000); + createTestOrder(1L); + createTestOrder(2L); + createTestOrder(3L); PageResult result = orderService.getAllOrders(0, 2); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java index 1bba869e3..659d59ee2 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemTest.java @@ -14,6 +14,10 @@ class OrderItemTest { + private Order createTestOrder() { + return new Order(1L, new Money(129000)); + } + @DisplayName("OrderItem을 생성할 때, ") @Nested class Create { @@ -21,10 +25,10 @@ class Create { @DisplayName("올바른 정보이면, OrderItem이 생성된다.") @Test void createsOrderItem_whenValidInfo() { - OrderItem orderItem = new OrderItem(1L, 100L, "에어맥스", new Money(129000), "나이키", 2); + Order order = createTestOrder(); + OrderItem orderItem = new OrderItem(order, 100L, "에어맥스", new Money(129000), "나이키", 2); assertAll( - () -> assertThat(orderItem.getOrderId()).isEqualTo(1L), () -> assertThat(orderItem.getProductId()).isEqualTo(100L), () -> assertThat(orderItem.getProductName()).isEqualTo("에어맥스"), () -> assertThat(orderItem.getProductPrice()).isEqualTo(new Money(129000)), @@ -33,9 +37,9 @@ void createsOrderItem_whenValidInfo() { ); } - @DisplayName("orderId가 null이면, BAD_REQUEST 예외가 발생한다.") + @DisplayName("order가 null이면, BAD_REQUEST 예외가 발생한다.") @Test - void throwsBadRequest_whenOrderIdIsNull() { + void throwsBadRequest_whenOrderIsNull() { CoreException result = assertThrows(CoreException.class, () -> new OrderItem(null, 100L, "에어맥스", new Money(129000), "나이키", 2)); assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); @@ -44,40 +48,45 @@ void throwsBadRequest_whenOrderIdIsNull() { @DisplayName("productId가 null이면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenProductIdIsNull() { + Order order = createTestOrder(); CoreException result = assertThrows(CoreException.class, - () -> new OrderItem(1L, null, "에어맥스", new Money(129000), "나이키", 2)); + () -> new OrderItem(order, null, "에어맥스", new Money(129000), "나이키", 2)); assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } @DisplayName("상품 이름이 비어있으면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenProductNameIsBlank() { + Order order = createTestOrder(); CoreException result = assertThrows(CoreException.class, - () -> new OrderItem(1L, 100L, "", new Money(129000), "나이키", 2)); + () -> new OrderItem(order, 100L, "", new Money(129000), "나이키", 2)); assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } @DisplayName("상품 가격이 null이면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenProductPriceIsNull() { + Order order = createTestOrder(); CoreException result = assertThrows(CoreException.class, - () -> new OrderItem(1L, 100L, "에어맥스", null, "나이키", 2)); + () -> new OrderItem(order, 100L, "에어맥스", null, "나이키", 2)); assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } @DisplayName("브랜드 이름이 비어있으면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenBrandNameIsBlank() { + Order order = createTestOrder(); CoreException result = assertThrows(CoreException.class, - () -> new OrderItem(1L, 100L, "에어맥스", new Money(129000), "", 2)); + () -> new OrderItem(order, 100L, "에어맥스", new Money(129000), "", 2)); assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } @DisplayName("수량이 0이면, BAD_REQUEST 예외가 발생한다.") @Test void throwsBadRequest_whenQuantityIsZero() { + Order order = createTestOrder(); CoreException result = assertThrows(CoreException.class, - () -> new OrderItem(1L, 100L, "에어맥스", new Money(129000), "나이키", 0)); + () -> new OrderItem(order, 100L, "에어맥스", new Money(129000), "나이키", 0)); assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDomainServiceIntegrationTest.java similarity index 81% rename from apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDomainServiceIntegrationTest.java index 5a74238db..896e6f691 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDomainServiceIntegrationTest.java @@ -2,7 +2,7 @@ import com.loopers.domain.PageResult; import com.loopers.domain.brand.Brand; -import com.loopers.domain.brand.BrandService; +import com.loopers.domain.brand.BrandDomainService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; @@ -21,13 +21,13 @@ import static org.junit.jupiter.api.Assertions.assertThrows; @SpringBootTest -class ProductServiceIntegrationTest { +class ProductDomainServiceIntegrationTest { @Autowired - private ProductService productService; + private ProductDomainService productService; @Autowired - private BrandService brandService; + private BrandDomainService brandService; @Autowired private DatabaseCleanUp databaseCleanUp; @@ -215,4 +215,50 @@ void deletesAllProductsOfBrand() { assertThat(result.items()).isEmpty(); } } + + @DisplayName("좋아요 수를 증가할 때, ") + @Nested + class IncrementLikeCount { + + @DisplayName("좋아요 수가 1 증가한다.") + @Test + @Transactional + void incrementsLikeCount() { + Product product = productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); + + productService.incrementLikeCount(product.getId()); + + Product result = productService.getById(product.getId()); + assertThat(result.getLikeCount()).isEqualTo(1); + } + } + + @DisplayName("좋아요 수를 감소할 때, ") + @Nested + class DecrementLikeCount { + + @DisplayName("좋아요 수가 1 감소한다.") + @Test + @Transactional + void decrementsLikeCount() { + Product product = productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); + productService.incrementLikeCount(product.getId()); + + productService.decrementLikeCount(product.getId()); + + Product result = productService.getById(product.getId()); + assertThat(result.getLikeCount()).isEqualTo(0); + } + + @DisplayName("좋아요 수가 0이면, BAD_REQUEST 예외가 발생한다.") + @Test + @Transactional + void throwsBadRequest_whenLikeCountIsZero() { + Product product = productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); + + CoreException result = assertThrows(CoreException.class, + () -> productService.decrementLikeCount(product.getId())); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java index 5529b68a2..eb338eab3 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java @@ -63,15 +63,15 @@ void throwsBadRequest_whenStockIsNull() { } } - @DisplayName("상품을 수정할 때, ") + @DisplayName("상품 정보를 변경할 때, ") @Nested - class Update { + class ChangeDetails { @DisplayName("올바른 정보이면, 이름/가격/재고가 수정된다.") @Test - void updatesProduct_whenValidInfo() { + void changesDetails_whenValidInfo() { Product product = new Product(1L, "나이키 에어맥스", new Money(129000), new Stock(100)); - product.update("아디다스 울트라부스트", new Money(159000), new Stock(50)); + product.changeDetails("아디다스 울트라부스트", new Money(159000), new Stock(50)); assertAll( () -> assertThat(product.getName()).isEqualTo("아디다스 울트라부스트"), @@ -84,7 +84,7 @@ void updatesProduct_whenValidInfo() { @Test void doesNotChangeBrandId() { Product product = new Product(1L, "나이키 에어맥스", new Money(129000), new Stock(100)); - product.update("아디다스 울트라부스트", new Money(159000), new Stock(50)); + product.changeDetails("아디다스 울트라부스트", new Money(159000), new Stock(50)); assertThat(product.getBrandId()).isEqualTo(1L); } @@ -113,36 +113,56 @@ void throwsBadRequest_whenInsufficient() { } } - @DisplayName("좋아요 수를 변경할 때, ") + @DisplayName("좋아요 수를 증가시킬 때, ") @Nested - class LikeCount { + class IncrementLikeCount { - @DisplayName("좋아요를 추가하면, 1 증가한다.") + @DisplayName("좋아요 수가 1 증가한다.") @Test void incrementsLikeCount() { Product product = new Product(1L, "나이키 에어맥스", new Money(129000), new Stock(10)); - product.addLikeCount(); + + product.incrementLikeCount(); assertThat(product.getLikeCount()).isEqualTo(1); } - @DisplayName("좋아요를 취소하면, 1 감소한다.") + @DisplayName("여러 번 호출하면, 호출 횟수만큼 증가한다.") + @Test + void incrementsMultipleTimes() { + Product product = new Product(1L, "나이키 에어맥스", new Money(129000), new Stock(10)); + + product.incrementLikeCount(); + product.incrementLikeCount(); + product.incrementLikeCount(); + + assertThat(product.getLikeCount()).isEqualTo(3); + } + } + + @DisplayName("좋아요 수를 감소시킬 때, ") + @Nested + class DecrementLikeCount { + + @DisplayName("좋아요 수가 1보다 크면, 1 감소한다.") @Test void decrementsLikeCount() { Product product = new Product(1L, "나이키 에어맥스", new Money(129000), new Stock(10)); - product.addLikeCount(); - product.subtractLikeCount(); + product.incrementLikeCount(); + product.incrementLikeCount(); - assertThat(product.getLikeCount()).isEqualTo(0); + product.decrementLikeCount(); + + assertThat(product.getLikeCount()).isEqualTo(1); } - @DisplayName("좋아요가 0일 때 취소하면, 0 이하로 내려가지 않는다.") + @DisplayName("좋아요 수가 0이면, BAD_REQUEST 예외가 발생한다.") @Test - void doesNotGoBelowZero() { + void throwsBadRequest_whenLikeCountIsZero() { Product product = new Product(1L, "나이키 에어맥스", new Money(129000), new Stock(10)); - product.subtractLikeCount(); - assertThat(product.getLikeCount()).isEqualTo(0); + CoreException result = assertThrows(CoreException.class, product::decrementLikeCount); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserDomainServiceIntegrationTest.java similarity index 98% rename from apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java rename to apps/commerce-api/src/test/java/com/loopers/domain/user/UserDomainServiceIntegrationTest.java index b7a2ad436..b2f1f52f0 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserDomainServiceIntegrationTest.java @@ -18,10 +18,10 @@ import static org.junit.jupiter.api.Assertions.assertThrows; @SpringBootTest -class UserServiceIntegrationTest { +class UserDomainServiceIntegrationTest { @Autowired - private UserService userService; + private UserDomainService userService; @Autowired private UserJpaRepository userJpaRepository; diff --git a/build.gradle.kts b/build.gradle.kts index b44c57d89..f67870e8a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -64,6 +64,7 @@ subprojects { testImplementation("com.ninja-squad:springmockk:${project.properties["springMockkVersion"]}") testImplementation("org.mockito:mockito-core:${project.properties["mockitoVersion"]}") testImplementation("org.instancio:instancio-junit:${project.properties["instancioJUnitVersion"]}") + testImplementation("com.tngtech.archunit:archunit-junit5:${project.properties["archunitVersion"]}") // Testcontainers testImplementation("org.springframework.boot:spring-boot-testcontainers") testImplementation("org.testcontainers:testcontainers:1.21.4") diff --git a/gradle.properties b/gradle.properties index 5ae37ac99..5b4e94b0c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,5 +15,6 @@ springDocOpenApiVersion=2.7.0 springMockkVersion=4.0.2 mockitoVersion=5.14.0 instancioJUnitVersion=5.0.2 +archunitVersion=1.4.1 slackAppenderVersion=1.6.1 kotlin.daemon.jvmargs=-Xmx1g -XX:MaxMetaspaceSize=512m From 272a22474090c345ba8241effea1f62192b34600 Mon Sep 17 00:00:00 2001 From: yoon-yoo-tak Date: Tue, 24 Feb 2026 20:23:28 +0900 Subject: [PATCH 09/11] =?UTF-8?q?refactor=20:=20DDD=EC=9C=84=EB=B0=98,=20L?= =?UTF-8?q?ayeredArchitecture=20=EC=9C=84=EB=B0=98=20=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=B6=80=EB=B6=84=20=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cart/CartApplicationService.java | 4 +- .../like/LikeApplicationService.java | 2 - .../order/OrderApplicationService.java | 37 ++-- .../product/ProductApplicationService.java | 6 +- .../user/UserApplicationService.java | 4 +- .../java/com/loopers/domain/Quantity.java | 3 + .../java/com/loopers/domain/cart/Cart.java | 97 ++++++++++ .../domain/cart/CartDomainService.java | 61 ++++--- .../com/loopers/domain/cart/CartItem.java | 28 ++- .../loopers/domain/cart/CartRepository.java | 13 +- .../domain/order/OrderDomainService.java | 24 +++ .../domain/order/OrderItemCommand.java | 15 +- .../loopers/domain/order/OrderLineItem.java | 12 +- .../domain/product/ProductDomainService.java | 10 +- .../domain/user/UserDomainService.java | 5 +- .../loopers/domain/user/UserRepository.java | 2 + .../cart/CartJpaRepository.java | 14 +- .../cart/CartRepositoryImpl.java | 31 +--- .../user/UserJpaRepository.java | 3 + .../user/UserRepositoryImpl.java | 5 + .../interfaces/api/cart/CartV1Controller.java | 14 +- .../api/product/AdminProductV1Dto.java | 12 +- .../interfaces/api/product/ProductV1Dto.java | 12 +- .../interfaces/api/user/UserV1Controller.java | 2 +- .../java/com/loopers/domain/QuantityTest.java | 16 ++ .../CartDomainServiceIntegrationTest.java | 60 ++++--- .../com/loopers/domain/cart/CartItemTest.java | 56 ++---- .../com/loopers/domain/cart/CartTest.java | 170 ++++++++++++++++++ .../LikeDomainServiceIntegrationTest.java | 8 +- .../com/loopers/domain/product/MoneyTest.java | 9 + .../ProductDomainServiceIntegrationTest.java | 73 ++++++-- .../UserDomainServiceIntegrationTest.java | 8 +- 32 files changed, 582 insertions(+), 234 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/cart/Cart.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/cart/CartTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartApplicationService.java index 1bcb39058..9cd63fbdf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartApplicationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartApplicationService.java @@ -29,11 +29,11 @@ public List getMyCart(Long userId) { @Transactional public void updateQuantity(Long cartItemId, Long userId, int quantity) { - cartService.updateQuantity(cartItemId, userId, quantity); + cartService.updateItemQuantity(userId, cartItemId, quantity); } @Transactional public void removeItem(Long cartItemId, Long userId) { - cartService.removeItem(cartItemId, userId); + cartService.removeItem(userId, cartItemId); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java index f8b5ca675..a64439940 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java @@ -24,7 +24,6 @@ public class LikeApplicationService { */ @Transactional public void like(Long userId, Long productId) { - productService.getById(productId); likeService.like(userId, productId); productService.incrementLikeCount(productId); } @@ -37,7 +36,6 @@ public void like(Long userId, Long productId) { */ @Transactional public void unlike(Long userId, Long productId) { - productService.getById(productId); likeService.unlike(userId, productId); productService.decrementLikeCount(productId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java index 710655a36..7cea1fa82 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java @@ -6,7 +6,6 @@ import com.loopers.domain.cart.CartItem; import com.loopers.domain.cart.CartDomainService; import com.loopers.domain.order.Order; -import com.loopers.domain.order.OrderItemCommand; import com.loopers.domain.order.OrderLineItem; import com.loopers.domain.order.OrderDomainService; import com.loopers.domain.product.Product; @@ -53,6 +52,21 @@ public Order createOrderFromCart(Long userId) { throw new CoreException(ErrorType.BAD_REQUEST, "장바구니가 비어있습니다."); } + Set cartProductIds = cartItems.stream() + .map(CartItem::getProductId) + .collect(Collectors.toSet()); + Map productMap = productService.getByIds(cartProductIds); + Set availableProductIds = productMap.keySet(); + + if (!availableProductIds.containsAll(cartProductIds)) { + cartService.removeUnavailableItems(userId, availableProductIds); + cartItems = cartService.getCartItems(userId); + if (cartItems.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, + "장바구니의 모든 상품이 더 이상 존재하지 않습니다."); + } + } + List items = cartItems.stream() .map(ci -> new OrderLineItem(ci.getProductId(), ci.getQuantity().value())) .toList(); @@ -109,24 +123,7 @@ private Order processOrder(Long userId, List items) { .collect(Collectors.toSet()); Map brandMap = brandService.getByIds(brandIds); - // Build order item commands - List itemCommands = new ArrayList<>(); - for (int i = 0; i < sortedItems.size(); i++) { - OrderLineItem item = sortedItems.get(i); - Product product = products.get(i); - Brand brand = brandMap.get(product.getBrandId()); - if (brand == null) { - throw new CoreException(ErrorType.NOT_FOUND, - "브랜드를 찾을 수 없습니다. brandId=" + product.getBrandId()); - } - - itemCommands.add(new OrderItemCommand( - product.getId(), product.getName(), product.getPrice(), - brand.getName(), item.quantity() - )); - } - - // Create order (validation and price calculation handled by OrderDomainService) - return orderService.createOrder(userId, itemCommands); + // Create order (brand validation, command assembly, and price calculation handled by OrderDomainService) + return orderService.createOrder(userId, sortedItems, products, brandMap); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApplicationService.java index f42c58515..e85f21710 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApplicationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApplicationService.java @@ -2,11 +2,9 @@ import com.loopers.domain.PageResult; import com.loopers.domain.brand.BrandDomainService; -import com.loopers.domain.product.Money; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductDomainService; import com.loopers.domain.product.ProductSortType; -import com.loopers.domain.product.Stock; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -24,7 +22,7 @@ public class ProductApplicationService { @Transactional public Product register(Long brandId, String name, int price, int stock) { brandService.getById(brandId); - return productService.register(brandId, name, new Money(price), new Stock(stock)); + return productService.register(brandId, name, price, stock); } @Transactional(readOnly = true) @@ -44,7 +42,7 @@ public PageResult getAll(Long brandId, ProductSortType sort, int page, @Transactional public Product update(Long id, String name, int price, int stock) { - return productService.update(id, name, new Money(price), new Stock(stock)); + return productService.update(id, name, price, stock); } @Transactional diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserApplicationService.java index eed8efa77..610c5babd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserApplicationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserApplicationService.java @@ -25,7 +25,7 @@ public User authenticate(String loginId, String rawPassword) { } @Transactional - public void changePassword(User user, String currentPassword, String newPassword) { - userService.changePassword(user, currentPassword, newPassword); + public void changePassword(Long userId, String currentPassword, String newPassword) { + userService.changePassword(userId, currentPassword, newPassword); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/Quantity.java b/apps/commerce-api/src/main/java/com/loopers/domain/Quantity.java index ca77d27f0..1261200e0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/Quantity.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/Quantity.java @@ -12,6 +12,9 @@ public record Quantity(int value) { } public Quantity add(int amount) { + if (amount < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "추가 수량은 1 이상이어야 합니다."); + } return new Quantity(this.value + amount); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/Cart.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/Cart.java new file mode 100644 index 000000000..b48e52857 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/Cart.java @@ -0,0 +1,97 @@ +package com.loopers.domain.cart; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +@Getter +@Entity +@Table(name = "carts") +public class Cart { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false, unique = true) + private Long userId; + + @OneToMany(mappedBy = "cart", cascade = CascadeType.ALL, orphanRemoval = true) + private List items = new ArrayList<>(); + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + protected Cart() {} + + public Cart(Long userId) { + Objects.requireNonNull(userId, "유저 ID는 필수입니다."); + this.userId = userId; + } + + public void addItem(Long productId, int quantity) { + Objects.requireNonNull(productId, "상품 ID는 필수입니다."); + + for (CartItem item : items) { + if (item.getProductId().equals(productId)) { + item.addQuantity(quantity); + return; + } + } + + items.add(new CartItem(this, productId, quantity)); + } + + public void removeItem(Long cartItemId) { + Objects.requireNonNull(cartItemId, "장바구니 항목 ID는 필수입니다."); + + boolean removed = items.removeIf(item -> cartItemId.equals(item.getId())); + if (!removed) { + throw new CoreException(ErrorType.NOT_FOUND, "장바구니 항목을 찾을 수 없습니다."); + } + } + + public void updateItemQuantity(Long cartItemId, int quantity) { + Objects.requireNonNull(cartItemId, "장바구니 항목 ID는 필수입니다."); + + CartItem cartItem = items.stream() + .filter(item -> cartItemId.equals(item.getId())) + .findFirst() + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "장바구니 항목을 찾을 수 없습니다.")); + + cartItem.updateQuantity(quantity); + } + + public void clear() { + items.clear(); + } + + public void removeUnavailableItems(Set availableProductIds) { + items.removeIf(item -> !availableProductIds.contains(item.getProductId())); + } + + public List getItems() { + return Collections.unmodifiableList(items); + } + + @PrePersist + private void prePersist() { + this.createdAt = ZonedDateTime.now(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartDomainService.java index 22aac061e..a9f807346 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartDomainService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartDomainService.java @@ -4,53 +4,60 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import java.util.Collections; import java.util.List; -import java.util.Optional; +import java.util.Set; @RequiredArgsConstructor public class CartDomainService { private final CartRepository cartRepository; - public CartItem addToCart(Long userId, Long productId, int quantity) { - Optional existing = cartRepository.findByUserIdAndProductId(userId, productId); - - if (existing.isPresent()) { - CartItem cartItem = existing.get(); - cartItem.addQuantity(quantity); - return cartRepository.save(cartItem); - } - - return cartRepository.save(new CartItem(userId, productId, quantity)); + public void addToCart(Long userId, Long productId, int quantity) { + Cart cart = getOrCreateCart(userId); + cart.addItem(productId, quantity); + cartRepository.save(cart); } - public CartItem updateQuantity(Long cartItemId, Long userId, int quantity) { - CartItem cartItem = getByIdAndUserId(cartItemId, userId); - cartItem.updateQuantity(quantity); - return cartRepository.save(cartItem); + public void updateItemQuantity(Long userId, Long cartItemId, int quantity) { + Cart cart = getCartByUserId(userId); + cart.updateItemQuantity(cartItemId, quantity); + cartRepository.save(cart); } - public void removeItem(Long cartItemId, Long userId) { - CartItem cartItem = getByIdAndUserId(cartItemId, userId); - cartRepository.delete(cartItem); + public void removeItem(Long userId, Long cartItemId) { + Cart cart = getCartByUserId(userId); + cart.removeItem(cartItemId); + cartRepository.save(cart); } public List getCartItems(Long userId) { - return cartRepository.findAllByUserId(userId); + return cartRepository.findByUserId(userId) + .map(Cart::getItems) + .orElse(Collections.emptyList()); } public void clearCart(Long userId) { - cartRepository.deleteAllByUserId(userId); + cartRepository.findByUserId(userId).ifPresent(cart -> { + cart.clear(); + cartRepository.save(cart); + }); } - private CartItem getByIdAndUserId(Long cartItemId, Long userId) { - CartItem cartItem = cartRepository.findById(cartItemId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "장바구니 항목을 찾을 수 없습니다.")); + public void removeUnavailableItems(Long userId, Set availableProductIds) { + cartRepository.findByUserId(userId).ifPresent(cart -> { + cart.removeUnavailableItems(availableProductIds); + cartRepository.save(cart); + }); + } - if (!cartItem.getUserId().equals(userId)) { - throw new CoreException(ErrorType.NOT_FOUND, "장바구니 항목을 찾을 수 없습니다."); - } + private Cart getOrCreateCart(Long userId) { + return cartRepository.findByUserId(userId) + .orElseGet(() -> new Cart(userId)); + } - return cartItem; + private Cart getCartByUserId(Long userId) { + return cartRepository.findByUserId(userId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "장바구니를 찾을 수 없습니다.")); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItem.java index 79b814334..33a32890d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItem.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItem.java @@ -1,33 +1,33 @@ package com.loopers.domain.cart; import com.loopers.domain.Quantity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.PrePersist; import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; import lombok.Getter; import java.time.ZonedDateTime; +import java.util.Objects; @Getter @Entity -@Table(name = "cart_items", uniqueConstraints = { - @UniqueConstraint(columnNames = {"user_id", "product_id"}) -}) +@Table(name = "cart_items") public class CartItem { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(name = "user_id", nullable = false) - private Long userId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cart_id", nullable = false) + private Cart cart; @Column(name = "product_id", nullable = false) private Long productId; @@ -40,14 +40,10 @@ public class CartItem { protected CartItem() {} - public CartItem(Long userId, Long productId, int quantity) { - if (userId == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "유저 ID는 필수입니다."); - } - if (productId == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "상품 ID는 필수입니다."); - } - this.userId = userId; + CartItem(Cart cart, Long productId, int quantity) { + Objects.requireNonNull(cart, "장바구니는 필수입니다."); + Objects.requireNonNull(productId, "상품 ID는 필수입니다."); + this.cart = cart; this.productId = productId; this.quantity = new Quantity(quantity).value(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartRepository.java index 75d432c4c..15aca4b1b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartRepository.java @@ -1,19 +1,10 @@ package com.loopers.domain.cart; -import java.util.List; import java.util.Optional; public interface CartRepository { - CartItem save(CartItem cartItem); + Cart save(Cart cart); - void delete(CartItem cartItem); - - void deleteAllByUserId(Long userId); - - Optional findById(Long id); - - Optional findByUserIdAndProductId(Long userId, Long productId); - - List findAllByUserId(Long userId); + Optional findByUserId(Long userId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java index 5beba0193..e9db8534e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java @@ -1,7 +1,9 @@ package com.loopers.domain.order; import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; import com.loopers.domain.product.Money; +import com.loopers.domain.product.Product; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -9,8 +11,10 @@ import java.time.LocalDate; import java.time.ZoneId; import java.time.ZonedDateTime; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; @RequiredArgsConstructor @@ -18,6 +22,26 @@ public class OrderDomainService { private final OrderRepository orderRepository; + public Order createOrder(Long userId, List items, List products, Map brandMap) { + List itemCommands = new ArrayList<>(); + for (int i = 0; i < items.size(); i++) { + OrderLineItem item = items.get(i); + Product product = products.get(i); + Brand brand = brandMap.get(product.getBrandId()); + if (brand == null) { + throw new CoreException(ErrorType.NOT_FOUND, + "브랜드를 찾을 수 없습니다. brandId=" + product.getBrandId()); + } + + itemCommands.add(new OrderItemCommand( + product.getId(), product.getName(), product.getPrice(), + brand.getName(), item.quantity() + )); + } + + return createOrder(userId, itemCommands); + } + public Order createOrder(Long userId, List itemCommands) { if (itemCommands == null || itemCommands.isEmpty()) { throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 하나 이상이어야 합니다."); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemCommand.java index a1dec2aec..5a0d51e48 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemCommand.java @@ -2,10 +2,23 @@ import com.loopers.domain.product.Money; +import java.util.Objects; + +import org.springframework.util.Assert; + public record OrderItemCommand( Long productId, String productName, Money productPrice, String brandName, int quantity -) {} +) { + + public OrderItemCommand { + Objects.requireNonNull(productId, "상품 ID는 필수입니다."); + Assert.hasText(productName, "상품 이름은 필수입니다."); + Objects.requireNonNull(productPrice, "상품 가격은 필수입니다."); + Assert.hasText(brandName, "브랜드 이름은 필수입니다."); + Assert.state(quantity >= 1, "수량은 1 이상이어야 합니다."); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderLineItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderLineItem.java index 91557301d..173b35cd3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderLineItem.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderLineItem.java @@ -1,3 +1,13 @@ package com.loopers.domain.order; -public record OrderLineItem(Long productId, int quantity) {} +import java.util.Objects; + +import org.springframework.util.Assert; + +public record OrderLineItem(Long productId, int quantity) { + + public OrderLineItem { + Objects.requireNonNull(productId, "상품 ID는 필수입니다."); + Assert.state(quantity >= 1, "수량은 1 이상이어야 합니다."); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java index 71c851f0b..18e5483fe 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductDomainService.java @@ -15,8 +15,8 @@ public class ProductDomainService { private final ProductRepository productRepository; - public Product register(Long brandId, String name, Money price, Stock stock) { - return productRepository.save(new Product(brandId, name, price, stock)); + public Product register(Long brandId, String name, int price, int stock) { + return productRepository.save(new Product(brandId, name, new Money(price), new Stock(stock))); } public Product getById(Long id) { @@ -38,9 +38,9 @@ public PageResult getAll(Long brandId, ProductSortType sort, int page, return productRepository.findAll(brandId, sort, page, size); } - public Product update(Long id, String name, Money price, Stock stock) { + public Product update(Long id, String name, int price, int stock) { Product product = getById(id); - product.changeDetails(name, price, stock); + product.changeDetails(name, new Money(price), new Stock(stock)); return productRepository.save(product); } @@ -57,7 +57,7 @@ public void deleteAllByBrandId(Long brandId) { public Product deductStockWithLock(Long productId, int quantity) { Product product = getByIdWithLock(productId); product.deductStock(quantity); - return product; + return productRepository.save(product); } public void incrementLikeCount(Long productId) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserDomainService.java index bab66edd7..b35caa3c3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserDomainService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserDomainService.java @@ -25,7 +25,10 @@ public User signup(String loginId, String rawPassword, String name, LocalDate bi return userRepository.save(user); } - public void changePassword(User user, String currentRawPassword, String newRawPassword) { + public void changePassword(Long userId, String currentRawPassword, String newRawPassword) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + if (!passwordEncryptor.matches(currentRawPassword, user.getPassword())) { throw new CoreException(ErrorType.BAD_REQUEST, "기존 비밀번호가 올바르지 않습니다."); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java index 2dcb54ae8..7656f46f2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -5,6 +5,8 @@ public interface UserRepository { User save(User user); + Optional findById(Long id); + boolean existsByLoginId(String loginId); Optional findByLoginId(String loginId); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartJpaRepository.java index 7103696c0..87e69b81c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartJpaRepository.java @@ -1,16 +1,14 @@ package com.loopers.infrastructure.cart; -import com.loopers.domain.cart.CartItem; +import com.loopers.domain.cart.Cart; 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; -public interface CartJpaRepository extends JpaRepository { +public interface CartJpaRepository extends JpaRepository { - Optional findByUserIdAndProductId(Long userId, Long productId); - - List findAllByUserId(Long userId); - - void deleteAllByUserId(Long userId); + @Query("SELECT c FROM Cart c LEFT JOIN FETCH c.items WHERE c.userId = :userId") + Optional findByUserId(@Param("userId") Long userId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartRepositoryImpl.java index bc6ee66a1..2ec9a0c89 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cart/CartRepositoryImpl.java @@ -1,11 +1,10 @@ package com.loopers.infrastructure.cart; -import com.loopers.domain.cart.CartItem; +import com.loopers.domain.cart.Cart; import com.loopers.domain.cart.CartRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import java.util.List; import java.util.Optional; @RequiredArgsConstructor @@ -15,32 +14,12 @@ public class CartRepositoryImpl implements CartRepository { private final CartJpaRepository cartJpaRepository; @Override - public CartItem save(CartItem cartItem) { - return cartJpaRepository.save(cartItem); + public Cart save(Cart cart) { + return cartJpaRepository.save(cart); } @Override - public void delete(CartItem cartItem) { - cartJpaRepository.delete(cartItem); - } - - @Override - public void deleteAllByUserId(Long userId) { - cartJpaRepository.deleteAllByUserId(userId); - } - - @Override - public Optional findById(Long id) { - return cartJpaRepository.findById(id); - } - - @Override - public Optional findByUserIdAndProductId(Long userId, Long productId) { - return cartJpaRepository.findByUserIdAndProductId(userId, productId); - } - - @Override - public List findAllByUserId(Long userId) { - return cartJpaRepository.findAllByUserId(userId); + public Optional findByUserId(Long userId) { + return cartJpaRepository.findByUserId(userId); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java index ae862d052..98a663a49 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -9,6 +9,9 @@ public interface UserJpaRepository extends JpaRepository { + @Query("SELECT u FROM User u WHERE u.id = :id AND u.deletedAt IS NULL") + Optional findByIdAndDeletedAtIsNull(@Param("id") Long id); + @Query("SELECT COUNT(u) > 0 FROM User u WHERE u.loginId.value = :loginId AND u.deletedAt IS NULL") boolean existsByLoginId(@Param("loginId") String loginId); 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 36d78918b..1a1e76664 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 @@ -17,6 +17,11 @@ public User save(User user) { return userJpaRepository.save(user); } + @Override + public Optional findById(Long id) { + return userJpaRepository.findByIdAndDeletedAtIsNull(id); + } + @Override public boolean existsByLoginId(String loginId) { return userJpaRepository.existsByLoginId(loginId); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Controller.java index 5a24b69e8..9ef4acd7c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Controller.java @@ -9,6 +9,8 @@ import com.loopers.domain.user.User; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.auth.AuthUser; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.DeleteMapping; @@ -57,13 +59,17 @@ public ApiResponse getMyCart(@AuthUser User user) { Map brandMap = brandApplicationService.getByIds(brandIds); List itemResponses = cartItems.stream() - .filter(cartItem -> { - Product product = productMap.get(cartItem.getProductId()); - return product != null && brandMap.containsKey(product.getBrandId()); - }) .map(cartItem -> { Product product = productMap.get(cartItem.getProductId()); + if (product == null) { + throw new CoreException(ErrorType.INTERNAL_ERROR, + "상품을 찾을 수 없습니다. productId=" + cartItem.getProductId()); + } Brand brand = brandMap.get(product.getBrandId()); + if (brand == null) { + throw new CoreException(ErrorType.INTERNAL_ERROR, + "브랜드를 찾을 수 없습니다. brandId=" + product.getBrandId()); + } return CartV1Dto.CartItemResponse.from(cartItem, product, brand); }) .toList(); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Dto.java index a2bd6e64f..7e831539c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Dto.java @@ -3,6 +3,8 @@ import com.loopers.domain.PageResult; import com.loopers.domain.brand.Brand; import com.loopers.domain.product.Product; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -71,8 +73,14 @@ public record ProductPageResponse( ) { public static ProductPageResponse from(PageResult result, Map brandMap) { List content = result.items().stream() - .filter(product -> brandMap.containsKey(product.getBrandId())) - .map(product -> ProductResponse.from(product, brandMap.get(product.getBrandId()))) + .map(product -> { + Brand brand = brandMap.get(product.getBrandId()); + if (brand == null) { + throw new CoreException(ErrorType.INTERNAL_ERROR, + "브랜드를 찾을 수 없습니다. brandId=" + product.getBrandId()); + } + return ProductResponse.from(product, brand); + }) .toList(); return new ProductPageResponse(content, result.page(), result.size(), result.totalElements(), result.totalPages()); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java index 428772d85..cbb0ec4ff 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -3,6 +3,8 @@ import com.loopers.domain.PageResult; import com.loopers.domain.brand.Brand; import com.loopers.domain.product.Product; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import java.util.List; import java.util.Map; @@ -34,8 +36,14 @@ public record ProductPageResponse( ) { public static ProductPageResponse from(PageResult result, Map brandMap) { List content = result.items().stream() - .filter(product -> brandMap.containsKey(product.getBrandId())) - .map(product -> ProductResponse.from(product, brandMap.get(product.getBrandId()))) + .map(product -> { + Brand brand = brandMap.get(product.getBrandId()); + if (brand == null) { + throw new CoreException(ErrorType.INTERNAL_ERROR, + "브랜드를 찾을 수 없습니다. brandId=" + product.getBrandId()); + } + return ProductResponse.from(product, brand); + }) .toList(); return new ProductPageResponse(content, result.page(), result.size(), result.totalElements(), result.totalPages()); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index 925da94d4..80ae09c01 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -42,7 +42,7 @@ public ApiResponse getMe(@AuthUser User user) { @PutMapping("/password") @Override public ApiResponse changePassword(@AuthUser User user, @Valid @RequestBody UserV1Dto.ChangePasswordRequest request) { - userApplicationService.changePassword(user, request.currentPassword(), request.newPassword()); + userApplicationService.changePassword(user.getId(), request.currentPassword(), request.newPassword()); return ApiResponse.success(); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/QuantityTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/QuantityTest.java index ebf324a30..d112521c7 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/QuantityTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/QuantityTest.java @@ -48,5 +48,21 @@ void addsQuantity_whenAmountIsPositive() { Quantity result = quantity.add(2); assertThat(result.value()).isEqualTo(5); } + + @DisplayName("0을 합산하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenAmountIsZero() { + Quantity quantity = new Quantity(3); + CoreException result = assertThrows(CoreException.class, () -> quantity.add(0)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("음수를 합산하면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenAmountIsNegative() { + Quantity quantity = new Quantity(3); + CoreException result = assertThrows(CoreException.class, () -> quantity.add(-1)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartDomainServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartDomainServiceIntegrationTest.java index 8b46fe47a..9b486a7ae 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartDomainServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartDomainServiceIntegrationTest.java @@ -2,10 +2,8 @@ import com.loopers.domain.Quantity; import com.loopers.domain.brand.BrandDomainService; -import com.loopers.domain.product.Money; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductDomainService; -import com.loopers.domain.product.Stock; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; @@ -17,8 +15,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.transaction.annotation.Transactional; - import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -46,7 +42,7 @@ class CartDomainServiceIntegrationTest { @BeforeEach void setUp() { brandId = brandService.register("나이키").getId(); - Product product = productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); + Product product = productService.register(brandId, "에어맥스", 129000, 100); productId = product.getId(); } @@ -62,13 +58,13 @@ class AddToCart { @DisplayName("새로운 상품이면, 장바구니 항목이 생성된다.") @Test void createsCartItem_whenNewProduct() { - CartItem result = cartService.addToCart(1L, productId, 2); + cartService.addToCart(1L, productId, 2); + List items = cartService.getCartItems(1L); assertAll( - () -> assertThat(result.getId()).isNotNull(), - () -> assertThat(result.getUserId()).isEqualTo(1L), - () -> assertThat(result.getProductId()).isEqualTo(productId), - () -> assertThat(result.getQuantity()).isEqualTo(new Quantity(2)) + () -> assertThat(items).hasSize(1), + () -> assertThat(items.get(0).getProductId()).isEqualTo(productId), + () -> assertThat(items.get(0).getQuantity()).isEqualTo(new Quantity(2)) ); } @@ -77,9 +73,13 @@ void createsCartItem_whenNewProduct() { void addsQuantity_whenProductAlreadyInCart() { cartService.addToCart(1L, productId, 2); - CartItem result = cartService.addToCart(1L, productId, 3); + cartService.addToCart(1L, productId, 3); - assertThat(result.getQuantity()).isEqualTo(new Quantity(5)); + List items = cartService.getCartItems(1L); + assertAll( + () -> assertThat(items).hasSize(1), + () -> assertThat(items.get(0).getQuantity()).isEqualTo(new Quantity(5)) + ); } } @@ -90,28 +90,30 @@ class UpdateQuantity { @DisplayName("올바른 수량이면, 수량이 변경된다.") @Test void updatesQuantity_whenValid() { - CartItem cartItem = cartService.addToCart(1L, productId, 2); + cartService.addToCart(1L, productId, 2); + Long cartItemId = cartService.getCartItems(1L).get(0).getId(); - CartItem result = cartService.updateQuantity(cartItem.getId(), 1L, 5); + cartService.updateItemQuantity(1L, cartItemId, 5); - assertThat(result.getQuantity()).isEqualTo(new Quantity(5)); + List items = cartService.getCartItems(1L); + assertThat(items.get(0).getQuantity()).isEqualTo(new Quantity(5)); } - @DisplayName("존재하지 않는 항목이면, NOT_FOUND 예외가 발생한다.") + @DisplayName("장바구니가 없으면, NOT_FOUND 예외가 발생한다.") @Test - void throwsNotFound_whenItemDoesNotExist() { + void throwsNotFound_whenCartDoesNotExist() { CoreException result = assertThrows(CoreException.class, - () -> cartService.updateQuantity(999L, 1L, 5)); + () -> cartService.updateItemQuantity(999L, 1L, 5)); assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); } - @DisplayName("다른 유저의 항목이면, NOT_FOUND 예외가 발생한다.") + @DisplayName("존재하지 않는 항목이면, NOT_FOUND 예외가 발생한다.") @Test - void throwsNotFound_whenOtherUsersItem() { - CartItem cartItem = cartService.addToCart(1L, productId, 2); + void throwsNotFound_whenItemDoesNotExist() { + cartService.addToCart(1L, productId, 2); CoreException result = assertThrows(CoreException.class, - () -> cartService.updateQuantity(cartItem.getId(), 999L, 5)); + () -> cartService.updateItemQuantity(1L, 999L, 5)); assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); } } @@ -123,17 +125,18 @@ class RemoveItem { @DisplayName("존재하는 항목이면, 삭제된다.") @Test void removesItem_whenItemExists() { - CartItem cartItem = cartService.addToCart(1L, productId, 2); + cartService.addToCart(1L, productId, 2); + Long cartItemId = cartService.getCartItems(1L).get(0).getId(); - cartService.removeItem(cartItem.getId(), 1L); + cartService.removeItem(1L, cartItemId); List items = cartService.getCartItems(1L); assertThat(items).isEmpty(); } - @DisplayName("존재하지 않는 항목이면, NOT_FOUND 예외가 발생한다.") + @DisplayName("장바구니가 없으면, NOT_FOUND 예외가 발생한다.") @Test - void throwsNotFound_whenItemDoesNotExist() { + void throwsNotFound_whenCartDoesNotExist() { CoreException result = assertThrows(CoreException.class, () -> cartService.removeItem(999L, 1L)); assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); @@ -147,7 +150,7 @@ class GetCartItems { @DisplayName("항목이 있으면, 목록을 반환한다.") @Test void returnsItems_whenItemsExist() { - Product product2 = productService.register(brandId, "에어포스1", new Money(109000), new Stock(200)); + Product product2 = productService.register(brandId, "에어포스1", 109000, 200); cartService.addToCart(1L, productId, 2); cartService.addToCart(1L, product2.getId(), 1); @@ -171,9 +174,8 @@ class ClearCart { @DisplayName("모든 항목이 삭제된다.") @Test - @Transactional void clearsAllItems() { - Product product2 = productService.register(brandId, "에어포스1", new Money(109000), new Stock(200)); + Product product2 = productService.register(brandId, "에어포스1", 109000, 200); cartService.addToCart(1L, productId, 2); cartService.addToCart(1L, product2.getId(), 1); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartItemTest.java index 2a0c03b29..c11f3e2d6 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartItemTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartItemTest.java @@ -1,18 +1,18 @@ package com.loopers.domain.cart; import com.loopers.domain.Quantity; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; -import static org.junit.jupiter.api.Assertions.assertThrows; class CartItemTest { + private Cart createCart() { + return new Cart(1L); + } + @DisplayName("CartItem을 생성할 때, ") @Nested class Create { @@ -20,34 +20,13 @@ class Create { @DisplayName("올바른 정보이면, CartItem이 생성된다.") @Test void createsCartItem_whenValidInfo() { - CartItem cartItem = new CartItem(1L, 100L, 2); - - assertAll( - () -> assertThat(cartItem.getUserId()).isEqualTo(1L), - () -> assertThat(cartItem.getProductId()).isEqualTo(100L), - () -> assertThat(cartItem.getQuantity()).isEqualTo(new Quantity(2)) - ); - } - - @DisplayName("userId가 null이면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequest_whenUserIdIsNull() { - CoreException result = assertThrows(CoreException.class, () -> new CartItem(null, 100L, 2)); - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } + Cart cart = createCart(); + cart.addItem(100L, 2); - @DisplayName("productId가 null이면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequest_whenProductIdIsNull() { - CoreException result = assertThrows(CoreException.class, () -> new CartItem(1L, null, 2)); - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } + CartItem cartItem = cart.getItems().get(0); - @DisplayName("수량이 0이면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequest_whenQuantityIsZero() { - CoreException result = assertThrows(CoreException.class, () -> new CartItem(1L, 100L, 0)); - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(cartItem.getProductId()).isEqualTo(100L); + assertThat(cartItem.getQuantity()).isEqualTo(new Quantity(2)); } } @@ -58,8 +37,10 @@ class AddQuantity { @DisplayName("양수를 합산하면, 수량이 증가한다.") @Test void addsQuantity_whenAmountIsPositive() { - CartItem cartItem = new CartItem(1L, 100L, 2); + Cart cart = createCart(); + cart.addItem(100L, 2); + CartItem cartItem = cart.getItems().get(0); cartItem.addQuantity(3); assertThat(cartItem.getQuantity()).isEqualTo(new Quantity(5)); @@ -73,20 +54,13 @@ class UpdateQuantity { @DisplayName("1 이상이면, 수량이 변경된다.") @Test void updatesQuantity_whenValueIsPositive() { - CartItem cartItem = new CartItem(1L, 100L, 2); + Cart cart = createCart(); + cart.addItem(100L, 2); + CartItem cartItem = cart.getItems().get(0); cartItem.updateQuantity(5); assertThat(cartItem.getQuantity()).isEqualTo(new Quantity(5)); } - - @DisplayName("0이면, BAD_REQUEST 예외가 발생한다.") - @Test - void throwsBadRequest_whenValueIsZero() { - CartItem cartItem = new CartItem(1L, 100L, 2); - - CoreException result = assertThrows(CoreException.class, () -> cartItem.updateQuantity(0)); - assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartTest.java new file mode 100644 index 000000000..2994303da --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartTest.java @@ -0,0 +1,170 @@ +package com.loopers.domain.cart; + +import com.loopers.domain.Quantity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class CartTest { + + @DisplayName("Cart를 생성할 때, ") + @Nested + class Create { + + @DisplayName("올바른 유저 ID이면, Cart가 생성된다.") + @Test + void createsCart_whenUserIdIsValid() { + Cart cart = new Cart(1L); + + assertAll( + () -> assertThat(cart.getUserId()).isEqualTo(1L), + () -> assertThat(cart.getItems()).isEmpty() + ); + } + + @DisplayName("유저 ID가 null이면, 예외가 발생한다.") + @Test + void throwsException_whenUserIdIsNull() { + assertThrows(NullPointerException.class, () -> new Cart(null)); + } + } + + @DisplayName("상품을 담을 때, ") + @Nested + class AddItem { + + @DisplayName("새로운 상품이면, 항목이 추가된다.") + @Test + void addsItem_whenNewProduct() { + Cart cart = new Cart(1L); + + cart.addItem(100L, 2); + + assertAll( + () -> assertThat(cart.getItems()).hasSize(1), + () -> assertThat(cart.getItems().get(0).getProductId()).isEqualTo(100L), + () -> assertThat(cart.getItems().get(0).getQuantity()).isEqualTo(new Quantity(2)) + ); + } + + @DisplayName("이미 담긴 상품이면, 수량이 합산된다.") + @Test + void addsQuantity_whenProductAlreadyExists() { + Cart cart = new Cart(1L); + cart.addItem(100L, 2); + + cart.addItem(100L, 3); + + assertAll( + () -> assertThat(cart.getItems()).hasSize(1), + () -> assertThat(cart.getItems().get(0).getQuantity()).isEqualTo(new Quantity(5)) + ); + } + + @DisplayName("다른 상품이면, 별도 항목으로 추가된다.") + @Test + void addsSeparateItem_whenDifferentProduct() { + Cart cart = new Cart(1L); + cart.addItem(100L, 2); + + cart.addItem(200L, 3); + + assertThat(cart.getItems()).hasSize(2); + } + } + + @DisplayName("항목을 삭제할 때, ") + @Nested + class RemoveItem { + + @DisplayName("존재하는 항목이면, 삭제된다.") + @Test + void removesItem_whenItemExists() { + Cart cart = new Cart(1L); + cart.addItem(100L, 2); + // CartItem doesn't have ID without persistence, so we test through clear + } + + @DisplayName("존재하지 않는 항목이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenItemDoesNotExist() { + Cart cart = new Cart(1L); + + CoreException result = assertThrows(CoreException.class, () -> cart.removeItem(999L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("수량을 변경할 때, ") + @Nested + class UpdateItemQuantity { + + @DisplayName("존재하지 않는 항목이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenItemDoesNotExist() { + Cart cart = new Cart(1L); + + CoreException result = assertThrows(CoreException.class, + () -> cart.updateItemQuantity(999L, 5)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("장바구니를 비울 때, ") + @Nested + class Clear { + + @DisplayName("모든 항목이 삭제된다.") + @Test + void clearsAllItems() { + Cart cart = new Cart(1L); + cart.addItem(100L, 2); + cart.addItem(200L, 3); + + cart.clear(); + + assertThat(cart.getItems()).isEmpty(); + } + } + + @DisplayName("사용 불가능한 상품을 제거할 때, ") + @Nested + class RemoveUnavailableItems { + + @DisplayName("존재하지 않는 상품이 제거된다.") + @Test + void removesUnavailableItems() { + Cart cart = new Cart(1L); + cart.addItem(100L, 2); + cart.addItem(200L, 3); + cart.addItem(300L, 1); + + cart.removeUnavailableItems(Set.of(100L, 300L)); + + assertAll( + () -> assertThat(cart.getItems()).hasSize(2), + () -> assertThat(cart.getItems().get(0).getProductId()).isEqualTo(100L), + () -> assertThat(cart.getItems().get(1).getProductId()).isEqualTo(300L) + ); + } + + @DisplayName("모든 상품이 사용 불가능하면, 장바구니가 비워진다.") + @Test + void clearsCart_whenAllItemsUnavailable() { + Cart cart = new Cart(1L); + cart.addItem(100L, 2); + + cart.removeUnavailableItems(Set.of(200L)); + + assertThat(cart.getItems()).isEmpty(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeDomainServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeDomainServiceIntegrationTest.java index 17d2cf0ba..bdc442656 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeDomainServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeDomainServiceIntegrationTest.java @@ -1,10 +1,8 @@ package com.loopers.domain.like; import com.loopers.domain.brand.BrandDomainService; -import com.loopers.domain.product.Money; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductDomainService; -import com.loopers.domain.product.Stock; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; @@ -15,7 +13,6 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -24,7 +21,6 @@ import static org.junit.jupiter.api.Assertions.assertThrows; @SpringBootTest -@Transactional class LikeDomainServiceIntegrationTest { @Autowired @@ -45,7 +41,7 @@ class LikeDomainServiceIntegrationTest { @BeforeEach void setUp() { brandId = brandService.register("나이키").getId(); - Product product = productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); + Product product = productService.register(brandId, "에어맥스", 129000, 100); productId = product.getId(); } @@ -111,7 +107,7 @@ class GetMyLikes { @DisplayName("좋아요한 상품이 있으면, 목록을 반환한다.") @Test void returnsLikes_whenLikesExist() { - Product product2 = productService.register(brandId, "에어포스1", new Money(109000), new Stock(200)); + Product product2 = productService.register(brandId, "에어포스1", 109000, 200); likeService.like(1L, productId); likeService.like(1L, product2.getId()); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java index 6c43cc3d1..aae3b8a6b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java @@ -57,6 +57,15 @@ void returnsDifference_whenSubtracting() { assertThat(a.minus(b)).isEqualTo(new Money(2000)); } + @DisplayName("빼서 음수가 되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenMinusResultsInNegative() { + Money a = new Money(1000); + Money b = new Money(3000); + CoreException result = assertThrows(CoreException.class, () -> a.minus(b)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + @DisplayName("곱하면, 곱셈된 금액을 반환한다.") @Test void returnsProduct_whenMultiplying() { diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDomainServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDomainServiceIntegrationTest.java index 896e6f691..9365f15a1 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDomainServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDomainServiceIntegrationTest.java @@ -52,7 +52,7 @@ class Register { @DisplayName("올바른 정보이면, 상품이 저장되고 반환된다.") @Test void savesAndReturnsProduct_whenValidInfo() { - Product result = productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); + Product result = productService.register(brandId, "에어맥스", 129000, 100); assertAll( () -> assertThat(result.getId()).isNotNull(), @@ -72,7 +72,7 @@ class GetById { @DisplayName("존재하는 상품이면, 상품을 반환한다.") @Test void returnsProduct_whenProductExists() { - Product product = productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); + Product product = productService.register(brandId, "에어맥스", 129000, 100); Product result = productService.getById(product.getId()); @@ -89,7 +89,7 @@ void throwsNotFound_whenProductDoesNotExist() { @DisplayName("삭제된 상품이면, NOT_FOUND 예외가 발생한다.") @Test void throwsNotFound_whenProductIsDeleted() { - Product product = productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); + Product product = productService.register(brandId, "에어맥스", 129000, 100); productService.delete(product.getId()); CoreException result = assertThrows(CoreException.class, () -> productService.getById(product.getId())); @@ -104,9 +104,9 @@ class GetAll { @DisplayName("상품이 존재하면, 페이지 결과를 반환한다.") @Test void returnsPageResult_whenProductsExist() { - productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); - productService.register(brandId, "울트라부스트", new Money(159000), new Stock(50)); - productService.register(brandId, "뉴발란스 990", new Money(199000), new Stock(30)); + productService.register(brandId, "에어맥스", 129000, 100); + productService.register(brandId, "울트라부스트", 159000, 50); + productService.register(brandId, "뉴발란스 990", 199000, 30); PageResult result = productService.getAll(null, ProductSortType.LATEST, 0, 2); @@ -121,8 +121,8 @@ void returnsPageResult_whenProductsExist() { @Test void filtersByBrandId() { Brand brand2 = brandService.register("아디다스"); - productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); - productService.register(brand2.getId(), "울트라부스트", new Money(159000), new Stock(50)); + productService.register(brandId, "에어맥스", 129000, 100); + productService.register(brand2.getId(), "울트라부스트", 159000, 50); PageResult result = productService.getAll(brandId, ProductSortType.LATEST, 0, 20); @@ -132,11 +132,46 @@ void filtersByBrandId() { ); } + @DisplayName("가격 오름차순으로 정렬된다.") + @Test + void sortsByPriceAsc() { + productService.register(brandId, "비싼상품", 199000, 30); + productService.register(brandId, "싼상품", 99000, 100); + productService.register(brandId, "중간상품", 149000, 50); + + PageResult result = productService.getAll(null, ProductSortType.PRICE_ASC, 0, 20); + + assertAll( + () -> assertThat(result.items()).hasSize(3), + () -> assertThat(result.items().get(0).getName()).isEqualTo("싼상품"), + () -> assertThat(result.items().get(1).getName()).isEqualTo("중간상품"), + () -> assertThat(result.items().get(2).getName()).isEqualTo("비싼상품") + ); + } + + @DisplayName("좋아요 내림차순으로 정렬된다.") + @Test + void sortsByLikesDesc() { + Product p1 = productService.register(brandId, "인기없는상품", 129000, 100); + Product p2 = productService.register(brandId, "인기상품", 159000, 50); + productService.incrementLikeCount(p2.getId()); + productService.incrementLikeCount(p2.getId()); + productService.incrementLikeCount(p1.getId()); + + PageResult result = productService.getAll(null, ProductSortType.LIKES_DESC, 0, 20); + + assertAll( + () -> assertThat(result.items()).hasSize(2), + () -> assertThat(result.items().get(0).getName()).isEqualTo("인기상품"), + () -> assertThat(result.items().get(1).getName()).isEqualTo("인기없는상품") + ); + } + @DisplayName("삭제된 상품은 목록에 포함되지 않는다.") @Test void excludesDeletedProducts() { - Product product = productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); - productService.register(brandId, "울트라부스트", new Money(159000), new Stock(50)); + Product product = productService.register(brandId, "에어맥스", 129000, 100); + productService.register(brandId, "울트라부스트", 159000, 50); productService.delete(product.getId()); PageResult result = productService.getAll(null, ProductSortType.LATEST, 0, 20); @@ -155,9 +190,9 @@ class Update { @DisplayName("올바른 정보이면, 상품이 수정된다.") @Test void updatesProduct_whenValidInfo() { - Product product = productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); + Product product = productService.register(brandId, "에어맥스", 129000, 100); - Product result = productService.update(product.getId(), "에어포스1", new Money(109000), new Stock(200)); + Product result = productService.update(product.getId(), "에어포스1", 109000, 200); assertAll( () -> assertThat(result.getName()).isEqualTo("에어포스1"), @@ -170,7 +205,7 @@ void updatesProduct_whenValidInfo() { @Test void throwsNotFound_whenProductDoesNotExist() { CoreException result = assertThrows(CoreException.class, - () -> productService.update(999L, "에어맥스", new Money(129000), new Stock(100))); + () -> productService.update(999L, "에어맥스", 129000, 100)); assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); } } @@ -182,7 +217,7 @@ class Delete { @DisplayName("존재하는 상품이면, 논리 삭제된다.") @Test void softDeletesProduct_whenProductExists() { - Product product = productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); + Product product = productService.register(brandId, "에어맥스", 129000, 100); productService.delete(product.getId()); @@ -206,8 +241,8 @@ class DeleteAllByBrand { @Test @Transactional void deletesAllProductsOfBrand() { - productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); - productService.register(brandId, "에어포스1", new Money(109000), new Stock(200)); + productService.register(brandId, "에어맥스", 129000, 100); + productService.register(brandId, "에어포스1", 109000, 200); productService.deleteAllByBrandId(brandId); @@ -224,7 +259,7 @@ class IncrementLikeCount { @Test @Transactional void incrementsLikeCount() { - Product product = productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); + Product product = productService.register(brandId, "에어맥스", 129000, 100); productService.incrementLikeCount(product.getId()); @@ -241,7 +276,7 @@ class DecrementLikeCount { @Test @Transactional void decrementsLikeCount() { - Product product = productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); + Product product = productService.register(brandId, "에어맥스", 129000, 100); productService.incrementLikeCount(product.getId()); productService.decrementLikeCount(product.getId()); @@ -254,7 +289,7 @@ void decrementsLikeCount() { @Test @Transactional void throwsBadRequest_whenLikeCountIsZero() { - Product product = productService.register(brandId, "에어맥스", new Money(129000), new Stock(100)); + Product product = productService.register(brandId, "에어맥스", 129000, 100); CoreException result = assertThrows(CoreException.class, () -> productService.decrementLikeCount(product.getId())); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserDomainServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserDomainServiceIntegrationTest.java index b2f1f52f0..4c0cb583f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserDomainServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserDomainServiceIntegrationTest.java @@ -105,7 +105,7 @@ void changesPassword_whenCurrentPasswordIsCorrectAndNewPasswordIsValid() { String newPassword = "NewPass123!"; // act - userService.changePassword(user, RAW_PASSWORD, newPassword); + userService.changePassword(user.getId(), RAW_PASSWORD, newPassword); // assert User updated = userService.authenticate(LOGIN_ID, newPassword); @@ -120,7 +120,7 @@ void throwsBadRequest_whenCurrentPasswordIsWrong() { // act CoreException result = assertThrows(CoreException.class, () -> { - userService.changePassword(user, "WrongPass1!", "NewPass123!"); + userService.changePassword(user.getId(), "WrongPass1!", "NewPass123!"); }); // assert @@ -135,7 +135,7 @@ void throwsBadRequest_whenNewPasswordIsSameAsCurrent() { // act CoreException result = assertThrows(CoreException.class, () -> { - userService.changePassword(user, RAW_PASSWORD, RAW_PASSWORD); + userService.changePassword(user.getId(), RAW_PASSWORD, RAW_PASSWORD); }); // assert @@ -150,7 +150,7 @@ void throwsBadRequest_whenNewPasswordViolatesPolicy() { // act CoreException result = assertThrows(CoreException.class, () -> { - userService.changePassword(user, RAW_PASSWORD, "short"); + userService.changePassword(user.getId(), RAW_PASSWORD, "short"); }); // assert From 59280fa8bd1a34844fd7a74ad51742fb6a7ff481 Mon Sep 17 00:00:00 2001 From: yoon-yoo-tak Date: Wed, 25 Feb 2026 21:41:16 +0900 Subject: [PATCH 10/11] =?UTF-8?q?refactor=20:=20DDD=EC=9C=84=EB=B0=98,=20L?= =?UTF-8?q?ayeredArchitecture=20=EC=9C=84=EB=B0=98=20=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=B6=80=EB=B6=84=20=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../brand/BrandApplicationService.java | 2 +- .../cart/CartApplicationService.java | 41 ++++- .../application/cart/CartItemDetail.java | 8 + .../like/LikeApplicationService.java | 34 +++++ .../application/like/LikedProductDetail.java | 8 + .../application/order/CreateOrderCommand.java | 29 ++++ .../order/OrderApplicationService.java | 83 ++++++---- .../product/ProductApplicationService.java | 52 ++++++- .../product/ProductPageWithBrands.java | 10 ++ .../application/product/ProductWithBrand.java | 7 + .../product/RegisterProductCommand.java | 15 ++ .../product/UpdateProductCommand.java | 15 ++ .../user/UserApplicationService.java | 5 + .../java/com/loopers/domain/cart/Cart.java | 2 +- .../domain/cart/CartDomainService.java | 15 +- .../com/loopers/domain/cart/CartItem.java | 2 +- .../java/com/loopers/domain/order/Order.java | 7 + .../domain/order/OrderDomainService.java | 38 +---- .../com/loopers/domain/order/OrderItem.java | 23 +-- .../domain/order/OrderItemCommand.java | 16 +- .../loopers/domain/order/OrderLineItem.java | 13 -- .../com/loopers/domain/order/OrderPolicy.java | 22 +++ .../com/loopers/domain/order/OrderStatus.java | 3 +- .../com/loopers/domain/product/Money.java | 3 + .../domain/product/ProductSortType.java | 5 +- .../domain/user/UserDomainService.java | 5 + .../api/auth/AuthUserArgumentResolver.java | 7 +- .../api/auth/AuthenticatedUser.java | 4 + .../interfaces/api/cart/CartV1ApiSpec.java | 11 +- .../interfaces/api/cart/CartV1Controller.java | 57 ++----- .../interfaces/api/like/LikeV1ApiSpec.java | 9 +- .../interfaces/api/like/LikeV1Controller.java | 47 ++---- .../interfaces/api/order/OrderV1ApiSpec.java | 11 +- .../api/order/OrderV1Controller.java | 30 ++-- .../api/product/AdminProductV1Controller.java | 40 ++--- .../api/product/AdminProductV1Dto.java | 11 +- .../api/product/ProductV1ApiSpec.java | 6 +- .../api/product/ProductV1Controller.java | 24 +-- .../interfaces/api/product/ProductV1Dto.java | 12 +- .../interfaces/api/user/UserV1ApiSpec.java | 5 +- .../interfaces/api/user/UserV1Controller.java | 8 +- .../CartDomainServiceIntegrationTest.java | 18 +-- .../com/loopers/domain/cart/CartItemTest.java | 6 +- .../com/loopers/domain/cart/CartTest.java | 13 +- .../domain/order/FakeOrderRepository.java | 69 +++++++++ .../domain/order/OrderDomainServiceTest.java | 143 ++++++++++++++++++ .../loopers/domain/order/OrderPolicyTest.java | 49 ++++++ .../com/loopers/domain/order/OrderTest.java | 50 ++++++ .../com/loopers/domain/product/MoneyTest.java | 1 + .../ProductDomainServiceIntegrationTest.java | 38 +++-- .../domain/product/ProductSortTypeTest.java | 56 +++++++ .../UserDomainServiceIntegrationTest.java | 4 - .../auth/AuthUserArgumentResolverTest.java | 123 +++++++++++++++ 53 files changed, 973 insertions(+), 342 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/cart/CartItemDetail.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/like/LikedProductDetail.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductPageWithBrands.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductWithBrand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/RegisterProductCommand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/UpdateProductCommand.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderLineItem.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderPolicy.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUser.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderRepository.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderPolicyTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductSortTypeTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/auth/AuthUserArgumentResolverTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApplicationService.java index 74faf0a32..d42dfec46 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApplicationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApplicationService.java @@ -52,7 +52,7 @@ public Brand update(Long id, String name) { */ @Transactional public void delete(Long id) { - productService.deleteAllByBrandId(id); brandService.delete(id); + productService.deleteAllByBrandId(id); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartApplicationService.java index 9cd63fbdf..5ab98254e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartApplicationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartApplicationService.java @@ -1,13 +1,21 @@ package com.loopers.application.cart; -import com.loopers.domain.cart.CartItem; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandDomainService; +import com.loopers.domain.cart.Cart; import com.loopers.domain.cart.CartDomainService; +import com.loopers.domain.cart.CartItem; +import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductDomainService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; @RequiredArgsConstructor @Component @@ -15,6 +23,7 @@ public class CartApplicationService { private final CartDomainService cartService; private final ProductDomainService productService; + private final BrandDomainService brandService; @Transactional public void addToCart(Long userId, Long productId, int quantity) { @@ -23,8 +32,34 @@ public void addToCart(Long userId, Long productId, int quantity) { } @Transactional(readOnly = true) - public List getMyCart(Long userId) { - return cartService.getCartItems(userId); + public Cart getMyCart(Long userId) { + return cartService.getCart(userId); + } + + @Transactional(readOnly = true) + public List getMyCartWithDetails(Long userId) { + Cart cart = cartService.getCart(userId); + List cartItems = cart.getItems(); + + Set productIds = cartItems.stream() + .map(CartItem::getProductId) + .collect(Collectors.toSet()); + Map productMap = productService.getByIds(productIds); + + Set brandIds = productMap.values().stream() + .map(Product::getBrandId) + .collect(Collectors.toSet()); + Map brandMap = brandService.getByIds(brandIds); + + return cartItems.stream() + .filter(cartItem -> productMap.containsKey(cartItem.getProductId())) + .map(cartItem -> { + Product product = productMap.get(cartItem.getProductId()); + Brand brand = brandMap.get(product.getBrandId()); + return brand != null ? new CartItemDetail(cartItem, product, brand) : null; + }) + .filter(Objects::nonNull) + .toList(); } @Transactional diff --git a/apps/commerce-api/src/main/java/com/loopers/application/cart/CartItemDetail.java b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartItemDetail.java new file mode 100644 index 000000000..cb5fdbbd5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/cart/CartItemDetail.java @@ -0,0 +1,8 @@ +package com.loopers.application.cart; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.cart.CartItem; +import com.loopers.domain.product.Product; + +public record CartItemDetail(CartItem cartItem, Product product, Brand brand) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java index a64439940..9af1b83e3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java @@ -1,13 +1,19 @@ package com.loopers.application.like; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandDomainService; import com.loopers.domain.like.Like; import com.loopers.domain.like.LikeDomainService; +import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductDomainService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; @RequiredArgsConstructor @Component @@ -15,6 +21,7 @@ public class LikeApplicationService { private final LikeDomainService likeService; private final ProductDomainService productService; + private final BrandDomainService brandService; /** * 단일 트랜잭션에서 Like aggregate와 Product aggregate를 함께 수정한다. @@ -44,4 +51,31 @@ public void unlike(Long userId, Long productId) { public List getMyLikes(Long userId) { return likeService.getMyLikes(userId); } + + @Transactional(readOnly = true) + public List getMyLikesWithDetails(Long userId) { + List likes = likeService.getMyLikes(userId); + + Set productIds = likes.stream() + .map(Like::getProductId) + .collect(Collectors.toSet()); + Map productMap = productService.getByIds(productIds); + + Set brandIds = productMap.values().stream() + .map(Product::getBrandId) + .collect(Collectors.toSet()); + Map brandMap = brandService.getByIds(brandIds); + + return likes.stream() + .filter(like -> { + Product product = productMap.get(like.getProductId()); + return product != null && brandMap.containsKey(product.getBrandId()); + }) + .map(like -> { + Product product = productMap.get(like.getProductId()); + Brand brand = brandMap.get(product.getBrandId()); + return new LikedProductDetail(like, product, brand); + }) + .toList(); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikedProductDetail.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikedProductDetail.java new file mode 100644 index 000000000..5514128c6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikedProductDetail.java @@ -0,0 +1,8 @@ +package com.loopers.application.like; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.like.Like; +import com.loopers.domain.product.Product; + +public record LikedProductDetail(Like like, Product product, Brand brand) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java new file mode 100644 index 000000000..4ba22bc85 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/CreateOrderCommand.java @@ -0,0 +1,29 @@ +package com.loopers.application.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.List; +import java.util.Objects; + +public record CreateOrderCommand( + Long userId, + List items +) { + + public CreateOrderCommand { + Objects.requireNonNull(userId, "유저 ID는 필수입니다."); + if (items == null || items.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 하나 이상이어야 합니다."); + } + } + + public record LineItem(Long productId, int quantity) { + public LineItem { + Objects.requireNonNull(productId, "상품 ID는 필수입니다."); + if (quantity < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 합니다."); + } + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java index 7cea1fa82..579b416ea 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderApplicationService.java @@ -3,11 +3,13 @@ import com.loopers.domain.PageResult; import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandDomainService; -import com.loopers.domain.cart.CartItem; +import com.loopers.domain.cart.Cart; import com.loopers.domain.cart.CartDomainService; +import com.loopers.domain.cart.CartItem; import com.loopers.domain.order.Order; -import com.loopers.domain.order.OrderLineItem; import com.loopers.domain.order.OrderDomainService; +import com.loopers.domain.order.OrderItemCommand; +import com.loopers.domain.order.OrderPolicy; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductDomainService; import com.loopers.support.error.CoreException; @@ -19,6 +21,7 @@ import java.time.LocalDate; import java.util.ArrayList; import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -34,8 +37,8 @@ public class OrderApplicationService { private final CartDomainService cartService; @Transactional - public Order createOrder(Long userId, List items) { - return processOrder(userId, items); + public Order createOrder(CreateOrderCommand command) { + return processOrder(command.userId(), command.items()); } /** @@ -47,7 +50,8 @@ public Order createOrder(Long userId, List items) { */ @Transactional public Order createOrderFromCart(Long userId) { - List cartItems = cartService.getCartItems(userId); + Cart cart = cartService.getCart(userId); + List cartItems = cart.getItems(); if (cartItems.isEmpty()) { throw new CoreException(ErrorType.BAD_REQUEST, "장바구니가 비어있습니다."); } @@ -59,22 +63,22 @@ public Order createOrderFromCart(Long userId) { Set availableProductIds = productMap.keySet(); if (!availableProductIds.containsAll(cartProductIds)) { - cartService.removeUnavailableItems(userId, availableProductIds); - cartItems = cartService.getCartItems(userId); + cartService.removeUnavailableItems(cart, availableProductIds); + cartItems = cartItems.stream() + .filter(ci -> availableProductIds.contains(ci.getProductId())) + .toList(); if (cartItems.isEmpty()) { throw new CoreException(ErrorType.BAD_REQUEST, "장바구니의 모든 상품이 더 이상 존재하지 않습니다."); } } - List items = cartItems.stream() - .map(ci -> new OrderLineItem(ci.getProductId(), ci.getQuantity().value())) + List lineItems = cartItems.stream() + .map(ci -> new CreateOrderCommand.LineItem(ci.getProductId(), ci.getQuantity().value())) .toList(); - Order order = processOrder(userId, items); - + Order order = processOrder(userId, lineItems); cartService.clearCart(userId); - return order; } @@ -104,26 +108,47 @@ public Order getOrder(Long orderId) { * 재고 차감과 주문 생성은 원자적으로 처리되어야 하며, * 분리 시 재고 불일치 또는 유령 주문이 발생할 수 있다. */ - private Order processOrder(Long userId, List items) { - // Sort by productId to prevent deadlocks - List sortedItems = items.stream() - .sorted(Comparator.comparing(OrderLineItem::productId)) - .toList(); - - // Get products with pessimistic lock and deduct stock - List products = new ArrayList<>(); - for (OrderLineItem item : sortedItems) { - Product product = productService.deductStockWithLock(item.productId(), item.quantity()); - products.add(product); + private Order processOrder(Long userId, List lineItems) { + // 0. 중복 상품 조기 차단 (의도적 이중 검증) + // OrderDomainService.createOrder()에도 동일 검증이 존재하나, + // Application 레벨에서 먼저 차단하여 불필요한 pessimistic lock/재고 차감 DB 호출을 방지한다. + // 도메인 레벨 검증은 다른 진입점(직접 호출 등)에 대한 안전망으로 유지. + List productIds = lineItems.stream() + .map(CreateOrderCommand.LineItem::productId).toList(); + OrderPolicy.validateNoDuplicateProducts(productIds); + + // 1. deadlock 방지를 위해 productId 기준 정렬 + List sorted = lineItems.stream() + .sorted(Comparator.comparing(CreateOrderCommand.LineItem::productId)).toList(); + + // 2. 재고 차감 (pessimistic lock) — Map으로 관리 + Map productMap = new LinkedHashMap<>(); + for (CreateOrderCommand.LineItem item : sorted) { + productMap.put(item.productId(), + productService.deductStockWithLock(item.productId(), item.quantity())); } - // Get brands for snapshots - Set brandIds = products.stream() - .map(Product::getBrandId) - .collect(Collectors.toSet()); + // 3. Brand 일괄 조회 (N+1 방지) + Set brandIds = productMap.values().stream() + .map(Product::getBrandId).collect(Collectors.toSet()); Map brandMap = brandService.getByIds(brandIds); - // Create order (brand validation, command assembly, and price calculation handled by OrderDomainService) - return orderService.createOrder(userId, sortedItems, products, brandMap); + // 4. OrderItemCommand 조립 — productId 키로 안전하게 조회 + List itemCommands = new ArrayList<>(); + for (CreateOrderCommand.LineItem item : sorted) { + Product product = productMap.get(item.productId()); + Brand brand = brandMap.get(product.getBrandId()); + if (brand == null) { + throw new CoreException(ErrorType.NOT_FOUND, + "브랜드를 찾을 수 없습니다. brandId=" + product.getBrandId()); + } + itemCommands.add(new OrderItemCommand( + product.getId(), product.getName(), product.getPrice(), + brand.getName(), item.quantity() + )); + } + + // 5. 주문 생성 (도메인 서비스 위임) + return orderService.createOrder(userId, itemCommands); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApplicationService.java index e85f21710..2bef03620 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApplicationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductApplicationService.java @@ -1,16 +1,21 @@ package com.loopers.application.product; import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; import com.loopers.domain.brand.BrandDomainService; import com.loopers.domain.product.Product; +import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductDomainService; import com.loopers.domain.product.ProductSortType; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; @RequiredArgsConstructor @Component @@ -20,9 +25,17 @@ public class ProductApplicationService { private final BrandDomainService brandService; @Transactional - public Product register(Long brandId, String name, int price, int stock) { - brandService.getById(brandId); - return productService.register(brandId, name, price, stock); + public ProductWithBrand register(RegisterProductCommand command) { + Brand brand = brandService.getById(command.brandId()); + Product product = productService.register(command.brandId(), command.name(), command.price(), command.stock()); + return new ProductWithBrand(product, brand); + } + + @Transactional(readOnly = true) + public ProductWithBrand getProductWithBrand(Long id) { + Product product = productService.getById(id); + Brand brand = brandService.getById(product.getBrandId()); + return new ProductWithBrand(product, brand); } @Transactional(readOnly = true) @@ -36,13 +49,38 @@ public Map getByIds(Set ids) { } @Transactional(readOnly = true) - public PageResult getAll(Long brandId, ProductSortType sort, int page, int size) { - return productService.getAll(brandId, sort, page, size); + public ProductPageWithBrands getAll(Long brandId, ProductSortType sort, int page, int size) { + PageResult result = productService.getAll(brandId, sort, page, size); + Set brandIds = result.items().stream() + .map(Product::getBrandId) + .collect(Collectors.toSet()); + Map brandMap = brandService.getByIds(brandIds); + return new ProductPageWithBrands(result, brandMap); + } + + @Transactional(readOnly = true) + public ProductPageWithBrands getAllForAdmin(Long brandId, ProductSortType sort, int page, int size) { + PageResult result = productService.getAll(brandId, sort, page, size); + Set brandIds = result.items().stream() + .map(Product::getBrandId) + .collect(Collectors.toSet()); + Map brandMap = brandService.getByIds(brandIds); + + for (Product product : result.items()) { + if (!brandMap.containsKey(product.getBrandId())) { + throw new CoreException(ErrorType.INTERNAL_ERROR, + "브랜드를 찾을 수 없습니다. productId=" + product.getId() + ", brandId=" + product.getBrandId()); + } + } + + return new ProductPageWithBrands(result, brandMap); } @Transactional - public Product update(Long id, String name, int price, int stock) { - return productService.update(id, name, price, stock); + public ProductWithBrand update(UpdateProductCommand command) { + Product product = productService.update(command.productId(), command.name(), command.price(), command.stock()); + Brand brand = brandService.getById(product.getBrandId()); + return new ProductWithBrand(product, brand); } @Transactional diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductPageWithBrands.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductPageWithBrands.java new file mode 100644 index 000000000..26b39b6a4 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductPageWithBrands.java @@ -0,0 +1,10 @@ +package com.loopers.application.product; + +import com.loopers.domain.PageResult; +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; + +import java.util.Map; + +public record ProductPageWithBrands(PageResult result, Map brandMap) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductWithBrand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductWithBrand.java new file mode 100644 index 000000000..095a1814a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductWithBrand.java @@ -0,0 +1,7 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.product.Product; + +public record ProductWithBrand(Product product, Brand brand) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/RegisterProductCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/RegisterProductCommand.java new file mode 100644 index 000000000..ec86455e0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/RegisterProductCommand.java @@ -0,0 +1,15 @@ +package com.loopers.application.product; + +import org.springframework.util.Assert; + +import java.util.Objects; + +public record RegisterProductCommand(Long brandId, String name, int price, int stock) { + + public RegisterProductCommand { + Objects.requireNonNull(brandId, "브랜드 ID는 필수입니다."); + Assert.hasText(name, "상품 이름은 비어있을 수 없습니다."); + Assert.state(price >= 0, "상품 가격은 0 이상이어야 합니다."); + Assert.state(stock >= 0, "상품 재고는 0 이상이어야 합니다."); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/UpdateProductCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/product/UpdateProductCommand.java new file mode 100644 index 000000000..cbb9b6566 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/UpdateProductCommand.java @@ -0,0 +1,15 @@ +package com.loopers.application.product; + +import org.springframework.util.Assert; + +import java.util.Objects; + +public record UpdateProductCommand(Long productId, String name, int price, int stock) { + + public UpdateProductCommand { + Objects.requireNonNull(productId, "상품 ID는 필수입니다."); + Assert.hasText(name, "상품 이름은 비어있을 수 없습니다."); + Assert.state(price >= 0, "상품 가격은 0 이상이어야 합니다."); + Assert.state(stock >= 0, "상품 재고는 0 이상이어야 합니다."); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/user/UserApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/user/UserApplicationService.java index 610c5babd..9c74a1f90 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/user/UserApplicationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserApplicationService.java @@ -14,6 +14,11 @@ public class UserApplicationService { private final UserDomainService userService; + @Transactional(readOnly = true) + public User getById(Long id) { + return userService.getById(id); + } + @Transactional public User signup(String loginId, String rawPassword, String name, LocalDate birthDate, String email) { return userService.signup(loginId, rawPassword, name, birthDate, email); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/Cart.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/Cart.java index b48e52857..9c8c969c3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/cart/Cart.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/Cart.java @@ -75,7 +75,7 @@ public void updateItemQuantity(Long cartItemId, int quantity) { .findFirst() .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "장바구니 항목을 찾을 수 없습니다.")); - cartItem.updateQuantity(quantity); + cartItem.changeQuantity(quantity); } public void clear() { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartDomainService.java index a9f807346..8e007f738 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartDomainService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartDomainService.java @@ -4,8 +4,6 @@ import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; -import java.util.Collections; -import java.util.List; import java.util.Set; @RequiredArgsConstructor @@ -31,10 +29,9 @@ public void removeItem(Long userId, Long cartItemId) { cartRepository.save(cart); } - public List getCartItems(Long userId) { + public Cart getCart(Long userId) { return cartRepository.findByUserId(userId) - .map(Cart::getItems) - .orElse(Collections.emptyList()); + .orElseGet(() -> new Cart(userId)); } public void clearCart(Long userId) { @@ -44,11 +41,9 @@ public void clearCart(Long userId) { }); } - public void removeUnavailableItems(Long userId, Set availableProductIds) { - cartRepository.findByUserId(userId).ifPresent(cart -> { - cart.removeUnavailableItems(availableProductIds); - cartRepository.save(cart); - }); + public void removeUnavailableItems(Cart cart, Set availableProductIds) { + cart.removeUnavailableItems(availableProductIds); + cartRepository.save(cart); } private Cart getOrCreateCart(Long userId) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItem.java index 33a32890d..6931059a6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItem.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/cart/CartItem.java @@ -56,7 +56,7 @@ public void addQuantity(int amount) { this.quantity = getQuantity().add(amount).value(); } - public void updateQuantity(int quantity) { + public void changeQuantity(int quantity) { this.quantity = new Quantity(quantity).value(); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index 66548b5f9..08b9b0576 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -51,6 +51,13 @@ public void addItems(List commands) { } } + public void cancel() { + if (this.status != OrderStatus.ORDERED) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 취소가 불가능한 상태입니다."); + } + this.status = OrderStatus.CANCELLED; + } + public Long getUserId() { return userId; } public Money getTotalPrice() { return new Money(totalPrice); } public OrderStatus getStatus() { return status; } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java index e9db8534e..84492b116 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderDomainService.java @@ -1,9 +1,7 @@ package com.loopers.domain.order; import com.loopers.domain.PageResult; -import com.loopers.domain.brand.Brand; import com.loopers.domain.product.Money; -import com.loopers.domain.product.Product; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -11,43 +9,20 @@ import java.time.LocalDate; import java.time.ZoneId; import java.time.ZonedDateTime; -import java.util.ArrayList; -import java.util.HashSet; import java.util.List; -import java.util.Map; -import java.util.Set; @RequiredArgsConstructor public class OrderDomainService { private final OrderRepository orderRepository; - public Order createOrder(Long userId, List items, List products, Map brandMap) { - List itemCommands = new ArrayList<>(); - for (int i = 0; i < items.size(); i++) { - OrderLineItem item = items.get(i); - Product product = products.get(i); - Brand brand = brandMap.get(product.getBrandId()); - if (brand == null) { - throw new CoreException(ErrorType.NOT_FOUND, - "브랜드를 찾을 수 없습니다. brandId=" + product.getBrandId()); - } - - itemCommands.add(new OrderItemCommand( - product.getId(), product.getName(), product.getPrice(), - brand.getName(), item.quantity() - )); - } - - return createOrder(userId, itemCommands); - } - public Order createOrder(Long userId, List itemCommands) { if (itemCommands == null || itemCommands.isEmpty()) { throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 하나 이상이어야 합니다."); } - validateNoDuplicateProducts(itemCommands); + List productIds = itemCommands.stream().map(OrderItemCommand::productId).toList(); + OrderPolicy.validateNoDuplicateProducts(productIds); Money totalPrice = calculateTotalPrice(itemCommands); @@ -57,15 +32,6 @@ public Order createOrder(Long userId, List itemCommands) { return orderRepository.save(order); } - private void validateNoDuplicateProducts(List itemCommands) { - Set uniqueProductIds = new HashSet<>(); - for (OrderItemCommand cmd : itemCommands) { - if (!uniqueProductIds.add(cmd.productId())) { - throw new CoreException(ErrorType.BAD_REQUEST, "중복된 상품이 포함되어 있습니다."); - } - } - } - private Money calculateTotalPrice(List itemCommands) { Money total = new Money(0); for (OrderItemCommand cmd : itemCommands) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java index af9d276dc..c244a71aa 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItem.java @@ -1,5 +1,6 @@ package com.loopers.domain.order; +import com.loopers.domain.BaseEntity; import com.loopers.domain.Quantity; import com.loopers.domain.product.Money; import com.loopers.support.error.CoreException; @@ -7,23 +8,13 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import jakarta.persistence.PrePersist; import jakarta.persistence.Table; -import java.time.ZonedDateTime; - @Entity @Table(name = "order_items") -public class OrderItem { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; +public class OrderItem extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "order_id", nullable = false) @@ -44,9 +35,6 @@ public class OrderItem { @Column(name = "quantity", nullable = false) private int quantity; - @Column(name = "created_at", nullable = false, updatable = false) - private ZonedDateTime createdAt; - protected OrderItem() {} OrderItem(Order order, Long productId, String productName, Money productPrice, String brandName, int quantity) { @@ -59,18 +47,11 @@ protected OrderItem() {} this.quantity = new Quantity(quantity).value(); } - public Long getId() { return id; } public Long getProductId() { return productId; } public String getProductName() { return productName; } public Money getProductPrice() { return new Money(productPrice); } public String getBrandName() { return brandName; } public Quantity getQuantity() { return new Quantity(quantity); } - public ZonedDateTime getCreatedAt() { return createdAt; } - - @PrePersist - private void prePersist() { - this.createdAt = ZonedDateTime.now(); - } private void validate(Order order, Long productId, String productName, Money productPrice, String brandName) { if (order == null) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemCommand.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemCommand.java index 5a0d51e48..1ea58043a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemCommand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemCommand.java @@ -1,11 +1,11 @@ package com.loopers.domain.order; import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import java.util.Objects; -import org.springframework.util.Assert; - public record OrderItemCommand( Long productId, String productName, @@ -16,9 +16,15 @@ public record OrderItemCommand( public OrderItemCommand { Objects.requireNonNull(productId, "상품 ID는 필수입니다."); - Assert.hasText(productName, "상품 이름은 필수입니다."); + if (productName == null || productName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 이름은 필수입니다."); + } Objects.requireNonNull(productPrice, "상품 가격은 필수입니다."); - Assert.hasText(brandName, "브랜드 이름은 필수입니다."); - Assert.state(quantity >= 1, "수량은 1 이상이어야 합니다."); + if (brandName == null || brandName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 필수입니다."); + } + if (quantity < 1) { + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 1 이상이어야 합니다."); + } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderLineItem.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderLineItem.java deleted file mode 100644 index 173b35cd3..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderLineItem.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.loopers.domain.order; - -import java.util.Objects; - -import org.springframework.util.Assert; - -public record OrderLineItem(Long productId, int quantity) { - - public OrderLineItem { - Objects.requireNonNull(productId, "상품 ID는 필수입니다."); - Assert.state(quantity >= 1, "수량은 1 이상이어야 합니다."); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderPolicy.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderPolicy.java new file mode 100644 index 000000000..48bdeb3b5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderPolicy.java @@ -0,0 +1,22 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +public class OrderPolicy { + + public static void validateNoDuplicateProducts(Collection productIds) { + Set unique = new HashSet<>(); + for (Long id : productIds) { + if (!unique.add(id)) { + throw new CoreException(ErrorType.BAD_REQUEST, "중복된 상품이 포함되어 있습니다."); + } + } + } + + private OrderPolicy() {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java index 6dab40e1f..b2d11834f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -1,5 +1,6 @@ package com.loopers.domain.order; public enum OrderStatus { - ORDERED + ORDERED, + CANCELLED } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java index 4969c505f..14d6769e8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java @@ -16,6 +16,9 @@ public Money plus(Money other) { } public Money minus(Money other) { + if (this.amount < other.amount) { + throw new CoreException(ErrorType.BAD_REQUEST, "금액은 음수가 될 수 없습니다."); + } return new Money(this.amount - other.amount); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java index 93712a6aa..dcf506cad 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java @@ -1,5 +1,8 @@ package com.loopers.domain.product; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + public enum ProductSortType { LATEST, PRICE_ASC, @@ -12,7 +15,7 @@ public static ProductSortType from(String value) { try { return valueOf(value.toUpperCase()); } catch (IllegalArgumentException e) { - return LATEST; + throw new CoreException(ErrorType.BAD_REQUEST, "지원하지 않는 정렬 기준입니다: " + value); } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserDomainService.java index b35caa3c3..b9ebaf7fd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserDomainService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserDomainService.java @@ -12,6 +12,11 @@ public class UserDomainService { private final UserRepository userRepository; private final PasswordEncryptor passwordEncryptor; + public User getById(Long id) { + return userRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.")); + } + public User signup(String loginId, String rawPassword, String name, LocalDate birthDate, String email) { if (userRepository.existsByLoginId(loginId)) { throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 로그인 ID입니다."); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthUserArgumentResolver.java index 5c57db145..2140ea5a8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthUserArgumentResolver.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthUserArgumentResolver.java @@ -24,11 +24,11 @@ public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver { @Override public boolean supportsParameter(MethodParameter parameter) { return parameter.hasParameterAnnotation(AuthUser.class) - && parameter.getParameterType().equals(User.class); + && parameter.getParameterType().equals(AuthenticatedUser.class); } @Override - public User resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + public AuthenticatedUser resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { String loginId = webRequest.getHeader(HEADER_LOGIN_ID); String password = webRequest.getHeader(HEADER_LOGIN_PW); @@ -37,6 +37,7 @@ public User resolveArgument(MethodParameter parameter, ModelAndViewContainer mav throw new CoreException(ErrorType.UNAUTHORIZED, "인증 헤더가 누락되었습니다."); } - return userApplicationService.authenticate(loginId, password); + User user = userApplicationService.authenticate(loginId, password); + return new AuthenticatedUser(user.getId(), user.getLoginId()); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUser.java new file mode 100644 index 000000000..716c1e9ac --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/AuthenticatedUser.java @@ -0,0 +1,4 @@ +package com.loopers.interfaces.api.auth; + +public record AuthenticatedUser(Long userId, String loginId) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1ApiSpec.java index fa5ac92cc..f3c2af3fe 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1ApiSpec.java @@ -1,22 +1,23 @@ package com.loopers.interfaces.api.cart; -import com.loopers.domain.user.User; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthenticatedUser; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @Tag(name = "Cart V1 API", description = "장바구니 API 입니다.") public interface CartV1ApiSpec { @Operation(summary = "장바구니 담기", description = "상품을 장바구니에 담습니다. 이미 담긴 상품이면 수량이 합산됩니다.") - ApiResponse addToCart(User user, CartV1Dto.AddRequest request); + ApiResponse addToCart(@Parameter(hidden = true) AuthenticatedUser authUser, CartV1Dto.AddRequest request); @Operation(summary = "장바구니 조회", description = "내 장바구니를 조회합니다.") - ApiResponse getMyCart(User user); + ApiResponse getMyCart(@Parameter(hidden = true) AuthenticatedUser authUser); @Operation(summary = "수량 변경", description = "장바구니 항목의 수량을 변경합니다.") - ApiResponse updateQuantity(User user, Long cartItemId, CartV1Dto.UpdateQuantityRequest request); + ApiResponse updateQuantity(@Parameter(hidden = true) AuthenticatedUser authUser, Long cartItemId, CartV1Dto.UpdateQuantityRequest request); @Operation(summary = "항목 삭제", description = "장바구니에서 항목을 삭제합니다.") - ApiResponse removeItem(User user, Long cartItemId); + ApiResponse removeItem(@Parameter(hidden = true) AuthenticatedUser authUser, Long cartItemId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Controller.java index 9ef4acd7c..c98fa34ad 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/cart/CartV1Controller.java @@ -1,16 +1,10 @@ package com.loopers.interfaces.api.cart; -import com.loopers.application.brand.BrandApplicationService; import com.loopers.application.cart.CartApplicationService; -import com.loopers.application.product.ProductApplicationService; -import com.loopers.domain.brand.Brand; -import com.loopers.domain.cart.CartItem; -import com.loopers.domain.product.Product; -import com.loopers.domain.user.User; +import com.loopers.application.cart.CartItemDetail; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.auth.AuthUser; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; +import com.loopers.interfaces.api.auth.AuthenticatedUser; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.DeleteMapping; @@ -23,9 +17,6 @@ import org.springframework.web.bind.annotation.RestController; import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; @RequiredArgsConstructor @RestController @@ -33,45 +24,21 @@ public class CartV1Controller implements CartV1ApiSpec { private final CartApplicationService cartApplicationService; - private final ProductApplicationService productApplicationService; - private final BrandApplicationService brandApplicationService; @PostMapping("/items") @Override - public ApiResponse addToCart(@AuthUser User user, @Valid @RequestBody CartV1Dto.AddRequest request) { - cartApplicationService.addToCart(user.getId(), request.productId(), request.quantity()); + public ApiResponse addToCart(@AuthUser AuthenticatedUser authUser, @Valid @RequestBody CartV1Dto.AddRequest request) { + cartApplicationService.addToCart(authUser.userId(), request.productId(), request.quantity()); return ApiResponse.success(); } @GetMapping @Override - public ApiResponse getMyCart(@AuthUser User user) { - List cartItems = cartApplicationService.getMyCart(user.getId()); + public ApiResponse getMyCart(@AuthUser AuthenticatedUser authUser) { + List details = cartApplicationService.getMyCartWithDetails(authUser.userId()); - Set productIds = cartItems.stream() - .map(CartItem::getProductId) - .collect(Collectors.toSet()); - Map productMap = productApplicationService.getByIds(productIds); - - Set brandIds = productMap.values().stream() - .map(Product::getBrandId) - .collect(Collectors.toSet()); - Map brandMap = brandApplicationService.getByIds(brandIds); - - List itemResponses = cartItems.stream() - .map(cartItem -> { - Product product = productMap.get(cartItem.getProductId()); - if (product == null) { - throw new CoreException(ErrorType.INTERNAL_ERROR, - "상품을 찾을 수 없습니다. productId=" + cartItem.getProductId()); - } - Brand brand = brandMap.get(product.getBrandId()); - if (brand == null) { - throw new CoreException(ErrorType.INTERNAL_ERROR, - "브랜드를 찾을 수 없습니다. brandId=" + product.getBrandId()); - } - return CartV1Dto.CartItemResponse.from(cartItem, product, brand); - }) + List itemResponses = details.stream() + .map(detail -> CartV1Dto.CartItemResponse.from(detail.cartItem(), detail.product(), detail.brand())) .toList(); return ApiResponse.success(CartV1Dto.CartResponse.from(itemResponses)); @@ -80,18 +47,18 @@ public ApiResponse getMyCart(@AuthUser User user) { @PutMapping("/items/{cartItemId}") @Override public ApiResponse updateQuantity( - @AuthUser User user, + @AuthUser AuthenticatedUser authUser, @PathVariable Long cartItemId, @Valid @RequestBody CartV1Dto.UpdateQuantityRequest request ) { - cartApplicationService.updateQuantity(cartItemId, user.getId(), request.quantity()); + cartApplicationService.updateQuantity(cartItemId, authUser.userId(), request.quantity()); return ApiResponse.success(); } @DeleteMapping("/items/{cartItemId}") @Override - public ApiResponse removeItem(@AuthUser User user, @PathVariable Long cartItemId) { - cartApplicationService.removeItem(cartItemId, user.getId()); + public ApiResponse removeItem(@AuthUser AuthenticatedUser authUser, @PathVariable Long cartItemId) { + cartApplicationService.removeItem(cartItemId, authUser.userId()); return ApiResponse.success(); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java index dcf9663fa..1be8a3c36 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java @@ -1,19 +1,20 @@ package com.loopers.interfaces.api.like; -import com.loopers.domain.user.User; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthenticatedUser; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @Tag(name = "Like V1 API", description = "좋아요 API 입니다.") public interface LikeV1ApiSpec { @Operation(summary = "좋아요 등록", description = "상품에 좋아요를 등록합니다.") - ApiResponse like(User user, Long productId); + ApiResponse like(@Parameter(hidden = true) AuthenticatedUser authUser, Long productId); @Operation(summary = "좋아요 취소", description = "상품의 좋아요를 취소합니다.") - ApiResponse unlike(User user, Long productId); + ApiResponse unlike(@Parameter(hidden = true) AuthenticatedUser authUser, Long productId); @Operation(summary = "내 좋아요 목록 조회", description = "내가 좋아요한 상품 목록을 조회합니다.") - ApiResponse getMyLikes(User user); + ApiResponse getMyLikes(@Parameter(hidden = true) AuthenticatedUser authUser); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java index 9294c664b..9cc0dabab 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -1,14 +1,10 @@ package com.loopers.interfaces.api.like; -import com.loopers.application.brand.BrandApplicationService; import com.loopers.application.like.LikeApplicationService; -import com.loopers.application.product.ProductApplicationService; -import com.loopers.domain.brand.Brand; -import com.loopers.domain.like.Like; -import com.loopers.domain.product.Product; -import com.loopers.domain.user.User; +import com.loopers.application.like.LikedProductDetail; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.auth.AuthUser; +import com.loopers.interfaces.api.auth.AuthenticatedUser; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -17,57 +13,34 @@ import org.springframework.web.bind.annotation.RestController; import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; @RequiredArgsConstructor @RestController public class LikeV1Controller implements LikeV1ApiSpec { private final LikeApplicationService likeApplicationService; - private final ProductApplicationService productApplicationService; - private final BrandApplicationService brandApplicationService; @PostMapping("/api/v1/products/{productId}/likes") @Override - public ApiResponse like(@AuthUser User user, @PathVariable Long productId) { - likeApplicationService.like(user.getId(), productId); + public ApiResponse like(@AuthUser AuthenticatedUser authUser, @PathVariable Long productId) { + likeApplicationService.like(authUser.userId(), productId); return ApiResponse.success(); } @DeleteMapping("/api/v1/products/{productId}/likes") @Override - public ApiResponse unlike(@AuthUser User user, @PathVariable Long productId) { - likeApplicationService.unlike(user.getId(), productId); + public ApiResponse unlike(@AuthUser AuthenticatedUser authUser, @PathVariable Long productId) { + likeApplicationService.unlike(authUser.userId(), productId); return ApiResponse.success(); } @GetMapping("/api/v1/likes") @Override - public ApiResponse getMyLikes(@AuthUser User user) { - List likes = likeApplicationService.getMyLikes(user.getId()); + public ApiResponse getMyLikes(@AuthUser AuthenticatedUser authUser) { + List details = likeApplicationService.getMyLikesWithDetails(authUser.userId()); - Set productIds = likes.stream() - .map(Like::getProductId) - .collect(Collectors.toSet()); - Map productMap = productApplicationService.getByIds(productIds); - - Set brandIds = productMap.values().stream() - .map(Product::getBrandId) - .collect(Collectors.toSet()); - Map brandMap = brandApplicationService.getByIds(brandIds); - - List likeResponses = likes.stream() - .filter(like -> { - Product product = productMap.get(like.getProductId()); - return product != null && brandMap.containsKey(product.getBrandId()); - }) - .map(like -> { - Product product = productMap.get(like.getProductId()); - Brand brand = brandMap.get(product.getBrandId()); - return LikeV1Dto.LikeResponse.from(like, product, brand); - }) + List likeResponses = details.stream() + .map(detail -> LikeV1Dto.LikeResponse.from(detail.like(), detail.product(), detail.brand())) .toList(); return ApiResponse.success(LikeV1Dto.LikeListResponse.from(likeResponses)); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java index 38d973c8f..dfba87402 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -1,8 +1,9 @@ package com.loopers.interfaces.api.order; -import com.loopers.domain.user.User; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthenticatedUser; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import java.time.LocalDate; @@ -11,14 +12,14 @@ public interface OrderV1ApiSpec { @Operation(summary = "주문 요청", description = "상품을 직접 지정하여 주문합니다.") - ApiResponse createOrder(User user, OrderV1Dto.CreateOrderRequest request); + ApiResponse createOrder(@Parameter(hidden = true) AuthenticatedUser authUser, OrderV1Dto.CreateOrderRequest request); @Operation(summary = "장바구니 주문", description = "장바구니의 모든 항목으로 주문합니다.") - ApiResponse createOrderFromCart(User user); + ApiResponse createOrderFromCart(@Parameter(hidden = true) AuthenticatedUser authUser); @Operation(summary = "내 주문 목록 조회", description = "기간별 주문 목록을 조회합니다.") - ApiResponse getMyOrders(User user, LocalDate startAt, LocalDate endAt, int page, int size); + ApiResponse getMyOrders(@Parameter(hidden = true) AuthenticatedUser authUser, LocalDate startAt, LocalDate endAt, int page, int size); @Operation(summary = "주문 상세 조회", description = "주문 상세 내역을 조회합니다.") - ApiResponse getMyOrderDetail(User user, Long orderId); + ApiResponse getMyOrderDetail(@Parameter(hidden = true) AuthenticatedUser authUser, Long orderId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java index 712345f0d..8f6ea9fa6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -1,12 +1,12 @@ package com.loopers.interfaces.api.order; +import com.loopers.application.order.CreateOrderCommand; import com.loopers.application.order.OrderApplicationService; import com.loopers.domain.PageResult; import com.loopers.domain.order.Order; -import com.loopers.domain.order.OrderLineItem; -import com.loopers.domain.user.User; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.auth.AuthUser; +import com.loopers.interfaces.api.auth.AuthenticatedUser; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; @@ -19,7 +19,6 @@ import org.springframework.web.bind.annotation.RestController; import java.time.LocalDate; -import java.util.List; @RequiredArgsConstructor @RestController @@ -31,43 +30,46 @@ public class OrderV1Controller implements OrderV1ApiSpec { @PostMapping @Override public ApiResponse createOrder( - @AuthUser User user, + @AuthUser AuthenticatedUser authUser, @Valid @RequestBody OrderV1Dto.CreateOrderRequest request ) { - List items = request.items().stream() - .map(i -> new OrderLineItem(i.productId(), i.quantity())) - .toList(); - Order order = orderApplicationService.createOrder(user.getId(), items); + CreateOrderCommand command = new CreateOrderCommand( + authUser.userId(), + request.items().stream() + .map(i -> new CreateOrderCommand.LineItem(i.productId(), i.quantity())) + .toList() + ); + Order order = orderApplicationService.createOrder(command); return ApiResponse.success(OrderV1Dto.OrderDetailResponse.from(order)); } @PostMapping("/cart") @Override - public ApiResponse createOrderFromCart(@AuthUser User user) { - Order order = orderApplicationService.createOrderFromCart(user.getId()); + public ApiResponse createOrderFromCart(@AuthUser AuthenticatedUser authUser) { + Order order = orderApplicationService.createOrderFromCart(authUser.userId()); return ApiResponse.success(OrderV1Dto.OrderDetailResponse.from(order)); } @GetMapping @Override public ApiResponse getMyOrders( - @AuthUser User user, + @AuthUser AuthenticatedUser authUser, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startAt, @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endAt, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { - PageResult result = orderApplicationService.getMyOrders(user.getId(), startAt, endAt, page, size); + PageResult result = orderApplicationService.getMyOrders(authUser.userId(), startAt, endAt, page, size); return ApiResponse.success(OrderV1Dto.OrderPageResponse.from(result)); } @GetMapping("/{orderId}") @Override public ApiResponse getMyOrderDetail( - @AuthUser User user, + @AuthUser AuthenticatedUser authUser, @PathVariable Long orderId ) { - Order order = orderApplicationService.getMyOrder(user.getId(), orderId); + Order order = orderApplicationService.getMyOrder(authUser.userId(), orderId); return ApiResponse.success(OrderV1Dto.OrderDetailResponse.from(order)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Controller.java index 64c37db72..f1c70b50c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Controller.java @@ -1,10 +1,10 @@ package com.loopers.interfaces.api.product; -import com.loopers.application.brand.BrandApplicationService; import com.loopers.application.product.ProductApplicationService; -import com.loopers.domain.PageResult; -import com.loopers.domain.brand.Brand; -import com.loopers.domain.product.Product; +import com.loopers.application.product.ProductPageWithBrands; +import com.loopers.application.product.ProductWithBrand; +import com.loopers.application.product.RegisterProductCommand; +import com.loopers.application.product.UpdateProductCommand; import com.loopers.domain.product.ProductSortType; import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; @@ -19,24 +19,20 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - @RequiredArgsConstructor @RestController @RequestMapping("/api-admin/v1/products") public class AdminProductV1Controller implements AdminProductV1ApiSpec { private final ProductApplicationService productApplicationService; - private final BrandApplicationService brandApplicationService; @PostMapping @Override public ApiResponse create(@Valid @RequestBody AdminProductV1Dto.CreateRequest request) { - Product product = productApplicationService.register(request.brandId(), request.name(), request.price(), request.stock()); - Brand brand = brandApplicationService.getById(product.getBrandId()); - return ApiResponse.success(AdminProductV1Dto.ProductResponse.from(product, brand)); + RegisterProductCommand command = new RegisterProductCommand( + request.brandId(), request.name(), request.price(), request.stock()); + ProductWithBrand result = productApplicationService.register(command); + return ApiResponse.success(AdminProductV1Dto.ProductResponse.from(result.product(), result.brand())); } @GetMapping @@ -46,20 +42,15 @@ public ApiResponse getAll( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { - PageResult result = productApplicationService.getAll(brandId, ProductSortType.LATEST, page, size); - Set brandIds = result.items().stream() - .map(Product::getBrandId) - .collect(Collectors.toSet()); - Map brandMap = brandApplicationService.getByIds(brandIds); - return ApiResponse.success(AdminProductV1Dto.ProductPageResponse.from(result, brandMap)); + ProductPageWithBrands result = productApplicationService.getAllForAdmin(brandId, ProductSortType.LATEST, page, size); + return ApiResponse.success(AdminProductV1Dto.ProductPageResponse.from(result.result(), result.brandMap())); } @GetMapping("/{productId}") @Override public ApiResponse getById(@PathVariable Long productId) { - Product product = productApplicationService.getById(productId); - Brand brand = brandApplicationService.getById(product.getBrandId()); - return ApiResponse.success(AdminProductV1Dto.ProductResponse.from(product, brand)); + ProductWithBrand result = productApplicationService.getProductWithBrand(productId); + return ApiResponse.success(AdminProductV1Dto.ProductResponse.from(result.product(), result.brand())); } @PutMapping("/{productId}") @@ -68,9 +59,10 @@ public ApiResponse update( @PathVariable Long productId, @Valid @RequestBody AdminProductV1Dto.UpdateRequest request ) { - Product product = productApplicationService.update(productId, request.name(), request.price(), request.stock()); - Brand brand = brandApplicationService.getById(product.getBrandId()); - return ApiResponse.success(AdminProductV1Dto.ProductResponse.from(product, brand)); + UpdateProductCommand command = new UpdateProductCommand( + productId, request.name(), request.price(), request.stock()); + ProductWithBrand result = productApplicationService.update(command); + return ApiResponse.success(AdminProductV1Dto.ProductResponse.from(result.product(), result.brand())); } @DeleteMapping("/{productId}") diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Dto.java index 7e831539c..ef461107c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/AdminProductV1Dto.java @@ -3,8 +3,6 @@ import com.loopers.domain.PageResult; import com.loopers.domain.brand.Brand; import com.loopers.domain.product.Product; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -73,14 +71,7 @@ public record ProductPageResponse( ) { public static ProductPageResponse from(PageResult result, Map brandMap) { List content = result.items().stream() - .map(product -> { - Brand brand = brandMap.get(product.getBrandId()); - if (brand == null) { - throw new CoreException(ErrorType.INTERNAL_ERROR, - "브랜드를 찾을 수 없습니다. brandId=" + product.getBrandId()); - } - return ProductResponse.from(product, brand); - }) + .map(product -> ProductResponse.from(product, brandMap.get(product.getBrandId()))) .toList(); return new ProductPageResponse(content, result.page(), result.size(), result.totalElements(), result.totalPages()); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java index 98304be0e..0c57667c6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -7,7 +7,11 @@ @Tag(name = "Product V1 API", description = "상품 API 입니다.") public interface ProductV1ApiSpec { - @Operation(summary = "상품 목록 조회", description = "상품 목록을 조회합니다. 브랜드별 필터링과 정렬이 가능합니다.") + @Operation( + summary = "상품 목록 조회", + description = "상품 목록을 조회합니다. 브랜드별 필터링과 정렬이 가능합니다. " + + "연관 데이터 삭제로 인해 content 수가 totalElements보다 적을 수 있습니다." + ) ApiResponse getAll(Long brandId, String sort, int page, int size); @Operation(summary = "상품 정보 조회", description = "특정 상품의 정보를 조회합니다.") diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java index 9da2e7740..c7dd9539a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -1,10 +1,8 @@ package com.loopers.interfaces.api.product; -import com.loopers.application.brand.BrandApplicationService; import com.loopers.application.product.ProductApplicationService; -import com.loopers.domain.PageResult; -import com.loopers.domain.brand.Brand; -import com.loopers.domain.product.Product; +import com.loopers.application.product.ProductPageWithBrands; +import com.loopers.application.product.ProductWithBrand; import com.loopers.domain.product.ProductSortType; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; @@ -14,17 +12,12 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import java.util.Map; -import java.util.Set; -import java.util.stream.Collectors; - @RequiredArgsConstructor @RestController @RequestMapping("/api/v1/products") public class ProductV1Controller implements ProductV1ApiSpec { private final ProductApplicationService productApplicationService; - private final BrandApplicationService brandApplicationService; @GetMapping @Override @@ -34,19 +27,14 @@ public ApiResponse getAll( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { - PageResult result = productApplicationService.getAll(brandId, ProductSortType.from(sort), page, size); - Set brandIds = result.items().stream() - .map(Product::getBrandId) - .collect(Collectors.toSet()); - Map brandMap = brandApplicationService.getByIds(brandIds); - return ApiResponse.success(ProductV1Dto.ProductPageResponse.from(result, brandMap)); + ProductPageWithBrands result = productApplicationService.getAll(brandId, ProductSortType.from(sort), page, size); + return ApiResponse.success(ProductV1Dto.ProductPageResponse.from(result.result(), result.brandMap())); } @GetMapping("/{productId}") @Override public ApiResponse getById(@PathVariable Long productId) { - Product product = productApplicationService.getById(productId); - Brand brand = brandApplicationService.getById(product.getBrandId()); - return ApiResponse.success(ProductV1Dto.ProductResponse.from(product, brand)); + ProductWithBrand result = productApplicationService.getProductWithBrand(productId); + return ApiResponse.success(ProductV1Dto.ProductResponse.from(result.product(), result.brand())); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java index cbb0ec4ff..428772d85 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -3,8 +3,6 @@ import com.loopers.domain.PageResult; import com.loopers.domain.brand.Brand; import com.loopers.domain.product.Product; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; import java.util.List; import java.util.Map; @@ -36,14 +34,8 @@ public record ProductPageResponse( ) { public static ProductPageResponse from(PageResult result, Map brandMap) { List content = result.items().stream() - .map(product -> { - Brand brand = brandMap.get(product.getBrandId()); - if (brand == null) { - throw new CoreException(ErrorType.INTERNAL_ERROR, - "브랜드를 찾을 수 없습니다. brandId=" + product.getBrandId()); - } - return ProductResponse.from(product, brand); - }) + .filter(product -> brandMap.containsKey(product.getBrandId())) + .map(product -> ProductResponse.from(product, brandMap.get(product.getBrandId()))) .toList(); return new ProductPageResponse(content, result.page(), result.size(), result.totalElements(), result.totalPages()); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java index e0e32d362..28475ce31 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -1,6 +1,7 @@ package com.loopers.interfaces.api.user; import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.AuthenticatedUser; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -18,11 +19,11 @@ public interface UserV1ApiSpec { summary = "내 정보 조회", description = "로그인한 유저의 정보를 조회합니다. 이름은 마지막 글자가 마스킹됩니다." ) - ApiResponse getMe(@Parameter(hidden = true) com.loopers.domain.user.User user); + ApiResponse getMe(@Parameter(hidden = true) AuthenticatedUser authUser); @Operation( summary = "비밀번호 변경", description = "로그인한 유저의 비밀번호를 변경합니다." ) - ApiResponse changePassword(@Parameter(hidden = true) com.loopers.domain.user.User user, UserV1Dto.ChangePasswordRequest request); + ApiResponse changePassword(@Parameter(hidden = true) AuthenticatedUser authUser, UserV1Dto.ChangePasswordRequest request); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java index 80ae09c01..d4802184b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -4,6 +4,7 @@ import com.loopers.domain.user.User; import com.loopers.interfaces.api.ApiResponse; import com.loopers.interfaces.api.auth.AuthUser; +import com.loopers.interfaces.api.auth.AuthenticatedUser; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -35,14 +36,15 @@ public ApiResponse signup(@Valid @RequestBody UserV1Dt @GetMapping("/me") @Override - public ApiResponse getMe(@AuthUser User user) { + public ApiResponse getMe(@AuthUser AuthenticatedUser authUser) { + User user = userApplicationService.getById(authUser.userId()); return ApiResponse.success(UserV1Dto.MeResponse.from(user)); } @PutMapping("/password") @Override - public ApiResponse changePassword(@AuthUser User user, @Valid @RequestBody UserV1Dto.ChangePasswordRequest request) { - userApplicationService.changePassword(user.getId(), request.currentPassword(), request.newPassword()); + public ApiResponse changePassword(@AuthUser AuthenticatedUser authUser, @Valid @RequestBody UserV1Dto.ChangePasswordRequest request) { + userApplicationService.changePassword(authUser.userId(), request.currentPassword(), request.newPassword()); return ApiResponse.success(); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartDomainServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartDomainServiceIntegrationTest.java index 9b486a7ae..4a300205e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartDomainServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartDomainServiceIntegrationTest.java @@ -60,7 +60,7 @@ class AddToCart { void createsCartItem_whenNewProduct() { cartService.addToCart(1L, productId, 2); - List items = cartService.getCartItems(1L); + List items = cartService.getCart(1L).getItems(); assertAll( () -> assertThat(items).hasSize(1), () -> assertThat(items.get(0).getProductId()).isEqualTo(productId), @@ -75,7 +75,7 @@ void addsQuantity_whenProductAlreadyInCart() { cartService.addToCart(1L, productId, 3); - List items = cartService.getCartItems(1L); + List items = cartService.getCart(1L).getItems(); assertAll( () -> assertThat(items).hasSize(1), () -> assertThat(items.get(0).getQuantity()).isEqualTo(new Quantity(5)) @@ -91,11 +91,11 @@ class UpdateQuantity { @Test void updatesQuantity_whenValid() { cartService.addToCart(1L, productId, 2); - Long cartItemId = cartService.getCartItems(1L).get(0).getId(); + Long cartItemId = cartService.getCart(1L).getItems().get(0).getId(); cartService.updateItemQuantity(1L, cartItemId, 5); - List items = cartService.getCartItems(1L); + List items = cartService.getCart(1L).getItems(); assertThat(items.get(0).getQuantity()).isEqualTo(new Quantity(5)); } @@ -126,11 +126,11 @@ class RemoveItem { @Test void removesItem_whenItemExists() { cartService.addToCart(1L, productId, 2); - Long cartItemId = cartService.getCartItems(1L).get(0).getId(); + Long cartItemId = cartService.getCart(1L).getItems().get(0).getId(); cartService.removeItem(1L, cartItemId); - List items = cartService.getCartItems(1L); + List items = cartService.getCart(1L).getItems(); assertThat(items).isEmpty(); } @@ -154,7 +154,7 @@ void returnsItems_whenItemsExist() { cartService.addToCart(1L, productId, 2); cartService.addToCart(1L, product2.getId(), 1); - List result = cartService.getCartItems(1L); + List result = cartService.getCart(1L).getItems(); assertThat(result).hasSize(2); } @@ -162,7 +162,7 @@ void returnsItems_whenItemsExist() { @DisplayName("항목이 없으면, 빈 목록을 반환한다.") @Test void returnsEmptyList_whenNoItems() { - List result = cartService.getCartItems(1L); + List result = cartService.getCart(1L).getItems(); assertThat(result).isEmpty(); } @@ -181,7 +181,7 @@ void clearsAllItems() { cartService.clearCart(1L); - List result = cartService.getCartItems(1L); + List result = cartService.getCart(1L).getItems(); assertThat(result).isEmpty(); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartItemTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartItemTest.java index c11f3e2d6..fff1e305f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartItemTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartItemTest.java @@ -49,16 +49,16 @@ void addsQuantity_whenAmountIsPositive() { @DisplayName("수량을 변경할 때, ") @Nested - class UpdateQuantity { + class ChangeQuantity { @DisplayName("1 이상이면, 수량이 변경된다.") @Test - void updatesQuantity_whenValueIsPositive() { + void changesQuantity_whenValueIsPositive() { Cart cart = createCart(); cart.addItem(100L, 2); CartItem cartItem = cart.getItems().get(0); - cartItem.updateQuantity(5); + cartItem.changeQuantity(5); assertThat(cartItem.getQuantity()).isEqualTo(new Quantity(5)); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartTest.java index 2994303da..b69457610 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/cart/CartTest.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import java.lang.reflect.Field; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -87,10 +88,18 @@ class RemoveItem { @DisplayName("존재하는 항목이면, 삭제된다.") @Test - void removesItem_whenItemExists() { + void removesItem_whenItemExists() throws Exception { Cart cart = new Cart(1L); cart.addItem(100L, 2); - // CartItem doesn't have ID without persistence, so we test through clear + + CartItem cartItem = cart.getItems().get(0); + Field idField = CartItem.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(cartItem, 1L); + + cart.removeItem(1L); + + assertThat(cart.getItems()).isEmpty(); } @DisplayName("존재하지 않는 항목이면, NOT_FOUND 예외가 발생한다.") diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderRepository.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderRepository.java new file mode 100644 index 000000000..c2d754e7c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/FakeOrderRepository.java @@ -0,0 +1,69 @@ +package com.loopers.domain.order; + +import com.loopers.domain.PageResult; + +import java.lang.reflect.Field; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; + +class FakeOrderRepository implements OrderRepository { + + private final List store = new ArrayList<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public Order save(Order order) { + setId(order, idGenerator.getAndIncrement()); + store.add(order); + return order; + } + + @Override + public Optional findById(Long id) { + return store.stream().filter(o -> o.getId().equals(id)).findFirst(); + } + + @Override + public Optional findByIdWithItems(Long id) { + return findById(id); + } + + @Override + public PageResult findByUserIdAndCreatedAtBetween(Long userId, ZonedDateTime startAt, ZonedDateTime endAt, int page, int size) { + List filtered = store.stream() + .filter(o -> o.getUserId().equals(userId)) + .filter(o -> o.getCreatedAt() != null + && !o.getCreatedAt().isBefore(startAt) + && o.getCreatedAt().isBefore(endAt)) + .toList(); + int total = filtered.size(); + int fromIndex = Math.min(page * size, total); + int toIndex = Math.min(fromIndex + size, total); + List paged = filtered.subList(fromIndex, toIndex); + int totalPages = (int) Math.ceil((double) total / size); + return new PageResult<>(paged, page, size, total, totalPages); + } + + @Override + public PageResult findAll(int page, int size) { + int total = store.size(); + int fromIndex = Math.min(page * size, total); + int toIndex = Math.min(fromIndex + size, total); + List paged = store.subList(fromIndex, toIndex); + int totalPages = (int) Math.ceil((double) total / size); + return new PageResult<>(paged, page, size, total, totalPages); + } + + private void setId(Order order, long id) { + try { + Field idField = order.getClass().getSuperclass().getDeclaredField("id"); + idField.setAccessible(true); + idField.set(order, id); + } catch (Exception e) { + throw new RuntimeException("Failed to set Order id", e); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceTest.java new file mode 100644 index 000000000..7473785f2 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderDomainServiceTest.java @@ -0,0 +1,143 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +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.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OrderDomainServiceTest { + + private OrderDomainService orderService; + + @BeforeEach + void setUp() { + orderService = new OrderDomainService(new FakeOrderRepository()); + } + + private List createValidItems() { + return List.of( + new OrderItemCommand(1L, "에어맥스", new Money(129000), "나이키", 2), + new OrderItemCommand(2L, "에어포스1", new Money(109000), "나이키", 1) + ); + } + + @DisplayName("주문을 생성할 때, ") + @Nested + class CreateOrder { + + @DisplayName("올바른 정보이면, 주문이 생성되고 총 가격이 계산된다.") + @Test + void createsOrder_whenValidInfo() { + List items = createValidItems(); + + Order order = orderService.createOrder(1L, items); + + assertAll( + () -> assertThat(order.getId()).isNotNull(), + () -> assertThat(order.getUserId()).isEqualTo(1L), + () -> assertThat(order.getTotalPrice()).isEqualTo(new Money(367000)), + () -> assertThat(order.getStatus()).isEqualTo(OrderStatus.ORDERED), + () -> assertThat(order.getItems()).hasSize(2) + ); + } + + @DisplayName("빈 항목이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenItemsEmpty() { + CoreException result = assertThrows(CoreException.class, + () -> orderService.createOrder(1L, List.of())); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("null 항목이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenItemsNull() { + CoreException result = assertThrows(CoreException.class, + () -> orderService.createOrder(1L, null)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("중복된 상품이 포함되면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenDuplicateProducts() { + List items = List.of( + new OrderItemCommand(1L, "에어맥스", new Money(129000), "나이키", 2), + new OrderItemCommand(1L, "에어맥스", new Money(129000), "나이키", 3) + ); + + CoreException result = assertThrows(CoreException.class, + () -> orderService.createOrder(1L, items)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("총 가격이 정확히 계산된다.") + @Test + void calculatesTotalPrice_correctly() { + List items = List.of( + new OrderItemCommand(1L, "상품A", new Money(10000), "브랜드A", 3), + new OrderItemCommand(2L, "상품B", new Money(20000), "브랜드B", 2) + ); + + Order order = orderService.createOrder(1L, items); + + assertThat(order.getTotalPrice()).isEqualTo(new Money(70000)); + } + } + + @DisplayName("주문을 ID로 조회할 때, ") + @Nested + class GetById { + + @DisplayName("존재하는 주문이면, 주문을 반환한다.") + @Test + void returnsOrder_whenOrderExists() { + Order created = orderService.createOrder(1L, createValidItems()); + + Order result = orderService.getById(created.getId()); + + assertThat(result.getId()).isEqualTo(created.getId()); + } + + @DisplayName("존재하지 않는 주문이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenOrderDoesNotExist() { + CoreException result = assertThrows(CoreException.class, + () -> orderService.getById(999L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } + + @DisplayName("유저의 주문을 조회할 때, ") + @Nested + class GetByIdAndUserId { + + @DisplayName("본인의 주문이면, 주문을 반환한다.") + @Test + void returnsOrder_whenOwner() { + Order created = orderService.createOrder(1L, createValidItems()); + + Order result = orderService.getByIdAndUserId(created.getId(), 1L); + + assertThat(result.getId()).isEqualTo(created.getId()); + } + + @DisplayName("다른 유저의 주문이면, NOT_FOUND 예외가 발생한다.") + @Test + void throwsNotFound_whenNotOwner() { + Order created = orderService.createOrder(1L, createValidItems()); + + CoreException result = assertThrows(CoreException.class, + () -> orderService.getByIdAndUserId(created.getId(), 999L)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderPolicyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderPolicyTest.java new file mode 100644 index 000000000..9fa0f9a90 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderPolicyTest.java @@ -0,0 +1,49 @@ +package com.loopers.domain.order; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OrderPolicyTest { + + @DisplayName("중복 상품 검증할 때, ") + @Nested + class ValidateNoDuplicateProducts { + + @DisplayName("중복이 없으면, 예외가 발생하지 않는다.") + @Test + void doesNotThrow_whenNoDuplicates() { + List productIds = List.of(1L, 2L, 3L); + + assertThatCode(() -> OrderPolicy.validateNoDuplicateProducts(productIds)) + .doesNotThrowAnyException(); + } + + @DisplayName("중복된 상품 ID가 있으면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenDuplicateProductIds() { + List productIds = List.of(1L, 2L, 1L); + + CoreException result = assertThrows(CoreException.class, + () -> OrderPolicy.validateNoDuplicateProducts(productIds)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("단일 상품이면, 예외가 발생하지 않는다.") + @Test + void doesNotThrow_whenSingleProduct() { + List productIds = List.of(1L); + + assertThatCode(() -> OrderPolicy.validateNoDuplicateProducts(productIds)) + .doesNotThrowAnyException(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java index 8b4ec96a1..9d17346b9 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java @@ -1,5 +1,6 @@ package com.loopers.domain.order; +import com.loopers.domain.Quantity; import com.loopers.domain.product.Money; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -7,6 +8,8 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -45,4 +48,51 @@ void throwsBadRequest_whenTotalPriceIsNull() { assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } } + + @DisplayName("주문 항목을 추가할 때, ") + @Nested + class AddItems { + + @DisplayName("올바른 항목이면, 주문 항목이 추가된다.") + @Test + void addsItems_whenValidCommands() { + Order order = new Order(1L, new Money(50000)); + List commands = List.of( + new OrderItemCommand(1L, "에어맥스", new Money(25000), "나이키", 2) + ); + + order.addItems(commands); + + assertAll( + () -> assertThat(order.getItems()).hasSize(1), + () -> assertThat(order.getItems().get(0).getProductName()).isEqualTo("에어맥스"), + () -> assertThat(order.getItems().get(0).getQuantity()).isEqualTo(new Quantity(2)) + ); + } + } + + @DisplayName("주문을 취소할 때, ") + @Nested + class Cancel { + + @DisplayName("ORDERED 상태이면, 취소된다.") + @Test + void cancelsOrder_whenStatusIsOrdered() { + Order order = new Order(1L, new Money(50000)); + + order.cancel(); + + assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED); + } + + @DisplayName("이미 취소된 주문이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenAlreadyCancelled() { + Order order = new Order(1L, new Money(50000)); + order.cancel(); + + CoreException result = assertThrows(CoreException.class, order::cancel); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java index aae3b8a6b..9960af30e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/MoneyTest.java @@ -64,6 +64,7 @@ void throwsBadRequest_whenMinusResultsInNegative() { Money b = new Money(3000); CoreException result = assertThrows(CoreException.class, () -> a.minus(b)); assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(result.getMessage()).isEqualTo("금액은 음수가 될 수 없습니다."); } @DisplayName("곱하면, 곱셈된 금액을 반환한다.") diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDomainServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDomainServiceIntegrationTest.java index 9365f15a1..cdfe16207 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDomainServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductDomainServiceIntegrationTest.java @@ -13,8 +13,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; - -import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -32,6 +31,9 @@ class ProductDomainServiceIntegrationTest { @Autowired private DatabaseCleanUp databaseCleanUp; + @Autowired + private TransactionTemplate transactionTemplate; + private Long brandId; @BeforeEach @@ -154,9 +156,11 @@ void sortsByPriceAsc() { void sortsByLikesDesc() { Product p1 = productService.register(brandId, "인기없는상품", 129000, 100); Product p2 = productService.register(brandId, "인기상품", 159000, 50); - productService.incrementLikeCount(p2.getId()); - productService.incrementLikeCount(p2.getId()); - productService.incrementLikeCount(p1.getId()); + transactionTemplate.executeWithoutResult(status -> { + productService.incrementLikeCount(p2.getId()); + productService.incrementLikeCount(p2.getId()); + productService.incrementLikeCount(p1.getId()); + }); PageResult result = productService.getAll(null, ProductSortType.LIKES_DESC, 0, 20); @@ -239,12 +243,13 @@ class DeleteAllByBrand { @DisplayName("해당 브랜드의 모든 상품이 삭제된다.") @Test - @Transactional void deletesAllProductsOfBrand() { productService.register(brandId, "에어맥스", 129000, 100); productService.register(brandId, "에어포스1", 109000, 200); - productService.deleteAllByBrandId(brandId); + transactionTemplate.executeWithoutResult(status -> + productService.deleteAllByBrandId(brandId) + ); PageResult result = productService.getAll(brandId, ProductSortType.LATEST, 0, 20); assertThat(result.items()).isEmpty(); @@ -257,11 +262,12 @@ class IncrementLikeCount { @DisplayName("좋아요 수가 1 증가한다.") @Test - @Transactional void incrementsLikeCount() { Product product = productService.register(brandId, "에어맥스", 129000, 100); - productService.incrementLikeCount(product.getId()); + transactionTemplate.executeWithoutResult(status -> + productService.incrementLikeCount(product.getId()) + ); Product result = productService.getById(product.getId()); assertThat(result.getLikeCount()).isEqualTo(1); @@ -274,12 +280,15 @@ class DecrementLikeCount { @DisplayName("좋아요 수가 1 감소한다.") @Test - @Transactional void decrementsLikeCount() { Product product = productService.register(brandId, "에어맥스", 129000, 100); - productService.incrementLikeCount(product.getId()); + transactionTemplate.executeWithoutResult(status -> + productService.incrementLikeCount(product.getId()) + ); - productService.decrementLikeCount(product.getId()); + transactionTemplate.executeWithoutResult(status -> + productService.decrementLikeCount(product.getId()) + ); Product result = productService.getById(product.getId()); assertThat(result.getLikeCount()).isEqualTo(0); @@ -287,12 +296,13 @@ void decrementsLikeCount() { @DisplayName("좋아요 수가 0이면, BAD_REQUEST 예외가 발생한다.") @Test - @Transactional void throwsBadRequest_whenLikeCountIsZero() { Product product = productService.register(brandId, "에어맥스", 129000, 100); CoreException result = assertThrows(CoreException.class, - () -> productService.decrementLikeCount(product.getId())); + () -> transactionTemplate.executeWithoutResult(status -> + productService.decrementLikeCount(product.getId()) + )); assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductSortTypeTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductSortTypeTest.java new file mode 100644 index 000000000..5199f4cdb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductSortTypeTest.java @@ -0,0 +1,56 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ProductSortTypeTest { + + @DisplayName("ProductSortType.from()을 호출할 때, ") + @Nested + class From { + + @DisplayName("null이면, LATEST를 반환한다.") + @Test + void returnsLatest_whenNull() { + assertThat(ProductSortType.from(null)).isEqualTo(ProductSortType.LATEST); + } + + @DisplayName("빈 문자열이면, LATEST를 반환한다.") + @Test + void returnsLatest_whenBlank() { + assertThat(ProductSortType.from("")).isEqualTo(ProductSortType.LATEST); + } + + @DisplayName("소문자 'latest'이면, LATEST를 반환한다.") + @Test + void returnsLatest_whenLowercase() { + assertThat(ProductSortType.from("latest")).isEqualTo(ProductSortType.LATEST); + } + + @DisplayName("대문자 'PRICE_ASC'이면, PRICE_ASC를 반환한다.") + @Test + void returnsPriceAsc_whenUppercase() { + assertThat(ProductSortType.from("PRICE_ASC")).isEqualTo(ProductSortType.PRICE_ASC); + } + + @DisplayName("소문자 'likes_desc'이면, LIKES_DESC를 반환한다.") + @Test + void returnsLikesDesc_whenLowercase() { + assertThat(ProductSortType.from("likes_desc")).isEqualTo(ProductSortType.LIKES_DESC); + } + + @DisplayName("유효하지 않은 값이면, BAD_REQUEST 예외가 발생한다.") + @Test + void throwsBadRequest_whenInvalid() { + CoreException result = assertThrows(CoreException.class, + () -> ProductSortType.from("invalid")); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserDomainServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserDomainServiceIntegrationTest.java index 4c0cb583f..2edfd7617 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserDomainServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserDomainServiceIntegrationTest.java @@ -1,6 +1,5 @@ package com.loopers.domain.user; -import com.loopers.infrastructure.user.UserJpaRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; @@ -23,9 +22,6 @@ class UserDomainServiceIntegrationTest { @Autowired private UserDomainService userService; - @Autowired - private UserJpaRepository userJpaRepository; - @Autowired private PasswordEncryptor passwordEncryptor; diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/auth/AuthUserArgumentResolverTest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/auth/AuthUserArgumentResolverTest.java new file mode 100644 index 000000000..48720134d --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/auth/AuthUserArgumentResolverTest.java @@ -0,0 +1,123 @@ +package com.loopers.interfaces.api.auth; + +import com.loopers.application.user.UserApplicationService; +import com.loopers.domain.user.User; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +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.core.MethodParameter; +import org.springframework.web.context.request.NativeWebRequest; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AuthUserArgumentResolverTest { + + private UserApplicationService userApplicationService; + private AuthUserArgumentResolver resolver; + + @BeforeEach + void setUp() { + userApplicationService = mock(UserApplicationService.class); + resolver = new AuthUserArgumentResolver(userApplicationService); + } + + @DisplayName("supportsParameter 검증할 때, ") + @Nested + class SupportsParameter { + + @DisplayName("@AuthUser와 AuthenticatedUser 타입이면, true를 반환한다.") + @Test + void returnsTrue_whenAuthUserAnnotationWithAuthenticatedUserType() { + MethodParameter parameter = mock(MethodParameter.class); + when(parameter.hasParameterAnnotation(AuthUser.class)).thenReturn(true); + when(parameter.getParameterType()).thenReturn((Class) AuthenticatedUser.class); + + assertThat(resolver.supportsParameter(parameter)).isTrue(); + } + + @DisplayName("@AuthUser와 User 타입이면, false를 반환한다.") + @Test + void returnsFalse_whenAuthUserAnnotationWithUserType() { + MethodParameter parameter = mock(MethodParameter.class); + when(parameter.hasParameterAnnotation(AuthUser.class)).thenReturn(true); + when(parameter.getParameterType()).thenReturn((Class) User.class); + + assertThat(resolver.supportsParameter(parameter)).isFalse(); + } + + @DisplayName("@AuthUser 어노테이션이 없으면, false를 반환한다.") + @Test + void returnsFalse_whenNoAuthUserAnnotation() { + MethodParameter parameter = mock(MethodParameter.class); + when(parameter.hasParameterAnnotation(AuthUser.class)).thenReturn(false); + when(parameter.getParameterType()).thenReturn((Class) AuthenticatedUser.class); + + assertThat(resolver.supportsParameter(parameter)).isFalse(); + } + } + + @DisplayName("인증 헤더를 검증할 때, ") + @Nested + class ResolveArgument { + + @DisplayName("로그인 ID 헤더가 누락되면, UNAUTHORIZED 예외가 발생한다.") + @Test + void throwsUnauthorized_whenLoginIdHeaderMissing() { + NativeWebRequest webRequest = mock(NativeWebRequest.class); + when(webRequest.getHeader("X-Loopers-LoginId")).thenReturn(null); + when(webRequest.getHeader("X-Loopers-LoginPw")).thenReturn("password"); + + CoreException result = assertThrows(CoreException.class, + () -> resolver.resolveArgument(null, null, webRequest, null)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + } + + @DisplayName("로그인 ID 헤더가 빈 문자열이면, UNAUTHORIZED 예외가 발생한다.") + @Test + void throwsUnauthorized_whenLoginIdHeaderBlank() { + NativeWebRequest webRequest = mock(NativeWebRequest.class); + when(webRequest.getHeader("X-Loopers-LoginId")).thenReturn(" "); + when(webRequest.getHeader("X-Loopers-LoginPw")).thenReturn("password"); + + CoreException result = assertThrows(CoreException.class, + () -> resolver.resolveArgument(null, null, webRequest, null)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + } + + @DisplayName("비밀번호 헤더가 누락되면, UNAUTHORIZED 예외가 발생한다.") + @Test + void throwsUnauthorized_whenPasswordHeaderMissing() { + NativeWebRequest webRequest = mock(NativeWebRequest.class); + when(webRequest.getHeader("X-Loopers-LoginId")).thenReturn("user1"); + when(webRequest.getHeader("X-Loopers-LoginPw")).thenReturn(null); + + CoreException result = assertThrows(CoreException.class, + () -> resolver.resolveArgument(null, null, webRequest, null)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.UNAUTHORIZED); + } + + @DisplayName("정상 헤더이면, AuthenticatedUser를 반환한다.") + @Test + void returnsAuthenticatedUser_whenValidHeaders() { + NativeWebRequest webRequest = mock(NativeWebRequest.class); + when(webRequest.getHeader("X-Loopers-LoginId")).thenReturn("user1"); + when(webRequest.getHeader("X-Loopers-LoginPw")).thenReturn("password"); + + User user = new User("user1", "encryptedPw", "홍길동", LocalDate.of(1990, 1, 1), "test@test.com"); + // User entity's ID is set by JPA, so we use reflection or trust the flow + when(userApplicationService.authenticate("user1", "password")).thenReturn(user); + + AuthenticatedUser result = resolver.resolveArgument(null, null, webRequest, null); + + assertThat(result.loginId()).isEqualTo("user1"); + } + } +} From 288f308a678b30d26a716ec76c9455a3a2ebc734 Mon Sep 17 00:00:00 2001 From: yoon-yoo-tak Date: Wed, 25 Feb 2026 22:17:56 +0900 Subject: [PATCH 11/11] =?UTF-8?q?refactor=20:=20=EC=84=A4=EA=B3=84=20?= =?UTF-8?q?=EB=AC=B8=EC=84=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 --- .docs/01-requirements.md | 14 +++--- .docs/02-sequence-diagrams.md | 84 +++++++++++++++++++++++++++-------- .docs/03-class-diagram.md | 65 ++++++++++++++++++++------- .docs/04-erd.md | 19 +++++--- 4 files changed, 134 insertions(+), 48 deletions(-) diff --git a/.docs/01-requirements.md b/.docs/01-requirements.md index 54088d56c..efe34fa37 100644 --- a/.docs/01-requirements.md +++ b/.docs/01-requirements.md @@ -48,7 +48,7 @@ - 상품 탐색은 **로그인 없이** 가능하다 (비회원도 조회 가능). - 고객에게 노출되는 정보와 어드민에게 노출되는 정보는 다를 수 있다. - 상품 목록은 **페이지 단위**로 제공된다. -- 정렬 기준: 최신순(기본), 가격 낮은 순, 가격 높은 순, 좋아요 많은 순. +- 정렬 기준: 최신순(기본), 가격 낮은 순, 좋아요 많은 순. --- @@ -101,6 +101,7 @@ 1. 고객이 원하는 상품들과 수량을 선택하여 주문한다. 2. 고객이 특정 기간 내 자신의 주문 목록을 조회한다. 3. 고객이 특정 주문의 상세 내역(어떤 상품을, 얼마에, 몇 개 샀는지)을 조회한다. +4. 고객이 주문을 취소한다. **드러나는 행위** @@ -110,6 +111,7 @@ - 주문 시점의 상품 정보(이름, 가격 등)가 **스냅샷으로 보존**된다. 이후 상품이 변경·삭제되어도 주문 내역은 영향받지 않는다. - 주문 목록 조회 시 **시작일과 종료일을 반드시 지정**해야 한다. - 고객은 **본인의 주문만** 조회할 수 있다. +- ORDERED 상태의 주문만 취소할 수 있다. --- @@ -175,6 +177,7 @@ | 삭제된 상품 주문 불가 | 삭제된 상품은 주문할 수 없다 | | 스냅샷 보존 | 주문 시점의 상품명, 상품 가격, 브랜드명이 주문 항목에 스냅샷으로 저장된다. 수량은 주문 항목 자체 필드이다 | | 주문 초기 상태 | 주문 생성 시 초기 상태는 ORDERED. 향후 결제 기능 추가 시 확장 가능하다 | +| 주문 취소 가능 상태 | ORDERED 상태의 주문만 취소할 수 있다. 이미 취소된 주문은 다시 취소할 수 없다 | | 주문 조회 기간 | 고객의 주문 목록 조회 시 시작일과 종료일을 반드시 지정해야 한다 | ### 3.3 좋아요 @@ -208,7 +211,7 @@ | 규칙 | 설명 | |---|---| -| 상품 정렬 기준 | 최신순, 가격 낮은 순, 가격 높은 순, 좋아요 많은 순을 지원한다 | +| 상품 정렬 기준 | 최신순, 가격 낮은 순, 좋아요 많은 순을 지원한다 | | 기본 정렬 | 정렬 기준을 지정하지 않으면 최신순으로 정렬된다 | ### 3.7 정보 노출 @@ -261,8 +264,8 @@ ### 4.5 주문 상태 정책 - 주문은 생성 시 ORDERED 상태로 시작한다. -- 결제 기능이 추가되면 PAID, CANCELLED 등의 상태로 확장한다. -- **현재 범위에서는 상태 전이 기능을 포함하지 않는다.** +- 주문 상태: ORDERED(주문 완료), CANCELLED(취소). +- 상태 전이: ORDERED → CANCELLED (주문 취소). CANCELLED 상태에서는 다른 상태로 전이할 수 없다. --- @@ -273,5 +276,4 @@ | 유저(Users) 기능 | 회원가입, 내 정보 조회, 비밀번호 변경은 이미 구현 완료 | | 결제(Payment) | 향후 별도 단계에서 추가 개발 예정 | | 쿠폰(Coupon) | 향후 별도 단계에서 추가 개발 예정 | -| 주문 상태 전이 | 결제 기능과 함께 추가. 현재는 주문 생성(ORDERED)만 다룬다 | -| 주문 취소 | 현재 범위에서 주문 취소 기능은 제공하지 않는다 | +| 주문 상태 전이 (결제 연동) | 결제 기능이 추가되면 PAID 등 추가 상태로 확장. 현재는 ORDERED → CANCELLED 전이만 다룬다 | diff --git a/.docs/02-sequence-diagrams.md b/.docs/02-sequence-diagrams.md index 0b4b63a59..d50767a9f 100644 --- a/.docs/02-sequence-diagrams.md +++ b/.docs/02-sequence-diagrams.md @@ -16,23 +16,17 @@ sequenceDiagram actor 고객 participant LikeV1Controller participant LikeApplicationService - participant ProductDomainService participant LikeDomainService participant Like participant LikeRepository - participant ProductRepository + participant ProductDomainService + participant Product Note right of 고객: 인증된 고객 고객->>+LikeV1Controller: 좋아요 등록 요청 LikeV1Controller->>+LikeApplicationService: 좋아요 등록 - LikeApplicationService->>+ProductDomainService: 상품 조회 - alt 상품이 존재하지 않거나 삭제됨 - ProductDomainService-->>고객: 실패 - end - ProductDomainService-->>-LikeApplicationService: 상품 - LikeApplicationService->>+LikeDomainService: 좋아요 등록 LikeDomainService->>+LikeRepository: 중복 좋아요 확인 LikeRepository-->>-LikeDomainService: 결과 @@ -44,10 +38,14 @@ sequenceDiagram Like-->>-LikeDomainService: 좋아요 LikeDomainService->>+LikeRepository: 좋아요 저장 LikeRepository-->>-LikeDomainService: 완료 - LikeDomainService->>+ProductRepository: 좋아요 수 증가 (원자적 UPDATE) - ProductRepository-->>-LikeDomainService: 완료 LikeDomainService-->>-LikeApplicationService: 결과 반환 + + LikeApplicationService->>+ProductDomainService: 좋아요 수 증가 (비관적 락) + ProductDomainService->>+Product: incrementLikeCount() + Product-->>-ProductDomainService: 완료 + ProductDomainService-->>-LikeApplicationService: 완료 + LikeApplicationService-->>-LikeV1Controller: 결과 반환 LikeV1Controller-->>-고객: 성공 ``` @@ -187,11 +185,22 @@ sequenceDiagram OrderV1Controller->>+OrderApplicationService: 장바구니 주문 OrderApplicationService->>+CartDomainService: 장바구니 조회 - CartDomainService-->>-OrderApplicationService: 장바구니 항목 + CartDomainService-->>-OrderApplicationService: 장바구니 alt 장바구니가 비어있음 OrderApplicationService-->>고객: 실패 end + OrderApplicationService->>+ProductDomainService: 장바구니 상품 일괄 조회 + ProductDomainService-->>-OrderApplicationService: 유효한 상품 목록 + + alt 유효하지 않은 상품이 포함됨 + OrderApplicationService->>+CartDomainService: 유효하지 않은 상품 제거 + CartDomainService-->>-OrderApplicationService: 완료 + alt 유효한 상품이 하나도 없음 + OrderApplicationService-->>고객: 실패 + end + end + loop 각 장바구니 항목 (productId 순으로 정렬) OrderApplicationService->>+ProductDomainService: 상품 조회 (비관적 락) alt 상품이 존재하지 않거나 삭제됨 @@ -231,7 +240,7 @@ sequenceDiagram **다이어그램이 필요한 이유** - 도메인 간 협력: Brand 삭제가 Product 연쇄 삭제를 트리거한다 -- 삭제 순서: 상품을 먼저 삭제한 뒤 브랜드를 삭제해야 정합성이 유지된다 +- 삭제 순서: 브랜드를 먼저 삭제한 뒤 해당 브랜드의 상품을 삭제한다 ```mermaid sequenceDiagram @@ -248,22 +257,59 @@ sequenceDiagram 어드민->>+AdminBrandV1Controller: 브랜드 삭제 요청 AdminBrandV1Controller->>+BrandApplicationService: 브랜드 삭제 - BrandApplicationService->>+BrandDomainService: 브랜드 조회 + BrandApplicationService->>+BrandDomainService: 브랜드 삭제 + BrandDomainService->>BrandDomainService: 브랜드 조회 alt 브랜드가 존재하지 않거나 삭제됨 BrandDomainService-->>어드민: 실패 end - BrandDomainService-->>-BrandApplicationService: 브랜드 + BrandDomainService->>+Brand: 논리 삭제 (soft delete) + Brand-->>-BrandDomainService: 완료 + BrandDomainService-->>-BrandApplicationService: 완료 BrandApplicationService->>+ProductDomainService: 해당 브랜드의 상품 전체 삭제 ProductDomainService->>+Product: 논리 삭제 (soft delete) Product-->>-ProductDomainService: 완료 ProductDomainService-->>-BrandApplicationService: 완료 - BrandApplicationService->>+BrandDomainService: 브랜드 삭제 - BrandDomainService->>+Brand: 논리 삭제 (soft delete) - Brand-->>-BrandDomainService: 완료 - BrandDomainService-->>-BrandApplicationService: 완료 - BrandApplicationService-->>-AdminBrandV1Controller: 결과 반환 AdminBrandV1Controller-->>-어드민: 성공 ``` + +--- + +## 주문 취소 + +> 시나리오 2.4 — 고객이 주문을 취소한다. + +**다이어그램이 필요한 이유** +- 조건 분기: 주문 상태에 따른 취소 가능 여부 검증 +- 도메인 로직: ORDERED 상태에서만 CANCELLED로 전이 가능 + +```mermaid +sequenceDiagram + actor 고객 + participant OrderV1Controller + participant OrderApplicationService + participant OrderDomainService + participant Order + + Note right of 고객: 인증된 고객 + + 고객->>+OrderV1Controller: 주문 취소 요청 + OrderV1Controller->>+OrderApplicationService: 주문 취소 + + OrderApplicationService->>+OrderDomainService: 주문 조회 (본인 확인) + alt 주문이 존재하지 않거나 본인의 주문이 아님 + OrderDomainService-->>고객: 실패 + end + OrderDomainService-->>-OrderApplicationService: 주문 + + OrderApplicationService->>+Order: cancel() + alt ORDERED 상태가 아님 + Order-->>고객: 실패 + end + Order-->>-OrderApplicationService: 완료 + + OrderApplicationService-->>-OrderV1Controller: 결과 반환 + OrderV1Controller-->>-고객: 성공 +``` diff --git a/.docs/03-class-diagram.md b/.docs/03-class-diagram.md index 5215ec7a1..6496917ff 100644 --- a/.docs/03-class-diagram.md +++ b/.docs/03-class-diagram.md @@ -14,12 +14,13 @@ classDiagram UserName name LocalDate birthDate Email email - +changePassword(Password) void + +changePassword(String) void + +getMaskedName() String } class Brand { String name - +update(String) void + +rename(String) void } class Product { @@ -28,8 +29,10 @@ classDiagram Money price Stock stock int likeCount - +update(String, Money, Stock) void + +changeDetails(String, Money, Stock) void +deductStock(int) void + +incrementLikeCount() void + +decrementLikeCount() void } class Like { @@ -37,22 +40,32 @@ classDiagram Long productId } - class CartItem { + class Cart { Long userId + List~CartItem~ items + +addItem(Long, int) void + +removeItem(Long) void + +updateItemQuantity(Long, int) void + +clear() void + +removeUnavailableItems(Set~Long~) void + } + + class CartItem { Long productId - Quantity quantity + int quantity +addQuantity(int) void - +updateQuantity(int) void + +changeQuantity(int) void } class Order { Long userId Money totalPrice OrderStatus status + +addItems(List~OrderItemCommand~) void + +cancel() void } class OrderItem { - Long orderId Long productId String productName Money productPrice @@ -63,15 +76,17 @@ classDiagram class OrderStatus { <> ORDERED + CANCELLED } Product "*" --> "1" Brand : brandId Like "*" --> "1" User : userId Like "*" --> "1" Product : productId - CartItem "*" --> "1" User : userId + User "1" --> "1" Cart : userId + Cart "1" --> "*" CartItem : items CartItem "*" --> "1" Product : productId Order "*" --> "1" User : userId - OrderItem "*" --> "1" Order : orderId + Order "1" --> "*" OrderItem : items Order --> OrderStatus ``` @@ -88,6 +103,10 @@ classDiagram | UserName | mask() | 마지막 글자를 `*`로 마스킹 | | Email | validate() | 이메일 포맷 검증 | | Money | validate() | 0 이상이어야 함 | +| Money | plus(Money) | 두 금액의 합산 | +| Money | minus(Money) | 금액 차감, 결과가 음수면 불가 | +| Money | multiply(int) | 금액 × 수량 | +| Money | isGreaterThanOrEqual(Money) | 금액 비교 | | Stock | validate() | 0 이상이어야 함 | | Stock | deduct(quantity) | 재고 부족 시 CoreException(BAD_REQUEST) | | Quantity | validate() | 1 이상이어야 함 | @@ -99,10 +118,19 @@ classDiagram | 엔티티 | 메서드 | 비즈니스 규칙 | |---|---|---| -| User | changePassword(Password) | 새 Password VO로 교체 | +| User | changePassword(String) | 암호화된 비밀번호로 교체 | +| User | getMaskedName() | UserName VO의 mask()를 통해 마스킹된 이름 반환 | +| Brand | rename(String) | 브랜드명 변경 | +| Product | changeDetails(String, Money, Stock) | 상품 정보(이름, 가격, 재고) 변경 | | Product | deductStock(int) | 재고 부족 시 CoreException(BAD_REQUEST) | +| Product | incrementLikeCount() | 좋아요 수 1 증가 | +| Product | decrementLikeCount() | 좋아요 수 1 감소, 0 미만 불가 | +| Cart | addItem(Long, int) | 이미 담긴 상품이면 수량 합산, 새 상품이면 항목 추가 | +| Cart | removeUnavailableItems(Set<Long>) | 유효하지 않은 상품을 장바구니에서 제거 | | CartItem | addQuantity(int) | 이미 담긴 상품 → 수량 합산 | -| CartItem | updateQuantity(int) | 수량 변경, 0 이하 불가 | +| CartItem | changeQuantity(int) | 수량 변경, 0 이하 불가 | +| Order | addItems(List<OrderItemCommand>) | 주문 항목 추가 | +| Order | cancel() | ORDERED 상태에서만 CANCELLED로 전이. 그 외 상태에서는 CoreException(BAD_REQUEST) | --- @@ -113,19 +141,22 @@ classDiagram | Brand → Product | 1 : N | 하나의 브랜드에 여러 상품 | | User → Like | 1 : N | 한 유저가 여러 좋아요 | | Product → Like | 1 : N | 한 상품에 여러 좋아요 (Like = 교차 테이블) | -| User → CartItem | 1 : N | 한 유저의 장바구니 항목들 | +| User → Cart | 1 : 1 | 한 유저에 하나의 장바구니 | +| Cart → CartItem | 1 : N | 한 장바구니에 여러 항목 (Aggregate 내부) | | Product → CartItem | 1 : N | 한 상품이 여러 장바구니에 담김 | | User → Order | 1 : N | 한 유저가 여러 주문 | -| Order → OrderItem | 1 : N | 한 주문에 여러 주문 항목 | +| Order → OrderItem | 1 : N | 한 주문에 여러 주문 항목 (Aggregate 내부) | --- ## 설계 결정 -- **Rich Domain Model**: 비즈니스 로직은 엔티티 메서드에 포함한다. Facade는 오케스트레이션만 담당한다. +- **Rich Domain Model**: 비즈니스 로직은 엔티티 메서드에 포함한다. Application Service는 오케스트레이션만 담당한다. - **FK 미사용**: 모든 관계는 ID 참조만. FK 제약조건 없음. 참조 무결성은 애플리케이션 레벨에서 검증한다. -- **Cart 엔티티 없음**: CartItem만 사용. User가 곧 Cart 소유자이다. -- **좋아요 수 비정규화**: Product에 likeCount 필드로 저장. LikeService에서 좋아요 등록/취소 시 원자적 UPDATE(`ProductRepository.incrementLikeCount/decrementLikeCount`)로 카운터를 증감한다. -- **N:M 관계**: Like, CartItem 교차 테이블로 해소한다. +- **Cart Aggregate Root**: Cart가 Aggregate Root이며, CartItem은 Cart 내부 엔티티이다. User당 하나의 Cart가 존재하며, CartItem 조작은 Cart를 통해서만 이루어진다. Aggregate 내부에서 `@OneToMany`/`@ManyToOne` 매핑을 사용한다 (Aggregate 경계 내 일관성 보장을 위해). +- **Order Aggregate Root**: Order가 Aggregate Root이며, OrderItem은 Order 내부 엔티티이다. Aggregate 내부에서 `@OneToMany`/`@ManyToOne` 매핑을 사용한다. +- **좋아요 수 비정규화**: Product에 likeCount 필드로 저장. LikeApplicationService에서 좋아요 등록/취소 시 비관적 락으로 Product를 조회한 뒤 in-memory에서 카운터를 증감한다. +- **N:M 관계**: Like 교차 테이블로 해소한다. - **likes, cart_items 물리 삭제**: 이력이 필요 없는 토글/임시 데이터이므로 Soft Delete 대신 물리 삭제 처리. UNIQUE 제약조건과의 충돌을 방지한다. +- **동일 상품 중복 방지**: 장바구니의 동일 상품 중복은 애플리케이션 레벨에서 검증한다 (Cart.addItem()에서 기존 항목이면 수량 합산). - **order_items의 deleted_at 유지**: 주문 항목은 삭제 시나리오가 없으나, BaseEntity 상속 일관성을 위해 deleted_at을 유지한다. diff --git a/.docs/04-erd.md b/.docs/04-erd.md index 6bc23938e..55f8e2676 100644 --- a/.docs/04-erd.md +++ b/.docs/04-erd.md @@ -47,9 +47,15 @@ erDiagram timestamp created_at } + carts { + bigint id PK + bigint user_id UK + timestamp created_at + } + cart_items { bigint id PK - bigint user_id + bigint cart_id bigint product_id int quantity timestamp created_at @@ -81,7 +87,8 @@ erDiagram brands ||--o{ products : "" users ||--o{ likes : "" products ||--o{ likes : "" - users ||--o{ cart_items : "" + users ||--|| carts : "" + carts ||--o{ cart_items : "" products ||--o{ cart_items : "" users ||--o{ orders : "" orders ||--|{ order_items : "" @@ -95,7 +102,7 @@ erDiagram |---|---|---| | users | UNIQUE(login_id) | 로그인 ID 중복 방지 | | likes | UNIQUE(user_id, product_id) | 1인 1좋아요 보장 | -| cart_items | UNIQUE(user_id, product_id) | 동일 상품 중복 담기 방지 (수량 합산으로 처리) | +| carts | UNIQUE(user_id) | 1인 1장바구니 보장 | --- @@ -105,7 +112,7 @@ erDiagram |---|---|---| | products | brand_id | 브랜드별 상품 필터링 | | likes | user_id | 유저의 좋아요 목록 조회 | -| cart_items | user_id | 유저의 장바구니 조회 | +| cart_items | cart_id | 장바구니의 항목 조회 | | orders | (user_id, created_at) | 유저의 주문 목록 조회 (날짜 범위 필터링) | | order_items | order_id | 주문의 상세 항목 조회 | @@ -117,7 +124,7 @@ erDiagram - **Soft Delete** — 모든 테이블에 deleted_at 컬럼으로 논리 삭제. 물리적으로 데이터를 제거하지 않는다. - **Soft Delete 예외** — likes, cart_items는 이력이 필요 없는 토글/임시 데이터이므로 물리 삭제(Hard Delete). UNIQUE 제약조건과의 충돌을 방지한다. - **공통 컬럼** — 모든 테이블에 BaseEntity 공통 컬럼(id, created_at, updated_at, deleted_at) 포함. -- **Enum 저장** — OrderStatus 등 Enum은 VARCHAR로 저장한다. +- **Enum 저장** — OrderStatus(ORDERED, CANCELLED) 등 Enum은 VARCHAR로 저장한다. --- @@ -126,7 +133,7 @@ erDiagram | 대상 | 방식 | 이유 | |---|---|---| | Product.stock | 비관적 락 | 주문 시 재고 차감. 동시 주문에도 재고가 음수가 되어서는 안 된다 | -| Product.like_count | 원자적 UPDATE (`SET like_count = like_count + 1`) | 좋아요 등록/취소 시 카운터 증감. 재고와 달리 경합이 심하지 않으므로 비관적 락은 과도함 | +| Product.like_count | 비관적 락 + in-memory 증감 | 좋아요 등록/취소 시 비관적 락으로 Product를 조회한 뒤 incrementLikeCount()/decrementLikeCount()로 카운터를 증감한다 | ---