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) + ); + } + } +}