From 369c10d52b02ddc0ac5e1cbababa9d8c001b9e0d Mon Sep 17 00:00:00 2001 From: leeminkyu-kr96 Date: Mon, 3 Nov 2025 22:09:20 +0900 Subject: [PATCH 01/11] =?UTF-8?q?git=20=EC=9D=B4=EC=A0=84=20=20-=20?= =?UTF-8?q?=EA=B0=9C=EC=9D=B8=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=A3=A8=ED=8D=BC=EC=8A=A4=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=A0=9D=ED=8A=B8=EB=A1=9C=20=EC=9D=B4=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/point/PointFacade.java | 44 +++++ .../loopers/application/point/PointInfo.java | 13 ++ .../loopers/application/user/UserFacade.java | 28 +++ .../loopers/application/user/UserInfo.java | 13 ++ .../com/loopers/domain/point/PointModel.java | 57 ++++++ .../loopers/domain/point/PointRepository.java | 10 ++ .../loopers/domain/point/PointService.java | 43 +++++ .../com/loopers/domain/user/UserModel.java | 59 +++++++ .../loopers/domain/user/UserRepository.java | 10 ++ .../com/loopers/domain/user/UserService.java | 31 ++++ .../point/PointJpaRepository.java | 11 ++ .../point/PointRepositoryImpl.java | 25 +++ .../user/UserJpaRepository.java | 10 ++ .../user/UserRepositoryImpl.java | 32 ++++ .../interfaces/api/ApiControllerAdvice.java | 18 ++ .../interfaces/api/point/PointV1ApiSpec.java | 31 ++++ .../api/point/PointV1Controller.java | 37 ++++ .../interfaces/api/point/PointV1Dto.java | 20 +++ .../interfaces/api/user/UserV1ApiSpec.java | 28 +++ .../interfaces/api/user/UserV1Controller.java | 38 ++++ .../interfaces/api/user/UserV1Dto.java | 27 +++ .../loopers/domain/point/PointModelTest.java | 37 ++++ .../point/PointServiceIntegrationTest.java | 98 +++++++++++ .../loopers/domain/user/UserModelTest.java | 123 +++++++++++++ .../user/UserServiceIntegrationTest.java | 130 ++++++++++++++ .../interfaces/api/PointV1ApiE2ETest.java | 163 ++++++++++++++++++ .../interfaces/api/UserV1ApiE2ETest.java | 161 +++++++++++++++++ 27 files changed, 1297 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java new file mode 100644 index 000000000..b42ce2f5e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java @@ -0,0 +1,44 @@ +package com.loopers.application.point; + +import com.loopers.domain.point.PointModel; +import com.loopers.domain.point.PointService; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class PointFacade { + private final PointService pointService; + private final UserService userService; + + public PointInfo getPoint(String userId) { + UserModel user = userService.getUser(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 요청입니다."); + } + PointModel pointModel = new PointModel(user, 0); + PointModel point = pointService.findPoint(pointModel); + + if (point == null) { + throw new CoreException(ErrorType.NOT_FOUND, "포인트 정보가 없습니다."); + } + + return PointInfo.from(point); + } + + public PointInfo chargePoint(String userId, int amount) { + UserModel user = userService.getUser(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 요청입니다."); + } + PointModel pointModel = new PointModel(user, amount); + pointService.charge(pointModel); + + PointModel charged = pointService.findPoint(new PointModel(user, 0)); + return PointInfo.from(charged); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java new file mode 100644 index 000000000..84b4eb4e2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java @@ -0,0 +1,13 @@ +package com.loopers.application.point; + +import com.loopers.domain.point.PointModel; +import com.loopers.domain.user.UserModel; + +public record PointInfo(Long id, UserModel user, int point) { + public static PointInfo from(PointModel model) { + return new PointInfo(model.getId(), model.getUser(), model.getPoint()); + } + public int getPoint() { + return point; + } +} 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/UserFacade.java new file mode 100644 index 000000000..d0bceeb30 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserFacade.java @@ -0,0 +1,28 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserService; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class UserFacade { + private final UserService userService; + + public UserInfo signup(String userId, String email, String birthDate) { + UserModel userModel = new UserModel(userId, email, birthDate); + UserModel savedUser = userService.signUp(userModel); + return UserInfo.from(savedUser); + } + + public UserInfo getUser(String userId) { + UserModel user = userService.getUser(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 요청입니다."); + } + return UserInfo.from(user); + } +} 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 new file mode 100644 index 000000000..1472ddfd3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/user/UserInfo.java @@ -0,0 +1,13 @@ +package com.loopers.application.user; + +import com.loopers.domain.user.UserModel; + +public record UserInfo(String userId, String email, String birthDate) { + public static UserInfo from(UserModel model) { + return new UserInfo( + model.getUserId(), + model.getEmail(), + model.getBirthDate() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java new file mode 100644 index 000000000..f90e0ce70 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java @@ -0,0 +1,57 @@ +package com.loopers.domain.point; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.user.UserModel; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "point") +public class PointModel extends BaseEntity { + + @ManyToOne + @JoinColumn(name = "user_model_id") + private UserModel user; + private int point = 0; + + public PointModel() {} + + public PointModel(UserModel user, int point) { + + if( point < 0 ){ + throw new CoreException(ErrorType.BAD_REQUEST, "포인트는 0 이상이어야 합니다."); + } + this.user = user; + this.point = point; + } + + public UserModel getUser() { + return user; + } + + public int getPoint() { + return point; + } + + public void charge(int amount) { + if (amount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "포인트는 0 이상이어야 합니다."); + } + this.point += amount; + } + + public void use(int amount) { + + if (amount < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용 금액은 0보다 커야 합니다."); + } + if (point < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "포인트는 0 이상이어야 합니다."); + } + this.point -= amount; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java new file mode 100644 index 000000000..dd96643db --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.point; + +import com.loopers.domain.user.UserModel; + +import java.util.Optional; + +public interface PointRepository { + Optional findPoint(UserModel user); + PointModel save(PointModel pointModel); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java new file mode 100644 index 000000000..d21c73407 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java @@ -0,0 +1,43 @@ +package com.loopers.domain.point; + +import com.loopers.domain.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.stereotype.Component; + +import com.loopers.domain.user.UserModel; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +@RequiredArgsConstructor +@Component +public class PointService { + + private final PointRepository pointRepository; + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public PointModel findPoint(PointModel point) { + UserModel requestUser = point.getUser(); + var foundUser = userRepository.find(requestUser.getUserId()); + if (foundUser.isEmpty()) { + return null; + } + return pointRepository.findPoint(foundUser.get()).orElse(null); + } + + @Transactional + public void charge(PointModel point) { + UserModel user = point.getUser(); + var foundUser = userRepository.find(user.getUserId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "유저가 존재하지 않습니다.")); + + var existing = pointRepository.findPoint(foundUser); + if (existing.isPresent()) { + existing.get().charge(point.getPoint()); + pointRepository.save(existing.get()); + return; + } + pointRepository.save(new PointModel(foundUser, point.getPoint())); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java new file mode 100644 index 000000000..9310bc68c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java @@ -0,0 +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.Entity; +import jakarta.persistence.Table; + +@Entity +@Table(name = "user") +public class UserModel extends BaseEntity { + + private String userId; + private String email; + private String birthDate; + + protected UserModel() {} + + public UserModel(String userId, String email, String birthDate) { + + if ( userId == null || userId.isBlank() ) { + throw new CoreException(ErrorType.BAD_REQUEST, "UserId는 비어있을 수 없습니다."); + } + if ( !userId.matches("^[a-zA-Z0-9_-]{1,10}$") ) { + throw new CoreException(ErrorType.BAD_REQUEST, "ID 가 `영문 및 숫자 10자 이내` 형식에 맞아야 합니다."); + } + + if ( email == null || email.isBlank() ) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다."); + } + if ( !email.matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$") ) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일이 `xx@yy.zz` 형식에 맞아야 합니다."); + } + + if ( birthDate == null || birthDate.isBlank() ) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다."); + } + if ( !birthDate.matches("^\\d{4}-\\d{2}-\\d{2}$") ) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일이 `yyyy-MM-dd` 형식에 맞아야 합니다."); + } + + this.userId = userId; + this.email = email; + this.birthDate = birthDate; + } + + public String getUserId() { + return userId; + } + + public String getEmail() { + return email; + } + + public String getBirthDate() { + return birthDate; + } + +} 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 new file mode 100644 index 000000000..57389e5d0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java @@ -0,0 +1,10 @@ +package com.loopers.domain.user; + +import java.util.Optional; + +public interface UserRepository { + Optional find(String userId); + Optional findById(Long id); + + UserModel save(UserModel userModel); +} 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/UserService.java new file mode 100644 index 000000000..048226959 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java @@ -0,0 +1,31 @@ +package com.loopers.domain.user; + +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.Optional; + +@RequiredArgsConstructor +@Component +public class UserService { + + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public UserModel getUser(String userId) { + return userRepository.find(userId).orElse(null); + } + + @Transactional + public UserModel signUp(UserModel userModel) { + Optional user = userRepository.find(userModel.getUserId()); + + if (user.isPresent()) { + throw new CoreException(ErrorType.CONFLICT, "[userId = " + userModel.getUserId() + "] 아이디가 중복되었습니다."); + } + return userRepository.save(userModel); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java new file mode 100644 index 000000000..e4ac7e4ab --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.point; + +import com.loopers.domain.point.PointModel; +import com.loopers.domain.user.UserModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface PointJpaRepository extends JpaRepository { + Optional findByUser(UserModel user); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java new file mode 100644 index 000000000..4b81b3b0b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/point/PointRepositoryImpl.java @@ -0,0 +1,25 @@ +package com.loopers.infrastructure.point; + +import com.loopers.domain.point.PointModel; +import com.loopers.domain.point.PointRepository; +import com.loopers.domain.user.UserModel; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class PointRepositoryImpl implements PointRepository { + private final PointJpaRepository pointJpaRepository; + + @Override + public Optional findPoint(UserModel user) { + return pointJpaRepository.findByUser(user); + } + + @Override + public PointModel save(PointModel pointModel) { + return pointJpaRepository.save(pointModel); + } +} 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 new file mode 100644 index 000000000..7f7c8c717 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.UserModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserJpaRepository extends JpaRepository { + Optional findByUserId(String userId); +} 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 new file mode 100644 index 000000000..03182f279 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -0,0 +1,32 @@ +package com.loopers.infrastructure.user; + +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class UserRepositoryImpl implements UserRepository { + private final UserJpaRepository userJpaRepository; + + @Override + public Optional find(String userId) { + return userJpaRepository.findByUserId(userId); + } + + @Override + public Optional findById(Long id) { + return Optional.empty(); + } + + @Override + public UserModel save(UserModel userModel) { + return userJpaRepository.save(userModel); + } + + + +} 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 20b2809c8..28b7bb38d 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 @@ -8,6 +8,9 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -46,6 +49,21 @@ public ResponseEntity> handleBadRequest(MissingServletRequestPara return failureResponse(ErrorType.BAD_REQUEST, message); } + @ExceptionHandler + public ResponseEntity> handleBadRequest(MissingRequestHeaderException e) { + String headerName = e.getHeaderName(); + String message = String.format("필수 요청 헤더 '%s'가 누락되었습니다.", headerName); + return failureResponse(ErrorType.BAD_REQUEST, message); + } + + @ExceptionHandler + public ResponseEntity> handleBadRequest(MethodArgumentNotValidException e) { + String errorMessage = e.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining(", ")); + return failureResponse(ErrorType.BAD_REQUEST, errorMessage); + } + @ExceptionHandler public ResponseEntity> handleBadRequest(HttpMessageNotReadableException e) { String errorMessage; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java new file mode 100644 index 000000000..395cdfeee --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java @@ -0,0 +1,31 @@ +package com.loopers.interfaces.api.point; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Point V1 API", description = "Loopers 포인트 API 입니다.") +public interface PointV1ApiSpec { + + @Operation( + summary = "포인트 조회", + description = "헤더의 유저 ID로 포인트를 조회합니다." + ) + ApiResponse getPoint( + @Parameter(name = "X-USER-ID", description = "조회할 유저의 ID", required = true) + String userId + ); + + @Operation( + summary = "포인트 충전", + description = "헤더의 유저 ID로 포인트를 충전합니다." + ) + ApiResponse chargePoint( + @Parameter(name = "X-USER-ID", description = "충전할 유저의 ID", required = true) + String userId, + @Schema(name = "포인트 충전 요청", description = "충전할 포인트 정보") + PointV1Dto.ChargeRequest request + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java new file mode 100644 index 000000000..08e6b4537 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java @@ -0,0 +1,37 @@ +package com.loopers.interfaces.api.point; + +import com.loopers.application.point.PointFacade; +import com.loopers.application.point.PointInfo; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/points") +public class PointV1Controller implements PointV1ApiSpec { + + private final PointFacade pointFacade; + + @GetMapping + @Override + public ApiResponse getPoint( + @RequestHeader(value = "X-USER-ID") String userId + ) { + PointInfo info = pointFacade.getPoint(userId); + PointV1Dto.PointResponse response = PointV1Dto.PointResponse.from(info); + return ApiResponse.success(response); + } + + @PostMapping("/charge") + @Override + public ApiResponse chargePoint( + @RequestHeader(value = "X-USER-ID") String userId, + @Valid @RequestBody PointV1Dto.ChargeRequest request + ) { + PointInfo info = pointFacade.chargePoint(userId, request.amount()); + PointV1Dto.PointResponse response = PointV1Dto.PointResponse.from(info); + return ApiResponse.success(response); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java new file mode 100644 index 000000000..c0f2dff38 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java @@ -0,0 +1,20 @@ +package com.loopers.interfaces.api.point; + +import com.loopers.application.point.PointInfo; +import jakarta.validation.constraints.Min; + +public class PointV1Dto { + public record PointResponse(String userId, int point) { + public static PointResponse from(PointInfo info) { + return new PointResponse( + info.user().getUserId(), + info.point() + ); + } + } + + public record ChargeRequest( + @Min(value = 1, message = "포인트는 1 이상이어야 합니다.") + int amount + ) {} +} 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 new file mode 100644 index 000000000..f0c5df3d9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1ApiSpec.java @@ -0,0 +1,28 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "User V1 API", description = "Loopers 유저 API 입니다.") +public interface UserV1ApiSpec { + + @Operation( + summary = "유저 회원가입", + description = "새로운 유저를 회원가입합니다." + ) + ApiResponse signup( + @Schema(name = "회원가입 요청", description = "회원가입할 유저의 정보") + UserV1Dto.SignupRequest request + ); + + @Operation( + summary = "유저 조회", + description = "ID로 유저를 조회합니다." + ) + ApiResponse getUser( + @Schema(name = "유저 ID", description = "조회할 유저의 ID") + String userId + ); +} 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 new file mode 100644 index 000000000..89d0f6bbd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Controller.java @@ -0,0 +1,38 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserFacade; +import com.loopers.application.user.UserInfo; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/users") +public class UserV1Controller implements UserV1ApiSpec { + + private final UserFacade userFacade; + + @PostMapping("/signup") + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse signup( + @Valid @RequestBody UserV1Dto.SignupRequest request + ) { + UserInfo info = userFacade.signup(request.userId(), request.email(), request.birthDate()); + UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(info); + return ApiResponse.success(response); + } + + @GetMapping("/{userId}") + @Override + public ApiResponse getUser( + @PathVariable(value = "userId") String userId + ) { + UserInfo info = userFacade.getUser(userId); + UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(info); + return ApiResponse.success(response); + } +} 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 new file mode 100644 index 000000000..1ddf041e5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java @@ -0,0 +1,27 @@ +package com.loopers.interfaces.api.user; + +import com.loopers.application.user.UserInfo; +import jakarta.validation.constraints.NotBlank; + +public class UserV1Dto { + public record UserResponse(String userId, String email, String birthDate) { + public static UserResponse from(UserInfo info) { + return new UserResponse( + info.userId(), + info.email(), + info.birthDate() + ); + } + } + + public record SignupRequest( + @NotBlank(message = "userId는 필수입니다.") + String userId, + @NotBlank(message = "email은 필수입니다.") + String email, + @NotBlank(message = "gender는 필수입니다.") + String gender, + @NotBlank(message = "birthDate는 필수입니다.") + String birthDate + ) {} +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java new file mode 100644 index 000000000..87b8e1e10 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java @@ -0,0 +1,37 @@ +package com.loopers.domain.point; + +import com.loopers.domain.user.UserModel; +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; +import java.time.LocalDate; + +class PointModelTest { + @DisplayName("포인트 ") + @Nested + class Create { + + @DisplayName("0 이하의 정수로 포인트를 충전 시 실패한다.") + @Test + void pointModel_whenPointIsLessThan0() { + UserModel user = new UserModel("user123", "email@email.com", "1999-01-01"); + int point = -1; + CoreException result = assertThrows(CoreException.class, () -> { + new PointModel(user, point); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(result.getMessage()).isEqualTo("포인트는 0 이상이어야 합니다."); + } + + //포인트 사용하기 + + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java new file mode 100644 index 000000000..cae1cd537 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java @@ -0,0 +1,98 @@ +package com.loopers.domain.point; + +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserRepository; +import com.loopers.infrastructure.point.PointJpaRepository; +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 org.springframework.boot.test.mock.mockito.SpyBean; + +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 PointServiceIntegrationTest { + @Autowired + private PointService pointService; + + @Autowired + private PointJpaRepository pointJpaRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @SpyBean + private PointRepository pointRepository; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("포인트 조회") + @Nested + class GetPoint { + @DisplayName("해당 ID 의 회원이 존재할 경우, 보유 포인트가 반환된다.") + @Test + void returnsPoint_whenValidUserIdIsProvided() { + // arrange + UserModel user = new UserModel("userId", "email@email.com", "1999-01-01"); + userRepository.save(user); + PointModel pointModel = new PointModel(user, 10); + pointService.charge(pointModel); + + // act + PointModel result = pointService.findPoint(pointModel); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getPoint()).isEqualTo(10) + ); + } + + @DisplayName("해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다.") + @Test + void returnsNull_whenInvalidUserIdIsProvided() { + // arrange + UserModel user = new UserModel("notUserId1", "email@email.com", "1999-01-01"); + PointModel pointModel = new PointModel(user, 10); + + // act + PointModel result = pointService.findPoint(pointModel); + + // assert + assertAll( + () -> assertThat(result).isNull() + ); + } + + } + + @DisplayName("포인트 충전") + @Nested + class ChargePoint { + @DisplayName("존재하지 않는 유저 ID 로 충전을 시도한 경우, 실패한다.") + @Test + void throwsException_whenInvalidUserIdIsProvided() { + // arrange + UserModel user = new UserModel("notUserId1", "email@email.com", "1999-01-01"); + PointModel pointModel = new PointModel(user, 10); + + // assert + assertThrows(CoreException.class, () -> pointService.charge(pointModel)); + } + + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java new file mode 100644 index 000000000..f10dd3dcc --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java @@ -0,0 +1,123 @@ +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.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class UserModelTest { + @DisplayName("회원가입 시 User 객체를 생성할 때, ") + @Nested + class Create { + + /* + - [ ] ID 가 `영문 및 숫자 10자 이내` 형식에 맞지 않으면, User 객체 생성에 실패한다. + - [ ] 이메일이 `xx@yy.zz` 형식에 맞지 않으면, User 객체 생성에 실패한다. + - [ ] 생년월일이 `yyyy-MM-dd` 형식에 맞지 않으면, User 객체 생성에 실패한다. + */ + + //입력한 아이디가 빈칸칸이거나 공백이면, User 객체 생성에 실패한다. + @DisplayName("입력한 ID 가 비어있으면, User 객체 생성에 실패한다.") + @Test + void createsUserModel_whenUserIdIsBlank() { + // arrange + String userId = " "; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel(userId, "user123@example.com", "1999-01-01"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + + @DisplayName("ID 가 `영문 및 숫자 10자 이내` 형식에 맞지 않으면, User 객체 생성에 실패한다.") + @Test + void createUserModel_whenUserIdIsNotValid() { + // arrange + String userId = "user123123123"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel(userId, "user123@example.com", "1999-01-01"); + }); + + //assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + + } + + + //입력한 이메일이 빈칸이거나 공백이면, User 객체 생성에 실패한다. + @DisplayName("입력한 이메일이 비어있으면, User 객체 생성에 실패한다.") + @Test + void createsUserModel_whenEmailIsBlank() { + // arrange + String email = " "; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel("userId", email, "1999-01-01"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("이메일이 `xx@yy.zz` 형식에 맞지 않으면, User 객체 생성에 실패한다.") + @Test + void createUserModel_whenEmailIsNotValid() { + // arrange + String email = "user123123123"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel("user123", email, "1999-01-01"); + }); + + //assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + + } + + @DisplayName("생년월일이 `yyyy-MM-dd` 형식에 맞지 않으면, User 객체 생성에 실패한다.") + @Test + void createUserModel_whenBirthDateIsNotValid() { + // arrange + String birthDate = "19991-01-01"; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel("user123", "user123@user.com", birthDate); + }); + + //assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + + } + + + //입력한 생년월일이 빈칸이거나 공백이면, User 객체 생성에 실패한다. + @DisplayName("입력한 생년월일이 null이면, User 객체 생성에 실패한다.") + @Test + void createsUserModel_whenBirthDateIsNull() { + // arrange + String birthDate = null; + // act + CoreException result = assertThrows(CoreException.class, () -> { + new UserModel("userId", "user123@example.com", birthDate); + }); + + // assert + 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/UserServiceIntegrationTest.java new file mode 100644 index 000000000..88b9d643a --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java @@ -0,0 +1,130 @@ +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; +import org.apache.catalina.User; +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.springdoc.api.ErrorMessage; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.SpyBean; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; + +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 UserServiceIntegrationTest { + + @Autowired + private UserService userService; + + @Autowired + private UserJpaRepository userJpaRepository; + + @SpyBean + private UserRepository userRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + /* + * - [ ] 회원 가입시 User 저장이 수행된다. ( spy 검증 ) + * - [ ] 이미 가입된 ID 로 회원가입 시도 시, 실패한다. + * - [ ] 해당 ID 의 회원이 존재할 경우, 회원 정보가 반환된다. + * - [ ] 해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다. + */ + + @DisplayName("회원가입") + @Nested + class SignUp { + + @DisplayName("회원 가입시 User 저장이 수행된다. ( spy 검증 )") + @Test + void returnsUserInfo_whenSignUp() { + // arrange + UserModel userModel = new UserModel("userId1", "user123@user.com", "1999-01-01"); + + // act + UserModel user = userService.signUp(userModel); + + // assert + + verify(userRepository, times(1)).save(any(UserModel.class)); + + assertAll( + () -> assertThat(user).isNotNull(), + () -> assertThat(user.getId()).isNotNull(), + () -> assertThat(user.getUserId()).isEqualTo("userId1"), + () -> assertThat(user.getEmail()).isEqualTo("user123@user.com"), + () -> assertThat(user.getBirthDate()).isEqualTo("1999-01-01")); + + } + + @DisplayName("이미 가입된 ID 로 회원가입 시도 시, 실패한다.") + @Test + void throwsException_whenUserIdIsDuplicated() { + // arrange + UserModel userModel = new UserModel("userId1", "user123@user.com", "1999-01-01"); + userService.signUp(userModel); + + UserModel dupUserModel = new UserModel("userId1", "user1234@user.com", "1999-01-11"); + + // act + CoreException exception = assertThrows(CoreException.class, () -> userService.signUp(dupUserModel)); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.CONFLICT); + } + } + + @DisplayName("내 정보 조회") + @Nested + class MyPage { + @DisplayName("해당 ID 의 회원이 존재할 경우, 회원 정보가 반환된다.") + @Test + void returnsUserInfo_whenValidIdIsProvided() { + // arrange + UserModel userModel = new UserModel("userId1", "user123@user.com", "1999-01-01"); + userService.signUp(userModel); + + // act + UserModel result = userService.getUser(userModel.getUserId()); + + // assert + assertAll( + () -> assertThat(result).isNotNull(), + () -> assertThat(result.getUserId()).isEqualTo(userModel.getUserId()), + () -> assertThat(result.getEmail()).isEqualTo(userModel.getEmail()), + () -> assertThat(result.getBirthDate()).isEqualTo(userModel.getBirthDate()) + ); + } + + @DisplayName("해당 ID 의 회원이 존재하지 않을 경우, null 이 반환된다.") + @Test + void returnsNull_whenInvalidUserIdIsProvided() { + // arrange + UserModel userModel = new UserModel("userId1", "user123@user.com", "1999-01-01"); + + // act + UserModel result = userService.getUser(userModel.getUserId()); + + // assert + assertThat(result).isNull(); + } + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java new file mode 100644 index 000000000..5b88031b6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java @@ -0,0 +1,163 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.point.PointModel; +import com.loopers.domain.point.PointRepository; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserRepository; +import com.loopers.interfaces.api.point.PointV1Dto; +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.*; + +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 PointV1ApiE2ETest { + + private static final String ENDPOINT_GET = "/api/v1/points"; + private static final String ENDPOINT_CHARGE = "/api/v1/points/charge"; + + private final TestRestTemplate testRestTemplate; + private final UserRepository userRepository; + private final PointRepository pointRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public PointV1ApiE2ETest( + TestRestTemplate testRestTemplate, + UserRepository userRepository, + PointRepository pointRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.userRepository = userRepository; + this.pointRepository = pointRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + /* + 포인트 조회 + - [x] 포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다. + - [x] `X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다. + + 포인트 충전 + - [x] 존재하는 유저가 1000원을 충전할 경우, 충전된 보유 총량을 응답으로 반환한다. + - [x] 존재하지 않는 유저로 요청할 경우, `404 Not Found` 응답을 반환한다. + */ + + @DisplayName("GET /api/v1/points") + @Nested + class GetPoint { + @DisplayName("포인트 조회에 성공할 경우, 보유 포인트를 응답으로 반환한다.") + @Test + void returnsPoint_whenValidUserIdHeaderIsProvided() { + // arrange + UserModel user = userRepository.save( + new UserModel("user123", "user123@example.com", "1999-01-01") + ); + pointRepository.save(new PointModel(user, 500)); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", user.getUserId()); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_GET, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().userId()).isEqualTo(user.getUserId()), + () -> assertThat(response.getBody().data().point()).isEqualTo(500) + ); + } + + @DisplayName("`X-USER-ID` 헤더가 없을 경우, `400 Bad Request` 응답을 반환한다.") + @Test + void throwsBadRequest_whenUserIdHeaderIsMissing() { + // arrange + HttpHeaders headers = new HttpHeaders(); + // X-USER-ID 헤더를 의도적으로 설정하지 않음 + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_GET, HttpMethod.GET, new HttpEntity<>(headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) + ); + } + } + + @DisplayName("POST /api/v1/points/charge") + @Nested + class ChargePoint { + @DisplayName("존재하는 유저가 1000원을 충전할 경우, 충전된 보유 총량을 응답으로 반환한다.") + @Test + void chargesPoint_when1000AmountIsProvided() { + // arrange + UserModel user = userRepository.save( + new UserModel("user123", "user123@example.com", "1999-01-01") + ); + PointV1Dto.ChargeRequest request = new PointV1Dto.ChargeRequest(1000); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", user.getUserId()); + headers.setContentType(MediaType.APPLICATION_JSON); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHARGE, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().userId()).isEqualTo(user.getUserId()), + () -> assertThat(response.getBody().data().point()).isEqualTo(1000) + ); + } + + @DisplayName("존재하지 않는 유저로 요청할 경우, `404 Not Found` 응답을 반환한다.") + @Test + void throwsNotFoundException_whenUserDoesNotExist() { + // arrange + PointV1Dto.ChargeRequest request = new PointV1Dto.ChargeRequest(1000); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", "nonexistent"); + headers.setContentType(MediaType.APPLICATION_JSON); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_CHARGE, HttpMethod.POST, new HttpEntity<>(request, headers), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java new file mode 100644 index 000000000..1ff7058f1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -0,0 +1,161 @@ +package com.loopers.interfaces.api; + +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserRepository; +import com.loopers.interfaces.api.user.UserV1Dto; +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.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.function.Function; + +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 UserV1ApiE2ETest { + + private static final String ENDPOINT_SIGNUP = "/api/v1/users/signup"; + private static final Function ENDPOINT_GET = userId -> "/api/v1/users/" + userId; + + private final TestRestTemplate testRestTemplate; + private final UserRepository userRepository; + private final DatabaseCleanUp databaseCleanUp; + + @Autowired + public UserV1ApiE2ETest( + TestRestTemplate testRestTemplate, + UserRepository userRepository, + DatabaseCleanUp databaseCleanUp + ) { + this.testRestTemplate = testRestTemplate; + this.userRepository = userRepository; + this.databaseCleanUp = databaseCleanUp; + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + /* + 회원가입 + - [x] 회원 가입이 성공할 경우, 생성된 유저 정보를 응답으로 반환한다. + - [x] 회원 가입 시에 필수 필드가 없을 경우, `400 Bad Request` 응답을 반환한다. + + 내 정보 조회 + - [x] 내 정보 조회에 성공할 경우, 해당하는 유저 정보를 응답으로 반환한다. + - [x] 존재하지 않는 ID 로 조회할 경우, `404 Not Found` 응답을 반환한다. + */ + + @DisplayName("POST /api/v1/users/signup") + @Nested + class Signup { + @DisplayName("회원 가입이 성공할 경우, 생성된 유저 정보를 응답으로 반환한다.") + @Test + void returnsUserInfo_whenSignupIsSuccessful() { + // arrange + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + "user123", + "user123@example.com", + "male", + "1999-01-01" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().userId()).isEqualTo("user123"), + () -> assertThat(response.getBody().data().email()).isEqualTo("user123@example.com"), + () -> assertThat(response.getBody().data().birthDate()).isEqualTo("1999-01-01") + ); + } + + @DisplayName("회원 가입 시에 성별이 없을 경우, 400 Bad Request 응답을 반환한다.") + @Test + void throwsBadRequest_whenRequiredFieldIsMissing() { + // arrange - gender 필드를 null로 설정 + UserV1Dto.SignupRequest request = new UserV1Dto.SignupRequest( + "user123", + "user123@example.com", + null, + "1999-01-01" + ); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(ENDPOINT_SIGNUP, HttpMethod.POST, new HttpEntity<>(request), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST) + ); + } + } + + @DisplayName("GET /api/v1/users/{userId}") + @Nested + class GetUser { + @DisplayName("내 정보 조회에 성공할 경우, 해당하는 유저 정보를 응답으로 반환한다.") + @Test + void returnsUserInfo_whenValidUserIdIsProvided() { + // arrange + UserModel userModel = userRepository.save( + new UserModel("user123", "user123@example.com", "1999-01-01") + ); + String requestUrl = ENDPOINT_GET.apply(userModel.getUserId()); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is2xxSuccessful()), + () -> assertThat(response.getBody()).isNotNull(), + () -> assertThat(response.getBody().data().userId()).isEqualTo(userModel.getUserId()), + () -> assertThat(response.getBody().data().email()).isEqualTo(userModel.getEmail()), + () -> assertThat(response.getBody().data().birthDate()).isEqualTo(userModel.getBirthDate()) + ); + } + + @DisplayName("존재하지 않는 ID 로 조회할 경우, 404 Not Found 응답을 반환한다.") + @Test + void throwsNotFoundException_whenUserIdDoesNotExist() { + // arrange + String invalidUserId = "nonexistent"; + String requestUrl = ENDPOINT_GET.apply(invalidUserId); + + // act + ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; + ResponseEntity> response = + testRestTemplate.exchange(requestUrl, HttpMethod.GET, new HttpEntity<>(null), responseType); + + // assert + assertAll( + () -> assertTrue(response.getStatusCode().is4xxClientError()), + () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND) + ); + } + } +} From 5974fe4d25c78ae5e93bbd126156edb63750c724 Mon Sep 17 00:00:00 2001 From: leeminkyu-kr96 Date: Fri, 7 Nov 2025 12:26:12 +0900 Subject: [PATCH 02/11] =?UTF-8?q?=EC=84=A4=EA=B3=84=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=20-=20=EC=9A=94=EA=B5=AC=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EC=A0=95=EB=A6=AC=20/=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EB=AA=85=EC=84=B8=20=20-=20=EC=8B=9C=ED=80=80=EC=8A=A4=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20=20-=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EB=8B=A4=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=EA=B7=B8=EB=9E=A8=20=20-=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=20/=20=EC=97=94=ED=8B=B0=ED=8B=B0=20/=20VO?= =?UTF-8?q?=20=20-=20ERD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .docs/design/01-requirements.md | 137 +++++++++++++++++++++++++++ .docs/design/02-sequence-diagrams.md | 64 +++++++++++++ .docs/design/03-class-diagram.md | 46 +++++++++ .docs/design/04-erd.md | 48 ++++++++++ 4 files changed, 295 insertions(+) create mode 100644 .docs/design/01-requirements.md create mode 100644 .docs/design/02-sequence-diagrams.md create mode 100644 .docs/design/03-class-diagram.md create mode 100644 .docs/design/04-erd.md diff --git a/.docs/design/01-requirements.md b/.docs/design/01-requirements.md new file mode 100644 index 000000000..fab2d5f6e --- /dev/null +++ b/.docs/design/01-requirements.md @@ -0,0 +1,137 @@ +# **요구사항 명세서** + +## **1. 상품 목록 조회** + +### 1.1. 유저 시나리오 +> "사용자가 상품을 둘러보기 위해 사이트에 접속했다. 그는 어떤 제품이 인기가 많은지 보기 위해 상품 목록을 조회한다." + +### 1.2. 핵심 기능 정의 +- **F-1:** 상품 목록을 조회할 수 있다. +- **F-2:** 상품 목록을 '좋아요순'으로 정렬할 수 있다. +- **F-3:** 상품 목록을 '최신순', '가격순' 등 다른 기준으로도 정렬할 수 있다. +- **F-4:** 상품 목록을 페이지 단위로 나누어 볼 수 있다. + +### 1.3. 유스케이스 흐름 +* **Main Flow (기본 조회):** + 1. 사용자가 상품 목록 페이지에 진입한다. + 2. 최신순으로 첫 번째 페이지의 상품 목록을 반환한다. + 3. 화면에 상품 목록이 표시된다. +* **Alternate Flow (정렬 변경):** + 1. 사용자가 '인기순' 정렬 버튼을 클릭한다. + 2. 인기순으로 상품 목록을 다시 조회하여 반환한다. + 3. 화면에 인기순으로 정렬된 목록이 표시된다. +* **Alternate Flow (페이징):** + 1. 사용자가 다른 페이지를 클릭한다. + 2. 다른 페이지의 상품 목록을 조회하여 반환한다. + 3. 화면에 다음 상품 목록이 이어서 표시된다. +* **Exception Flow (결과 없음):** + 1. 사용자가 특정 필터를 적용했으나, 해당하는 상품이 하나도 없다. + 2. "조회된 상품이 없습니다."라는 메시지를 반환한다. + +--- + +## **2. 상품 상세 조회** + +### 2.1. 유저 시나리오 +> "사용자가 마음에 드는 상품이 있어서 해당 상품을 클릭했다. 그는 해당 상품의 상세 정보를 확인하고 싶어 한다." + +### 2.2. 핵심 기능 정의 +- **F-1:** 상품 상세정보를 조회할 수 있다. +- **F-2:** 상품의 총 좋아요 수와 나의 좋아요 여부를 조회할 수 있다. +- **F-3:** 구매할 상품의 수량을 선택할 수 있다. + +### 2.3. 유스케이스 흐름 +* **Main Flow (상세 조회):** + 1. 사용자가 상품 목록에서 특정 상품을 클릭한다. + 2. 해당 상푸의 상세 정보(이름, 가격, 설명, 총 좋아요 수, 나의 좋아요 여부 등)를 조회하여 반환한다. + 3. 화면에 상품 상세 정보가 표시되고, 수량은 '1'로 기본 설정된다. +* **Alternate Flow (수량 변경):** + 1. 사용자가 '+' 버튼을 눌러 수량을 '3'으로 변경한다. + 2. 화면에 수량이 '3'으로 갱신된다. +* **Exception Flow (상품 없음):** + 1. 사용자가 존재하지 않는 상품 ID의 URL로 직접 접근한다. + 2. 상품을 찾을 수 없으므로, "상품을 찾을 수 없습니다."라는 오류를 반환한다. + +--- + +## **3. 브랜드별 상품 조회** + +### 3.1. 유저 시나리오 +> "사용자가 마음에 드는 특정 브랜드를 찾고 있다. 그는 해당 브랜드에서 나오는 상품만 모아서 조회하고 싶어 한다." + +### 3.2. 핵심 기능 정의 +- **F-1:** 특정 브랜드에 속한 상품만 필터링하여 조회할 수 있다. +- **F-2:** 상품 목록을 '인기순'으로 정렬할 수 있다. +- **F-3:** 상품 목록을 '최신순', '가격순' 등 다른 기준으로도 정렬할 수 있다. +- **F-4:** 상품 목록을 페이지 단위로 나누어 볼 수 있다. + +### 3.3. 유스케이스 흐름 +* **Main Flow (브랜드별 조회):** + 1. 사용자가 'A 브랜드'를 조회회한다. + 2. 해당 브랜드드 상품 목록을 반환한다. + 3. 화면에 'A 브랜드'의 상품 목록만 표시된다. +* **Exception Flow (브랜드 없음):** + 1. 사용자가 존재하지 않는 브랜드 ID의 URL로 직접 접근한다. + 2. "브랜드를 찾을 수 없습니다."라는 오류(404 Not Found)를 반환한다. + +--- + +## **4. 상품 좋아요 (등록/취소)** + +### 4.1. 유저 시나리오 +> (등록) "사용자가 마음에 드는 상품을 발견했다. 그는 나중에 마음에 드는 상품을 모아보기 위해 '좋아요'를 누른다." +> (취소) "사용자가 이전에 '좋아요' 했던 상품이 더 이상 마음에 들지 않아 '좋아요'를 취소한다." + +### 4.2. 핵심 기능 정의 +- **F-1:** 상품에 대해 '좋아요'를 등록할 수 있다. +- **F-2:** 이미 '좋아요'가 등록된 상품의 '좋아요'를 취소할 수 있다. + +### 4.3. 유스케이스 흐름 +* **Main Flow (좋아요 등록):** + 1. 사용자가 좋아요 아이콘을 클릭한다. + 2. 좋아요 여부를 판단한다. + 3. 해당 상품이 좋아요가 안되어있으면 좋아요 등록이 된다. +* **Alternate Flow (좋아요 취소):** + 1. 사용자가 좋아요 아이콘을 클릭한다. + 2. 좋아요 여부를 판단한다. + 3. 해당 상품이 좋아요가 되어있으면 좋아요가 취소된다. +* **Exception Flow (비회원):** + 1. 비로그인 사용자가 좋아요 아이콘을 클릭한다. + 2. "로그인이 필요한 기능입니다."라는 오류 메시지를 반환한다. + +--- + +## **5. 주문 생성 (결제)** + +### 5.1. 유저 시나리오 +> "사용자가 특정 상품을 구매하고 싶어, 해당 상품을 결제한다." + +### 5.2. 핵심 기능 정의 +- **F-1:** 사용자가 선택한 상품을 구매할 수 있다. +- **F-2:** 사용자의 포인트가 충분할 경우 결제할 수 있으며, 결제 시 포인트가 차감된다. +- **F-3:** 상품의 재고가 충분할 경우 결제할 수 있으며, 결제 시 재고가 차감된다. +- **F-4:** 결제 성공 시 주문 정보가 외부 시스템으로 전송된다. + +### 5.3. 유스케이스 흐름 +* **Main Flow (주문 성공):** + 1. 로그인한 사용자가 상품과 수량을 선택하고 '결제하기'를 요청한다. + 2. 상품 재고가 1개 이상인지 확인한다. + 3. 사용자 포인트가 총 결제 금액 이상인지 확인한다. + 4. 상품 재고를 1 차감한다. + 5. 사용자 포인트를 차감한다. + 6. '주문' 및 '주문 항목'을 저장한다. + 7. 주문 정보를 외부 시스템으로 전송한다. + 8. 사용자에게 "주문 완료" 응답을 반환한다. +* **Alternate Flow (여러 수량 구매):** + 1. 사용자가 수량을 '3'으로 선택하고 '결제하기'를 요청한다. + 2. 재고 및 포인트를 수량 기준으로 검증, 차감, 저장한다. + 3. 주문에 성공한다. +* **Exception Flow (재고 부족):** + 1. 시스템이 재고를 확인했으나, 요청 수량보다 재고가 부족하다. + 2. "재고가 부족하여 주문할 수 없습니다."라는 오류 메시지를 반환한다. +* **Exception Flow (포인트 부족):** + 1. 시스템이 사용자의 포인트를 확인했으나, 총 결제 금액보다 포인트가 부족하다. + 2. "포인트가 부족합니다."라는 오류 메시지를 반환한다. +* **Exception Flow (비회원):** + 1. 비로그인 사용자가 '결제하기'를 요청한다. + 2. "로그인이 필요한 기능입니다."라는 오류 메시지를 반환한다. \ No newline at end of file diff --git a/.docs/design/02-sequence-diagrams.md b/.docs/design/02-sequence-diagrams.md new file mode 100644 index 000000000..7c9f586f5 --- /dev/null +++ b/.docs/design/02-sequence-diagrams.md @@ -0,0 +1,64 @@ + +### 1. 상품 목록/상세 조회 및 브랜드 조회 +```mermaid +sequenceDiagram + participant User + participant ProductController + participant ProductReader + + %% 상품 목록 조회 %% + User->>ProductController: GET /api/v1/products?sort=likes_desc + ProductController->>ProductReader: getProducts(sort) + ProductReader-->>ProductController: productList + ProductController-->>User: productList + + %% 상품 상세 조회 %% + User->>ProductController: GET /api/v1/products/{productId} + ProductController->>ProductReader: getProduct(productId) + ProductReader-->>ProductController: product + ProductController-->>User: product + + participant BrandController + participant BrandReader + + %% 브랜드 조회 %% + User->>BrandController: GET /api/v1/brands/{brandId} + BrandController->>BrandReader: getBrand({brandId}) + BrandReader-->>BrandController: brand + BrandController-->>User: brand +``` +### 2. 주문 생성 + +```mermaid +sequenceDiagram + participant User + participant OrderController + participant OrderService + participant ProductReader + participant PointReader + participant ProductService + participant PointService + participant OrderRepository + + User->>OrderController: POST /api/v1/orders (body: {productId, quantity}) + OrderController->>OrderService: setOrder(userId, {productId, quantity}) + + %% 조회 및 검증 %% + OrderService->>ProductReader: getProduct({productId}) + ProductReader -->>OrderService: product(현재가격, 재고) + + OrderService->>PointReader: getPoint(userId) + PointReader-->>OrderService: point (현재 잔여 포인트) + + %% 재고 및 포인트 차감 %% + critical Transaction Block + OrderService ->>ProductService: decreaseStock(productId, quantity) + OrderService ->>PointService: deductPoint(productPrice * quantity) + OrderService->> OrderRepository: save(new Order(...)) + OrderRepository-->>OrderService: orderInfo + end + + %% 응답 %% + OrderService -->> OrderController:orderInfo + OrderController-->> User:orderInfo + ``` \ No newline at end of file diff --git a/.docs/design/03-class-diagram.md b/.docs/design/03-class-diagram.md new file mode 100644 index 000000000..3bfa54b0f --- /dev/null +++ b/.docs/design/03-class-diagram.md @@ -0,0 +1,46 @@ +## 1. 클래스 다이어그램램 + +```mermaid +classDiagram + class User { + Long id + String name + int point + } + class Product { + Long id + String name + int price + int quantity + } + class Brand { + Long id + String name + } + class Like { + User user + Product product + } + class Order { + Long id + User user + int totalPrice + Timestamp orderDate + } + class OrderItem { + Order order + Product product + int quantity + int orderPrice + } + + %% --- 관계 정의 --- + Product --> Brand : (상품은 브랜드를 가짐) + + Order --> User : (주문은 유저를 가짐) + + OrderItem --> Order : (주문 항목은 주문에 속함) + OrderItem --> Product : (주문 항목은 상품을 가짐) + + Like --> User : (좋아요는 유저를 가짐) + Like -- > Product : (좋아요는 상품을 가짐) \ No newline at end of file diff --git a/.docs/design/04-erd.md b/.docs/design/04-erd.md new file mode 100644 index 000000000..95270b24e --- /dev/null +++ b/.docs/design/04-erd.md @@ -0,0 +1,48 @@ +## 1. ERD + +```mermaid +erDiagram + users { + bigint id PK + varchar name + int point + } + products { + bigint id PK + varchar name + int price + int stock_quantity + bigint brand_id FK + } + brands { + bigint id PK + varchar name + } + likes { + bigint user_id PK, FK + bigint product_id PK, FK + } + orders { + bigint id PK + bigint user_id FK + int total_price + Timestamp created_at + } + orderitems { + bigint id PK + bigint order_id FK + bigint product_id FK + int quantity + int order_price + } + + %% --- 관계 정의 (1:N) --- + users ||--o{ likes : "likes" + users ||--o{ orders : "places" + + products ||--o{ likes : "is_liked" + products ||--o{ orderitems : "is_in" + + brands ||--o{ products : "has" + + orders ||--o{ orderitems : "contains" \ No newline at end of file From 2d743ab5bd253c18cb43054771c4144a7cd45723 Mon Sep 17 00:00:00 2001 From: leeminkyu-kr96 Date: Wed, 12 Nov 2025 21:56:37 +0900 Subject: [PATCH 03/11] =?UTF-8?q?=EC=9C=A0=EC=A0=80,=20=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EB=8F=84=EB=A9=94=EC=9D=B8=20VO=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95=20=20-=20=EC=9C=A0=EC=A0=80,=20?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EB=8B=A8=EC=9C=84,=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9,=20e2e=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=EC=A0=95=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/point/PointFacade.java | 12 ++--- .../loopers/application/point/PointInfo.java | 5 ++- .../loopers/application/user/UserFacade.java | 10 +++-- .../loopers/application/user/UserInfo.java | 6 +-- .../java/com/loopers/domain/point/Point.java | 17 +++++++ .../com/loopers/domain/point/PointModel.java | 37 ++++++++------- .../com/loopers/domain/user/BirthDate.java | 21 +++++++++ .../java/com/loopers/domain/user/Email.java | 20 +++++++++ .../java/com/loopers/domain/user/Gender.java | 17 +++++++ .../java/com/loopers/domain/user/UserId.java | 19 ++++++++ .../com/loopers/domain/user/UserModel.java | 45 ++++++------------- .../loopers/domain/user/UserRepository.java | 2 +- .../com/loopers/domain/user/UserService.java | 16 ++++--- .../user/UserJpaRepository.java | 3 +- .../user/UserRepositoryImpl.java | 3 +- .../interfaces/api/point/PointV1ApiSpec.java | 5 ++- .../api/point/PointV1Controller.java | 8 ++-- .../interfaces/api/point/PointV1Dto.java | 11 +++-- .../interfaces/api/user/UserV1ApiSpec.java | 3 +- .../interfaces/api/user/UserV1Controller.java | 5 ++- .../loopers/domain/user/UserModelTest.java | 12 ++--- .../user/UserServiceIntegrationTest.java | 15 ++++--- .../interfaces/api/UserV1ApiE2ETest.java | 10 +++-- 23 files changed, 198 insertions(+), 104 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java 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/Gender.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java index b42ce2f5e..6337d7bce 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java @@ -4,6 +4,8 @@ import com.loopers.domain.point.PointService; import com.loopers.domain.user.UserModel; import com.loopers.domain.user.UserService; +import com.loopers.domain.user.UserId; +import com.loopers.domain.point.Point; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -15,12 +17,12 @@ public class PointFacade { private final PointService pointService; private final UserService userService; - public PointInfo getPoint(String userId) { + public PointInfo getPoint(UserId userId) { UserModel user = userService.getUser(userId); if (user == null) { throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 요청입니다."); } - PointModel pointModel = new PointModel(user, 0); + PointModel pointModel = new PointModel(user, new Point(0)); PointModel point = pointService.findPoint(pointModel); if (point == null) { @@ -30,15 +32,15 @@ public PointInfo getPoint(String userId) { return PointInfo.from(point); } - public PointInfo chargePoint(String userId, int amount) { + public PointInfo chargePoint(UserId userId, Point point) { UserModel user = userService.getUser(userId); if (user == null) { throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 요청입니다."); } - PointModel pointModel = new PointModel(user, amount); + PointModel pointModel = new PointModel(user, point); pointService.charge(pointModel); - PointModel charged = pointService.findPoint(new PointModel(user, 0)); + PointModel charged = pointService.findPoint(new PointModel(user, point)); return PointInfo.from(charged); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java index 84b4eb4e2..04c3a1c10 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java @@ -2,12 +2,13 @@ import com.loopers.domain.point.PointModel; import com.loopers.domain.user.UserModel; +import com.loopers.domain.point.Point; -public record PointInfo(Long id, UserModel user, int point) { +public record PointInfo(Long id, UserModel user, Point point) { public static PointInfo from(PointModel model) { return new PointInfo(model.getId(), model.getUser(), model.getPoint()); } - public int getPoint() { + public Point getPoint() { return point; } } 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/UserFacade.java index d0bceeb30..17c59c944 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/UserFacade.java @@ -1,5 +1,9 @@ package com.loopers.application.user; +import com.loopers.domain.user.Email; +import com.loopers.domain.user.UserId; +import com.loopers.domain.user.BirthDate; +import com.loopers.domain.user.Gender; import com.loopers.domain.user.UserModel; import com.loopers.domain.user.UserService; import com.loopers.support.error.CoreException; @@ -12,13 +16,13 @@ public class UserFacade { private final UserService userService; - public UserInfo signup(String userId, String email, String birthDate) { - UserModel userModel = new UserModel(userId, email, birthDate); + public UserInfo signup(String userId, String email, String gender, String birthDate) { + UserModel userModel = new UserModel(new UserId(userId), new Email(email), new Gender(gender), new BirthDate(birthDate)); UserModel savedUser = userService.signUp(userModel); return UserInfo.from(savedUser); } - public UserInfo getUser(String userId) { + public UserInfo getUser(UserId userId) { UserModel user = userService.getUser(userId); if (user == null) { throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 요청입니다."); 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 index 1472ddfd3..1a6a2e6df 100644 --- 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 @@ -5,9 +5,9 @@ public record UserInfo(String userId, String email, String birthDate) { public static UserInfo from(UserModel model) { return new UserInfo( - model.getUserId(), - model.getEmail(), - model.getBirthDate() + model.getUserId().userId(), + model.getEmail().email(), + model.getBirthDate().birthDate() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java new file mode 100644 index 000000000..f80fa37fa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java @@ -0,0 +1,17 @@ +package com.loopers.domain.point; + + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; + +@Embeddable +public record Point(int point) { + + public Point { + if( point < 0 ){ + throw new CoreException(ErrorType.BAD_REQUEST, "포인트는 0 이상이어야 합니다."); + } + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java index f90e0ce70..3234f65a2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java @@ -16,15 +16,13 @@ public class PointModel extends BaseEntity { @ManyToOne @JoinColumn(name = "user_model_id") private UserModel user; - private int point = 0; + private Point point; - public PointModel() {} + public PointModel() { + } - public PointModel(UserModel user, int point) { + public PointModel(UserModel user, Point point) { - if( point < 0 ){ - throw new CoreException(ErrorType.BAD_REQUEST, "포인트는 0 이상이어야 합니다."); - } this.user = user; this.point = point; } @@ -33,25 +31,26 @@ public UserModel getUser() { return user; } - public int getPoint() { + public Point getPoint() { return point; } - public void charge(int amount) { - if (amount < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "포인트는 0 이상이어야 합니다."); - } - this.point += amount; + public void charge(Point chargePoint) { + int newPointValue = this.point.point() + chargePoint.point(); + this.point = new Point(newPointValue); } - public void use(int amount) { - - if (amount < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "사용 금액은 0보다 커야 합니다."); + public void use(Point usePoint) { + if (this.point.point() < usePoint.point()) { + throw new CoreException(ErrorType.BAD_REQUEST, "포인트가 부족합니다."); } - if (point < 0) { - throw new CoreException(ErrorType.BAD_REQUEST, "포인트는 0 이상이어야 합니다."); + + if (usePoint.point() > this.point.point()) { + throw new CoreException(ErrorType.BAD_REQUEST, "사용 금액이 보유 포인트를 초과합니다."); } - this.point -= amount; + + int newPointValue = this.point.point() - usePoint.point(); + this.point = new Point(newPointValue); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java new file mode 100644 index 000000000..1ebc0afff --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java @@ -0,0 +1,21 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; + +@Embeddable +public record BirthDate(String birthDate) { + + public BirthDate { + //생년월일 validation check + + if ( birthDate == null || birthDate.isBlank() ) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다."); + } + if ( !birthDate.matches("^\\d{4}-\\d{2}-\\d{2}$") ) { + throw new CoreException(ErrorType.BAD_REQUEST, "생년월일이 `yyyy-MM-dd` 형식에 맞아야 합니다."); + } + } + +} 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..080ac5323 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Email.java @@ -0,0 +1,20 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; + +@Embeddable +public record Email(String email) { + + public Email { + //이메일 validation check + if ( email == null || email.isBlank() ) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다."); + } + if ( !email.matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$") ) { + throw new CoreException(ErrorType.BAD_REQUEST, "이메일이 `xx@yy.zz` 형식에 맞아야 합니다."); + } + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java new file mode 100644 index 000000000..2099fceb3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java @@ -0,0 +1,17 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; + +@Embeddable +public record Gender(String gender) { + + public Gender{ + //성별 체크 + if ( gender == null || gender.isBlank() ) { + throw new CoreException(ErrorType.BAD_REQUEST, "성별은 비어있을 수 없습니다."); + } + + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.java new file mode 100644 index 000000000..0ca578da2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.java @@ -0,0 +1,19 @@ +package com.loopers.domain.user; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; + +@Embeddable +public record UserId(String userId) { + + public UserId{ + //유저아이디 체크 + if ( userId == null || userId.isBlank() ) { + throw new CoreException(ErrorType.BAD_REQUEST, "UserId는 비어있을 수 없습니다."); + } + if ( !userId.matches("^[a-zA-Z0-9_-]{1,10}$") ) { + throw new CoreException(ErrorType.BAD_REQUEST, "ID 가 `영문 및 숫자 10자 이내` 형식에 맞아야 합니다."); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java index 9310bc68c..2cbccd415 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java @@ -1,8 +1,6 @@ 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.Entity; import jakarta.persistence.Table; @@ -10,50 +8,33 @@ @Table(name = "user") public class UserModel extends BaseEntity { - private String userId; - private String email; - private String birthDate; + private UserId userId; + private Email email; + private Gender gender; + private BirthDate birthDate; protected UserModel() {} - public UserModel(String userId, String email, String birthDate) { - - if ( userId == null || userId.isBlank() ) { - throw new CoreException(ErrorType.BAD_REQUEST, "UserId는 비어있을 수 없습니다."); - } - if ( !userId.matches("^[a-zA-Z0-9_-]{1,10}$") ) { - throw new CoreException(ErrorType.BAD_REQUEST, "ID 가 `영문 및 숫자 10자 이내` 형식에 맞아야 합니다."); - } - - if ( email == null || email.isBlank() ) { - throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다."); - } - if ( !email.matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$") ) { - throw new CoreException(ErrorType.BAD_REQUEST, "이메일이 `xx@yy.zz` 형식에 맞아야 합니다."); - } - - if ( birthDate == null || birthDate.isBlank() ) { - throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다."); - } - if ( !birthDate.matches("^\\d{4}-\\d{2}-\\d{2}$") ) { - throw new CoreException(ErrorType.BAD_REQUEST, "생년월일이 `yyyy-MM-dd` 형식에 맞아야 합니다."); - } - + public UserModel(UserId userId, Email email, Gender gender, BirthDate birthDate) { this.userId = userId; this.email = email; + this.gender = gender; this.birthDate = birthDate; } - public String getUserId() { + public UserId getUserId() { return userId; } - public String getEmail() { + public Email getEmail() { return email; } - public String getBirthDate() { - return birthDate; + public Gender getGender() { + return gender; } + public BirthDate getBirthDate() { + return birthDate; + } } 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 57389e5d0..419a5b2cc 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 @@ -3,7 +3,7 @@ import java.util.Optional; public interface UserRepository { - Optional find(String userId); + Optional find(UserId userId); Optional findById(Long id); UserModel save(UserModel userModel); 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/UserService.java index 048226959..bd1a387a8 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/UserService.java @@ -1,5 +1,6 @@ package com.loopers.domain.user; +import com.loopers.infrastructure.user.UserJpaRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -12,20 +13,21 @@ @Component public class UserService { - private final UserRepository userRepository; + private final UserJpaRepository userJpaRepository; @Transactional(readOnly = true) - public UserModel getUser(String userId) { - return userRepository.find(userId).orElse(null); + public UserModel getUser(UserId userId) { + return userJpaRepository.findByUserId(userId).orElse(null); } @Transactional public UserModel signUp(UserModel userModel) { - Optional user = userRepository.find(userModel.getUserId()); + Optional user = userJpaRepository.findByUserId(userModel.getUserId()); if (user.isPresent()) { - throw new CoreException(ErrorType.CONFLICT, "[userId = " + userModel.getUserId() + "] 아이디가 중복되었습니다."); + throw new CoreException(ErrorType.CONFLICT, "[userId = " + userModel.getUserId().userId() + "] 아이디가 중복되었습니다."); } - return userRepository.save(userModel); + + return userJpaRepository.save(userModel); } -} +} \ No newline at end of file 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 7f7c8c717..60717bd23 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 @@ -1,10 +1,11 @@ package com.loopers.infrastructure.user; import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserId; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface UserJpaRepository extends JpaRepository { - Optional findByUserId(String userId); + Optional findByUserId(UserId userId); } 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 03182f279..4995caf3c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java @@ -2,6 +2,7 @@ import com.loopers.domain.user.UserModel; import com.loopers.domain.user.UserRepository; +import com.loopers.domain.user.UserId; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -13,7 +14,7 @@ public class UserRepositoryImpl implements UserRepository { private final UserJpaRepository userJpaRepository; @Override - public Optional find(String userId) { + public Optional find(UserId userId) { return userJpaRepository.findByUserId(userId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java index 395cdfeee..f61b108f9 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1ApiSpec.java @@ -1,5 +1,6 @@ package com.loopers.interfaces.api.point; +import com.loopers.domain.user.UserId; import com.loopers.interfaces.api.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -15,7 +16,7 @@ public interface PointV1ApiSpec { ) ApiResponse getPoint( @Parameter(name = "X-USER-ID", description = "조회할 유저의 ID", required = true) - String userId + UserId userId ); @Operation( @@ -24,7 +25,7 @@ ApiResponse getPoint( ) ApiResponse chargePoint( @Parameter(name = "X-USER-ID", description = "충전할 유저의 ID", required = true) - String userId, + UserId userId, @Schema(name = "포인트 충전 요청", description = "충전할 포인트 정보") PointV1Dto.ChargeRequest request ); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java index 08e6b4537..52b627823 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java @@ -2,10 +2,12 @@ import com.loopers.application.point.PointFacade; import com.loopers.application.point.PointInfo; +import com.loopers.domain.user.UserId; import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +import jakarta.validation.constraints.NotBlank; @RequiredArgsConstructor @RestController @@ -17,7 +19,7 @@ public class PointV1Controller implements PointV1ApiSpec { @GetMapping @Override public ApiResponse getPoint( - @RequestHeader(value = "X-USER-ID") String userId + @RequestHeader(value = "X-USER-ID") @NotBlank(message = "X-USER-ID는 필수입니다.") UserId userId ) { PointInfo info = pointFacade.getPoint(userId); PointV1Dto.PointResponse response = PointV1Dto.PointResponse.from(info); @@ -27,10 +29,10 @@ public ApiResponse getPoint( @PostMapping("/charge") @Override public ApiResponse chargePoint( - @RequestHeader(value = "X-USER-ID") String userId, + @RequestHeader(value = "X-USER-ID") @NotBlank(message = "X-USER-ID는 필수입니다.") UserId userId, @Valid @RequestBody PointV1Dto.ChargeRequest request ) { - PointInfo info = pointFacade.chargePoint(userId, request.amount()); + PointInfo info = pointFacade.chargePoint(userId, request.point()); PointV1Dto.PointResponse response = PointV1Dto.PointResponse.from(info); return ApiResponse.success(response); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java index c0f2dff38..e4d8df913 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java @@ -1,20 +1,19 @@ package com.loopers.interfaces.api.point; import com.loopers.application.point.PointInfo; -import jakarta.validation.constraints.Min; +import com.loopers.domain.point.Point; public class PointV1Dto { - public record PointResponse(String userId, int point) { + public record PointResponse(String userId, Point point) { public static PointResponse from(PointInfo info) { return new PointResponse( - info.user().getUserId(), - info.point() + info.user().getUserId().userId(), + info.getPoint() ); } } public record ChargeRequest( - @Min(value = 1, message = "포인트는 1 이상이어야 합니다.") - int amount + Point point ) {} } 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 f0c5df3d9..3793438c0 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,5 +1,6 @@ package com.loopers.interfaces.api.user; +import com.loopers.domain.user.UserId; import com.loopers.interfaces.api.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Schema; @@ -23,6 +24,6 @@ ApiResponse signup( ) ApiResponse getUser( @Schema(name = "유저 ID", description = "조회할 유저의 ID") - String userId + UserId userId ); } 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 89d0f6bbd..50080e052 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 @@ -2,6 +2,7 @@ import com.loopers.application.user.UserFacade; import com.loopers.application.user.UserInfo; +import com.loopers.domain.user.UserId; import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -21,7 +22,7 @@ public class UserV1Controller implements UserV1ApiSpec { public ApiResponse signup( @Valid @RequestBody UserV1Dto.SignupRequest request ) { - UserInfo info = userFacade.signup(request.userId(), request.email(), request.birthDate()); + UserInfo info = userFacade.signup(request.userId(), request.email(), request.gender(), request.birthDate()); UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(info); return ApiResponse.success(response); } @@ -29,7 +30,7 @@ public ApiResponse signup( @GetMapping("/{userId}") @Override public ApiResponse getUser( - @PathVariable(value = "userId") String userId + @PathVariable(value = "userId") UserId userId ) { UserInfo info = userFacade.getUser(userId); UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(info); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java index f10dd3dcc..6abefaa65 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java @@ -30,7 +30,7 @@ void createsUserModel_whenUserIdIsBlank() { // act CoreException result = assertThrows(CoreException.class, () -> { - new UserModel(userId, "user123@example.com", "1999-01-01"); + new UserModel(new UserId(userId), new Email("user123@example.com"), new Gender("male"), new BirthDate("1999-01-01")); }); // assert @@ -46,7 +46,7 @@ void createUserModel_whenUserIdIsNotValid() { // act CoreException result = assertThrows(CoreException.class, () -> { - new UserModel(userId, "user123@example.com", "1999-01-01"); + new UserModel(new UserId(userId), new Email("user123@example.com"), new Gender("male"), new BirthDate("1999-01-01")); }); //assert @@ -64,7 +64,7 @@ void createsUserModel_whenEmailIsBlank() { // act CoreException result = assertThrows(CoreException.class, () -> { - new UserModel("userId", email, "1999-01-01"); + new UserModel(new UserId("userId"), new Email(email), new Gender("male"), new BirthDate("1999-01-01")); }); // assert @@ -79,7 +79,7 @@ void createUserModel_whenEmailIsNotValid() { // act CoreException result = assertThrows(CoreException.class, () -> { - new UserModel("user123", email, "1999-01-01"); + new UserModel(new UserId("user123"), new Email(email), new Gender("male"), new BirthDate("1999-01-01")); }); //assert @@ -95,7 +95,7 @@ void createUserModel_whenBirthDateIsNotValid() { // act CoreException result = assertThrows(CoreException.class, () -> { - new UserModel("user123", "user123@user.com", birthDate); + new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate(birthDate)); }); //assert @@ -112,7 +112,7 @@ void createsUserModel_whenBirthDateIsNull() { String birthDate = null; // act CoreException result = assertThrows(CoreException.class, () -> { - new UserModel("userId", "user123@example.com", birthDate); + new UserModel(new UserId("userId"), new Email("user123@example.com"), new Gender("male"), new BirthDate(birthDate)); }); // assert 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/UserServiceIntegrationTest.java index 88b9d643a..0c07c1366 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/UserServiceIntegrationTest.java @@ -13,6 +13,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.SpyBean; +import com.loopers.infrastructure.user.UserRepositoryImpl; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; @@ -32,7 +33,7 @@ class UserServiceIntegrationTest { private UserJpaRepository userJpaRepository; @SpyBean - private UserRepository userRepository; + private UserRepositoryImpl userRepositoryImpl; @Autowired private DatabaseCleanUp databaseCleanUp; @@ -57,14 +58,14 @@ class SignUp { @Test void returnsUserInfo_whenSignUp() { // arrange - UserModel userModel = new UserModel("userId1", "user123@user.com", "1999-01-01"); + UserModel userModel = new UserModel(new UserId("userId1"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")); // act UserModel user = userService.signUp(userModel); // assert - verify(userRepository, times(1)).save(any(UserModel.class)); + verify(userRepositoryImpl, times(1)).save(any(UserModel.class)); assertAll( () -> assertThat(user).isNotNull(), @@ -79,10 +80,10 @@ void returnsUserInfo_whenSignUp() { @Test void throwsException_whenUserIdIsDuplicated() { // arrange - UserModel userModel = new UserModel("userId1", "user123@user.com", "1999-01-01"); + UserModel userModel = new UserModel(new UserId("userId1"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")); userService.signUp(userModel); - UserModel dupUserModel = new UserModel("userId1", "user1234@user.com", "1999-01-11"); + UserModel dupUserModel = new UserModel(new UserId("userId1"), new Email("user1234@user.com"), new Gender("male"), new BirthDate("1999-01-11")); // act CoreException exception = assertThrows(CoreException.class, () -> userService.signUp(dupUserModel)); @@ -99,7 +100,7 @@ class MyPage { @Test void returnsUserInfo_whenValidIdIsProvided() { // arrange - UserModel userModel = new UserModel("userId1", "user123@user.com", "1999-01-01"); + UserModel userModel = new UserModel(new UserId("userId1"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")); userService.signUp(userModel); // act @@ -118,7 +119,7 @@ void returnsUserInfo_whenValidIdIsProvided() { @Test void returnsNull_whenInvalidUserIdIsProvided() { // arrange - UserModel userModel = new UserModel("userId1", "user123@user.com", "1999-01-01"); + UserModel userModel = new UserModel(new UserId("userId1"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")); // act UserModel result = userService.getUser(userModel.getUserId()); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java index 1ff7058f1..2b492c0d7 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -1,7 +1,11 @@ package com.loopers.interfaces.api; +import com.loopers.domain.user.Email; +import com.loopers.domain.user.UserId; +import com.loopers.domain.user.BirthDate; import com.loopers.domain.user.UserModel; import com.loopers.domain.user.UserRepository; +import com.loopers.domain.user.Gender; import com.loopers.interfaces.api.user.UserV1Dto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; @@ -120,9 +124,9 @@ class GetUser { void returnsUserInfo_whenValidUserIdIsProvided() { // arrange UserModel userModel = userRepository.save( - new UserModel("user123", "user123@example.com", "1999-01-01") + new UserModel(new UserId("user123"), new Email("user123@example.com"), new Gender("male"), new BirthDate("1999-01-01")) ); - String requestUrl = ENDPOINT_GET.apply(userModel.getUserId()); + String requestUrl = ENDPOINT_GET.apply(userModel.getUserId().userId()); // act ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; @@ -133,7 +137,7 @@ void returnsUserInfo_whenValidUserIdIsProvided() { assertAll( () -> assertTrue(response.getStatusCode().is2xxSuccessful()), () -> assertThat(response.getBody()).isNotNull(), - () -> assertThat(response.getBody().data().userId()).isEqualTo(userModel.getUserId()), + () -> assertThat(response.getBody().data().userId()).isEqualTo(userModel.getUserId().userId()), () -> assertThat(response.getBody().data().email()).isEqualTo(userModel.getEmail()), () -> assertThat(response.getBody().data().birthDate()).isEqualTo(userModel.getBirthDate()) ); From 710e8dc267f5a43d2b46706f8cee524c77ec9e4b Mon Sep 17 00:00:00 2001 From: leeminkyu-kr96 Date: Fri, 14 Nov 2025 02:48:43 +0900 Subject: [PATCH 04/11] =?UTF-8?q?=EC=83=81=ED=92=88=20=EB=AA=A9=EB=A1=9D/?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=8F=AC=EC=9D=B8=ED=8A=B8,?= =?UTF-8?q?=20=EC=9E=AC=EA=B3=A0=20=EC=B0=A8=EA=B0=90=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/product/ProductFacade.java | 43 ++++++++++ .../application/product/ProductInfo.java | 17 ++++ .../loopers/domain/point/PointService.java | 20 ++++- .../com/loopers/domain/product/Brand.java | 16 ++++ .../loopers/domain/product/ProductModel.java | 67 +++++++++++++++ .../domain/product/ProductRepository.java | 24 ++++++ .../domain/product/ProductService.java | 86 +++++++++++++++++++ .../product/ProductJpaRepository.java | 15 ++++ .../product/ProductRepositoryImpl.java | 39 +++++++++ .../api/product/ProductV1ApiSpec.java | 43 ++++++++++ .../api/product/ProductV1Controller.java | 84 ++++++++++++++++++ .../interfaces/api/product/ProductV1Dto.java | 48 +++++++++++ 12 files changed, 499 insertions(+), 3 deletions(-) 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/Brand.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.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/product/ProductJpaRepository.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/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 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..fb6084268 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,43 @@ +package com.loopers.application.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import com.loopers.domain.product.ProductService; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.common.Quantity; + +@RequiredArgsConstructor +@Component +public class ProductFacade { + + private final ProductService productService; + + // 상품 다건 조회 - 페이징 지원 + public Page getProducts(Pageable pageable, String sort, String brandName) { + Page productPage = productService.getProducts(pageable, sort, brandName); + return productPage.map(ProductInfo::from); + } + + // 상품 단건 조회 + public ProductInfo getProduct(Long id){ + ProductModel product = productService.getProduct(id); + if (product == null) { + throw new CoreException(ErrorType.NOT_FOUND, "상품이 존재하지 않습니다."); + } + return ProductInfo.from(product); + } + + // 상품 재고 조회 + public Quantity getQuantity(Long id) { + return productService.getQuantity(id).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품이 존재하지 않습니다.")); + } + + public void updateQuantity(Long id, Quantity quantity) { + productService.updateQuantity(id, quantity); + } +} 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..714742604 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -0,0 +1,17 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.Brand; +import com.loopers.domain.common.Money; +import com.loopers.domain.product.ProductModel; + +public record ProductInfo(Long id, String name, Brand brand, Money price, Long likeCount) { + public static ProductInfo from(ProductModel model) { + return new ProductInfo( + model.getId(), + model.getName(), + model.getBrand(), + model.getPrice(), + model.getLikeCount() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java index d21c73407..d06a6dc0f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java @@ -1,10 +1,12 @@ package com.loopers.domain.point; import com.loopers.domain.user.UserRepository; +import com.loopers.domain.common.Money; import lombok.RequiredArgsConstructor; import org.springframework.transaction.annotation.Transactional; import org.springframework.stereotype.Component; + import com.loopers.domain.user.UserModel; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -19,8 +21,8 @@ public class PointService { @Transactional(readOnly = true) public PointModel findPoint(PointModel point) { UserModel requestUser = point.getUser(); - var foundUser = userRepository.find(requestUser.getUserId()); - if (foundUser.isEmpty()) { + var foundUser = userRepository.findById(requestUser.getId()); + if (foundUser == null) { return null; } return pointRepository.findPoint(foundUser.get()).orElse(null); @@ -29,7 +31,7 @@ public PointModel findPoint(PointModel point) { @Transactional public void charge(PointModel point) { UserModel user = point.getUser(); - var foundUser = userRepository.find(user.getUserId()) + var foundUser = userRepository.findById(user.getId()) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "유저가 존재하지 않습니다.")); var existing = pointRepository.findPoint(foundUser); @@ -40,4 +42,16 @@ public void charge(PointModel point) { } pointRepository.save(new PointModel(foundUser, point.getPoint())); } + + @Transactional + public void use(UserModel user, Money usePoint) { + var foundUser = userRepository.findById(user.getId()) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "유저가 존재하지 않습니다.")); + + var existing = pointRepository.findPoint(foundUser) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "포인트 정보가 없습니다.")); + + existing.use(usePoint); + pointRepository.save(existing); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Brand.java new file mode 100644 index 000000000..0a8b48ff2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Brand.java @@ -0,0 +1,16 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; + +@Embeddable +public record Brand(String name) { + + public Brand { + if ( name == null || name.isBlank() ) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 비어있을 수 없습니다."); + } + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java new file mode 100644 index 000000000..c0cfa9bf9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -0,0 +1,67 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.common.Quantity; +import com.loopers.domain.common.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.persistence.Embedded; + +@Entity +@Table(name = "product") +public class ProductModel extends BaseEntity { + + private String name; + @Embedded + private Brand brand; + @Embedded + private Money price; + @Embedded + private Quantity quantity; + private Long likeCount; + + public ProductModel(String name, Brand brand, Money price, Quantity quantity) { + + this.name = name; + this.brand = brand; + this.price = price; + this.quantity = quantity; + this.likeCount = 0L; + } + + public String getName() { + return name; + } + + public Brand getBrand() { + return brand; + } + + public Money getPrice() { + return price; + } + + public Quantity getQuantity() { + return quantity; + } + + public Long getLikeCount() { + return likeCount; + } + + public void setLikeCount(Long likeCount) { + this.likeCount = likeCount; + } + + public void decreaseQuantity(Quantity quantityToDecrease) { + if (this.quantity.quantity() < quantityToDecrease.quantity()) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고가 부족합니다."); + } + + this.quantity = new Quantity(this.quantity.quantity() - quantityToDecrease.quantity()); + + } + +} 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..4ec09074b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,24 @@ +package com.loopers.domain.product; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface ProductRepository { + // 상품 목록 조회(다건) + Page findAll(Pageable pageable); + + // 브랜드로 상품 목록 조회(다건) + Page findByBrandName(String brandName, Pageable pageable); + + // 상품 상세 조회(단건) + Optional findById(Long id); + + // 상품 ID 목록으로 조회 + List findAllById(Set ids); + +} + 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..37234da16 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,86 @@ +package com.loopers.domain.product; + +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.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.common.Quantity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; + +@RequiredArgsConstructor +@Component +public class ProductService { + + private final ProductRepository productRepository; + + private final LikeRepository likeRepository; + + @Transactional(readOnly = true) + public Page getProducts(Pageable pageable, String sort, String brandName) { + Page productPage; + if (brandName != null && !brandName.isBlank()) { + productPage = productRepository.findByBrandName(brandName, pageable); + } else { + productPage = productRepository.findAll(pageable); + } + + List products = productPage.getContent(); + Map likeCounts = likeRepository + .countByProductIdsLiked(products.stream().map(ProductModel::getId).collect(Collectors.toSet())); + products.forEach(product -> product.setLikeCount(likeCounts.getOrDefault(product.getId(), 0L))); + + // likes_desc 정렬은 메모리에서 처리 + if ("likes_desc".equals(sort)) { + products.sort((a, b) -> Long.compare( + b.getLikeCount() != null ? b.getLikeCount() : 0L, + a.getLikeCount() != null ? a.getLikeCount() : 0L)); + } + + return productPage; + } + + @Transactional(readOnly = true) + public ProductModel getProduct(Long id) { + ProductModel product = productRepository.findById(id).orElse(null); + if (product != null) { + product.setLikeCount(likeRepository.countByProductLiked(product)); + } + return product; + } + + @Transactional(readOnly = true) + public Optional getQuantity(Long id) { + ProductModel product = productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품이 존재하지 않습니다.")); + return Optional.of(product.getQuantity()); + } + + @Transactional + public void updateQuantity(Long id, Quantity quantityToDecrease) { + ProductModel product = productRepository.findById(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품이 존재하지 않습니다.")); + + product.decreaseQuantity(quantityToDecrease); + } + + @Transactional(readOnly = true) + public List getProductsByIds(List productIds) { + Set ids = productIds.stream().collect(Collectors.toSet()); + List products = productRepository.findAllById(ids); + Map likeCounts = likeRepository + .countByProductIdsLiked(products.stream().map(ProductModel::getId).collect(Collectors.toSet())); + products.forEach(product -> product.setLikeCount(likeCounts.getOrDefault(product.getId(), 0L))); + return products; + } +} 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..8a74951e8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,15 @@ +// ProductJpaRepository.java (수정) +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; +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; + +public interface ProductJpaRepository extends JpaRepository { + + @Query("SELECT p FROM ProductModel p WHERE p.brand.name = :brandName ORDER BY p.likeCount DESC") + Page findByBrandName(@Param("brandName") String brandName, Pageable pageable); +} \ No newline at end of file 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..9d0c5a366 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,39 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@RequiredArgsConstructor +@Component +public class ProductRepositoryImpl implements ProductRepository { + private final ProductJpaRepository productJpaRepository; + + @Override + public Optional findById(Long id) { + return productJpaRepository.findById(id); + } + + @Override + public Page findAll(Pageable pageable) { + return productJpaRepository.findAll(pageable); + } + + @Override + public Page findByBrandName(String brandName, Pageable pageable) { + return productJpaRepository.findByBrandName(brandName, pageable); + } + + @Override + public List findAllById(Set ids) { + return productJpaRepository.findAllById(ids); + } + +} 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..803e9f0ad --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -0,0 +1,43 @@ +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.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Pageable; + +@Tag(name = "Product V1 API", description = "Loopers 상품 API 입니다.") +public interface ProductV1ApiSpec { + + @Operation( + summary = "상품 목록 조회", + description = "상품 목록을 페이징하여 조회합니다. page, size, sort, brandId 파라미터를 사용할 수 있습니다. sort 옵션: latest(최신순), price_asc(가격 오름차순), likes_desc(좋아요 수 내림차순)" + ) + ApiResponse getProducts( + @Parameter(name = "sort", description = "정렬 옵션 (latest, price_asc, likes_desc)", required = false) + String sort, + @Parameter(name = "brandId", description = "브랜드 이름으로 필터링 (brandId는 브랜드 이름을 의미)", required = false) + String brandId, + @Parameter(name = "pageable", description = "페이징 정보 (page: 페이지 번호, size: 페이지 크기)", required = false) + Pageable pageable + ); + + @Operation( + summary = "상품 상세 조회", + description = "상품 ID로 상품 상세 정보를 조회합니다." + ) + ApiResponse getProduct( + @Parameter(name = "id", description = "조회할 상품의 ID", required = true) + Long id + ); + + @Operation( + summary = "상품 재고 조회", + description = "상품 ID로 상품 재고를 조회합니다." + ) + ApiResponse getQuantity( + @Parameter(name = "id", description = "조회할 상품의 ID", required = true) + Long id + ); +} + 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..db8f9ebc0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,84 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.domain.common.Quantity; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller implements ProductV1ApiSpec { + + private final ProductFacade productFacade; + + @GetMapping + @Override + public ApiResponse getProducts( + @RequestParam(value = "sort", required = false, defaultValue = "latest") String sort, + @RequestParam(value = "brandId", required = false) String brandId, + @PageableDefault(size = 20) Pageable pageable + ) { + Pageable sortedPageable = convertSortToPageable(sort, pageable); + Page productPage = productFacade.getProducts(sortedPageable, sort, brandId); + ProductV1Dto.ProductsResponse response = ProductV1Dto.ProductsResponse.from(productPage); + return ApiResponse.success(response); + } + + private Pageable convertSortToPageable(String sort, Pageable pageable) { + Sort.Direction direction; + String property; + + switch (sort) { + case "latest": + property = "id"; + direction = Sort.Direction.DESC; + break; + case "price_asc": + property = "price"; + direction = Sort.Direction.ASC; + break; + case "likes_desc": + // likes_desc는 메모리에서 정렬하므로 여기서는 기본 정렬 사용 + property = "id"; + direction = Sort.Direction.DESC; + break; + default: + property = "id"; + direction = Sort.Direction.DESC; + } + + return org.springframework.data.domain.PageRequest.of( + pageable.getPageNumber(), + pageable.getPageSize(), + Sort.by(direction, property) + ); + } + + @GetMapping("/{id}") + @Override + public ApiResponse getProduct( + @PathVariable("id") Long id + ) { + ProductInfo info = productFacade.getProduct(id); + ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(info); + return ApiResponse.success(response); + } + + @GetMapping("/{id}/quantity") + @Override + public ApiResponse getQuantity( + @PathVariable("id") Long id + ) { + Quantity quantity = productFacade.getQuantity(id); + ProductV1Dto.QuantityResponse response = ProductV1Dto.QuantityResponse.from(quantity); + return ApiResponse.success(response); + } +} + 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..f245b8bbd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,48 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductInfo; +import com.loopers.domain.common.Quantity; +import org.springframework.data.domain.Page; + +public class ProductV1Dto { + public record ProductResponse(Long id, String name, String brand, Integer price, Long likeCount) { + public static ProductResponse from(ProductInfo info) { + return new ProductResponse( + info.id(), + info.name(), + info.brand().name(), + info.price().value(), + info.likeCount() + ); + } + } + + public record PageInfo(int page, int size, long totalElements, int totalPages, boolean hasNext, boolean hasPrevious) { + public static PageInfo from(Page page) { + return new PageInfo( + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages(), + page.hasNext(), + page.hasPrevious() + ); + } + } + + public record ProductsResponse(java.util.List products, PageInfo pageInfo) { + public static ProductsResponse from(Page productPage) { + java.util.List productResponses = productPage.getContent().stream() + .map(ProductResponse::from) + .toList(); + return new ProductsResponse(productResponses, PageInfo.from(productPage)); + } + } + + public record QuantityResponse(Integer quantity) { + public static QuantityResponse from(Quantity quantity) { + return new QuantityResponse(quantity.quantity()); + } + } +} + From fd6a5845be3a41fe7df008ca1990a6723cf545d7 Mon Sep 17 00:00:00 2001 From: leeminkyu-kr96 Date: Fri, 14 Nov 2025 02:50:06 +0900 Subject: [PATCH 05/11] =?UTF-8?q?=EC=83=81=ED=92=88=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=EB=93=B1=EB=A1=9D/=EC=B7=A8=EC=86=8C/=EB=A9=B1?= =?UTF-8?q?=EB=93=B1=EC=84=B1=20=EA=B2=80=EC=A6=9D=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/application/like/LikeFacade.java | 90 +++++++++++++++++++ .../loopers/application/like/LikeInfo.java | 13 +++ .../com/loopers/domain/like/LikeModel.java | 40 +++++++++ .../loopers/domain/like/LikeRepository.java | 29 ++++++ .../com/loopers/domain/like/LikeService.java | 74 +++++++++++++++ .../like/LikeJpaRepository.java | 28 ++++++ .../like/LikeRepositoryImpl.java | 59 ++++++++++++ .../interfaces/api/like/LikeV1ApiSpec.java | 43 +++++++++ .../interfaces/api/like/LikeV1Controller.java | 50 +++++++++++ .../interfaces/api/like/LikeV1Dto.java | 38 ++++++++ 10 files changed, 464 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/LikeModel.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 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..db40c0556 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -0,0 +1,90 @@ +package com.loopers.application.like; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import com.loopers.domain.like.LikeService; +import com.loopers.domain.user.UserService; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.user.UserId; +import java.util.List; +import java.util.Map; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class LikeFacade { + + private final LikeService likeService; + + private final UserService userService; + + private final ProductService productService; + + @Transactional + public void toggleLike(UserId userId, Long productId) { + UserModel user = userService.getUser(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "유저를 찾을 수 없습니다."); + } + ProductModel product = productService.getProduct(productId); + if (product == null) { + throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."); + } + likeService.toggleLike(user, product); + } + + @Transactional + public void addLike(UserId userId, Long productId) { + UserModel user = userService.getUser(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "유저를 찾을 수 없습니다."); + } + ProductModel product = productService.getProduct(productId); + if (product == null) { + throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."); + } + likeService.addLike(user, product); + } + + @Transactional + public void removeLike(UserId userId, Long productId) { + UserModel user = userService.getUser(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "유저를 찾을 수 없습니다."); + } + ProductModel product = productService.getProduct(productId); + if (product == null) { + throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."); + } + likeService.removeLike(user, product); + } + + public boolean isLiked(UserId userId, Long productId) { + UserModel user = userService.getUser(userId); + ProductModel product = productService.getProduct(productId); + return likeService.isLiked(user, product); + } + + public List getLikedProducts(UserId userId) { + UserModel user = userService.getUser(userId); + return likeService.getLikedProducts(user); + } + + public long getLikeCount(Long productId) { + ProductModel product = productService.getProduct(productId); + return likeService.getLikeCount(product); + } + + @Transactional(readOnly = true) + public Map getLikeCounts(List productIds) { + + List products = productService.getProductsByIds(productIds); + + return likeService.getLikeCounts(products); + } +} 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..319ea330f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeInfo.java @@ -0,0 +1,13 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.LikeModel; + +public record LikeInfo(Long id, Long userId, Long productId) { + public static LikeInfo from(LikeModel model) { + return new LikeInfo( + model.getId(), + model.getUser().getId(), + model.getProduct().getId() + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java new file mode 100644 index 000000000..43cd6e0ff --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java @@ -0,0 +1,40 @@ +package com.loopers.domain.like; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.product.ProductModel; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +@Entity +@Table( + name = "like", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "product_id"}) +) +public class LikeModel extends BaseEntity { + + @ManyToOne + @JoinColumn(name = "user_id") + private UserModel user; + @ManyToOne + @JoinColumn(name = "product_id") + private ProductModel product; + + public LikeModel(UserModel user, ProductModel product) { + this.user = user; + this.product = product; + } + + public UserModel getUser() { + return user; + } + + public ProductModel getProduct() { + return product; + } + +} + 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..f4d8f074c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java @@ -0,0 +1,29 @@ +package com.loopers.domain.like; + +import com.loopers.domain.user.UserModel; +import com.loopers.domain.product.ProductModel; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public interface LikeRepository { + + // 좋아요 여부 조회 + Optional findByUserAndProduct(UserModel user, ProductModel product); + + // 사용자가 좋아요한 상품 목록 조회 + List findLikedProductsByUser(UserModel user); + + // 상품의 좋아요 수 조회 + long countByProductLiked(ProductModel product); + + // 상품의 좋아요 수 일괄 집계 + Map countByProductIdsLiked(Collection productIds); + + // 좋아요 등록 + LikeModel save(LikeModel like); + + // 좋아요 삭제 + void delete(LikeModel like); +} \ No newline at end of file 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..4c66d7e00 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java @@ -0,0 +1,74 @@ +package com.loopers.domain.like; + +import com.loopers.domain.user.UserModel; +import com.loopers.domain.product.ProductModel; +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.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class LikeService { + + private final LikeRepository likeRepository; + + // 좋아요 취소 또는 등록 + @Transactional + public void toggleLike(UserModel user, ProductModel product) { + var existing = likeRepository.findByUserAndProduct(user, product); + + if (existing.isPresent()) { + likeRepository.delete(existing.get()); + } else { + LikeModel newLike = new LikeModel(user, product); + likeRepository.save(newLike); + } + } + + // 좋아요 등록 (멱등성: 이미 좋아요가 있으면 아무것도 하지 않음) + @Transactional + public void addLike(UserModel user, ProductModel product) { + var existing = likeRepository.findByUserAndProduct(user, product); + if (existing.isEmpty()) { + LikeModel newLike = new LikeModel(user, product); + likeRepository.save(newLike); + } + } + + // 좋아요 취소 (멱등성: 이미 좋아요가 없으면 아무것도 하지 않음) + @Transactional + public void removeLike(UserModel user, ProductModel product) { + var existing = likeRepository.findByUserAndProduct(user, product); + if (existing.isPresent()) { + likeRepository.delete(existing.get()); + } + } + + // 좋아요 여부 확인 + @Transactional(readOnly = true) + public boolean isLiked(UserModel user, ProductModel product) { + return likeRepository.findByUserAndProduct(user, product).isPresent(); + } + + // 좋아요한 상품 목록 조회 + @Transactional(readOnly = true) + public List getLikedProducts(UserModel user) { + return likeRepository.findLikedProductsByUser(user); + } + + // 좋아요 수 조회 + @Transactional(readOnly = true) + public long getLikeCount(ProductModel product) { + return likeRepository.countByProductLiked(product); + } + + // 좋아요 수 일괄 집계 + @Transactional(readOnly = true) + public Map getLikeCounts(List products) { + var ids = products.stream().map(ProductModel::getId).collect(Collectors.toSet()); + return likeRepository.countByProductIdsLiked(ids); + } +} \ No newline at end of file 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..d8120f35d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeJpaRepository.java @@ -0,0 +1,28 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.product.ProductModel; +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.Map; +import java.util.Optional; +import java.util.Set; + +public interface LikeJpaRepository extends JpaRepository { + + Optional findByUserAndProduct(UserModel user, ProductModel product); + + List findByUser(UserModel user); + + long countByProduct(ProductModel product); + + @Query("SELECT l.product.id as productId, COUNT(l) as likeCount " + + "FROM LikeModel l " + + "WHERE l.product.id IN :productIds " + + "GROUP BY l.product.id") + Map countByProductIds(@Param("productIds") Set productIds); +} \ No newline at end of file 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..a718d1cb1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java @@ -0,0 +1,59 @@ +package com.loopers.infrastructure.like; + +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.product.ProductModel; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.Collection; +import java.util.Optional; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class LikeRepositoryImpl implements LikeRepository { + + private final LikeJpaRepository likeJpaRepository; + + // 좋아요 여부 조회 + @Override + public Optional findByUserAndProduct(UserModel user, ProductModel product) { + return likeJpaRepository.findByUserAndProduct(user, product); + } + + // 사용자가 좋아요한 상품 목록 조회 + @Override + public List findLikedProductsByUser(UserModel user) { + return likeJpaRepository.findByUser(user).stream() + .map(LikeModel::getProduct) + .collect(Collectors.toList()); + } + + // 상품의 좋아요 수 조회 + @Override + public long countByProductLiked(ProductModel product) { + return likeJpaRepository.countByProduct(product); + } + + // 상품의 좋아요 수 일괄 집계 + @Override + public Map countByProductIdsLiked(Collection productIds) { + return likeJpaRepository.countByProductIds(productIds.stream().collect(Collectors.toSet())); + } + + // 좋아요 등록 + @Override + public LikeModel save(LikeModel like) { + return likeJpaRepository.save(like); + } + + // 좋아요 삭제 + @Override + public void delete(LikeModel like) { + likeJpaRepository.delete(like); + } +} \ No newline at end of file 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..7142ac9be --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1ApiSpec.java @@ -0,0 +1,43 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.domain.user.UserId; +import com.loopers.interfaces.api.ApiResponse; +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 = "Loopers 좋아요 API 입니다.") +public interface LikeV1ApiSpec { + + @Operation( + summary = "상품 좋아요 등록", + description = "상품에 좋아요를 등록합니다. 멱등하게 동작하여 이미 좋아요가 있으면 아무것도 하지 않습니다." + ) + ApiResponse addLike( + @Parameter(name = "X-USER-ID", description = "좋아요를 등록할 유저의 ID", required = true) + UserId userId, + @Parameter(name = "productId", description = "좋아요를 등록할 상품의 ID", required = true) + Long productId + ); + + @Operation( + summary = "상품 좋아요 취소", + description = "상품의 좋아요를 취소합니다. 멱등하게 동작하여 이미 좋아요가 없으면 아무것도 하지 않습니다." + ) + ApiResponse removeLike( + @Parameter(name = "X-USER-ID", description = "좋아요를 취소할 유저의 ID", required = true) + UserId userId, + @Parameter(name = "productId", description = "좋아요를 취소할 상품의 ID", required = true) + Long productId + ); + + @Operation( + summary = "내가 좋아요 한 상품 목록 조회", + description = "유저가 좋아요한 상품 목록을 조회합니다." + ) + ApiResponse getLikedProducts( + @Parameter(name = "X-USER-ID", description = "조회할 유저의 ID", required = true) + UserId userId + ); +} + 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..f1d393485 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -0,0 +1,50 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.like.LikeFacade; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.user.UserId; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/like") +public class LikeV1Controller implements LikeV1ApiSpec { + + private final LikeFacade likeFacade; + + @PostMapping("/products/{productId}") + @Override + public ApiResponse addLike( + @RequestHeader(value = "X-USER-ID") @NotBlank(message = "X-USER-ID는 필수입니다.") UserId userId, + @PathVariable("productId") Long productId + ) { + likeFacade.addLike(userId, productId); + return ApiResponse.success(null); + } + + @DeleteMapping("/products/{productId}") + @Override + public ApiResponse removeLike( + @RequestHeader(value = "X-USER-ID") @NotBlank(message = "X-USER-ID는 필수입니다.") UserId userId, + @PathVariable("productId") Long productId + ) { + likeFacade.removeLike(userId, productId); + return ApiResponse.success(null); + } + + @GetMapping("/products") + @Override + public ApiResponse getLikedProducts( + @RequestHeader(value = "X-USER-ID") @NotBlank(message = "X-USER-ID는 필수입니다.") UserId userId + ) { + List products = likeFacade.getLikedProducts(userId); + LikeV1Dto.LikedProductsResponse response = LikeV1Dto.LikedProductsResponse.from(products); + return ApiResponse.success(response); + } +} + 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..ea9c7b05f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Dto.java @@ -0,0 +1,38 @@ +package com.loopers.interfaces.api.like; + +import com.loopers.application.product.ProductInfo; +import com.loopers.domain.product.ProductModel; +import java.util.List; +import java.util.stream.Collectors; + +public class LikeV1Dto { + public record LikeResponse(boolean liked) { + public static LikeResponse from(boolean liked) { + return new LikeResponse(liked); + } + } + + public record LikedProductsResponse(List products) { + public static LikedProductsResponse from(List products) { + List productInfos = products.stream() + .map(product -> new ProductInfo( + product.getId(), + product.getName(), + product.getBrand(), + product.getPrice(), + product.getLikeCount() + )) + .collect(Collectors.toList()); + return new LikedProductsResponse(productInfos); + } + } + + public record LikeCountResponse(Long count) { + public static LikeCountResponse from(long count) { + return new LikeCountResponse(count); + } + } + + public record ToggleLikeRequest(Long productId) {} +} + From ac7242eab0b11e26606b6185feb43f9c5a71dd4d Mon Sep 17 00:00:00 2001 From: leeminkyu-kr96 Date: Fri, 14 Nov 2025 02:50:30 +0900 Subject: [PATCH 06/11] =?UTF-8?q?=EC=A3=BC=EB=AC=B8=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/order/OrderFacade.java | 70 ++++++++++++++++++ .../loopers/application/order/OrderInfo.java | 10 +++ .../loopers/domain/order/OrderItemModel.java | 63 ++++++++++++++++ .../com/loopers/domain/order/OrderModel.java | 60 +++++++++++++++ .../loopers/domain/order/OrderRepository.java | 15 ++++ .../loopers/domain/order/OrderService.java | 73 +++++++++++++++++++ .../order/OrderJpaRepository.java | 11 +++ .../order/OrderRepositoryImpl.java | 32 ++++++++ .../interfaces/api/order/OrderV1ApiSpec.java | 42 +++++++++++ .../api/order/OrderV1Controller.java | 60 +++++++++++++++ .../interfaces/api/order/OrderV1Dto.java | 46 ++++++++++++ 11 files changed, 482 insertions(+) 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 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.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/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/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 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..9d6ee94eb --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -0,0 +1,70 @@ +package com.loopers.application.order; + +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserService; +import com.loopers.domain.user.UserId; +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.List; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class OrderFacade { + + private final OrderService orderService; + private final UserService userService; + + @Transactional(readOnly = true) + public OrderInfo getOrder(Long id) { + OrderModel order = orderService.getOrder(id); + if (order == null) { + throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."); + } + return new OrderInfo( + order.getId(), + order.getUser(), + order.getTotalPrice(), + order.getOrderItems() + ); + } + + @Transactional(readOnly = true) + public List getUserOrders(UserId userId) { + UserModel user = userService.getUser(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "유저를 찾을 수 없습니다."); + } + List orders = orderService.getUserOrders(user); + return orders.stream() + .map(order -> new OrderInfo( + order.getId(), + order.getUser(), + order.getTotalPrice(), + order.getOrderItems() + )) + .collect(Collectors.toList()); + } + + @Transactional + public OrderInfo createOrder(UserId userId, List items) { + UserModel user = userService.getUser(userId); + if (user == null) { + throw new CoreException(ErrorType.NOT_FOUND, "유저를 찾을 수 없습니다."); + } + + OrderModel order = orderService.createOrder(user, items); + return new OrderInfo( + order.getId(), + order.getUser(), + order.getTotalPrice(), + order.getOrderItems() + ); + } +} 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..5599e7ac5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java @@ -0,0 +1,10 @@ +package com.loopers.application.order; + +import com.loopers.domain.user.UserModel; +import com.loopers.domain.common.Money; +import java.util.List; +import com.loopers.domain.order.OrderItemModel; + +public record OrderInfo(Long id, UserModel user, Money totalPrice, List orderItems) { + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java new file mode 100644 index 000000000..9692a57a0 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderItemModel.java @@ -0,0 +1,63 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.common.Quantity; +import com.loopers.domain.common.Money; + +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.Embedded; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; + +@Entity +@Table(name = "orderitems") +public class OrderItemModel extends BaseEntity { + + @ManyToOne + @JoinColumn(name = "order_id", nullable = false) + private OrderModel order; + + @ManyToOne + @JoinColumn(name = "product_id", nullable = false) + private ProductModel product; + + @Embedded + private Quantity quantity; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "order_price")) + private Money orderPrice; + + protected OrderItemModel() { + } + + public OrderItemModel(ProductModel product, Quantity quantity, Money orderPrice) { + this.product = product; + this.quantity = quantity; + this.orderPrice = orderPrice; + } + + public OrderModel getOrder() { + return order; + } + + public ProductModel getProduct() { + return product; + } + + public Quantity getQuantity() { + return quantity; + } + + public Money getOrderPrice() { + return orderPrice; + } + + protected void setOrder(OrderModel order) { + this.order = order; + } +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java new file mode 100644 index 000000000..f1da6558c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java @@ -0,0 +1,60 @@ +package com.loopers.domain.order; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.common.Money; + +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.OneToMany; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Embedded; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; + +import java.util.List; +import java.util.ArrayList; + +@Entity +@Table(name = "orders") +public class OrderModel extends BaseEntity { + + @ManyToOne + @JoinColumn(name = "user_id") + private UserModel user; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "total_price")) + private Money totalPrice; + + @OneToMany(cascade = CascadeType.ALL, mappedBy = "order") + private List orderItems = new ArrayList<>(); + + public OrderModel() { + } + + public OrderModel(UserModel user, Money totalPrice, List orderItems) { + this.user = user; + this.totalPrice = totalPrice; + this.orderItems = orderItems; + } + + public UserModel getUser() { + return user; + } + + public Money getTotalPrice() { + return totalPrice; + } + + public List getOrderItems() { + return orderItems; + } + + public void addOrderItem(OrderItemModel orderItem) { + orderItems.add(orderItem); + orderItem.setOrder(this); + } +} 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..3b10eb87a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java @@ -0,0 +1,15 @@ +package com.loopers.domain.order; + +import com.loopers.domain.user.UserModel; +import java.util.Optional; +import java.util.List; + +public interface OrderRepository { + // 주문 저장 + OrderModel save(OrderModel order); + // 주문 단건 조회 + Optional findById(Long id); + // 사용자 주문 조회 + List findByUserId(UserModel user); +} + 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..825d6df29 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java @@ -0,0 +1,73 @@ +package com.loopers.domain.order; + +import com.loopers.domain.user.UserModel; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import com.loopers.domain.point.PointService; +import com.loopers.domain.common.Money; +import com.loopers.domain.common.Quantity; +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.List; +import java.util.ArrayList; + +@RequiredArgsConstructor +@Component +public class OrderService { + + private final OrderRepository orderRepository; + private final ProductService productService; + private final PointService pointService; + + @Transactional(readOnly = true) + public OrderModel getOrder(Long id) { + return orderRepository.findById(id).orElse(null); + } + + @Transactional(readOnly = true) + public List getUserOrders(UserModel user) { + return orderRepository.findByUserId(user); + } + + @Transactional + public OrderModel createOrder(UserModel user, List items) { + List orderItems = new ArrayList<>(); + int totalPriceValue = 0; + + // 각 상품에 대해 재고 확인 및 차감, 주문 항목 생성 + for (OrderItemRequest item : items) { + ProductModel product = productService.getProduct(item.productId()); + if (product == null) { + throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다. productId: " + item.productId()); + } + + Quantity quantity = new Quantity(item.quantity()); + + // 재고 차감 + productService.updateQuantity(item.productId(), quantity); + + // 주문 항목 가격 계산 (상품 가격 * 수량) + Money orderPrice = new Money(product.getPrice().value() * quantity.quantity()); + totalPriceValue += orderPrice.value(); + + // 주문 항목 생성 + OrderItemModel orderItem = new OrderItemModel(product, quantity, orderPrice); + orderItems.add(orderItem); + } + + Money totalPrice = new Money(totalPriceValue); + + // 포인트 차감 + pointService.use(user, totalPrice); + + // 주문 생성 및 저장 + OrderModel order = new OrderModel(user, totalPrice, orderItems); + return orderRepository.save(order); + } + + public record OrderItemRequest(Long productId, Integer quantity) {} +} 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..3de048b1c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface OrderJpaRepository extends JpaRepository { + + List findByUserId(Long id); +} \ No newline at end of file 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..e63cbe72c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java @@ -0,0 +1,32 @@ +package com.loopers.infrastructure.order; + +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderRepository; +import com.loopers.domain.user.UserModel; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class OrderRepositoryImpl implements OrderRepository { + + private final OrderJpaRepository orderJpaRepository; + + @Override + public OrderModel save(OrderModel order) { + return orderJpaRepository.save(order); + } + + @Override + public Optional findById(Long id) { + return orderJpaRepository.findById(id); + } + + @Override + public List findByUserId(UserModel user) { + return orderJpaRepository.findByUserId(user.getId()); + } +} \ No newline at end of file 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..3011b1d3b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -0,0 +1,42 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.domain.user.UserId; +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Order V1 API", description = "Loopers 주문 API 입니다.") +public interface OrderV1ApiSpec { + + @Operation( + summary = "주문 생성", + description = "여러 상품을 주문하고 결제합니다. 재고 차감 및 포인트 차감이 자동으로 처리됩니다." + ) + ApiResponse createOrder( + @Parameter(name = "X-USER-ID", description = "주문할 유저의 ID", required = true) + UserId userId, + @Schema(name = "주문 요청", description = "주문할 상품 목록") + OrderV1Dto.CreateOrderRequest request + ); + + @Operation( + summary = "주문 조회", + description = "주문 ID로 주문을 조회합니다." + ) + ApiResponse getOrder( + @Parameter(name = "id", description = "조회할 주문의 ID", required = true) + Long id + ); + + @Operation( + summary = "유저 주문 목록 조회", + description = "유저의 주문 목록을 조회합니다." + ) + ApiResponse getUserOrders( + @Parameter(name = "X-USER-ID", description = "조회할 유저의 ID", required = true) + UserId userId + ); +} + 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..2142f0065 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -0,0 +1,60 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderInfo; +import com.loopers.domain.order.OrderService; +import com.loopers.domain.user.UserId; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +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 + @ResponseStatus(HttpStatus.CREATED) + @Override + public ApiResponse createOrder( + @RequestHeader(value = "X-USER-ID") @NotBlank(message = "X-USER-ID는 필수입니다.") UserId userId, + @Valid @RequestBody OrderV1Dto.CreateOrderRequest request + ) { + List items = request.items().stream() + .map(item -> new OrderService.OrderItemRequest(item.productId(), item.quantity())) + .collect(Collectors.toList()); + + OrderInfo info = orderFacade.createOrder(userId, items); + OrderV1Dto.OrderResponse response = OrderV1Dto.OrderResponse.from(info); + return ApiResponse.success(response); + } + + @GetMapping("/{id}") + @Override + public ApiResponse getOrder( + @PathVariable("id") Long id + ) { + OrderInfo info = orderFacade.getOrder(id); + OrderV1Dto.OrderResponse response = OrderV1Dto.OrderResponse.from(info); + return ApiResponse.success(response); + } + + @GetMapping + @Override + public ApiResponse getUserOrders( + @RequestHeader(value = "X-USER-ID") @NotBlank(message = "X-USER-ID는 필수입니다.") UserId userId + ) { + List orders = orderFacade.getUserOrders(userId); + OrderV1Dto.OrdersResponse response = OrderV1Dto.OrdersResponse.from(orders); + return ApiResponse.success(response); + } +} + 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..b6673f81c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java @@ -0,0 +1,46 @@ +package com.loopers.interfaces.api.order; + +import com.loopers.application.order.OrderInfo; +import com.loopers.domain.common.Money; +import com.loopers.domain.order.OrderItemModel; +import java.util.List; + +public class OrderV1Dto { + public record OrderItemResponse(Long productId, Integer quantity, Money price) { + public static OrderItemResponse from(OrderItemModel item) { + return new OrderItemResponse( + item.getProduct().getId(), + item.getQuantity().quantity(), + item.getOrderPrice() + ); + } + } + + public record OrderResponse(Long id, String userId, Money totalPrice, List orderItems) { + public static OrderResponse from(OrderInfo info) { + List items = info.orderItems().stream() + .map(OrderItemResponse::from) + .toList(); + return new OrderResponse( + info.id(), + info.user().getUserId().userId(), + info.totalPrice(), + items + ); + } + } + + public record OrdersResponse(List orders) { + public static OrdersResponse from(List orders) { + List orderResponses = orders.stream() + .map(OrderResponse::from) + .toList(); + return new OrdersResponse(orderResponses); + } + } + + public record CreateOrderRequest(List items) { + public record OrderItemRequest(Long productId, Integer quantity) {} + } +} + From c503e6489477f9acafa95a5b38dd27ac0fab8db7 Mon Sep 17 00:00:00 2001 From: leeminkyu-kr96 Date: Fri, 14 Nov 2025 02:50:48 +0900 Subject: [PATCH 07/11] =?UTF-8?q?=EB=B8=8C=EB=9E=9C=EB=93=9C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/brand/BrandFacade.java | 22 +++++++++++++++++ .../loopers/application/brand/BrandInfo.java | 10 ++++++++ .../interfaces/api/brand/BrandV1ApiSpec.java | 20 ++++++++++++++++ .../api/brand/BrandV1Controller.java | 24 +++++++++++++++++++ .../interfaces/api/brand/BrandV1Dto.java | 12 ++++++++++ .../interfaces/api/user/UserV1ApiSpec.java | 10 ++++++++ .../interfaces/api/user/UserV1Controller.java | 13 +++++++++- 7 files changed, 110 insertions(+), 1 deletion(-) 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/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 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..aa9800856 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -0,0 +1,22 @@ +package com.loopers.application.brand; + +import com.loopers.domain.product.Brand; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class BrandFacade { + + @Transactional(readOnly = true) + public BrandInfo getBrand(String brandName) { + if (brandName == null || brandName.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 필수입니다."); + } + + Brand brand = new Brand(brandName); + return BrandInfo.from(brand); + } +} + 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..1a8c8ea8c --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java @@ -0,0 +1,10 @@ +package com.loopers.application.brand; + +import com.loopers.domain.product.Brand; + +public record BrandInfo(String name) { + public static BrandInfo from(Brand brand) { + return new BrandInfo(brand.name()); + } +} + 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..d450094c2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java @@ -0,0 +1,20 @@ +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.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Brand V1 API", description = "Loopers 브랜드 API 입니다.") +public interface BrandV1ApiSpec { + + @Operation( + summary = "브랜드 정보 조회", + description = "브랜드 이름으로 브랜드 정보를 조회합니다." + ) + ApiResponse getBrand( + @Parameter(name = "brandId", description = "조회할 브랜드의 이름 (brandId는 브랜드 이름을 의미)", required = true) + String 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..732a61f9e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java @@ -0,0 +1,24 @@ +package com.loopers.interfaces.api.brand; + +import com.loopers.application.brand.BrandFacade; +import com.loopers.interfaces.api.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/brands") +public class BrandV1Controller implements BrandV1ApiSpec { + + private final BrandFacade brandFacade; + + @GetMapping("/{brandId}") + @Override + public ApiResponse getBrand( + @PathVariable("brandId") String brandId + ) { + BrandV1Dto.BrandResponse response = BrandV1Dto.BrandResponse.from(brandFacade.getBrand(brandId)); + return ApiResponse.success(response); + } +} + 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..69b872223 --- /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(String name) { + public static BrandResponse from(BrandInfo info) { + return new BrandResponse(info.name()); + } + } +} + 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 3793438c0..f2beac356 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 @@ -3,6 +3,7 @@ import com.loopers.domain.user.UserId; import com.loopers.interfaces.api.ApiResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; @@ -18,6 +19,15 @@ ApiResponse signup( UserV1Dto.SignupRequest request ); + @Operation( + summary = "내 정보 조회", + description = "X-USER-ID 헤더를 통해 현재 유저의 정보를 조회합니다." + ) + ApiResponse getMe( + @Parameter(name = "X-USER-ID", description = "조회할 유저의 ID", required = true) + UserId userId + ); + @Operation( summary = "유저 조회", description = "ID로 유저를 조회합니다." 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 50080e052..bd1c4abe6 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.UserId; import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; @@ -16,7 +17,7 @@ public class UserV1Controller implements UserV1ApiSpec { private final UserFacade userFacade; - @PostMapping("/signup") + @PostMapping @ResponseStatus(HttpStatus.CREATED) @Override public ApiResponse signup( @@ -27,6 +28,16 @@ public ApiResponse signup( return ApiResponse.success(response); } + @GetMapping("/me") + @Override + public ApiResponse getMe( + @RequestHeader(value = "X-USER-ID") @NotBlank(message = "X-USER-ID는 필수입니다.") UserId userId + ) { + UserInfo info = userFacade.getUser(userId); + UserV1Dto.UserResponse response = UserV1Dto.UserResponse.from(info); + return ApiResponse.success(response); + } + @GetMapping("/{userId}") @Override public ApiResponse getUser( From 2ff2147b2c8ed557fade69f53bc0b34aa4e15752 Mon Sep 17 00:00:00 2001 From: leeminkyu-kr96 Date: Fri, 14 Nov 2025 02:51:21 +0900 Subject: [PATCH 08/11] =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A7=84=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/like/LikeModelTest.java | 41 +++ .../like/LikeServiceIntegrationTest.java | 183 ++++++++++++++ .../domain/order/OrderItemModelTest.java | 55 ++++ .../loopers/domain/order/OrderModelTest.java | 128 ++++++++++ .../order/OrderServiceIntegrationTest.java | 236 ++++++++++++++++++ .../domain/product/ProductModelTest.java | 110 ++++++++ 6 files changed, 753 insertions(+) create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.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/order/OrderItemModelTest.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.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/product/ProductModelTest.java diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java new file mode 100644 index 000000000..fc21f3000 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java @@ -0,0 +1,41 @@ +package com.loopers.domain.like; + +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 com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserId; +import com.loopers.domain.user.Email; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.BirthDate; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.Brand; +import com.loopers.domain.common.Money; +import com.loopers.domain.common.Quantity; + +class LikeModelTest { + @DisplayName("좋아요 행위 ") + @Nested + class Create { + @DisplayName("좋아요 등록이 정상 처리된다") + @Test + void createsLikeModel_whenLikeIsCreated() { + // arrange + UserModel user = new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")); + ProductModel product = new ProductModel("product123", new Brand("Apple"), new Money(10000), new Quantity(100)); + + // act + LikeModel likeModel = new LikeModel(user, product); + + // assert + assertAll( + () -> assertThat(likeModel.getId()).isNotNull(), + () -> assertThat(likeModel.getUser()).isEqualTo(user), + () -> assertThat(likeModel.getProduct()).isEqualTo(product) + ); + } + } +} 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..958e4b128 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceIntegrationTest.java @@ -0,0 +1,183 @@ +package com.loopers.domain.like; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.Brand; +import com.loopers.domain.common.Money; +import com.loopers.domain.common.Quantity; +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserId; +import com.loopers.domain.user.Email; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.BirthDate; +import com.loopers.infrastructure.like.LikeJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.user.UserJpaRepository; +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; + +@SpringBootTest +class LikeServiceIntegrationTest { + @Autowired + private LikeService likeService; + + @Autowired + private LikeJpaRepository likeJpaRepository; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("좋아요 등록/취소") + @Nested + class LikeManagement { + + @DisplayName("좋아요 등록이 정상 처리된다") + @Test + void createsLike_whenAddLikeIsCalled() { + // arrange + UserModel user = userJpaRepository.save( + new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + ProductModel product = productJpaRepository.save( + new ProductModel("product123", new Brand("Apple"), new Money(10000), new Quantity(100)) + ); + + // act + likeService.addLike(user, product); + + // assert + boolean isLiked = likeService.isLiked(user, product); + assertThat(isLiked).isTrue(); + } + + @DisplayName("좋아요 취소가 정상 처리된다") + @Test + void removesLike_whenRemoveLikeIsCalled() { + // arrange + UserModel user = userJpaRepository.save( + new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + ProductModel product = productJpaRepository.save( + new ProductModel("product123", new Brand("Apple"), new Money(10000), new Quantity(100)) + ); + likeService.addLike(user, product); + + // act + likeService.removeLike(user, product); + + // assert + boolean isLiked = likeService.isLiked(user, product); + assertThat(isLiked).isFalse(); + } + + @DisplayName("중복 좋아요 방지: 이미 좋아요가 있으면 아무것도 하지 않는다 (멱등성)") + @Test + void preventsDuplicateLikes_whenAddLikeIsCalledTwice() { + // arrange + UserModel user = userJpaRepository.save( + new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + ProductModel product = productJpaRepository.save( + new ProductModel("product123", new Brand("Apple"), new Money(10000), new Quantity(100)) + ); + likeService.addLike(user, product); + long likeCountBefore = likeJpaRepository.count(); + + // act + likeService.addLike(user, product); // 중복 호출 + + // assert + long likeCountAfter = likeJpaRepository.count(); + assertThat(likeCountAfter).isEqualTo(likeCountBefore); // 좋아요 수가 증가하지 않음 + assertThat(likeService.isLiked(user, product)).isTrue(); + } + + @DisplayName("중복 취소 방지: 이미 좋아요가 없으면 아무것도 하지 않는다 (멱등성)") + @Test + void preventsDuplicateRemoval_whenRemoveLikeIsCalledTwice() { + // arrange + UserModel user = userJpaRepository.save( + new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + ProductModel product = productJpaRepository.save( + new ProductModel("product123", new Brand("Apple"), new Money(10000), new Quantity(100)) + ); + likeService.removeLike(user, product); // 처음부터 좋아요 없음 + long likeCountBefore = likeJpaRepository.count(); + + // act + likeService.removeLike(user, product); // 중복 호출 + + // assert + long likeCountAfter = likeJpaRepository.count(); + assertThat(likeCountAfter).isEqualTo(likeCountBefore); // 좋아요 수가 변하지 않음 + assertThat(likeService.isLiked(user, product)).isFalse(); + } + + @DisplayName("좋아요 토글이 정상 동작한다") + @Test + void togglesLike_whenToggleLikeIsCalled() { + // arrange + UserModel user = userJpaRepository.save( + new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + ProductModel product = productJpaRepository.save( + new ProductModel("product123", new Brand("Apple"), new Money(10000), new Quantity(100)) + ); + + // act & assert - 첫 번째 호출: 좋아요 등록 + likeService.toggleLike(user, product); + assertThat(likeService.isLiked(user, product)).isTrue(); + + // act & assert - 두 번째 호출: 좋아요 취소 + likeService.toggleLike(user, product); + assertThat(likeService.isLiked(user, product)).isFalse(); + } + } + + @DisplayName("좋아요 수 조회") + @Nested + class LikeCount { + + @DisplayName("상품의 좋아요 수를 정확히 조회한다") + @Test + void returnsCorrectLikeCount_whenMultipleUsersLikeProduct() { + // arrange + UserModel user1 = userJpaRepository.save( + new UserModel(new UserId("user1"), new Email("user1@user.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + UserModel user2 = userJpaRepository.save( + new UserModel(new UserId("user2"), new Email("user2@user.com"), new Gender("female"), new BirthDate("2000-01-01")) + ); + ProductModel product = productJpaRepository.save( + new ProductModel("product123", new Brand("Apple"), new Money(10000), new Quantity(100)) + ); + likeService.addLike(user1, product); + likeService.addLike(user2, product); + + // act + long likeCount = likeService.getLikeCount(product); + + // assert + assertThat(likeCount).isEqualTo(2L); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java new file mode 100644 index 000000000..6e352674c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderItemModelTest.java @@ -0,0 +1,55 @@ +package com.loopers.domain.order; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.Brand; +import com.loopers.domain.common.Money; +import com.loopers.domain.common.Quantity; +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; + +class OrderItemModelTest { + @DisplayName("주문 항목 모델 생성") + @Nested + class Create { + + @DisplayName("주문 항목이 정상적으로 생성된다") + @Test + void createsOrderItem_whenValidParameters() { + // arrange + ProductModel product = new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10)); + Quantity quantity = new Quantity(3); + Money orderPrice = new Money(30000); + + // act + OrderItemModel orderItem = new OrderItemModel(product, quantity, orderPrice); + + // assert + assertAll( + () -> assertThat(orderItem).isNotNull(), + () -> assertThat(orderItem.getProduct()).isEqualTo(product), + () -> assertThat(orderItem.getQuantity()).isEqualTo(quantity), + () -> assertThat(orderItem.getOrderPrice()).isEqualTo(orderPrice) + ); + } + + @DisplayName("주문 항목의 가격이 상품 가격과 수량의 곱과 일치한다") + @Test + void createsOrderItem_withCorrectPriceCalculation() { + // arrange + ProductModel product = new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10)); + Quantity quantity = new Quantity(2); + Money expectedOrderPrice = new Money(20000); // 10000 * 2 + + // act + OrderItemModel orderItem = new OrderItemModel(product, quantity, expectedOrderPrice); + + // assert + assertThat(orderItem.getOrderPrice().value()).isEqualTo(expectedOrderPrice.value()); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java new file mode 100644 index 000000000..64b5b5bf7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java @@ -0,0 +1,128 @@ +package com.loopers.domain.order; + +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserId; +import com.loopers.domain.user.Email; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.BirthDate; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.Brand; +import com.loopers.domain.common.Money; +import com.loopers.domain.common.Quantity; +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; + +class OrderModelTest { + @DisplayName("주문 모델 생성") + @Nested + class Create { + + @DisplayName("주문이 정상적으로 생성된다") + @Test + void createsOrder_whenValidParameters() { + // arrange + UserModel user = new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")); + Money totalPrice = new Money(30000); + ProductModel product = new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10)); + OrderItemModel orderItem = new OrderItemModel(product, new Quantity(3), new Money(30000)); + List orderItems = List.of(orderItem); + + // act + OrderModel order = new OrderModel(user, totalPrice, orderItems); + + // assert + assertAll( + () -> assertThat(order).isNotNull(), + () -> assertThat(order.getUser()).isEqualTo(user), + () -> assertThat(order.getTotalPrice()).isEqualTo(totalPrice), + () -> assertThat(order.getOrderItems()).hasSize(1), + () -> assertThat(order.getOrderItems().get(0)).isEqualTo(orderItem) + ); + } + + @DisplayName("여러 주문 항목이 포함된 주문이 정상적으로 생성된다") + @Test + void createsOrder_whenMultipleOrderItems() { + // arrange + UserModel user = new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")); + Money totalPrice = new Money(50000); + ProductModel product1 = new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10)); + ProductModel product2 = new ProductModel("product2", new Brand("Samsung"), new Money(20000), new Quantity(5)); + OrderItemModel orderItem1 = new OrderItemModel(product1, new Quantity(2), new Money(20000)); + OrderItemModel orderItem2 = new OrderItemModel(product2, new Quantity(1), new Money(20000)); + List orderItems = List.of(orderItem1, orderItem2); + + // act + OrderModel order = new OrderModel(user, totalPrice, orderItems); + + // assert + assertAll( + () -> assertThat(order).isNotNull(), + () -> assertThat(order.getUser()).isEqualTo(user), + () -> assertThat(order.getTotalPrice()).isEqualTo(totalPrice), + () -> assertThat(order.getOrderItems()).hasSize(2), + () -> assertThat(order.getOrderItems().get(0)).isEqualTo(orderItem1), + () -> assertThat(order.getOrderItems().get(1)).isEqualTo(orderItem2) + ); + } + } + + @DisplayName("주문 항목 추가") + @Nested + class AddOrderItem { + + @DisplayName("주문 항목이 정상적으로 추가된다") + @Test + void addsOrderItem_whenValidOrderItem() { + // arrange + UserModel user = new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")); + Money totalPrice = new Money(10000); + OrderModel order = new OrderModel(user, totalPrice, new java.util.ArrayList<>()); + ProductModel product = new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10)); + OrderItemModel orderItem = new OrderItemModel(product, new Quantity(1), new Money(10000)); + + // act + order.addOrderItem(orderItem); + + // assert + assertAll( + () -> assertThat(order.getOrderItems()).hasSize(1), + () -> assertThat(order.getOrderItems().get(0)).isEqualTo(orderItem), + () -> assertThat(orderItem.getOrder()).isEqualTo(order) + ); + } + + @DisplayName("여러 주문 항목이 순차적으로 추가된다") + @Test + void addsMultipleOrderItems_whenCalledMultipleTimes() { + // arrange + UserModel user = new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")); + Money totalPrice = new Money(30000); + OrderModel order = new OrderModel(user, totalPrice, new java.util.ArrayList<>()); + ProductModel product1 = new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10)); + ProductModel product2 = new ProductModel("product2", new Brand("Samsung"), new Money(20000), new Quantity(5)); + OrderItemModel orderItem1 = new OrderItemModel(product1, new Quantity(1), new Money(10000)); + OrderItemModel orderItem2 = new OrderItemModel(product2, new Quantity(1), new Money(20000)); + + // act + order.addOrderItem(orderItem1); + order.addOrderItem(orderItem2); + + // assert + assertAll( + () -> assertThat(order.getOrderItems()).hasSize(2), + () -> assertThat(order.getOrderItems().get(0)).isEqualTo(orderItem1), + () -> assertThat(order.getOrderItems().get(1)).isEqualTo(orderItem2), + () -> assertThat(orderItem1.getOrder()).isEqualTo(order), + () -> assertThat(orderItem2.getOrder()).isEqualTo(order) + ); + } + } +} + 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..29c71308f --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java @@ -0,0 +1,236 @@ +package com.loopers.domain.order; + +import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.UserId; +import com.loopers.domain.user.Email; +import com.loopers.domain.user.Gender; +import com.loopers.domain.user.BirthDate; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.Brand; +import com.loopers.domain.common.Money; +import com.loopers.domain.common.Quantity; +import com.loopers.domain.point.PointModel; +import com.loopers.infrastructure.order.OrderJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.user.UserJpaRepository; +import com.loopers.infrastructure.point.PointJpaRepository; +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.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 OrderJpaRepository orderJpaRepository; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private PointJpaRepository pointJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("주문 생성") + @Nested + class CreateOrder { + + @DisplayName("정상 주문이 성공적으로 생성된다") + @Test + void createsOrder_whenValidOrderRequest() { + // arrange + UserModel user = userJpaRepository.save( + new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + pointJpaRepository.save( + new PointModel(user, new Money(50000)) + ); + ProductModel product1 = productJpaRepository.save( + new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10)) + ); + ProductModel product2 = productJpaRepository.save( + new ProductModel("product2", new Brand("Samsung"), new Money(20000), new Quantity(5)) + ); + + List items = List.of( + new OrderService.OrderItemRequest(product1.getId(), 2), + new OrderService.OrderItemRequest(product2.getId(), 1) + ); + + // act + OrderModel order = orderService.createOrder(user, items); + + // assert + assertAll( + () -> assertThat(order).isNotNull(), + () -> assertThat(order.getUser()).isEqualTo(user), + () -> assertThat(order.getTotalPrice().value()).isEqualTo(40000), // 10000*2 + 20000*1 + () -> assertThat(order.getOrderItems()).hasSize(2), + () -> assertThat(order.getOrderItems().get(0).getProduct().getId()).isEqualTo(product1.getId()), + () -> assertThat(order.getOrderItems().get(0).getQuantity().quantity()).isEqualTo(2), + () -> assertThat(order.getOrderItems().get(1).getProduct().getId()).isEqualTo(product2.getId()), + () -> assertThat(order.getOrderItems().get(1).getQuantity().quantity()).isEqualTo(1) + ); + } + + @DisplayName("재고 부족 시 주문 생성이 실패한다") + @Test + void throwsException_whenInsufficientStock() { + // arrange + UserModel user = userJpaRepository.save( + new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + pointJpaRepository.save( + new PointModel(user, new Money(50000)) + ); + ProductModel product = productJpaRepository.save( + new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(5)) + ); + + List items = List.of( + new OrderService.OrderItemRequest(product.getId(), 10) // 재고보다 많은 수량 + ); + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + orderService.createOrder(user, items); + }); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).contains("재고가 부족합니다"); + } + + @DisplayName("포인트 부족 시 주문 생성이 실패한다") + @Test + void throwsException_whenInsufficientPoints() { + // arrange + UserModel user = userJpaRepository.save( + new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + pointJpaRepository.save( + new PointModel(user, new Money(10000)) // 부족한 포인트 + ); + ProductModel product = productJpaRepository.save( + new ProductModel("product1", new Brand("Apple"), new Money(20000), new Quantity(10)) + ); + + List items = List.of( + new OrderService.OrderItemRequest(product.getId(), 1) // 20000원 필요 + ); + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + orderService.createOrder(user, items); + }); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(exception.getMessage()).contains("포인트가 부족합니다"); + } + + @DisplayName("존재하지 않는 상품으로 주문 시 실패한다") + @Test + void throwsException_whenProductNotFound() { + // arrange + UserModel user = userJpaRepository.save( + new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + pointJpaRepository.save( + new PointModel(user, new Money(50000)) + ); + + List items = List.of( + new OrderService.OrderItemRequest(999L, 1) // 존재하지 않는 상품 ID + ); + + // act + CoreException exception = assertThrows(CoreException.class, () -> { + orderService.createOrder(user, items); + }); + + // assert + assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); + assertThat(exception.getMessage()).contains("상품을 찾을 수 없습니다"); + } + + @DisplayName("주문 생성 시 재고가 정확히 차감된다") + @Test + void decreasesStock_whenOrderIsCreated() { + // arrange + UserModel user = userJpaRepository.save( + new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + pointJpaRepository.save( + new PointModel(user, new Money(50000)) + ); + ProductModel product = productJpaRepository.save( + new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10)) + ); + int initialQuantity = product.getQuantity().quantity(); + + List items = List.of( + new OrderService.OrderItemRequest(product.getId(), 3) + ); + + // act + orderService.createOrder(user, items); + + // assert + ProductModel updatedProduct = productJpaRepository.findById(product.getId()).orElseThrow(); + assertThat(updatedProduct.getQuantity().quantity()).isEqualTo(initialQuantity - 3); + } + + @DisplayName("주문 생성 시 포인트가 정확히 차감된다") + @Test + void decreasesPoints_whenOrderIsCreated() { + // arrange + UserModel user = userJpaRepository.save( + new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")) + ); + PointModel point = pointJpaRepository.save( + new PointModel(user, new Money(50000)) + ); + ProductModel product = productJpaRepository.save( + new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10)) + ); + int initialPoints = point.getPoint().value(); + + List items = List.of( + new OrderService.OrderItemRequest(product.getId(), 2) + ); + + // act + orderService.createOrder(user, items); + + // assert + PointModel updatedPoint = pointJpaRepository.findByUser(user).orElseThrow(); + assertThat(updatedPoint.getPoint().value()).isEqualTo(initialPoints - 20000); + } + } +} + diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java new file mode 100644 index 000000000..26985a99b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java @@ -0,0 +1,110 @@ +package com.loopers.domain.product; + +import com.loopers.domain.common.Quantity; +import com.loopers.domain.common.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 ProductModelTest { + @DisplayName("상품 모델을 생성할 때, ") + @Nested + class Create { + + @DisplayName("상품 재고는 0 이상이어야 한다.") + @Test + void productModel_whenCreateQuantityIsLessThan0() { + // arrange + Quantity quantity = new Quantity(-1); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new ProductModel("제목", new Brand("Apple"), new Money(10000), quantity); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("상품 재고를 차감할 때, 재고가 부족하면 BAD_REQUEST 예외가 발생한다.") + @Test + void productModel_whenDecreaseQuantityIsLessThan0() { + // arrange + ProductModel product = new ProductModel("제목", new Brand("Apple"), new Money(10000), new Quantity(10)); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + product.decreaseQuantity(new Quantity(11)); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + assertThat(result.getMessage()).contains("재고가 부족합니다"); + } + + @DisplayName("상품 재고를 정상적으로 차감한다") + @Test + void decreasesQuantity_whenValidQuantityIsProvided() { + // arrange + ProductModel product = new ProductModel("제목", new Brand("Apple"), new Money(10000), new Quantity(10)); + int initialQuantity = product.getQuantity().quantity(); + + // act + product.decreaseQuantity(new Quantity(3)); + + // assert + assertThat(product.getQuantity().quantity()).isEqualTo(initialQuantity - 3); + } + + @DisplayName("상품 재고를 0까지 차감할 수 있다") + @Test + void decreasesQuantityToZero_whenQuantityEqualsStock() { + // arrange + ProductModel product = new ProductModel("제목", new Brand("Apple"), new Money(10000), new Quantity(10)); + + // act + product.decreaseQuantity(new Quantity(10)); + + // assert + assertThat(product.getQuantity().quantity()).isEqualTo(0); + } + + @DisplayName("상품 등록 시 브랜드가 빈칸이면 BAD_REQUEST 예외가 발생한다.") + @Test + void productModel_whenCreateBrandIsBlank() { + // arrange + Brand brand = new Brand(""); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new ProductModel("제목", brand, new Money(10000), new Quantity(10)); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("상품 등록 시 이름이 빈칸이면 BAD_REQUEST 예외가 발생한다.") + @Test + void productModel_whenCreateNameIsBlank() { + // arrange + String name = ""; + + // act + CoreException result = assertThrows(CoreException.class, () -> { + new ProductModel(name, new Brand("Apple"), new Money(10000), new Quantity(10)); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + + } +} From dcf70d2199bdd5fa0dff372a57ea9ed28a45249b Mon Sep 17 00:00:00 2001 From: leeminkyu-kr96 Date: Fri, 14 Nov 2025 02:52:35 +0900 Subject: [PATCH 09/11] =?UTF-8?q?=EC=9C=A0=EC=A0=80=20=EB=B0=8F=20?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20VO=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20repository=20=EC=88=98=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/point/PointFacade.java | 6 +- .../loopers/application/point/PointInfo.java | 6 +- .../java/com/loopers/domain/common/Money.java | 16 +++++ .../com/loopers/domain/common/Quantity.java | 16 +++++ .../java/com/loopers/domain/point/Point.java | 17 ----- .../com/loopers/domain/point/PointModel.java | 24 +++---- .../java/com/loopers/domain/user/UserId.java | 1 - .../com/loopers/domain/user/UserModel.java | 21 ++++-- .../loopers/domain/user/UserRepository.java | 4 +- .../com/loopers/domain/user/UserService.java | 10 ++- .../user/UserJpaRepository.java | 2 +- .../user/UserRepositoryImpl.java | 9 +-- .../api/point/PointV1Controller.java | 3 +- .../interfaces/api/point/PointV1Dto.java | 8 +-- .../loopers/domain/point/PointModelTest.java | 11 +++- .../point/PointServiceIntegrationTest.java | 19 ++++-- .../ProductServiceIntegrationTest.java | 66 +++++++++++++++++++ .../loopers/domain/user/UserModelTest.java | 2 - .../user/UserServiceIntegrationTest.java | 4 +- .../interfaces/api/PointV1ApiE2ETest.java | 23 ++++--- 20 files changed, 183 insertions(+), 85 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java create mode 100644 apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java index 6337d7bce..75b33104d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointFacade.java @@ -5,7 +5,7 @@ import com.loopers.domain.user.UserModel; import com.loopers.domain.user.UserService; import com.loopers.domain.user.UserId; -import com.loopers.domain.point.Point; +import com.loopers.domain.common.Money; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -22,7 +22,7 @@ public PointInfo getPoint(UserId userId) { if (user == null) { throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 요청입니다."); } - PointModel pointModel = new PointModel(user, new Point(0)); + PointModel pointModel = new PointModel(user, new Money(0)); PointModel point = pointService.findPoint(pointModel); if (point == null) { @@ -32,7 +32,7 @@ public PointInfo getPoint(UserId userId) { return PointInfo.from(point); } - public PointInfo chargePoint(UserId userId, Point point) { + public PointInfo chargePoint(UserId userId, Money point) { UserModel user = userService.getUser(userId); if (user == null) { throw new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 요청입니다."); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java index 04c3a1c10..5e14d764f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/point/PointInfo.java @@ -2,13 +2,13 @@ import com.loopers.domain.point.PointModel; import com.loopers.domain.user.UserModel; -import com.loopers.domain.point.Point; +import com.loopers.domain.common.Money; -public record PointInfo(Long id, UserModel user, Point point) { +public record PointInfo(Long id, UserModel user, Money point) { public static PointInfo from(PointModel model) { return new PointInfo(model.getId(), model.getUser(), model.getPoint()); } - public Point getPoint() { + public Money getPoint() { return point; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java new file mode 100644 index 000000000..568405a00 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java @@ -0,0 +1,16 @@ +package com.loopers.domain.common; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; + +@Embeddable +public record Money(int value) { + + public Money { + if ( value < 0 ) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java new file mode 100644 index 000000000..22ab44818 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java @@ -0,0 +1,16 @@ +package com.loopers.domain.common; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Embeddable; + +@Embeddable +public record Quantity(int quantity) { + + public Quantity { + if( quantity < 0 ){ + throw new CoreException(ErrorType.BAD_REQUEST, "수량은 0 이상이어야 합니다."); + } + } + +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java deleted file mode 100644 index f80fa37fa..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/Point.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.loopers.domain.point; - - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import jakarta.persistence.Embeddable; - -@Embeddable -public record Point(int point) { - - public Point { - if( point < 0 ){ - throw new CoreException(ErrorType.BAD_REQUEST, "포인트는 0 이상이어야 합니다."); - } - } - -} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java index 3234f65a2..ed7e69e6c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java @@ -1,6 +1,7 @@ package com.loopers.domain.point; import com.loopers.domain.BaseEntity; +import com.loopers.domain.common.Money; import com.loopers.domain.user.UserModel; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -9,6 +10,7 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; + @Entity @Table(name = "point") public class PointModel extends BaseEntity { @@ -16,12 +18,12 @@ public class PointModel extends BaseEntity { @ManyToOne @JoinColumn(name = "user_model_id") private UserModel user; - private Point point; + private Money point; public PointModel() { } - public PointModel(UserModel user, Point point) { + public PointModel(UserModel user, Money point) { this.user = user; this.point = point; @@ -31,26 +33,26 @@ public UserModel getUser() { return user; } - public Point getPoint() { + public Money getPoint() { return point; } - public void charge(Point chargePoint) { - int newPointValue = this.point.point() + chargePoint.point(); - this.point = new Point(newPointValue); + public void charge(Money chargePoint) { + int newPointValue = this.point.value() + chargePoint.value(); + this.point = new Money(newPointValue); } - public void use(Point usePoint) { - if (this.point.point() < usePoint.point()) { + public void use(Money usePoint) { + if (this.point.value() < usePoint.value()) { throw new CoreException(ErrorType.BAD_REQUEST, "포인트가 부족합니다."); } - if (usePoint.point() > this.point.point()) { + if (usePoint.value() > this.point.value()) { throw new CoreException(ErrorType.BAD_REQUEST, "사용 금액이 보유 포인트를 초과합니다."); } - int newPointValue = this.point.point() - usePoint.point(); - this.point = new Point(newPointValue); + int newPointValue = this.point.value() - usePoint.value(); + this.point = new Money(newPointValue); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.java index 0ca578da2..04d5b8bd2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.java @@ -8,7 +8,6 @@ public record UserId(String userId) { public UserId{ - //유저아이디 체크 if ( userId == null || userId.isBlank() ) { throw new CoreException(ErrorType.BAD_REQUEST, "UserId는 비어있을 수 없습니다."); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java index 2cbccd415..b50e14e37 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserModel.java @@ -3,17 +3,26 @@ import com.loopers.domain.BaseEntity; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import jakarta.persistence.Embedded; @Entity @Table(name = "user") public class UserModel extends BaseEntity { + @Embedded private UserId userId; + + @Embedded private Email email; + + @Embedded private Gender gender; + + @Embedded private BirthDate birthDate; - protected UserModel() {} + protected UserModel() { + } public UserModel(UserId userId, Email email, Gender gender, BirthDate birthDate) { this.userId = userId; @@ -23,18 +32,18 @@ public UserModel(UserId userId, Email email, Gender gender, BirthDate birthDate) } public UserId getUserId() { - return userId; + return this.userId; } public Email getEmail() { - return email; + return this.email; } public Gender getGender() { - return gender; + return this.gender; } public BirthDate getBirthDate() { - return birthDate; + return this.birthDate; } -} +} \ No newline at end of file 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 419a5b2cc..a6caeebe5 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 @@ -4,7 +4,7 @@ public interface UserRepository { Optional find(UserId userId); + Optional findById(Long id); - UserModel save(UserModel userModel); -} +} \ No newline at end of file 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/UserService.java index bd1a387a8..af6ee8d1c 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/UserService.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 lombok.RequiredArgsConstructor; @@ -13,21 +12,20 @@ @Component public class UserService { - private final UserJpaRepository userJpaRepository; + private final UserRepository userRepository; @Transactional(readOnly = true) public UserModel getUser(UserId userId) { - return userJpaRepository.findByUserId(userId).orElse(null); + return userRepository.find(userId).orElse(null); } @Transactional public UserModel signUp(UserModel userModel) { - Optional user = userJpaRepository.findByUserId(userModel.getUserId()); + Optional user = userRepository.find(userModel.getUserId()); if (user.isPresent()) { throw new CoreException(ErrorType.CONFLICT, "[userId = " + userModel.getUserId().userId() + "] 아이디가 중복되었습니다."); } - - return userJpaRepository.save(userModel); + return userRepository.save(userModel); } } \ No newline at end of file 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 60717bd23..2e06ebfd8 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 @@ -8,4 +8,4 @@ public interface UserJpaRepository extends JpaRepository { Optional findByUserId(UserId userId); -} +} \ No newline at end of file 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 4995caf3c..d6dfe16cc 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 @@ -13,21 +13,18 @@ public class UserRepositoryImpl implements UserRepository { private final UserJpaRepository userJpaRepository; - @Override + @Override public Optional find(UserId userId) { return userJpaRepository.findByUserId(userId); } @Override public Optional findById(Long id) { - return Optional.empty(); + return userJpaRepository.findById(id); } @Override public UserModel save(UserModel userModel) { return userJpaRepository.save(userModel); } - - - -} +} \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java index 52b627823..ffa4bc458 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Controller.java @@ -2,12 +2,13 @@ import com.loopers.application.point.PointFacade; import com.loopers.application.point.PointInfo; -import com.loopers.domain.user.UserId; +import com.loopers.domain.common.Money; import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import jakarta.validation.constraints.NotBlank; +import com.loopers.domain.user.UserId; @RequiredArgsConstructor @RestController diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java index e4d8df913..81a805d96 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/point/PointV1Dto.java @@ -1,19 +1,19 @@ package com.loopers.interfaces.api.point; import com.loopers.application.point.PointInfo; -import com.loopers.domain.point.Point; +import com.loopers.domain.common.Money; public class PointV1Dto { - public record PointResponse(String userId, Point point) { + public record PointResponse(String userId, Money point) { public static PointResponse from(PointInfo info) { return new PointResponse( info.user().getUserId().userId(), - info.getPoint() + info.point() ); } } public record ChargeRequest( - Point point + Money point ) {} } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java index 87b8e1e10..41cd7cb18 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java @@ -1,6 +1,11 @@ package com.loopers.domain.point; +import com.loopers.domain.user.Email; +import com.loopers.domain.user.UserId; +import com.loopers.domain.user.BirthDate; import com.loopers.domain.user.UserModel; +import com.loopers.domain.user.Gender; +import com.loopers.domain.common.Money; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.DisplayName; @@ -20,10 +25,10 @@ class Create { @DisplayName("0 이하의 정수로 포인트를 충전 시 실패한다.") @Test void pointModel_whenPointIsLessThan0() { - UserModel user = new UserModel("user123", "email@email.com", "1999-01-01"); - int point = -1; + UserModel user = new UserModel(new UserId("user123"), new Email("email@email.com"), new Gender("male"), new BirthDate("1999-01-01")); + CoreException result = assertThrows(CoreException.class, () -> { - new PointModel(user, point); + new PointModel(user, new Money(-1)); }); // assert diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java index cae1cd537..d6df530a8 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointServiceIntegrationTest.java @@ -1,7 +1,12 @@ package com.loopers.domain.point; +import com.loopers.domain.user.Email; +import com.loopers.domain.user.UserId; +import com.loopers.domain.user.BirthDate; import com.loopers.domain.user.UserModel; import com.loopers.domain.user.UserRepository; +import com.loopers.domain.user.Gender; +import com.loopers.domain.common.Money; import com.loopers.infrastructure.point.PointJpaRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -47,9 +52,9 @@ class GetPoint { @Test void returnsPoint_whenValidUserIdIsProvided() { // arrange - UserModel user = new UserModel("userId", "email@email.com", "1999-01-01"); + UserModel user = new UserModel(new UserId("userId"), new Email("email@email.com"), new Gender("male"), new BirthDate("1999-01-01")); userRepository.save(user); - PointModel pointModel = new PointModel(user, 10); + PointModel pointModel = new PointModel(user, new Money(10)); pointService.charge(pointModel); // act @@ -58,7 +63,7 @@ void returnsPoint_whenValidUserIdIsProvided() { // assert assertAll( () -> assertThat(result).isNotNull(), - () -> assertThat(result.getPoint()).isEqualTo(10) + () -> assertThat(result.getPoint().value()).isEqualTo(10) ); } @@ -66,8 +71,8 @@ void returnsPoint_whenValidUserIdIsProvided() { @Test void returnsNull_whenInvalidUserIdIsProvided() { // arrange - UserModel user = new UserModel("notUserId1", "email@email.com", "1999-01-01"); - PointModel pointModel = new PointModel(user, 10); + UserModel user = new UserModel(new UserId("notUserId1"), new Email("email@email.com"), new Gender("male"), new BirthDate("1999-01-01")); + PointModel pointModel = new PointModel(user, new Money(10)); // act PointModel result = pointService.findPoint(pointModel); @@ -87,8 +92,8 @@ class ChargePoint { @Test void throwsException_whenInvalidUserIdIsProvided() { // arrange - UserModel user = new UserModel("notUserId1", "email@email.com", "1999-01-01"); - PointModel pointModel = new PointModel(user, 10); + UserModel user = new UserModel(new UserId("notUserId1"), new Email("email@email.com"), new Gender("male"), new BirthDate("1999-01-01")); + PointModel pointModel = new PointModel(user, new Money(10)); // assert assertThrows(CoreException.class, () -> pointService.charge(pointModel)); 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..81b388c51 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java @@ -0,0 +1,66 @@ +package com.loopers.domain.product; + +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.domain.common.Quantity; +import com.loopers.domain.common.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 static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +class ProductServiceIntegrationTest { + @Autowired + private ProductService productService; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + @DisplayName("예시를 조회할 때,") + @Nested + class Get { + + @DisplayName("상품 다건 조회 시 상품이 없으면 NOT_FOUND 예외가 발생한다.") + @Test + void productService_whenGetProductsIsNotFound() { + // arrange + productJpaRepository.save(new ProductModel("제목", new Brand("Apple"), new Money(10000), new Quantity(10))); + + } + + @DisplayName("상품 단건 조회 시 상품이 없으면 NOT_FOUND 예외가 발생한다.") + @Test + void productService_whenGetProductIsNotFound() { + // arrange + Long id = 1L; + productJpaRepository.findById(id).orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품이 존재하지 않습니다.")); + + // act + CoreException result = assertThrows(CoreException.class, () -> { + productService.getProduct(id); + }); + + // assert + assertAll( + () -> assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND) + ); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java index 6abefaa65..915cacff5 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/user/UserModelTest.java @@ -54,8 +54,6 @@ void createUserModel_whenUserIdIsNotValid() { } - - //입력한 이메일이 빈칸이거나 공백이면, User 객체 생성에 실패한다. @DisplayName("입력한 이메일이 비어있으면, User 객체 생성에 실패한다.") @Test void createsUserModel_whenEmailIsBlank() { 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/UserServiceIntegrationTest.java index 0c07c1366..a9e837172 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/UserServiceIntegrationTest.java @@ -4,12 +4,10 @@ import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import com.loopers.utils.DatabaseCleanUp; -import org.apache.catalina.User; 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.springdoc.api.ErrorMessage; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.SpyBean; @@ -69,7 +67,7 @@ void returnsUserInfo_whenSignUp() { assertAll( () -> assertThat(user).isNotNull(), - () -> assertThat(user.getId()).isNotNull(), + () -> assertThat(user.getUserId()).isNotNull(), () -> assertThat(user.getUserId()).isEqualTo("userId1"), () -> assertThat(user.getEmail()).isEqualTo("user123@user.com"), () -> assertThat(user.getBirthDate()).isEqualTo("1999-01-01")); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java index 5b88031b6..c6cb508d5 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java @@ -2,9 +2,14 @@ import com.loopers.domain.point.PointModel; import com.loopers.domain.point.PointRepository; +import com.loopers.domain.user.Email; +import com.loopers.domain.user.UserId; +import com.loopers.domain.user.BirthDate; import com.loopers.domain.user.UserModel; import com.loopers.domain.user.UserRepository; +import com.loopers.domain.user.Gender; import com.loopers.interfaces.api.point.PointV1Dto; +import com.loopers.domain.common.Money; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -67,12 +72,12 @@ class GetPoint { void returnsPoint_whenValidUserIdHeaderIsProvided() { // arrange UserModel user = userRepository.save( - new UserModel("user123", "user123@example.com", "1999-01-01") + new UserModel(new UserId("user123"), new Email("user123@example.com"), new Gender("male"), new BirthDate("1999-01-01")) ); - pointRepository.save(new PointModel(user, 500)); + pointRepository.save(new PointModel(user, new Money(500))); HttpHeaders headers = new HttpHeaders(); - headers.set("X-USER-ID", user.getUserId()); + headers.set("X-USER-ID", user.getUserId().userId()); // act ParameterizedTypeReference> responseType = new ParameterizedTypeReference<>() {}; @@ -84,7 +89,7 @@ void returnsPoint_whenValidUserIdHeaderIsProvided() { () -> assertTrue(response.getStatusCode().is2xxSuccessful()), () -> assertThat(response.getBody()).isNotNull(), () -> assertThat(response.getBody().data().userId()).isEqualTo(user.getUserId()), - () -> assertThat(response.getBody().data().point()).isEqualTo(500) + () -> assertThat(response.getBody().data().point().value()).isEqualTo(500) ); } @@ -116,12 +121,12 @@ class ChargePoint { void chargesPoint_when1000AmountIsProvided() { // arrange UserModel user = userRepository.save( - new UserModel("user123", "user123@example.com", "1999-01-01") + new UserModel(new UserId("user123"), new Email("user123@example.com"), new Gender("male"), new BirthDate("1999-01-01")) ); - PointV1Dto.ChargeRequest request = new PointV1Dto.ChargeRequest(1000); + PointV1Dto.ChargeRequest request = new PointV1Dto.ChargeRequest(new Money(1000)); HttpHeaders headers = new HttpHeaders(); - headers.set("X-USER-ID", user.getUserId()); + headers.set("X-USER-ID", user.getUserId().userId()); headers.setContentType(MediaType.APPLICATION_JSON); // act @@ -134,7 +139,7 @@ void chargesPoint_when1000AmountIsProvided() { () -> assertTrue(response.getStatusCode().is2xxSuccessful()), () -> assertThat(response.getBody()).isNotNull(), () -> assertThat(response.getBody().data().userId()).isEqualTo(user.getUserId()), - () -> assertThat(response.getBody().data().point()).isEqualTo(1000) + () -> assertThat(response.getBody().data().point().value()).isEqualTo(1000) ); } @@ -142,7 +147,7 @@ void chargesPoint_when1000AmountIsProvided() { @Test void throwsNotFoundException_whenUserDoesNotExist() { // arrange - PointV1Dto.ChargeRequest request = new PointV1Dto.ChargeRequest(1000); + PointV1Dto.ChargeRequest request = new PointV1Dto.ChargeRequest(new Money(1000)); HttpHeaders headers = new HttpHeaders(); headers.set("X-USER-ID", "nonexistent"); From 27a4b1e15f2c6b817cbe60094112add6ad644caa Mon Sep 17 00:00:00 2001 From: leeminkyu-kr96 Date: Fri, 14 Nov 2025 03:09:43 +0900 Subject: [PATCH 10/11] =?UTF-8?q?VO=20recode=20=EC=97=90=EB=9F=AC=EB=A1=9C?= =?UTF-8?q?=20=EC=9D=B8=ED=95=9C=20=EC=9E=84=EC=8B=9C=20class=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EB=8B=A8=EC=9C=84=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/loopers/domain/common/Money.java | 27 +++++++++++++++-- .../com/loopers/domain/common/Quantity.java | 27 +++++++++++++++-- .../com/loopers/domain/like/LikeService.java | 9 ++++-- .../com/loopers/domain/point/PointModel.java | 2 ++ .../com/loopers/domain/product/Brand.java | 27 +++++++++++++++-- .../loopers/domain/product/ProductModel.java | 7 ++++- .../com/loopers/domain/user/BirthDate.java | 30 +++++++++++++++---- .../java/com/loopers/domain/user/Email.java | 29 +++++++++++++++--- .../java/com/loopers/domain/user/Gender.java | 27 +++++++++++++++-- .../java/com/loopers/domain/user/UserId.java | 30 ++++++++++++++++--- .../loopers/domain/point/PointModelTest.java | 2 +- .../domain/product/ProductModelTest.java | 14 +++------ 12 files changed, 192 insertions(+), 39 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java index 568405a00..b62d58bf8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java @@ -5,12 +5,33 @@ import jakarta.persistence.Embeddable; @Embeddable -public record Money(int value) { +public class Money { + private int value; - public Money { - if ( value < 0 ) { + protected Money() { + } + + public Money(int value) { + if (value < 0) { throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); } + this.value = value; + } + + public int value() { + return value; } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Money money = (Money) o; + return value == money.value; + } + + @Override + public int hashCode() { + return Integer.hashCode(value); + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java index 22ab44818..a701e72e3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/Quantity.java @@ -5,12 +5,33 @@ import jakarta.persistence.Embeddable; @Embeddable -public record Quantity(int quantity) { +public class Quantity { + private int quantity; - public Quantity { - if( quantity < 0 ){ + protected Quantity() { + } + + public Quantity(int quantity) { + if (quantity < 0) { throw new CoreException(ErrorType.BAD_REQUEST, "수량은 0 이상이어야 합니다."); } + this.quantity = quantity; + } + + public int quantity() { + return quantity; } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Quantity quantity1 = (Quantity) o; + return quantity == quantity1.quantity; + } + + @Override + public int hashCode() { + return Integer.hashCode(quantity); + } } 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 index 4c66d7e00..302c68140 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/LikeService.java @@ -28,22 +28,27 @@ public void toggleLike(UserModel user, ProductModel product) { } } - // 좋아요 등록 (멱등성: 이미 좋아요가 있으면 아무것도 하지 않음) + // 좋아요 등록: 좋아요가 없으면 추가, 있으면 취소 @Transactional public void addLike(UserModel user, ProductModel product) { var existing = likeRepository.findByUserAndProduct(user, product); if (existing.isEmpty()) { LikeModel newLike = new LikeModel(user, product); likeRepository.save(newLike); + } else { + likeRepository.delete(existing.get()); } } - // 좋아요 취소 (멱등성: 이미 좋아요가 없으면 아무것도 하지 않음) + // 좋아요 취소: 좋아요가 있으면 취소, 없으면 추가 @Transactional public void removeLike(UserModel user, ProductModel product) { var existing = likeRepository.findByUserAndProduct(user, product); if (existing.isPresent()) { likeRepository.delete(existing.get()); + } else { + LikeModel newLike = new LikeModel(user, product); + likeRepository.save(newLike); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java index ed7e69e6c..5e6a5a661 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java @@ -9,6 +9,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.persistence.Embedded; @Entity @@ -18,6 +19,7 @@ public class PointModel extends BaseEntity { @ManyToOne @JoinColumn(name = "user_model_id") private UserModel user; + @Embedded private Money point; public PointModel() { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Brand.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Brand.java index 0a8b48ff2..129f4a572 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Brand.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Brand.java @@ -5,12 +5,33 @@ import jakarta.persistence.Embeddable; @Embeddable -public record Brand(String name) { +public class Brand { + private String name; - public Brand { - if ( name == null || name.isBlank() ) { + protected Brand() { + } + + public Brand(String name) { + if (name == null || name.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 비어있을 수 없습니다."); } + this.name = name; + } + + public String name() { + return name; } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Brand brand = (Brand) o; + return name != null ? name.equals(brand.name) : brand.name == null; + } + + @Override + public int hashCode() { + return name != null ? name.hashCode() : 0; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java index c0cfa9bf9..53ad91954 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -8,6 +8,8 @@ import jakarta.persistence.Entity; import jakarta.persistence.Table; import jakarta.persistence.Embedded; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; @Entity @Table(name = "product") @@ -15,6 +17,7 @@ public class ProductModel extends BaseEntity { private String name; @Embedded + @AttributeOverride(name = "name", column = @Column(name = "brand_name")) private Brand brand; @Embedded private Money price; @@ -23,7 +26,9 @@ public class ProductModel extends BaseEntity { private Long likeCount; public ProductModel(String name, Brand brand, Money price, Quantity quantity) { - + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품 이름은 비어있을 수 없습니다."); + } this.name = name; this.brand = brand; this.price = price; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java index 1ebc0afff..97eab71e3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/BirthDate.java @@ -5,17 +5,37 @@ import jakarta.persistence.Embeddable; @Embeddable -public record BirthDate(String birthDate) { +public class BirthDate { + private String birthDate; - public BirthDate { - //생년월일 validation check + protected BirthDate() { + } - if ( birthDate == null || birthDate.isBlank() ) { + public BirthDate(String birthDate) { + //생년월일 validation check + if (birthDate == null || birthDate.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "생년월일은 비어있을 수 없습니다."); } - if ( !birthDate.matches("^\\d{4}-\\d{2}-\\d{2}$") ) { + if (!birthDate.matches("^\\d{4}-\\d{2}-\\d{2}$")) { throw new CoreException(ErrorType.BAD_REQUEST, "생년월일이 `yyyy-MM-dd` 형식에 맞아야 합니다."); } + this.birthDate = birthDate; + } + + public String birthDate() { + return birthDate; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + BirthDate birthDate1 = (BirthDate) o; + return birthDate != null ? birthDate.equals(birthDate1.birthDate) : birthDate1.birthDate == null; } + @Override + public int hashCode() { + return birthDate != null ? birthDate.hashCode() : 0; + } } 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 index 080ac5323..11b324361 100644 --- 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 @@ -5,16 +5,37 @@ import jakarta.persistence.Embeddable; @Embeddable -public record Email(String email) { +public class Email { + private String email; - public Email { + protected Email() { + } + + public Email(String email) { //이메일 validation check - if ( email == null || email.isBlank() ) { + if (email == null || email.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다."); } - if ( !email.matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$") ) { + if (!email.matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$")) { throw new CoreException(ErrorType.BAD_REQUEST, "이메일이 `xx@yy.zz` 형식에 맞아야 합니다."); } + this.email = email; + } + + public String email() { + return email; } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Email email1 = (Email) o; + return email != null ? email.equals(email1.email) : email1.email == null; + } + + @Override + public int hashCode() { + return email != null ? email.hashCode() : 0; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java index 2099fceb3..0767c451a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/Gender.java @@ -5,13 +5,34 @@ import jakarta.persistence.Embeddable; @Embeddable -public record Gender(String gender) { +public class Gender { + private String gender; - public Gender{ + protected Gender() { + } + + public Gender(String gender) { //성별 체크 - if ( gender == null || gender.isBlank() ) { + if (gender == null || gender.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "성별은 비어있을 수 없습니다."); } + this.gender = gender; + } + + public String gender() { + return gender; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Gender gender1 = (Gender) o; + return gender != null ? gender.equals(gender1.gender) : gender1.gender == null; + } + @Override + public int hashCode() { + return gender != null ? gender.hashCode() : 0; } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.java index 04d5b8bd2..db192f67b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserId.java @@ -5,14 +5,36 @@ import jakarta.persistence.Embeddable; @Embeddable -public record UserId(String userId) { +public class UserId { + private String userId; - public UserId{ - if ( userId == null || userId.isBlank() ) { + protected UserId() { + } + + public UserId(String userId) { + if (userId == null || userId.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "UserId는 비어있을 수 없습니다."); } - if ( !userId.matches("^[a-zA-Z0-9_-]{1,10}$") ) { + if (!userId.matches("^[a-zA-Z0-9_-]{1,10}$")) { throw new CoreException(ErrorType.BAD_REQUEST, "ID 가 `영문 및 숫자 10자 이내` 형식에 맞아야 합니다."); } + this.userId = userId; + } + + public String userId() { + return userId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + UserId userId1 = (UserId) o; + return userId != null ? userId.equals(userId1.userId) : userId1.userId == null; + } + + @Override + public int hashCode() { + return userId != null ? userId.hashCode() : 0; } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java index 41cd7cb18..f84cfac9e 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/point/PointModelTest.java @@ -33,7 +33,7 @@ void pointModel_whenPointIsLessThan0() { // assert assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); - assertThat(result.getMessage()).isEqualTo("포인트는 0 이상이어야 합니다."); + assertThat(result.getMessage()).isEqualTo("가격은 0 이상이어야 합니다."); } //포인트 사용하기 diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java index 26985a99b..b05ae7376 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java @@ -20,12 +20,9 @@ class Create { @DisplayName("상품 재고는 0 이상이어야 한다.") @Test void productModel_whenCreateQuantityIsLessThan0() { - // arrange - Quantity quantity = new Quantity(-1); - - // act + // arrange & act CoreException result = assertThrows(CoreException.class, () -> { - new ProductModel("제목", new Brand("Apple"), new Money(10000), quantity); + new Quantity(-1); }); // assert @@ -78,12 +75,9 @@ void decreasesQuantityToZero_whenQuantityEqualsStock() { @DisplayName("상품 등록 시 브랜드가 빈칸이면 BAD_REQUEST 예외가 발생한다.") @Test void productModel_whenCreateBrandIsBlank() { - // arrange - Brand brand = new Brand(""); - - // act + // arrange & act CoreException result = assertThrows(CoreException.class, () -> { - new ProductModel("제목", brand, new Money(10000), new Quantity(10)); + new Brand(""); }); // assert From 0ce5fe181e5a58c7277e135852c4d17944772b50 Mon Sep 17 00:00:00 2001 From: leeminkyu-kr96 Date: Sat, 15 Nov 2025 14:58:34 +0900 Subject: [PATCH 11/11] =?UTF-8?q?[=EC=BD=94=EB=93=9C=EB=9E=98=EB=B9=97=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=9A=94=EC=B2=AD=20=EC=82=AC=EC=95=88=20?= =?UTF-8?q?=EC=88=98=EC=A0=95]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .docs/design/01-requirements.md | 6 ++-- .docs/design/02-sequence-diagrams.md | 13 +++++---- .docs/design/03-class-diagram.md | 18 +++++++----- .docs/design/04-erd.md | 23 ++++++++------- .../application/brand/BrandFacade.java | 22 --------------- .../loopers/application/brand/BrandInfo.java | 10 ------- .../loopers/application/like/LikeFacade.java | 3 ++ .../application/order/OrderFacade.java | 21 ++------------ .../loopers/application/order/OrderInfo.java | 24 ++++++++++++---- .../application/order/OrderItemInfo.java | 15 ++++++++++ .../loopers/application/user/UserInfo.java | 5 ++-- .../java/com/loopers/domain/common/Money.java | 8 +++--- .../com/loopers/domain/like/LikeModel.java | 3 ++ .../com/loopers/domain/like/LikeService.java | 9 +----- .../com/loopers/domain/order/OrderModel.java | 2 +- .../loopers/domain/order/OrderService.java | 11 +++++++- .../com/loopers/domain/point/PointModel.java | 4 +-- .../loopers/domain/point/PointService.java | 2 +- .../loopers/domain/product/ProductModel.java | 3 ++ .../domain/product/ProductService.java | 5 ++++ .../java/com/loopers/domain/user/Email.java | 11 +++++--- .../interfaces/api/brand/BrandV1ApiSpec.java | 20 ------------- .../api/brand/BrandV1Controller.java | 24 ---------------- .../interfaces/api/brand/BrandV1Dto.java | 12 -------- .../interfaces/api/like/LikeV1Controller.java | 2 +- .../api/order/OrderV1Controller.java | 5 ++++ .../interfaces/api/order/OrderV1Dto.java | 16 +++++------ .../interfaces/api/product/ProductV1Dto.java | 2 +- .../loopers/domain/like/LikeModelTest.java | 1 - .../like/LikeServiceIntegrationTest.java | 28 ++++++++----------- .../order/OrderServiceIntegrationTest.java | 2 +- .../ProductServiceIntegrationTest.java | 11 +++++++- .../interfaces/api/PointV1ApiE2ETest.java | 4 +-- .../interfaces/api/UserV1ApiE2ETest.java | 4 +-- 34 files changed, 155 insertions(+), 194 deletions(-) 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/order/OrderItemInfo.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java diff --git a/.docs/design/01-requirements.md b/.docs/design/01-requirements.md index fab2d5f6e..d500ef3de 100644 --- a/.docs/design/01-requirements.md +++ b/.docs/design/01-requirements.md @@ -43,7 +43,7 @@ ### 2.3. 유스케이스 흐름 * **Main Flow (상세 조회):** 1. 사용자가 상품 목록에서 특정 상품을 클릭한다. - 2. 해당 상푸의 상세 정보(이름, 가격, 설명, 총 좋아요 수, 나의 좋아요 여부 등)를 조회하여 반환한다. + 2. 해당 상품의 상세 정보(이름, 가격, 설명, 총 좋아요 수, 나의 좋아요 여부 등)를 조회하여 반환한다. 3. 화면에 상품 상세 정보가 표시되고, 수량은 '1'로 기본 설정된다. * **Alternate Flow (수량 변경):** 1. 사용자가 '+' 버튼을 눌러 수량을 '3'으로 변경한다. @@ -67,8 +67,8 @@ ### 3.3. 유스케이스 흐름 * **Main Flow (브랜드별 조회):** - 1. 사용자가 'A 브랜드'를 조회회한다. - 2. 해당 브랜드드 상품 목록을 반환한다. + 1. 사용자가 'A 브랜드'를 조회한다. + 2. 해당 브랜드 상품 목록을 반환한다. 3. 화면에 'A 브랜드'의 상품 목록만 표시된다. * **Exception Flow (브랜드 없음):** 1. 사용자가 존재하지 않는 브랜드 ID의 URL로 직접 접근한다. diff --git a/.docs/design/02-sequence-diagrams.md b/.docs/design/02-sequence-diagrams.md index 7c9f586f5..bd6d61447 100644 --- a/.docs/design/02-sequence-diagrams.md +++ b/.docs/design/02-sequence-diagrams.md @@ -41,20 +41,23 @@ sequenceDiagram participant OrderRepository User->>OrderController: POST /api/v1/orders (body: {productId, quantity}) - OrderController->>OrderService: setOrder(userId, {productId, quantity}) + OrderController->>OrderService: createOrder(userId, {productId, quantity}) - %% 조회 및 검증 %% + %% 조회 및 검증 (서버가 가격/포인트 확인) %% OrderService->>ProductReader: getProduct({productId}) ProductReader -->>OrderService: product(현재가격, 재고) OrderService->>PointReader: getPoint(userId) PointReader-->>OrderService: point (현재 잔여 포인트) + + %% 서버가 totalPrice를 직접 계산하는 로직 명시 %% + Note right of OrderService: 3. 서버가 totalPrice를 직접 계산
(product.getPrice() * quantity) - %% 재고 및 포인트 차감 %% + %% 재고 및 포인트 차감 (계산된 totalPrice 사용) %% critical Transaction Block OrderService ->>ProductService: decreaseStock(productId, quantity) - OrderService ->>PointService: deductPoint(productPrice * quantity) - OrderService->> OrderRepository: save(new Order(...)) + OrderService ->>PointService: deductPoint(calculatedTotalPrice) + OrderService->> OrderRepository: save(new Order(..., calculatedTotalPrice)) OrderRepository-->>OrderService: orderInfo end diff --git a/.docs/design/03-class-diagram.md b/.docs/design/03-class-diagram.md index 3bfa54b0f..d72b631aa 100644 --- a/.docs/design/03-class-diagram.md +++ b/.docs/design/03-class-diagram.md @@ -1,6 +1,3 @@ -## 1. 클래스 다이어그램램 - -```mermaid classDiagram class User { Long id @@ -10,8 +7,8 @@ classDiagram class Product { Long id String name - int price - int quantity + Price price %% int -> Price (VO) + Quantity quantity %% int -> Quantity (VO) } class Brand { Long id @@ -20,12 +17,13 @@ classDiagram class Like { User user Product product + %% boolean liked 제거 } class Order { Long id User user int totalPrice - Timestamp orderDate + Timestamp created_at } class OrderItem { Order order @@ -33,9 +31,15 @@ classDiagram int quantity int orderPrice } + + %% --- VO 정의 --- + class Price { <> } + class Quantity { <> } %% --- 관계 정의 --- Product --> Brand : (상품은 브랜드를 가짐) + Product --> Price + Product --> Quantity Order --> User : (주문은 유저를 가짐) @@ -43,4 +47,4 @@ classDiagram OrderItem --> Product : (주문 항목은 상품을 가짐) Like --> User : (좋아요는 유저를 가짐) - Like -- > Product : (좋아요는 상품을 가짐) \ No newline at end of file + Like --> Product : (좋아요는 상품을 가짐) \ No newline at end of file diff --git a/.docs/design/04-erd.md b/.docs/design/04-erd.md index 95270b24e..59f99fff5 100644 --- a/.docs/design/04-erd.md +++ b/.docs/design/04-erd.md @@ -1,21 +1,22 @@ -## 1. ERD - -```mermaid erDiagram users { - bigint id PK + %% BaseEntity가 id, created_at 등을 제공합니다. varchar name + } + points { + bigint id PK + bigint user_id FK int point } products { - bigint id PK + %% BaseEntity가 id, created_at 등을 제공합니다. varchar name int price int stock_quantity bigint brand_id FK } brands { - bigint id PK + %% BaseEntity가 id, created_at 등을 제공합니다. varchar name } likes { @@ -23,13 +24,13 @@ erDiagram bigint product_id PK, FK } orders { - bigint id PK + %% BaseEntity가 id, created_at 등을 제공합니다. bigint user_id FK int total_price - Timestamp created_at + %% Timestamp created_at 제거 } orderitems { - bigint id PK + %% BaseEntity가 id, created_at 등을 제공합니다. bigint order_id FK bigint product_id FK int quantity @@ -39,10 +40,8 @@ erDiagram %% --- 관계 정의 (1:N) --- users ||--o{ likes : "likes" users ||--o{ orders : "places" - + users ||--o{ points : "has" products ||--o{ likes : "is_liked" products ||--o{ orderitems : "is_in" - brands ||--o{ products : "has" - orders ||--o{ orderitems : "contains" \ No newline at end of file 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 aa9800856..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.loopers.application.brand; - -import com.loopers.domain.product.Brand; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@Component -public class BrandFacade { - - @Transactional(readOnly = true) - public BrandInfo getBrand(String brandName) { - if (brandName == null || brandName.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "브랜드 이름은 필수입니다."); - } - - Brand brand = new Brand(brandName); - return BrandInfo.from(brand); - } -} - 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 1a8c8ea8c..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.loopers.application.brand; - -import com.loopers.domain.product.Brand; - -public record BrandInfo(String name) { - public static BrandInfo from(Brand brand) { - return new BrandInfo(brand.name()); - } -} - 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 db40c0556..225b57ef7 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 @@ -64,17 +64,20 @@ public void removeLike(UserId userId, Long productId) { likeService.removeLike(user, product); } + @Transactional(readOnly = true) public boolean isLiked(UserId userId, Long productId) { UserModel user = userService.getUser(userId); ProductModel product = productService.getProduct(productId); return likeService.isLiked(user, product); } + @Transactional(readOnly = true) public List getLikedProducts(UserId userId) { UserModel user = userService.getUser(userId); return likeService.getLikedProducts(user); } + @Transactional(readOnly = true) public long getLikeCount(Long productId) { ProductModel product = productService.getProduct(productId); return likeService.getLikeCount(product); 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 index 9d6ee94eb..04f24f1fa 100644 --- 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 @@ -27,12 +27,7 @@ public OrderInfo getOrder(Long id) { if (order == null) { throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."); } - return new OrderInfo( - order.getId(), - order.getUser(), - order.getTotalPrice(), - order.getOrderItems() - ); + return OrderInfo.from(order); } @Transactional(readOnly = true) @@ -43,12 +38,7 @@ public List getUserOrders(UserId userId) { } List orders = orderService.getUserOrders(user); return orders.stream() - .map(order -> new OrderInfo( - order.getId(), - order.getUser(), - order.getTotalPrice(), - order.getOrderItems() - )) + .map(OrderInfo::from) .collect(Collectors.toList()); } @@ -60,11 +50,6 @@ public OrderInfo createOrder(UserId userId, List } OrderModel order = orderService.createOrder(user, items); - return new OrderInfo( - order.getId(), - order.getUser(), - order.getTotalPrice(), - order.getOrderItems() - ); + return OrderInfo.from(order); } } 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 index 5599e7ac5..7d039bbb8 100644 --- 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 @@ -1,10 +1,24 @@ package com.loopers.application.order; -import com.loopers.domain.user.UserModel; import com.loopers.domain.common.Money; import java.util.List; -import com.loopers.domain.order.OrderItemModel; +import com.loopers.domain.order.OrderModel; +import com.loopers.application.order.OrderItemInfo; -public record OrderInfo(Long id, UserModel user, Money totalPrice, List orderItems) { - -} +public record OrderInfo(Long id, Long userId, Money totalPrice, List orderItems) { + public static OrderInfo from(OrderModel order) { + List items = order.getOrderItems().stream() + .map(item -> new OrderItemInfo( + item.getProduct().getId(), + item.getQuantity().quantity(), + item.getOrderPrice() + )) + .toList(); + return new OrderInfo( + order.getId(), + order.getUser().getId(), + order.getTotalPrice(), + items + ); + } + } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java new file mode 100644 index 000000000..8897a01dc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java @@ -0,0 +1,15 @@ +package com.loopers.application.order; + +import com.loopers.domain.common.Money; +import com.loopers.domain.order.OrderItemModel; +import com.loopers.application.order.OrderItemInfo; + +public record OrderItemInfo(Long productId, Integer quantity, Money orderPrice) { + public static OrderItemInfo from(OrderItemModel item) { + return new OrderItemInfo( + item.getProduct().getId(), + item.getQuantity().quantity(), + item.getOrderPrice() + ); + } + } 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 index 1a6a2e6df..3821e5395 100644 --- 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 @@ -2,12 +2,13 @@ import com.loopers.domain.user.UserModel; -public record UserInfo(String userId, String email, String birthDate) { +public record UserInfo(String userId, String email, String birthDate, String gender) { public static UserInfo from(UserModel model) { return new UserInfo( model.getUserId().userId(), model.getEmail().email(), - model.getBirthDate().birthDate() + model.getBirthDate().birthDate(), + model.getGender().gender() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java index b62d58bf8..67887a971 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java @@ -6,19 +6,19 @@ @Embeddable public class Money { - private int value; + private long value; protected Money() { } - public Money(int value) { + public Money(long value) { if (value < 0) { throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); } this.value = value; } - public int value() { + public long value() { return value; } @@ -32,6 +32,6 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Integer.hashCode(value); + return Long.hashCode(value); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java index 43cd6e0ff..c3f000741 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java @@ -23,6 +23,9 @@ public class LikeModel extends BaseEntity { @JoinColumn(name = "product_id") private ProductModel product; + public LikeModel() { + } + public LikeModel(UserModel user, ProductModel product) { this.user = user; this.product = product; 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 index 302c68140..e3b39b328 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/LikeService.java @@ -35,8 +35,6 @@ public void addLike(UserModel user, ProductModel product) { if (existing.isEmpty()) { LikeModel newLike = new LikeModel(user, product); likeRepository.save(newLike); - } else { - likeRepository.delete(existing.get()); } } @@ -44,12 +42,7 @@ public void addLike(UserModel user, ProductModel product) { @Transactional public void removeLike(UserModel user, ProductModel product) { var existing = likeRepository.findByUserAndProduct(user, product); - if (existing.isPresent()) { - likeRepository.delete(existing.get()); - } else { - LikeModel newLike = new LikeModel(user, product); - likeRepository.save(newLike); - } + existing.ifPresent(likeRepository::delete); } // 좋아요 여부 확인 diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java index f1da6558c..b90595a2b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java @@ -38,7 +38,7 @@ public OrderModel() { public OrderModel(UserModel user, Money totalPrice, List orderItems) { this.user = user; this.totalPrice = totalPrice; - this.orderItems = orderItems; + orderItems.forEach(this::addOrderItem); } public UserModel getUser() { 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 index 825d6df29..567b2bf09 100644 --- 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 @@ -35,11 +35,20 @@ public List getUserOrders(UserModel user) { @Transactional public OrderModel createOrder(UserModel user, List items) { + + if (items == null || items.isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목이 비어있습니다."); + } + List orderItems = new ArrayList<>(); - int totalPriceValue = 0; + long totalPriceValue = 0; // 각 상품에 대해 재고 확인 및 차감, 주문 항목 생성 for (OrderItemRequest item : items) { + if (item.quantity() == null || item.quantity() <= 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 수량은 1개 이상이어야 합니다."); + } + ProductModel product = productService.getProduct(item.productId()); if (product == null) { throw new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다. productId: " + item.productId()); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java index 5e6a5a661..27162addb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointModel.java @@ -40,7 +40,7 @@ public Money getPoint() { } public void charge(Money chargePoint) { - int newPointValue = this.point.value() + chargePoint.value(); + long newPointValue = this.point.value() + chargePoint.value(); this.point = new Money(newPointValue); } @@ -53,7 +53,7 @@ public void use(Money usePoint) { throw new CoreException(ErrorType.BAD_REQUEST, "사용 금액이 보유 포인트를 초과합니다."); } - int newPointValue = this.point.value() - usePoint.value(); + long newPointValue = this.point.value() - usePoint.value(); this.point = new Money(newPointValue); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java index d06a6dc0f..ec8e4d64c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java @@ -22,7 +22,7 @@ public class PointService { public PointModel findPoint(PointModel point) { UserModel requestUser = point.getUser(); var foundUser = userRepository.findById(requestUser.getId()); - if (foundUser == null) { + if (foundUser.isEmpty()) { return null; } return pointRepository.findPoint(foundUser.get()).orElse(null); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java index 53ad91954..d16899c0e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -25,6 +25,9 @@ public class ProductModel extends BaseEntity { private Quantity quantity; private Long likeCount; + public ProductModel() { + } + public ProductModel(String name, Brand brand, Money price, Quantity quantity) { if (name == null || name.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "상품 이름은 비어있을 수 없습니다."); 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 37234da16..53e4dd15e 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 @@ -12,6 +12,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.PageImpl; import com.loopers.domain.like.LikeRepository; import com.loopers.domain.common.Quantity; @@ -45,11 +46,15 @@ public Page getProducts(Pageable pageable, String sort, String bra products.sort((a, b) -> Long.compare( b.getLikeCount() != null ? b.getLikeCount() : 0L, a.getLikeCount() != null ? a.getLikeCount() : 0L)); + + // 정렬된 리스트로 새로운 Page 객체 생성하여 반환 + return new PageImpl<>(products, pageable, productPage.getTotalElements()); } return productPage; } + @Transactional(readOnly = true) public ProductModel getProduct(Long id) { ProductModel product = productRepository.findById(id).orElse(null); 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 index 11b324361..9d7829096 100644 --- 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 @@ -12,11 +12,12 @@ protected Email() { } public Email(String email) { - //이메일 validation check + // 이메일 validation check if (email == null || email.isBlank()) { throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 비어있을 수 없습니다."); } - if (!email.matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$")) { + + if (!email.matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,63}$")) { throw new CoreException(ErrorType.BAD_REQUEST, "이메일이 `xx@yy.zz` 형식에 맞아야 합니다."); } this.email = email; @@ -28,8 +29,10 @@ public String email() { @Override public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; Email email1 = (Email) o; return email != null ? email.equals(email1.email) : email1.email == null; } 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 deleted file mode 100644 index d450094c2..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1ApiSpec.java +++ /dev/null @@ -1,20 +0,0 @@ -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.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; - -@Tag(name = "Brand V1 API", description = "Loopers 브랜드 API 입니다.") -public interface BrandV1ApiSpec { - - @Operation( - summary = "브랜드 정보 조회", - description = "브랜드 이름으로 브랜드 정보를 조회합니다." - ) - ApiResponse getBrand( - @Parameter(name = "brandId", description = "조회할 브랜드의 이름 (brandId는 브랜드 이름을 의미)", required = true) - String 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 deleted file mode 100644 index 732a61f9e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Controller.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.loopers.interfaces.api.brand; - -import com.loopers.application.brand.BrandFacade; -import com.loopers.interfaces.api.ApiResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api/v1/brands") -public class BrandV1Controller implements BrandV1ApiSpec { - - private final BrandFacade brandFacade; - - @GetMapping("/{brandId}") - @Override - public ApiResponse getBrand( - @PathVariable("brandId") String brandId - ) { - BrandV1Dto.BrandResponse response = BrandV1Dto.BrandResponse.from(brandFacade.getBrand(brandId)); - return ApiResponse.success(response); - } -} - 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 deleted file mode 100644 index 69b872223..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandV1Dto.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.loopers.interfaces.api.brand; - -import com.loopers.application.brand.BrandInfo; - -public class BrandV1Dto { - public record BrandResponse(String name) { - public static BrandResponse from(BrandInfo info) { - return new BrandResponse(info.name()); - } - } -} - 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 f1d393485..4f4bdd7b5 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 @@ -20,7 +20,7 @@ public class LikeV1Controller implements LikeV1ApiSpec { @PostMapping("/products/{productId}") @Override public ApiResponse addLike( - @RequestHeader(value = "X-USER-ID") @NotBlank(message = "X-USER-ID는 필수입니다.") UserId userId, + @RequestHeader(value = "X-USER-ID") UserId userId, @PathVariable("productId") Long productId ) { likeFacade.addLike(userId, productId); 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 2142f0065..f72bd7887 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 @@ -4,6 +4,8 @@ import com.loopers.application.order.OrderInfo; import com.loopers.domain.order.OrderService; import com.loopers.domain.user.UserId; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; import com.loopers.interfaces.api.ApiResponse; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; @@ -28,6 +30,9 @@ public ApiResponse createOrder( @RequestHeader(value = "X-USER-ID") @NotBlank(message = "X-USER-ID는 필수입니다.") UserId userId, @Valid @RequestBody OrderV1Dto.CreateOrderRequest request ) { + if (request.items() == null || request.items().isEmpty()) { + throw new CoreException(ErrorType.BAD_REQUEST, "주문 항목은 비어 있을 수 없습니다."); + } List items = request.items().stream() .map(item -> new OrderService.OrderItemRequest(item.productId(), item.quantity())) .collect(Collectors.toList()); 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 b6673f81c..efed3f2b1 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 @@ -2,16 +2,16 @@ import com.loopers.application.order.OrderInfo; import com.loopers.domain.common.Money; -import com.loopers.domain.order.OrderItemModel; +import com.loopers.application.order.OrderItemInfo; import java.util.List; public class OrderV1Dto { public record OrderItemResponse(Long productId, Integer quantity, Money price) { - public static OrderItemResponse from(OrderItemModel item) { + public static OrderItemResponse from(OrderItemInfo item) { return new OrderItemResponse( - item.getProduct().getId(), - item.getQuantity().quantity(), - item.getOrderPrice() + item.productId(), + item.quantity(), + item.orderPrice() ); } } @@ -19,11 +19,11 @@ public static OrderItemResponse from(OrderItemModel item) { public record OrderResponse(Long id, String userId, Money totalPrice, List orderItems) { public static OrderResponse from(OrderInfo info) { List items = info.orderItems().stream() - .map(OrderItemResponse::from) + .map(item -> OrderItemResponse.from(item)) .toList(); return new OrderResponse( info.id(), - info.user().getUserId().userId(), + info.userId().toString(), info.totalPrice(), items ); @@ -33,7 +33,7 @@ public static OrderResponse from(OrderInfo info) { public record OrdersResponse(List orders) { public static OrdersResponse from(List orders) { List orderResponses = orders.stream() - .map(OrderResponse::from) + .map(info -> OrderResponse.from(info)) .toList(); return new OrdersResponse(orderResponses); } 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 f245b8bbd..52accf147 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 @@ -5,7 +5,7 @@ import org.springframework.data.domain.Page; public class ProductV1Dto { - public record ProductResponse(Long id, String name, String brand, Integer price, Long likeCount) { + public record ProductResponse(Long id, String name, String brand, long price, Long likeCount) { public static ProductResponse from(ProductInfo info) { return new ProductResponse( info.id(), diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java index fc21f3000..1cc2ef6ba 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeModelTest.java @@ -32,7 +32,6 @@ void createsLikeModel_whenLikeIsCreated() { // assert assertAll( - () -> assertThat(likeModel.getId()).isNotNull(), () -> assertThat(likeModel.getUser()).isEqualTo(user), () -> assertThat(likeModel.getProduct()).isEqualTo(product) ); 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 index 958e4b128..a4fe27545 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/LikeServiceIntegrationTest.java @@ -88,9 +88,9 @@ void removesLike_whenRemoveLikeIsCalled() { assertThat(isLiked).isFalse(); } - @DisplayName("중복 좋아요 방지: 이미 좋아요가 있으면 아무것도 하지 않는다 (멱등성)") + @DisplayName("좋아요 등록: 이미 좋아요가 있으면 취소한다") @Test - void preventsDuplicateLikes_whenAddLikeIsCalledTwice() { + void cancelsLike_whenAddLikeIsCalledOnLikedProduct() { // arrange UserModel user = userJpaRepository.save( new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")) @@ -98,21 +98,19 @@ void preventsDuplicateLikes_whenAddLikeIsCalledTwice() { ProductModel product = productJpaRepository.save( new ProductModel("product123", new Brand("Apple"), new Money(10000), new Quantity(100)) ); - likeService.addLike(user, product); - long likeCountBefore = likeJpaRepository.count(); + likeService.addLike(user, product); // 첫 번째 호출: 좋아요 추가 + assertThat(likeService.isLiked(user, product)).isTrue(); // act - likeService.addLike(user, product); // 중복 호출 + likeService.addLike(user, product); // 두 번째 호출: 좋아요 취소 // assert - long likeCountAfter = likeJpaRepository.count(); - assertThat(likeCountAfter).isEqualTo(likeCountBefore); // 좋아요 수가 증가하지 않음 - assertThat(likeService.isLiked(user, product)).isTrue(); + assertThat(likeService.isLiked(user, product)).isFalse(); } - @DisplayName("중복 취소 방지: 이미 좋아요가 없으면 아무것도 하지 않는다 (멱등성)") + @DisplayName("좋아요 취소: 이미 좋아요가 없으면 추가한다") @Test - void preventsDuplicateRemoval_whenRemoveLikeIsCalledTwice() { + void addsLike_whenRemoveLikeIsCalledOnUnlikedProduct() { // arrange UserModel user = userJpaRepository.save( new UserModel(new UserId("user123"), new Email("user123@user.com"), new Gender("male"), new BirthDate("1999-01-01")) @@ -120,16 +118,14 @@ void preventsDuplicateRemoval_whenRemoveLikeIsCalledTwice() { ProductModel product = productJpaRepository.save( new ProductModel("product123", new Brand("Apple"), new Money(10000), new Quantity(100)) ); - likeService.removeLike(user, product); // 처음부터 좋아요 없음 - long likeCountBefore = likeJpaRepository.count(); + // 처음부터 좋아요 없음 + assertThat(likeService.isLiked(user, product)).isFalse(); // act - likeService.removeLike(user, product); // 중복 호출 + likeService.removeLike(user, product); // 좋아요 추가 // assert - long likeCountAfter = likeJpaRepository.count(); - assertThat(likeCountAfter).isEqualTo(likeCountBefore); // 좋아요 수가 변하지 않음 - assertThat(likeService.isLiked(user, product)).isFalse(); + assertThat(likeService.isLiked(user, product)).isTrue(); } @DisplayName("좋아요 토글이 정상 동작한다") 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 index 29c71308f..b18554270 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/OrderServiceIntegrationTest.java @@ -218,7 +218,7 @@ void decreasesPoints_whenOrderIsCreated() { ProductModel product = productJpaRepository.save( new ProductModel("product1", new Brand("Apple"), new Money(10000), new Quantity(10)) ); - int initialPoints = point.getPoint().value(); + long initialPoints = point.getPoint().value(); List items = List.of( new OrderService.OrderItemRequest(product.getId(), 2) 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 index 81b388c51..ea42e67cf 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/ProductServiceIntegrationTest.java @@ -17,6 +17,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertAll; +import org.springframework.data.domain.Pageable; + @SpringBootTest class ProductServiceIntegrationTest { @Autowired @@ -33,7 +35,7 @@ void tearDown() { databaseCleanUp.truncateAllTables(); } - @DisplayName("예시를 조회할 때,") + @DisplayName("상품를 조회할 때,") @Nested class Get { @@ -43,6 +45,13 @@ void productService_whenGetProductsIsNotFound() { // arrange productJpaRepository.save(new ProductModel("제목", new Brand("Apple"), new Money(10000), new Quantity(10))); + // act + CoreException result = assertThrows(CoreException.class, () -> { + productService.getProducts(Pageable.ofSize(10), "latest", "Apple"); + }); + + // assert + assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); } @DisplayName("상품 단건 조회 시 상품이 없으면 NOT_FOUND 예외가 발생한다.") diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java index c6cb508d5..47a8d8914 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PointV1ApiE2ETest.java @@ -88,7 +88,7 @@ void returnsPoint_whenValidUserIdHeaderIsProvided() { assertAll( () -> assertTrue(response.getStatusCode().is2xxSuccessful()), () -> assertThat(response.getBody()).isNotNull(), - () -> assertThat(response.getBody().data().userId()).isEqualTo(user.getUserId()), + () -> assertThat(response.getBody().data().userId()).isEqualTo(user.getUserId().userId()), () -> assertThat(response.getBody().data().point().value()).isEqualTo(500) ); } @@ -138,7 +138,7 @@ void chargesPoint_when1000AmountIsProvided() { assertAll( () -> assertTrue(response.getStatusCode().is2xxSuccessful()), () -> assertThat(response.getBody()).isNotNull(), - () -> assertThat(response.getBody().data().userId()).isEqualTo(user.getUserId()), + () -> assertThat(response.getBody().data().userId()).isEqualTo(user.getUserId().userId()), () -> assertThat(response.getBody().data().point().value()).isEqualTo(1000) ); } diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java index 2b492c0d7..f88d29e26 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/UserV1ApiE2ETest.java @@ -138,8 +138,8 @@ void returnsUserInfo_whenValidUserIdIsProvided() { () -> assertTrue(response.getStatusCode().is2xxSuccessful()), () -> assertThat(response.getBody()).isNotNull(), () -> assertThat(response.getBody().data().userId()).isEqualTo(userModel.getUserId().userId()), - () -> assertThat(response.getBody().data().email()).isEqualTo(userModel.getEmail()), - () -> assertThat(response.getBody().data().birthDate()).isEqualTo(userModel.getBirthDate()) + () -> assertThat(response.getBody().data().email()).isEqualTo(userModel.getEmail().email()), + () -> assertThat(response.getBody().data().birthDate()).isEqualTo(userModel.getBirthDate().birthDate()) ); }